Coverage for ivatar/opentelemetry_config.py: 81%

139 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-10-24 23:06 +0000

1""" 

2OpenTelemetry configuration for ivatar project. 

3 

4This module provides OpenTelemetry setup and configuration for the ivatar 

5Django application, including tracing, metrics, and logging integration. 

6""" 

7 

8import os 

9import logging 

10 

11from opentelemetry import trace, metrics 

12from opentelemetry.sdk.trace import TracerProvider 

13from opentelemetry.sdk.trace.export import BatchSpanProcessor 

14from opentelemetry.sdk.metrics import MeterProvider 

15from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader 

16from opentelemetry.sdk.resources import Resource 

17from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter 

18from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter 

19from opentelemetry.exporter.prometheus import PrometheusMetricReader 

20from opentelemetry.instrumentation.psycopg2 import Psycopg2Instrumentor 

21from opentelemetry.instrumentation.pymysql import PyMySQLInstrumentor 

22from opentelemetry.instrumentation.requests import RequestsInstrumentor 

23from opentelemetry.instrumentation.urllib3 import URLLib3Instrumentor 

24 

25# Note: Memcached instrumentation not available in OpenTelemetry Python 

26 

27logger = logging.getLogger("ivatar") 

28 

29 

30class OpenTelemetryConfig: 

31 """ 

32 OpenTelemetry configuration manager for ivatar. 

33 

34 Handles setup of tracing, metrics, and instrumentation for the Django application. 

35 """ 

36 

37 def __init__(self): 

38 self.enabled = True # Always enable OpenTelemetry instrumentation 

39 self.export_enabled = self._is_export_enabled() 

40 self.service_name = self._get_service_name() 

41 self.environment = self._get_environment() 

42 self.resource = self._create_resource() 

43 

44 def _is_export_enabled(self) -> bool: 

45 """Check if OpenTelemetry data export is enabled via environment variable.""" 

46 return os.environ.get("OTEL_EXPORT_ENABLED", "false").lower() in ( 

47 "true", 

48 "1", 

49 "yes", 

50 ) 

51 

52 def _get_service_name(self) -> str: 

53 """Get service name from environment or default.""" 

54 return os.environ.get("OTEL_SERVICE_NAME", "ivatar") 

55 

56 def _get_environment(self) -> str: 

57 """Get environment name (production, development, etc.).""" 

58 return os.environ.get("OTEL_ENVIRONMENT", "development") 

59 

60 def _create_resource(self) -> Resource: 

61 """Create OpenTelemetry resource with service information.""" 

62 return Resource.create( 

63 { 

64 "service.name": self.service_name, 

65 "service.version": os.environ.get("IVATAR_VERSION", "1.8.0"), 

66 "service.namespace": "libravatar", 

67 "deployment.environment": self.environment, 

68 "service.instance.id": os.environ.get("HOSTNAME", "unknown"), 

69 } 

70 ) 

71 

72 def setup_tracing(self) -> None: 

73 """Set up OpenTelemetry tracing.""" 

74 try: 

75 # Only set up tracing if export is enabled 

76 if not self.export_enabled: 

77 logger.info("OpenTelemetry tracing disabled (export disabled)") 

78 return 

79 

80 # Set up tracer provider 

81 trace.set_tracer_provider(TracerProvider(resource=self.resource)) 

82 tracer_provider = trace.get_tracer_provider() 

83 

84 # Configure OTLP exporter if endpoint is provided 

85 otlp_endpoint = os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT") 

86 if otlp_endpoint: 

87 otlp_exporter = OTLPSpanExporter(endpoint=otlp_endpoint) 

88 span_processor = BatchSpanProcessor(otlp_exporter) 

89 tracer_provider.add_span_processor(span_processor) 

90 logger.info( 

91 f"OpenTelemetry tracing configured with OTLP endpoint: {otlp_endpoint}" 

92 ) 

93 else: 

94 logger.info("OpenTelemetry tracing configured without OTLP endpoint") 

95 

96 except Exception as e: 

97 logger.error(f"Failed to setup OpenTelemetry tracing: {e}") 

98 # Don't disable OpenTelemetry entirely - metrics and instrumentation can still work 

99 

100 def setup_metrics(self) -> None: 

101 """Set up OpenTelemetry metrics.""" 

102 try: 

103 # Configure metric readers based on environment 

104 metric_readers = [] 

105 

106 # Configure OTLP exporter if export is enabled and endpoint is provided 

107 if self.export_enabled: 

108 otlp_endpoint = os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT") 

109 if otlp_endpoint: 

110 otlp_exporter = OTLPMetricExporter(endpoint=otlp_endpoint) 

111 metric_reader = PeriodicExportingMetricReader(otlp_exporter) 

112 metric_readers.append(metric_reader) 

113 logger.info( 

114 f"OpenTelemetry metrics configured with OTLP endpoint: {otlp_endpoint}" 

115 ) 

116 

117 # For development/local testing, also configure Prometheus HTTP server 

118 # In production, metrics are scraped by external Prometheus server 

119 prometheus_endpoint = os.environ.get("OTEL_PROMETHEUS_ENDPOINT") 

120 if prometheus_endpoint: 

121 prometheus_reader = PrometheusMetricReader() 

122 metric_readers.append(prometheus_reader) 

123 

124 # Set up meter provider with readers 

125 meter_provider = MeterProvider( 

126 resource=self.resource, metric_readers=metric_readers 

127 ) 

128 

129 # Only set meter provider if it's not already set 

130 try: 

131 metrics.set_meter_provider(meter_provider) 

132 except Exception as e: 

133 if "Overriding of current MeterProvider is not allowed" in str(e): 

134 logger.warning("MeterProvider already set, using existing provider") 

135 # Get the existing meter provider and add our readers 

136 existing_provider = metrics.get_meter_provider() 

137 if hasattr(existing_provider, "add_metric_reader"): 

138 for reader in metric_readers: 

139 existing_provider.add_metric_reader(reader) 

140 else: 

141 raise 

142 

143 # Start Prometheus HTTP server for local development (if configured) 

144 if prometheus_endpoint: 

145 self._start_prometheus_server(prometheus_reader, prometheus_endpoint) 

146 logger.info( 

147 f"OpenTelemetry metrics configured with Prometheus endpoint: {prometheus_endpoint}" 

148 ) 

149 

150 if not metric_readers: 

151 logger.warning( 

152 "No metric readers configured - metrics will not be exported" 

153 ) 

154 

155 except Exception as e: 

156 logger.error(f"Failed to setup OpenTelemetry metrics: {e}") 

157 # Don't disable OpenTelemetry entirely - tracing and instrumentation can still work 

158 

159 def _start_prometheus_server( 

160 self, prometheus_reader: PrometheusMetricReader, endpoint: str 

161 ) -> None: 

162 """Start Prometheus HTTP server for metrics endpoint.""" 

163 try: 

164 from prometheus_client import start_http_server, REGISTRY 

165 

166 # Parse endpoint to get host and port 

167 if ":" in endpoint: 

168 host, port = endpoint.split(":", 1) 

169 port = int(port) 

170 else: 

171 host = "0.0.0.0" 

172 port = int(endpoint) 

173 

174 # Register the PrometheusMetricReader collector with prometheus_client 

175 REGISTRY.register(prometheus_reader._collector) 

176 

177 # Start HTTP server 

178 start_http_server(port, addr=host) 

179 

180 logger.info(f"Prometheus metrics server started on {host}:{port}") 

181 

182 except OSError as e: 

183 if e.errno == 98: # Address already in use 

184 logger.warning( 

185 f"Prometheus metrics server already running on {endpoint}" 

186 ) 

187 else: 

188 logger.error(f"Failed to start Prometheus metrics server: {e}") 

189 # Don't disable OpenTelemetry entirely - metrics can still be exported via OTLP 

190 except Exception as e: 

191 logger.error(f"Failed to start Prometheus metrics server: {e}") 

192 # Don't disable OpenTelemetry entirely - metrics can still be exported via OTLP 

193 

194 def setup_instrumentation(self) -> None: 

195 """Set up OpenTelemetry instrumentation for various libraries.""" 

196 try: 

197 # Django instrumentation - TEMPORARILY DISABLED TO TEST HEADER ISSUE 

198 # DjangoInstrumentor().instrument() 

199 # logger.info("Django instrumentation enabled") 

200 

201 # Database instrumentation 

202 Psycopg2Instrumentor().instrument() 

203 PyMySQLInstrumentor().instrument() 

204 logger.info("Database instrumentation enabled") 

205 

206 # HTTP client instrumentation 

207 RequestsInstrumentor().instrument() 

208 URLLib3Instrumentor().instrument() 

209 logger.info("HTTP client instrumentation enabled") 

210 

211 # Note: Memcached instrumentation not available in OpenTelemetry Python 

212 # Cache operations will be traced through Django instrumentation 

213 

214 except Exception as e: 

215 logger.error(f"Failed to setup OpenTelemetry instrumentation: {e}") 

216 # Don't disable OpenTelemetry entirely - tracing and metrics can still work 

217 

218 def get_tracer(self, name: str) -> trace.Tracer: 

219 """Get a tracer instance.""" 

220 return trace.get_tracer(name) 

221 

222 def get_meter(self, name: str) -> metrics.Meter: 

223 """Get a meter instance.""" 

224 return metrics.get_meter(name) 

225 

226 

227# Global OpenTelemetry configuration instance (lazy-loaded) 

228_ot_config = None 

229_ot_initialized = False 

230 

231 

232def get_ot_config(): 

233 """Get the global OpenTelemetry configuration instance.""" 

234 global _ot_config 

235 if _ot_config is None: 

236 _ot_config = OpenTelemetryConfig() 

237 return _ot_config 

238 

239 

240def setup_opentelemetry() -> None: 

241 """ 

242 Set up OpenTelemetry for the ivatar application. 

243 

244 This function should be called during Django application startup. 

245 """ 

246 global _ot_initialized 

247 

248 if _ot_initialized: 

249 logger.debug("OpenTelemetry already initialized, skipping setup") 

250 return 

251 

252 logger.info("Setting up OpenTelemetry...") 

253 

254 ot_config = get_ot_config() 

255 ot_config.setup_tracing() 

256 ot_config.setup_metrics() 

257 ot_config.setup_instrumentation() 

258 

259 if ot_config.enabled: 

260 if ot_config.export_enabled: 

261 logger.info("OpenTelemetry setup completed successfully (export enabled)") 

262 else: 

263 logger.info("OpenTelemetry setup completed successfully (export disabled)") 

264 _ot_initialized = True 

265 else: 

266 logger.info("OpenTelemetry setup failed") 

267 

268 

269def get_tracer(name: str) -> trace.Tracer: 

270 """Get a tracer instance for the given name.""" 

271 return get_ot_config().get_tracer(name) 

272 

273 

274def get_meter(name: str) -> metrics.Meter: 

275 """Get a meter instance for the given name.""" 

276 return get_ot_config().get_meter(name) 

277 

278 

279def is_enabled() -> bool: 

280 """Check if OpenTelemetry is enabled (always True now).""" 

281 return True 

282 

283 

284def is_export_enabled() -> bool: 

285 """Check if OpenTelemetry data export is enabled.""" 

286 return get_ot_config().export_enabled