@@ -309,7 +309,16 @@ def response_hook(span: Span, status: str, response_headers: List):
309309
310310flask_version = version ("flask" )
311311
312- if package_version .parse (flask_version ) >= package_version .parse ("2.2.0" ):
312+ if package_version .parse (flask_version ) >= package_version .parse ("3.1.0" ):
313+ # Flask 3.1+ introduced changes to request context handling
314+ def _request_ctx_ref () -> weakref .ReferenceType :
315+ try :
316+ return weakref .ref (flask .globals .request_ctx ._get_current_object ())
317+ except (RuntimeError , AttributeError ):
318+ # Handle cases where request context is not available or has changed
319+ return weakref .ref (None )
320+
321+ elif package_version .parse (flask_version ) >= package_version .parse ("2.2.0" ):
313322
314323 def _request_ctx_ref () -> weakref .ReferenceType :
315324 return weakref .ref (flask .globals .request_ctx ._get_current_object ())
@@ -333,6 +342,84 @@ def get_default_span_name():
333342 return span_name
334343
335344
345+ def _should_trace_request (excluded_urls ) -> bool :
346+ """Check if request should be traced based on excluded URLs."""
347+ return bool (
348+ flask .request
349+ and (
350+ excluded_urls is None
351+ or not excluded_urls .url_disabled (flask .request .url )
352+ )
353+ )
354+
355+
356+ def _handle_response_headers (
357+ status , response_headers , attributes , span , response_hook , sem_conv_opt_in_mode
358+ ):
359+ """Handle response headers and span attributes."""
360+ propagator = get_global_response_propagator ()
361+ if propagator :
362+ propagator .inject (
363+ response_headers ,
364+ setter = otel_wsgi .default_response_propagation_setter ,
365+ )
366+
367+ if span :
368+ otel_wsgi .add_response_attributes (
369+ span ,
370+ status ,
371+ response_headers ,
372+ attributes ,
373+ sem_conv_opt_in_mode ,
374+ )
375+ if (
376+ span .is_recording ()
377+ and span .kind == trace .SpanKind .SERVER
378+ ):
379+ custom_attributes = otel_wsgi .collect_custom_response_headers_attributes (
380+ response_headers
381+ )
382+ if len (custom_attributes ) > 0 :
383+ span .set_attributes (custom_attributes )
384+ else :
385+ _logger .warning (
386+ "Flask environ's OpenTelemetry span "
387+ "missing at _start_response(%s)" ,
388+ status ,
389+ )
390+ if response_hook is not None :
391+ response_hook (span , status , response_headers )
392+
393+
394+ def _record_metrics (
395+ duration_s , start_time , request_route , attributes , duration_histogram_old , duration_histogram_new
396+ ):
397+ """Record duration metrics."""
398+ if duration_histogram_old :
399+ duration_attrs_old = otel_wsgi ._parse_duration_attrs (
400+ attributes , _StabilityMode .DEFAULT
401+ )
402+
403+ if request_route :
404+ # http.target to be included in old semantic conventions
405+ duration_attrs_old [HTTP_TARGET ] = str (request_route )
406+
407+ duration_histogram_old .record (
408+ max (round (duration_s * 1000 ), 0 ), duration_attrs_old
409+ )
410+ if duration_histogram_new :
411+ duration_attrs_new = otel_wsgi ._parse_duration_attrs (
412+ attributes , _StabilityMode .HTTP
413+ )
414+
415+ if request_route :
416+ duration_attrs_new [HTTP_ROUTE ] = str (request_route )
417+
418+ duration_histogram_new .record (
419+ max (duration_s , 0 ), duration_attrs_new
420+ )
421+
422+
336423def _rewrapped_app (
337424 wsgi_app ,
338425 active_requests_counter ,
@@ -361,89 +448,88 @@ def _wrapped_app(wrapped_app_environ, start_response):
361448
362449 active_requests_counter .add (1 , active_requests_count_attrs )
363450 request_route = None
364-
365451 should_trace = True
366452
367453 def _start_response (status , response_headers , * args , ** kwargs ):
368- nonlocal should_trace
369- should_trace = _should_trace (excluded_urls )
454+ nonlocal should_trace , request_route
455+ should_trace = _should_trace_request (excluded_urls )
456+
370457 if should_trace :
371- nonlocal request_route
372458 request_route = flask .request .url_rule
373-
374459 span = flask .request .environ .get (_ENVIRON_SPAN_KEY )
460+ _handle_response_headers (
461+ status , response_headers , attributes , span , response_hook , sem_conv_opt_in_mode
462+ )
463+ return start_response (status , response_headers , * args , ** kwargs )
375464
376- propagator = get_global_response_propagator ()
377- if propagator :
378- propagator .inject (
379- response_headers ,
380- setter = otel_wsgi .default_response_propagation_setter ,
381- )
465+ try :
466+ result = wsgi_app (wrapped_app_environ , _start_response )
382467
383- if span :
384- otel_wsgi .add_response_attributes (
385- span ,
386- status ,
387- response_headers ,
388- attributes ,
389- sem_conv_opt_in_mode ,
390- )
391- if (
392- span .is_recording ()
393- and span .kind == trace .SpanKind .SERVER
394- ):
395- custom_attributes = otel_wsgi .collect_custom_response_headers_attributes (
396- response_headers
397- )
398- if len (custom_attributes ) > 0 :
399- span .set_attributes (custom_attributes )
400- else :
401- _logger .warning (
402- "Flask environ's OpenTelemetry span "
403- "missing at _start_response(%s)" ,
404- status ,
405- )
406- if response_hook is not None :
407- response_hook (span , status , response_headers )
408- return start_response (status , response_headers , * args , ** kwargs )
468+ # Handle streaming responses by ensuring proper cleanup
469+ is_streaming = (
470+ hasattr (result , "__iter__" )
471+ and not isinstance (result , (bytes , str ))
472+ and hasattr (result , "__next__" )
473+ )
474+
475+ if is_streaming :
476+ # For streaming responses, defer cleanup until the response is consumed
477+ # We'll use a weakref callback or rely on the teardown handler
478+ pass
479+ else :
480+ # Non-streaming response, cleanup immediately
481+ _cleanup_context_safely (wrapped_app_environ )
409482
410- result = wsgi_app (wrapped_app_environ , _start_response )
411- if should_trace :
412- duration_s = default_timer () - start
413- if duration_histogram_old :
414- duration_attrs_old = otel_wsgi ._parse_duration_attrs (
415- attributes , _StabilityMode .DEFAULT
483+ if should_trace :
484+ duration_s = default_timer () - start
485+ _record_metrics (
486+ duration_s , start , request_route , attributes , duration_histogram_old , duration_histogram_new
416487 )
488+ except Exception :
489+ # Ensure cleanup on exception
490+ _cleanup_context_safely (wrapped_app_environ )
491+ raise
492+ finally :
493+ active_requests_counter .add (- 1 , active_requests_count_attrs )
417494
418- if request_route :
419- # http.target to be included in old semantic conventions
420- duration_attrs_old [HTTP_TARGET ] = str (request_route )
495+ return result
421496
422- duration_histogram_old .record (
423- max (round (duration_s * 1000 ), 0 ), duration_attrs_old
424- )
425- if duration_histogram_new :
426- duration_attrs_new = otel_wsgi ._parse_duration_attrs (
427- attributes , _StabilityMode .HTTP
428- )
497+ def _cleanup_context_safely (wrapped_app_environ ):
498+ """Clean up context and tokens safely"""
499+ try :
500+ # Clean up activation and token to prevent context leaks
501+ activation = wrapped_app_environ .get (_ENVIRON_ACTIVATION_KEY )
502+ token = wrapped_app_environ .get (_ENVIRON_TOKEN )
503+
504+ if activation and hasattr (activation , "__exit__" ):
505+ try :
506+ activation .__exit__ (None , None , None )
507+ except (RuntimeError , AttributeError ):
508+ _logger .debug (
509+ "Failed to exit activation during context cleanup" ,
510+ exc_info = True ,
511+ )
429512
430- if request_route :
431- duration_attrs_new [HTTP_ROUTE ] = str (request_route )
513+ if token :
514+ try :
515+ context .detach (token )
516+ except (RuntimeError , AttributeError ):
517+ _logger .debug (
518+ "Failed to detach token during context cleanup" ,
519+ exc_info = True ,
520+ )
432521
433- duration_histogram_new .record (
434- max (duration_s , 0 ), duration_attrs_new
435- )
436- active_requests_counter .add (- 1 , active_requests_count_attrs )
437- return result
522+ # Clean up environment keys
523+ for key in [
524+ _ENVIRON_ACTIVATION_KEY ,
525+ _ENVIRON_TOKEN ,
526+ _ENVIRON_SPAN_KEY ,
527+ _ENVIRON_REQCTX_REF_KEY ,
528+ ]:
529+ wrapped_app_environ .pop (key , None )
438530
439- def _should_trace (excluded_urls ) -> bool :
440- return bool (
441- flask .request
442- and (
443- excluded_urls is None
444- or not excluded_urls .url_disabled (flask .request .url )
445- )
446- )
531+ except (RuntimeError , AttributeError , KeyError ):
532+ _logger .debug ("Exception during context cleanup" , exc_info = True )
447533
448534 return _wrapped_app
449535
@@ -537,12 +623,26 @@ def _teardown_request(exc):
537623 return
538624
539625 activation = flask .request .environ .get (_ENVIRON_ACTIVATION_KEY )
626+ token = flask .request .environ .get (_ENVIRON_TOKEN )
627+
628+ # Check if this is a response that has already been cleaned up
629+ if not activation and not token :
630+ # Already cleaned up by streaming response handler
631+ return
540632
541633 original_reqctx_ref = flask .request .environ .get (
542634 _ENVIRON_REQCTX_REF_KEY
543635 )
544- current_reqctx_ref = _request_ctx_ref ()
545- if not activation or original_reqctx_ref != current_reqctx_ref :
636+
637+ try :
638+ current_reqctx_ref = _request_ctx_ref ()
639+ except (RuntimeError , AttributeError ):
640+ # Flask 3.1+ might raise exceptions when context is not available
641+ current_reqctx_ref = None
642+
643+ if not activation or (
644+ original_reqctx_ref and original_reqctx_ref != current_reqctx_ref
645+ ):
546646 # This request didn't start a span, maybe because it was created in
547647 # a way that doesn't run `before_request`, like when it is created
548648 # with `app.test_request_context`.
@@ -554,15 +654,28 @@ def _teardown_request(exc):
554654 # like any decorated with `flask.copy_current_request_context`.
555655
556656 return
557- if exc is None :
558- activation .__exit__ (None , None , None )
559- else :
560- activation .__exit__ (
561- type (exc ), exc , getattr (exc , "__traceback__" , None )
562- )
563657
564- if flask .request .environ .get (_ENVIRON_TOKEN , None ):
565- context .detach (flask .request .environ .get (_ENVIRON_TOKEN ))
658+ try :
659+ if exc is None :
660+ activation .__exit__ (None , None , None )
661+ else :
662+ activation .__exit__ (
663+ type (exc ), exc , getattr (exc , "__traceback__" , None )
664+ )
665+ except (RuntimeError , AttributeError ) as teardown_exc :
666+ _logger .debug ("Failed to exit activation in teardown" , exc_info = teardown_exc )
667+
668+ try :
669+ if token :
670+ context .detach (token )
671+ except (RuntimeError , AttributeError ) as detach_exc :
672+ _logger .debug ("Failed to detach context in teardown" , exc_info = detach_exc )
673+
674+ # Clean up environment keys to prevent memory leaks
675+ flask .request .environ .pop (_ENVIRON_ACTIVATION_KEY , None )
676+ flask .request .environ .pop (_ENVIRON_TOKEN , None )
677+ flask .request .environ .pop (_ENVIRON_SPAN_KEY , None )
678+ flask .request .environ .pop (_ENVIRON_REQCTX_REF_KEY , None )
566679
567680 return _teardown_request
568681
0 commit comments