Coverage for ivatar/opentelemetry_config.py: 67%

166 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-20 05:10 +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 

20 

21 

22from django.conf import settings 

23from django.core.exceptions import ImproperlyConfigured 

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 # Get IVATAR_VERSION from environment or settings, handling case where 

63 # Django settings might not be configured yet 

64 ivatar_version = os.environ.get("IVATAR_VERSION") 

65 if not ivatar_version: 

66 # Try to access settings, but handle case where Django isn't configured 

67 try: 

68 ivatar_version = getattr(settings, "IVATAR_VERSION", "2.0") 

69 except ImproperlyConfigured: 

70 # Django settings not configured yet, use default 

71 ivatar_version = "2.0" 

72 

73 return Resource.create( 

74 { 

75 "service.name": self.service_name, 

76 "service.version": ivatar_version, 

77 "service.namespace": "libravatar", 

78 "deployment.environment": self.environment, 

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

80 } 

81 ) 

82 

83 def setup_tracing(self) -> None: 

84 """Set up OpenTelemetry tracing.""" 

85 try: 

86 # Only set up tracing if export is enabled 

87 if not self.export_enabled: 

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

89 return 

90 

91 # Set up tracer provider 

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

93 tracer_provider = trace.get_tracer_provider() 

94 

95 # Configure OTLP exporter if endpoint is provided 

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

97 if otlp_endpoint: 

98 otlp_exporter = OTLPSpanExporter(endpoint=otlp_endpoint) 

99 span_processor = BatchSpanProcessor(otlp_exporter) 

100 tracer_provider.add_span_processor(span_processor) 

101 logger.info( 

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

103 ) 

104 else: 

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

106 

107 except Exception as e: 

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

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

110 

111 def setup_metrics(self) -> None: 

112 """Set up OpenTelemetry metrics.""" 

113 try: 

114 # Configure metric readers based on environment 

115 metric_readers = [] 

116 

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

118 if self.export_enabled: 

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

120 if otlp_endpoint: 

121 otlp_exporter = OTLPMetricExporter(endpoint=otlp_endpoint) 

122 metric_reader = PeriodicExportingMetricReader(otlp_exporter) 

123 metric_readers.append(metric_reader) 

124 logger.info( 

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

126 ) 

127 

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

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

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

131 if prometheus_endpoint: 

132 prometheus_reader = PrometheusMetricReader() 

133 metric_readers.append(prometheus_reader) 

134 

135 # Set up meter provider with readers 

136 meter_provider = MeterProvider( 

137 resource=self.resource, metric_readers=metric_readers 

138 ) 

139 

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

141 try: 

142 metrics.set_meter_provider(meter_provider) 

143 except Exception as e: 

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

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

146 # Get the existing meter provider and add our readers 

147 existing_provider = metrics.get_meter_provider() 

148 if hasattr(existing_provider, "add_metric_reader"): 

149 for reader in metric_readers: 

150 existing_provider.add_metric_reader(reader) 

151 else: 

152 raise 

153 

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

155 if prometheus_endpoint: 

156 self._start_prometheus_server(prometheus_reader, prometheus_endpoint) 

157 logger.info( 

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

159 ) 

160 

161 if not metric_readers: 

162 logger.warning( 

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

164 ) 

165 

166 except Exception as e: 

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

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

169 

170 def _start_prometheus_server( 

171 self, prometheus_reader: PrometheusMetricReader, endpoint: str 

172 ) -> None: 

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

174 try: 

175 from prometheus_client import start_http_server, REGISTRY 

176 

177 # Parse endpoint to get host and port 

178 if ":" in endpoint: 

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

180 port = int(port) 

181 else: 

182 host = "0.0.0.0" 

183 port = int(endpoint) 

184 

185 # Register the PrometheusMetricReader collector with prometheus_client 

186 REGISTRY.register(prometheus_reader._collector) 

187 

188 # Start HTTP server 

189 start_http_server(port, addr=host) 

190 

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

192 

193 except OSError as e: 

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

195 logger.warning( 

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

197 ) 

198 else: 

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

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

201 except Exception as e: 

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

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

204 

205 def setup_instrumentation(self) -> None: 

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

207 # Only set up instrumentation if export is enabled 

208 if not self.export_enabled: 

209 logger.info("OpenTelemetry instrumentation disabled (export disabled)") 

210 return 

211 

212 try: 

213 # Django instrumentation - TEMPORARILY DISABLED TO TEST HEADER ISSUE 

214 # DjangoInstrumentor().instrument() 

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

216 

217 # Database instrumentation (psycopg3 preferred, psycopg2 fallback) 

218 try: 

219 from opentelemetry.instrumentation.psycopg import PsycopgInstrumentor 

220 

221 PsycopgInstrumentor().instrument() 

222 except ImportError: 

223 try: 

224 from opentelemetry.instrumentation.psycopg2 import ( 

225 Psycopg2Instrumentor, 

226 ) 

227 

228 Psycopg2Instrumentor().instrument() 

229 except ImportError: 

230 logger.warning("Psycopg instrumentation not available") 

231 

232 try: 

233 from opentelemetry.instrumentation.pymysql import PyMySQLInstrumentor 

234 

235 PyMySQLInstrumentor().instrument() 

236 except ImportError: 

237 logger.warning("PyMySQL instrumentation not available") 

238 

239 logger.info("Database instrumentation enabled") 

240 

241 # HTTP client instrumentation 

242 try: 

243 from opentelemetry.instrumentation.requests import RequestsInstrumentor 

244 

245 RequestsInstrumentor().instrument() 

246 except ImportError: 

247 logger.warning("Requests instrumentation not available") 

248 

249 try: 

250 from opentelemetry.instrumentation.urllib3 import URLLib3Instrumentor 

251 

252 URLLib3Instrumentor().instrument() 

253 except ImportError: 

254 logger.warning("URLLib3 instrumentation not available") 

255 

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

257 

258 # Note: Memcached instrumentation not available in OpenTelemetry Python 

259 # Cache operations will be traced through Django instrumentation 

260 

261 except Exception as e: 

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

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

264 

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

266 """Get a tracer instance.""" 

267 return trace.get_tracer(name) 

268 

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

270 """Get a meter instance.""" 

271 return metrics.get_meter(name) 

272 

273 

274# Global OpenTelemetry configuration instance (lazy-loaded) 

275_ot_config = None 

276_ot_initialized = False 

277 

278 

279def get_ot_config(): 

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

281 global _ot_config 

282 if _ot_config is None: 

283 _ot_config = OpenTelemetryConfig() 

284 return _ot_config 

285 

286 

287def setup_opentelemetry() -> None: 

288 """ 

289 Set up OpenTelemetry for the ivatar application. 

290 

291 This function should be called during Django application startup. 

292 """ 

293 global _ot_initialized 

294 

295 if _ot_initialized: 

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

297 return 

298 

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

300 

301 ot_config = get_ot_config() 

302 ot_config.setup_tracing() 

303 ot_config.setup_metrics() 

304 ot_config.setup_instrumentation() 

305 

306 if ot_config.enabled: 

307 if ot_config.export_enabled: 

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

309 else: 

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

311 _ot_initialized = True 

312 else: 

313 logger.info("OpenTelemetry setup failed") 

314 

315 

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

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

318 return get_ot_config().get_tracer(name) 

319 

320 

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

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

323 return get_ot_config().get_meter(name) 

324 

325 

326def is_enabled() -> bool: 

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

328 return True 

329 

330 

331def is_export_enabled() -> bool: 

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

333 return get_ot_config().export_enabled