Coverage for ivatar/opentelemetry_middleware.py: 87%

157 statements  

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

1""" 

2OpenTelemetry middleware and custom instrumentation for ivatar. 

3 

4This module provides custom OpenTelemetry instrumentation for avatar-specific 

5operations, including metrics and tracing for avatar generation, file uploads, 

6and authentication flows. 

7""" 

8 

9import logging 

10import time 

11from functools import wraps 

12 

13from django.http import HttpRequest, HttpResponse 

14from django.utils.deprecation import MiddlewareMixin 

15 

16from opentelemetry import trace 

17from opentelemetry.trace import Status, StatusCode 

18 

19from ivatar.opentelemetry_config import get_tracer, get_meter, is_enabled 

20 

21logger = logging.getLogger("ivatar") 

22 

23 

24class OpenTelemetryMiddleware(MiddlewareMixin): 

25 """ 

26 Custom OpenTelemetry middleware for ivatar-specific metrics and tracing. 

27 

28 This middleware adds custom attributes and metrics to OpenTelemetry spans 

29 for avatar-related operations. 

30 """ 

31 

32 def __init__(self, get_response): 

33 self.get_response = get_response 

34 # Don't get metrics instance here - get it lazily in __call__ 

35 

36 def __call__(self, request): 

37 # Get metrics instance lazily 

38 if not hasattr(self, "metrics"): 

39 self.metrics = get_avatar_metrics() 

40 

41 # Process request to start tracing 

42 self.process_request(request) 

43 

44 response = self.get_response(request) 

45 

46 # Process response to complete tracing 

47 self.process_response(request, response) 

48 

49 return response 

50 

51 def process_request(self, request: HttpRequest) -> None: 

52 """Process incoming request and start tracing.""" 

53 # Start span for the request 

54 span_name = f"{request.method} {request.path}" 

55 span = get_tracer("ivatar.middleware").start_span(span_name) 

56 

57 # Add request attributes 

58 span.set_attributes( 

59 { 

60 "http.method": request.method, 

61 "http.url": request.build_absolute_uri(), 

62 "http.user_agent": request.META.get("HTTP_USER_AGENT", ""), 

63 "http.remote_addr": self._get_client_ip(request), 

64 "ivatar.path": request.path, 

65 } 

66 ) 

67 

68 # Check if this is an avatar request 

69 if self._is_avatar_request(request): 

70 span.set_attribute("ivatar.request_type", "avatar") 

71 self._add_avatar_attributes(span, request) 

72 

73 # Store span in request for later use 

74 request._ot_span = span 

75 

76 # Record request start time 

77 request._ot_start_time = time.time() 

78 

79 def process_response( 

80 self, request: HttpRequest, response: HttpResponse 

81 ) -> HttpResponse: 

82 """Process response and complete tracing.""" 

83 span = getattr(request, "_ot_span", None) 

84 if not span: 

85 return response 

86 

87 try: 

88 # Calculate request duration 

89 start_time = getattr(request, "_ot_start_time", time.time()) 

90 duration = time.time() - start_time 

91 

92 # Add response attributes 

93 span.set_attributes( 

94 { 

95 "http.status_code": response.status_code, 

96 "http.response_size": ( 

97 len(response.content) if hasattr(response, "content") else 0 

98 ), 

99 "http.request.duration": duration, 

100 } 

101 ) 

102 

103 # Set span status based on response 

104 if response.status_code >= 400: 

105 span.set_status( 

106 Status(StatusCode.ERROR, f"HTTP {response.status_code}") 

107 ) 

108 else: 

109 span.set_status(Status(StatusCode.OK)) 

110 

111 # Record metrics 

112 # Note: HTTP request metrics are handled by Django instrumentation 

113 # We only record avatar-specific metrics here 

114 

115 # Record avatar-specific metrics 

116 if self._is_avatar_request(request): 

117 # Record avatar request metric using the new metrics system 

118 self.metrics.record_avatar_request( 

119 size=self._get_avatar_size(request), 

120 format_type=self._get_avatar_format(request), 

121 ) 

122 

123 finally: 

124 span.end() 

125 

126 return response 

127 

128 def _is_avatar_request(self, request: HttpRequest) -> bool: 

129 """Check if this is an avatar request.""" 

130 return request.path.startswith("/avatar/") or request.path.startswith("/avatar") 

131 

132 def _add_avatar_attributes(self, span: trace.Span, request: HttpRequest) -> None: 

133 """Add avatar-specific attributes to span.""" 

134 try: 

135 # Extract avatar parameters 

136 size = self._get_avatar_size(request) 

137 format_type = self._get_avatar_format(request) 

138 email = self._get_avatar_email(request) 

139 

140 span.set_attributes( 

141 { 

142 "ivatar.avatar_size": size, 

143 "ivatar.avatar_format": format_type, 

144 "ivatar.avatar_email": email, 

145 } 

146 ) 

147 

148 except Exception as e: 

149 logger.debug(f"Failed to add avatar attributes: {e}") 

150 

151 def _get_avatar_size(self, request: HttpRequest) -> str: 

152 """Extract avatar size from request.""" 

153 size = request.GET.get("s", "80") 

154 return str(size) 

155 

156 def _get_avatar_format(self, request: HttpRequest) -> str: 

157 """Extract avatar format from request.""" 

158 format_type = request.GET.get("d", "png") 

159 return str(format_type) 

160 

161 def _get_avatar_email(self, request: HttpRequest) -> str: 

162 """Extract email from avatar request path.""" 

163 try: 

164 # Extract email from path like /avatar/user@example.com 

165 path_parts = request.path.strip("/").split("/") 

166 if len(path_parts) >= 2 and path_parts[0] == "avatar": 

167 return path_parts[1] 

168 except Exception: 

169 pass 

170 return "unknown" 

171 

172 def _get_client_ip(self, request: HttpRequest) -> str: 

173 """Get client IP address from request.""" 

174 x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR") 

175 if x_forwarded_for: 

176 return x_forwarded_for.split(",")[0].strip() 

177 return request.META.get("REMOTE_ADDR", "unknown") 

178 

179 

180def trace_avatar_operation(operation_name: str): 

181 """ 

182 Decorator to trace avatar operations. 

183 

184 Args: 

185 operation_name: Name of the operation being traced 

186 """ 

187 

188 def decorator(func): 

189 @wraps(func) 

190 def wrapper(*args, **kwargs): 

191 if not is_enabled(): 

192 return func(*args, **kwargs) 

193 

194 tracer = get_tracer("ivatar.avatar") 

195 with tracer.start_as_current_span(f"avatar.{operation_name}") as span: 

196 try: 

197 result = func(*args, **kwargs) 

198 span.set_status(Status(StatusCode.OK)) 

199 return result 

200 except Exception as e: 

201 span.set_status(Status(StatusCode.ERROR, str(e))) 

202 span.set_attribute("error.message", str(e)) 

203 raise 

204 

205 return wrapper 

206 

207 return decorator 

208 

209 

210def trace_file_upload(operation_name: str): 

211 """ 

212 Decorator to trace file upload operations. 

213 

214 Args: 

215 operation_name: Name of the file upload operation being traced 

216 """ 

217 

218 def decorator(func): 

219 @wraps(func) 

220 def wrapper(*args, **kwargs): 

221 tracer = get_tracer("ivatar.file_upload") 

222 with tracer.start_as_current_span(f"file_upload.{operation_name}") as span: 

223 try: 

224 # Add file information if available 

225 if args and hasattr(args[0], "FILES"): 

226 files = args[0].FILES 

227 if files: 

228 file_info = list(files.values())[0] 

229 span.set_attributes( 

230 { 

231 "file.name": file_info.name, 

232 "file.size": file_info.size, 

233 "file.content_type": file_info.content_type, 

234 } 

235 ) 

236 

237 result = func(*args, **kwargs) 

238 span.set_status(Status(StatusCode.OK)) 

239 return result 

240 except Exception as e: 

241 span.set_status(Status(StatusCode.ERROR, str(e))) 

242 span.set_attribute("error.message", str(e)) 

243 raise 

244 

245 return wrapper 

246 

247 return decorator 

248 

249 

250def trace_authentication(operation_name: str): 

251 """ 

252 Decorator to trace authentication operations. 

253 

254 Args: 

255 operation_name: Name of the authentication operation being traced 

256 """ 

257 

258 def decorator(func): 

259 @wraps(func) 

260 def wrapper(*args, **kwargs): 

261 tracer = get_tracer("ivatar.auth") 

262 with tracer.start_as_current_span(f"auth.{operation_name}") as span: 

263 try: 

264 result = func(*args, **kwargs) 

265 span.set_status(Status(StatusCode.OK)) 

266 return result 

267 except Exception as e: 

268 span.set_status(Status(StatusCode.ERROR, str(e))) 

269 span.set_attribute("error.message", str(e)) 

270 raise 

271 

272 return wrapper 

273 

274 return decorator 

275 

276 

277class AvatarMetrics: 

278 """ 

279 Custom metrics for avatar operations. 

280 

281 This class provides methods to record custom metrics for avatar-specific 

282 operations like generation, caching, and external service calls. 

283 """ 

284 

285 def __init__(self): 

286 self.meter = get_meter("ivatar.avatar") 

287 

288 # Create custom metrics 

289 self.avatar_generated = self.meter.create_counter( 

290 name="ivatar_avatars_generated_total", 

291 description="Total number of avatars generated", 

292 unit="1", 

293 ) 

294 

295 self.avatar_requests = self.meter.create_counter( 

296 name="ivatar_avatar_requests_total", 

297 description="Total number of avatar image requests", 

298 unit="1", 

299 ) 

300 

301 self.avatar_cache_hits = self.meter.create_counter( 

302 name="ivatar_avatar_cache_hits_total", 

303 description="Total number of avatar cache hits", 

304 unit="1", 

305 ) 

306 

307 self.avatar_cache_misses = self.meter.create_counter( 

308 name="ivatar_avatar_cache_misses_total", 

309 description="Total number of avatar cache misses", 

310 unit="1", 

311 ) 

312 

313 self.external_avatar_requests = self.meter.create_counter( 

314 name="ivatar_external_avatar_requests_total", 

315 description="Total number of external avatar requests", 

316 unit="1", 

317 ) 

318 

319 self.file_uploads = self.meter.create_counter( 

320 name="ivatar_file_uploads_total", 

321 description="Total number of file uploads", 

322 unit="1", 

323 ) 

324 

325 self.file_upload_size = self.meter.create_histogram( 

326 name="ivatar_file_upload_size_bytes", 

327 description="File upload size in bytes", 

328 unit="bytes", 

329 ) 

330 

331 def record_avatar_request(self, size: str, format_type: str): 

332 """Record avatar request.""" 

333 self.avatar_requests.add( 

334 1, 

335 { 

336 "size": size, 

337 "format": format_type, 

338 }, 

339 ) 

340 

341 def record_avatar_generated( 

342 self, size: str, format_type: str, source: str = "generated" 

343 ): 

344 """Record avatar generation.""" 

345 self.avatar_generated.add( 

346 1, 

347 { 

348 "size": size, 

349 "format": format_type, 

350 "source": source, 

351 }, 

352 ) 

353 

354 def record_cache_hit(self, size: str, format_type: str): 

355 """Record cache hit.""" 

356 self.avatar_cache_hits.add( 

357 1, 

358 { 

359 "size": size, 

360 "format": format_type, 

361 }, 

362 ) 

363 

364 def record_cache_miss(self, size: str, format_type: str): 

365 """Record cache miss.""" 

366 self.avatar_cache_misses.add( 

367 1, 

368 { 

369 "size": size, 

370 "format": format_type, 

371 }, 

372 ) 

373 

374 def record_external_request(self, service: str, status_code: int): 

375 """Record external avatar service request.""" 

376 self.external_avatar_requests.add( 

377 1, 

378 { 

379 "service": service, 

380 "status_code": str(status_code), 

381 }, 

382 ) 

383 

384 def record_file_upload(self, file_size: int, content_type: str, success: bool): 

385 """Record file upload.""" 

386 self.file_uploads.add( 

387 1, 

388 { 

389 "content_type": content_type, 

390 "success": str(success), 

391 }, 

392 ) 

393 

394 self.file_upload_size.record( 

395 file_size, 

396 { 

397 "content_type": content_type, 

398 "success": str(success), 

399 }, 

400 ) 

401 

402 

403# Global metrics instance (lazy-loaded) 

404_avatar_metrics = None 

405 

406 

407def get_avatar_metrics(): 

408 """Get the global avatar metrics instance.""" 

409 global _avatar_metrics 

410 if _avatar_metrics is None: 

411 _avatar_metrics = AvatarMetrics() 

412 return _avatar_metrics 

413 

414 

415def reset_avatar_metrics(): 

416 """Reset the global avatar metrics instance (for testing).""" 

417 global _avatar_metrics 

418 _avatar_metrics = None