Skip to content

Commit 6c58d98

Browse files
committed
chore: compatiable flask 3.1+
1 parent bd3c1f2 commit 6c58d98

File tree

2 files changed

+195
-80
lines changed

2 files changed

+195
-80
lines changed

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4343
- `opentelemetry-instrumentation-aiohttp-server`: delay initialization of tracer, meter and excluded urls to instrumentation for testability
4444
([#3836](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3836))
4545
- `opentelemetry-instrumentation-elasticsearch`: Enhance elasticsearch query body sanitization
46-
([#3919](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3919))
46+
([#3919](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3919))
47+
- `opentelemetry-instrumentation-flask`: Add Flask 3.1+ compatibility with proper context cleanup for streaming responses to prevent memory leaks and token reuse
48+
([#3937](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3938))
4749

4850

4951
## Version 1.38.0/0.59b0 (2025-10-16)

instrumentation/opentelemetry-instrumentation-flask/src/opentelemetry/instrumentation/flask/__init__.py

Lines changed: 192 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,16 @@ def response_hook(span: Span, status: str, response_headers: List):
309309

310310
flask_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+
336423
def _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

Comments
 (0)