Skip to content

Commit c41aecb

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

File tree

3 files changed

+414
-36
lines changed

3 files changed

+414
-36
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: 118 additions & 35 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())
@@ -361,12 +370,12 @@ def _wrapped_app(wrapped_app_environ, start_response):
361370

362371
active_requests_counter.add(1, active_requests_count_attrs)
363372
request_route = None
364-
365373
should_trace = True
366374

367375
def _start_response(status, response_headers, *args, **kwargs):
368376
nonlocal should_trace
369377
should_trace = _should_trace(excluded_urls)
378+
370379
if should_trace:
371380
nonlocal request_route
372381
request_route = flask.request.url_rule
@@ -407,35 +416,84 @@ def _start_response(status, response_headers, *args, **kwargs):
407416
response_hook(span, status, response_headers)
408417
return start_response(status, response_headers, *args, **kwargs)
409418

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
416-
)
419+
try:
420+
result = wsgi_app(wrapped_app_environ, _start_response)
417421

418-
if request_route:
419-
# http.target to be included in old semantic conventions
420-
duration_attrs_old[HTTP_TARGET] = str(request_route)
422+
# Handle streaming responses by ensuring proper cleanup
423+
is_streaming = (
424+
hasattr(result, '__iter__') and
425+
not isinstance(result, (bytes, str)) and
426+
hasattr(result, '__next__')
427+
)
421428

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-
)
429+
if is_streaming:
430+
# For streaming responses, defer cleanup until the response is consumed
431+
# We'll use a weakref callback or rely on the teardown handler
432+
pass
433+
else:
434+
# Non-streaming response, cleanup immediately
435+
_cleanup_context_safely(wrapped_app_environ)
429436

430-
if request_route:
431-
duration_attrs_new[HTTP_ROUTE] = str(request_route)
437+
if should_trace:
438+
duration_s = default_timer() - start
439+
if duration_histogram_old:
440+
duration_attrs_old = otel_wsgi._parse_duration_attrs(
441+
attributes, _StabilityMode.DEFAULT
442+
)
443+
444+
if request_route:
445+
# http.target to be included in old semantic conventions
446+
duration_attrs_old[HTTP_TARGET] = str(request_route)
447+
448+
duration_histogram_old.record(
449+
max(round(duration_s * 1000), 0), duration_attrs_old
450+
)
451+
if duration_histogram_new:
452+
duration_attrs_new = otel_wsgi._parse_duration_attrs(
453+
attributes, _StabilityMode.HTTP
454+
)
455+
456+
if request_route:
457+
duration_attrs_new[HTTP_ROUTE] = str(request_route)
458+
459+
duration_histogram_new.record(
460+
max(duration_s, 0), duration_attrs_new
461+
)
462+
except Exception:
463+
# Ensure cleanup on exception
464+
_cleanup_context_safely(wrapped_app_environ)
465+
raise
466+
finally:
467+
active_requests_counter.add(-1, active_requests_count_attrs)
432468

433-
duration_histogram_new.record(
434-
max(duration_s, 0), duration_attrs_new
435-
)
436-
active_requests_counter.add(-1, active_requests_count_attrs)
437469
return result
438470

471+
def _cleanup_context_safely(wrapped_app_environ):
472+
"""Clean up context and tokens safely"""
473+
try:
474+
# Clean up activation and token to prevent context leaks
475+
activation = wrapped_app_environ.get(_ENVIRON_ACTIVATION_KEY)
476+
token = wrapped_app_environ.get(_ENVIRON_TOKEN)
477+
478+
if activation and hasattr(activation, '__exit__'):
479+
try:
480+
activation.__exit__(None, None, None)
481+
except Exception:
482+
_logger.debug("Failed to exit activation during context cleanup", exc_info=True)
483+
484+
if token:
485+
try:
486+
context.detach(token)
487+
except Exception:
488+
_logger.debug("Failed to detach token during context cleanup", exc_info=True)
489+
490+
# Clean up environment keys
491+
for key in [_ENVIRON_ACTIVATION_KEY, _ENVIRON_TOKEN, _ENVIRON_SPAN_KEY, _ENVIRON_REQCTX_REF_KEY]:
492+
wrapped_app_environ.pop(key, None)
493+
494+
except Exception:
495+
_logger.debug("Exception during context cleanup", exc_info=True)
496+
439497
def _should_trace(excluded_urls) -> bool:
440498
return bool(
441499
flask.request
@@ -537,12 +595,24 @@ def _teardown_request(exc):
537595
return
538596

539597
activation = flask.request.environ.get(_ENVIRON_ACTIVATION_KEY)
598+
token = flask.request.environ.get(_ENVIRON_TOKEN)
599+
600+
# Check if this is a response that has already been cleaned up
601+
if not activation and not token:
602+
# Already cleaned up by streaming response handler
603+
return
540604

541605
original_reqctx_ref = flask.request.environ.get(
542606
_ENVIRON_REQCTX_REF_KEY
543607
)
544-
current_reqctx_ref = _request_ctx_ref()
545-
if not activation or original_reqctx_ref != current_reqctx_ref:
608+
609+
try:
610+
current_reqctx_ref = _request_ctx_ref()
611+
except (RuntimeError, AttributeError):
612+
# Flask 3.1+ might raise exceptions when context is not available
613+
current_reqctx_ref = None
614+
615+
if not activation or (original_reqctx_ref and original_reqctx_ref != current_reqctx_ref):
546616
# This request didn't start a span, maybe because it was created in
547617
# a way that doesn't run `before_request`, like when it is created
548618
# with `app.test_request_context`.
@@ -554,15 +624,28 @@ def _teardown_request(exc):
554624
# like any decorated with `flask.copy_current_request_context`.
555625

556626
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-
)
563627

564-
if flask.request.environ.get(_ENVIRON_TOKEN, None):
565-
context.detach(flask.request.environ.get(_ENVIRON_TOKEN))
628+
try:
629+
if exc is None:
630+
activation.__exit__(None, None, None)
631+
else:
632+
activation.__exit__(
633+
type(exc), exc, getattr(exc, "__traceback__", None)
634+
)
635+
except Exception as e:
636+
_logger.debug("Failed to exit activation in teardown", exc_info=e)
637+
638+
try:
639+
if token:
640+
context.detach(token)
641+
except Exception as e:
642+
_logger.debug("Failed to detach context in teardown", exc_info=e)
643+
644+
# Clean up environment keys to prevent memory leaks
645+
flask.request.environ.pop(_ENVIRON_ACTIVATION_KEY, None)
646+
flask.request.environ.pop(_ENVIRON_TOKEN, None)
647+
flask.request.environ.pop(_ENVIRON_SPAN_KEY, None)
648+
flask.request.environ.pop(_ENVIRON_REQCTX_REF_KEY, None)
566649

567650
return _teardown_request
568651

0 commit comments

Comments
 (0)