Coverage for ivatar/opentelemetry_config.py: 69%

162 statements  

« prev     ^ index     » next       coverage.py v7.14.2, created at 2026-06-21 23:07 +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 

218 try: 

219 from opentelemetry.instrumentation.psycopg2 import Psycopg2Instrumentor 

220 

221 Psycopg2Instrumentor().instrument() 

222 except ImportError: 

223 logger.warning("Psycopg2 instrumentation not available") 

224 

225 try: 

226 from opentelemetry.instrumentation.pymysql import PyMySQLInstrumentor 

227 

228 PyMySQLInstrumentor().instrument() 

229 except ImportError: 

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

231 

232 logger.info("Database instrumentation enabled") 

233 

234 # HTTP client instrumentation 

235 try: 

236 from opentelemetry.instrumentation.requests import RequestsInstrumentor 

237 

238 RequestsInstrumentor().instrument() 

239 except ImportError: 

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

241 

242 try: 

243 from opentelemetry.instrumentation.urllib3 import URLLib3Instrumentor 

244 

245 URLLib3Instrumentor().instrument() 

246 except ImportError: 

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

248 

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

250 

251 # Note: Memcached instrumentation not available in OpenTelemetry Python 

252 # Cache operations will be traced through Django instrumentation 

253 

254 except Exception as e: 

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

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

257 

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

259 """Get a tracer instance.""" 

260 return trace.get_tracer(name) 

261 

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

263 """Get a meter instance.""" 

264 return metrics.get_meter(name) 

265 

266 

267# Global OpenTelemetry configuration instance (lazy-loaded) 

268_ot_config = None 

269_ot_initialized = False 

270 

271 

272def get_ot_config(): 

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

274 global _ot_config 

275 if _ot_config is None: 

276 _ot_config = OpenTelemetryConfig() 

277 return _ot_config 

278 

279 

280def setup_opentelemetry() -> None: 

281 """ 

282 Set up OpenTelemetry for the ivatar application. 

283 

284 This function should be called during Django application startup. 

285 """ 

286 global _ot_initialized 

287 

288 if _ot_initialized: 

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

290 return 

291 

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

293 

294 ot_config = get_ot_config() 

295 ot_config.setup_tracing() 

296 ot_config.setup_metrics() 

297 ot_config.setup_instrumentation() 

298 

299 if ot_config.enabled: 

300 if ot_config.export_enabled: 

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

302 else: 

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

304 _ot_initialized = True 

305 else: 

306 logger.info("OpenTelemetry setup failed") 

307 

308 

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

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

311 return get_ot_config().get_tracer(name) 

312 

313 

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

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

316 return get_ot_config().get_meter(name) 

317 

318 

319def is_enabled() -> bool: 

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

321 return True 

322 

323 

324def is_export_enabled() -> bool: 

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

326 return get_ot_config().export_enabled