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
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-24 23:06 +0000
1"""
2OpenTelemetry middleware and custom instrumentation for ivatar.
4This module provides custom OpenTelemetry instrumentation for avatar-specific
5operations, including metrics and tracing for avatar generation, file uploads,
6and authentication flows.
7"""
9import logging
10import time
11from functools import wraps
13from django.http import HttpRequest, HttpResponse
14from django.utils.deprecation import MiddlewareMixin
16from opentelemetry import trace
17from opentelemetry.trace import Status, StatusCode
19from ivatar.opentelemetry_config import get_tracer, get_meter, is_enabled
21logger = logging.getLogger("ivatar")
24class OpenTelemetryMiddleware(MiddlewareMixin):
25 """
26 Custom OpenTelemetry middleware for ivatar-specific metrics and tracing.
28 This middleware adds custom attributes and metrics to OpenTelemetry spans
29 for avatar-related operations.
30 """
32 def __init__(self, get_response):
33 self.get_response = get_response
34 # Don't get metrics instance here - get it lazily in __call__
36 def __call__(self, request):
37 # Get metrics instance lazily
38 if not hasattr(self, "metrics"):
39 self.metrics = get_avatar_metrics()
41 # Process request to start tracing
42 self.process_request(request)
44 response = self.get_response(request)
46 # Process response to complete tracing
47 self.process_response(request, response)
49 return response
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)
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 )
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)
73 # Store span in request for later use
74 request._ot_span = span
76 # Record request start time
77 request._ot_start_time = time.time()
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
87 try:
88 # Calculate request duration
89 start_time = getattr(request, "_ot_start_time", time.time())
90 duration = time.time() - start_time
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 )
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))
111 # Record metrics
112 # Note: HTTP request metrics are handled by Django instrumentation
113 # We only record avatar-specific metrics here
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 )
123 finally:
124 span.end()
126 return response
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")
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)
140 span.set_attributes(
141 {
142 "ivatar.avatar_size": size,
143 "ivatar.avatar_format": format_type,
144 "ivatar.avatar_email": email,
145 }
146 )
148 except Exception as e:
149 logger.debug(f"Failed to add avatar attributes: {e}")
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)
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)
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"
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")
180def trace_avatar_operation(operation_name: str):
181 """
182 Decorator to trace avatar operations.
184 Args:
185 operation_name: Name of the operation being traced
186 """
188 def decorator(func):
189 @wraps(func)
190 def wrapper(*args, **kwargs):
191 if not is_enabled():
192 return func(*args, **kwargs)
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
205 return wrapper
207 return decorator
210def trace_file_upload(operation_name: str):
211 """
212 Decorator to trace file upload operations.
214 Args:
215 operation_name: Name of the file upload operation being traced
216 """
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 )
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
245 return wrapper
247 return decorator
250def trace_authentication(operation_name: str):
251 """
252 Decorator to trace authentication operations.
254 Args:
255 operation_name: Name of the authentication operation being traced
256 """
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
272 return wrapper
274 return decorator
277class AvatarMetrics:
278 """
279 Custom metrics for avatar operations.
281 This class provides methods to record custom metrics for avatar-specific
282 operations like generation, caching, and external service calls.
283 """
285 def __init__(self):
286 self.meter = get_meter("ivatar.avatar")
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )
394 self.file_upload_size.record(
395 file_size,
396 {
397 "content_type": content_type,
398 "success": str(success),
399 },
400 )
403# Global metrics instance (lazy-loaded)
404_avatar_metrics = None
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
415def reset_avatar_metrics():
416 """Reset the global avatar metrics instance (for testing)."""
417 global _avatar_metrics
418 _avatar_metrics = None