@@ -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,91 @@ 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 ,
358+ response_headers ,
359+ attributes ,
360+ span ,
361+ response_hook ,
362+ sem_conv_opt_in_mode ,
363+ ):
364+ """Handle response headers and span attributes."""
365+ propagator = get_global_response_propagator ()
366+ if propagator :
367+ propagator .inject (
368+ response_headers ,
369+ setter = otel_wsgi .default_response_propagation_setter ,
370+ )
371+
372+ if span :
373+ otel_wsgi .add_response_attributes (
374+ span ,
375+ status ,
376+ response_headers ,
377+ attributes ,
378+ sem_conv_opt_in_mode ,
379+ )
380+ if span .is_recording () and span .kind == trace .SpanKind .SERVER :
381+ custom_attributes = (
382+ otel_wsgi .collect_custom_response_headers_attributes (
383+ response_headers
384+ )
385+ )
386+ if len (custom_attributes ) > 0 :
387+ span .set_attributes (custom_attributes )
388+ else :
389+ _logger .warning (
390+ "Flask environ's OpenTelemetry span "
391+ "missing at _start_response(%s)" ,
392+ status ,
393+ )
394+ if response_hook is not None :
395+ response_hook (span , status , response_headers )
396+
397+
398+ def _record_metrics (
399+ duration_s ,
400+ start_time ,
401+ request_route ,
402+ attributes ,
403+ duration_histogram_old ,
404+ duration_histogram_new ,
405+ ):
406+ """Record duration metrics."""
407+ if duration_histogram_old :
408+ duration_attrs_old = otel_wsgi ._parse_duration_attrs (
409+ attributes , _StabilityMode .DEFAULT
410+ )
411+
412+ if request_route :
413+ # http.target to be included in old semantic conventions
414+ duration_attrs_old [HTTP_TARGET ] = str (request_route )
415+
416+ duration_histogram_old .record (
417+ max (round (duration_s * 1000 ), 0 ), duration_attrs_old
418+ )
419+ if duration_histogram_new :
420+ duration_attrs_new = otel_wsgi ._parse_duration_attrs (
421+ attributes , _StabilityMode .HTTP
422+ )
423+
424+ if request_route :
425+ duration_attrs_new [HTTP_ROUTE ] = str (request_route )
426+
427+ duration_histogram_new .record (max (duration_s , 0 ), duration_attrs_new )
428+
429+
336430def _rewrapped_app (
337431 wsgi_app ,
338432 active_requests_counter ,
@@ -361,89 +455,98 @@ def _wrapped_app(wrapped_app_environ, start_response):
361455
362456 active_requests_counter .add (1 , active_requests_count_attrs )
363457 request_route = None
364-
365458 should_trace = True
366459
367460 def _start_response (status , response_headers , * args , ** kwargs ):
368- nonlocal should_trace
369- should_trace = _should_trace (excluded_urls )
461+ nonlocal should_trace , request_route
462+ should_trace = _should_trace_request (excluded_urls )
463+
370464 if should_trace :
371- nonlocal request_route
372465 request_route = flask .request .url_rule
373-
374466 span = flask .request .environ .get (_ENVIRON_SPAN_KEY )
467+ _handle_response_headers (
468+ status ,
469+ response_headers ,
470+ attributes ,
471+ span ,
472+ response_hook ,
473+ sem_conv_opt_in_mode ,
474+ )
475+ return start_response (status , response_headers , * args , ** kwargs )
375476
376- propagator = get_global_response_propagator ()
377- if propagator :
378- propagator .inject (
379- response_headers ,
380- setter = otel_wsgi .default_response_propagation_setter ,
381- )
477+ try :
478+ result = wsgi_app (wrapped_app_environ , _start_response )
382479
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 )
480+ # Handle streaming responses by ensuring proper cleanup
481+ is_streaming = (
482+ hasattr (result , "__iter__" )
483+ and not isinstance (result , (bytes , str ))
484+ and hasattr (result , "__next__" )
485+ )
409486
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
487+ if is_streaming :
488+ # For streaming responses, defer cleanup until the response is consumed
489+ # We'll use a weakref callback or rely on the teardown handler
490+ pass
491+ else :
492+ # Non-streaming response, cleanup immediately
493+ _cleanup_context_safely (wrapped_app_environ )
494+
495+ if should_trace :
496+ duration_s = default_timer () - start
497+ _record_metrics (
498+ duration_s ,
499+ start ,
500+ request_route ,
501+ attributes ,
502+ duration_histogram_old ,
503+ duration_histogram_new ,
416504 )
505+ except Exception :
506+ # Ensure cleanup on exception
507+ _cleanup_context_safely (wrapped_app_environ )
508+ raise
509+ finally :
510+ active_requests_counter .add (- 1 , active_requests_count_attrs )
417511
418- if request_route :
419- # http.target to be included in old semantic conventions
420- duration_attrs_old [HTTP_TARGET ] = str (request_route )
512+ return result
421513
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- )
514+ def _cleanup_context_safely (wrapped_app_environ ):
515+ """Clean up context and tokens safely"""
516+ try :
517+ # Clean up activation and token to prevent context leaks
518+ activation = wrapped_app_environ .get (_ENVIRON_ACTIVATION_KEY )
519+ token = wrapped_app_environ .get (_ENVIRON_TOKEN )
520+
521+ if activation and hasattr (activation , "__exit__" ):
522+ try :
523+ activation .__exit__ (None , None , None )
524+ except (RuntimeError , AttributeError ):
525+ _logger .debug (
526+ "Failed to exit activation during context cleanup" ,
527+ exc_info = True ,
528+ )
429529
430- if request_route :
431- duration_attrs_new [HTTP_ROUTE ] = str (request_route )
530+ if token :
531+ try :
532+ context .detach (token )
533+ except (RuntimeError , AttributeError ):
534+ _logger .debug (
535+ "Failed to detach token during context cleanup" ,
536+ exc_info = True ,
537+ )
432538
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
539+ # Clean up environment keys
540+ for key in [
541+ _ENVIRON_ACTIVATION_KEY ,
542+ _ENVIRON_TOKEN ,
543+ _ENVIRON_SPAN_KEY ,
544+ _ENVIRON_REQCTX_REF_KEY ,
545+ ]:
546+ wrapped_app_environ .pop (key , None )
438547
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- )
548+ except (RuntimeError , AttributeError , KeyError ):
549+ _logger .debug ("Exception during context cleanup" , exc_info = True )
447550
448551 return _wrapped_app
449552
@@ -537,12 +640,26 @@ def _teardown_request(exc):
537640 return
538641
539642 activation = flask .request .environ .get (_ENVIRON_ACTIVATION_KEY )
643+ token = flask .request .environ .get (_ENVIRON_TOKEN )
644+
645+ # Check if this is a response that has already been cleaned up
646+ if not activation and not token :
647+ # Already cleaned up by streaming response handler
648+ return
540649
541650 original_reqctx_ref = flask .request .environ .get (
542651 _ENVIRON_REQCTX_REF_KEY
543652 )
544- current_reqctx_ref = _request_ctx_ref ()
545- if not activation or original_reqctx_ref != current_reqctx_ref :
653+
654+ try :
655+ current_reqctx_ref = _request_ctx_ref ()
656+ except (RuntimeError , AttributeError ):
657+ # Flask 3.1+ might raise exceptions when context is not available
658+ current_reqctx_ref = None
659+
660+ if not activation or (
661+ original_reqctx_ref and original_reqctx_ref != current_reqctx_ref
662+ ):
546663 # This request didn't start a span, maybe because it was created in
547664 # a way that doesn't run `before_request`, like when it is created
548665 # with `app.test_request_context`.
@@ -554,15 +671,32 @@ def _teardown_request(exc):
554671 # like any decorated with `flask.copy_current_request_context`.
555672
556673 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 )
674+
675+ try :
676+ if exc is None :
677+ activation .__exit__ (None , None , None )
678+ else :
679+ activation .__exit__ (
680+ type (exc ), exc , getattr (exc , "__traceback__" , None )
681+ )
682+ except (RuntimeError , AttributeError ) as teardown_exc :
683+ _logger .debug (
684+ "Failed to exit activation in teardown" , exc_info = teardown_exc
685+ )
686+
687+ try :
688+ if token :
689+ context .detach (token )
690+ except (RuntimeError , AttributeError ) as detach_exc :
691+ _logger .debug (
692+ "Failed to detach context in teardown" , exc_info = detach_exc
562693 )
563694
564- if flask .request .environ .get (_ENVIRON_TOKEN , None ):
565- context .detach (flask .request .environ .get (_ENVIRON_TOKEN ))
695+ # Clean up environment keys to prevent memory leaks
696+ flask .request .environ .pop (_ENVIRON_ACTIVATION_KEY , None )
697+ flask .request .environ .pop (_ENVIRON_TOKEN , None )
698+ flask .request .environ .pop (_ENVIRON_SPAN_KEY , None )
699+ flask .request .environ .pop (_ENVIRON_REQCTX_REF_KEY , None )
566700
567701 return _teardown_request
568702
0 commit comments