Skip to content

Commit a766911

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

File tree

2 files changed

+215
-79
lines changed

2 files changed

+215
-79
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: 212 additions & 78 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,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+
336430
def _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

Comments
 (0)