From a85bee61387aba75f3ff42ba9cbb412524dda3a6 Mon Sep 17 00:00:00 2001 From: Punit Maheshwari Date: Thu, 18 Dec 2025 00:20:44 +0530 Subject: [PATCH] fix(middleware): Add handling for Middleware wrapped app in FastAPI --- CHANGELOG.md | 1 + .../instrumentation/fastapi/__init__.py | 25 ++++++++++++++ .../tests/test_fastapi_instrumentation.py | 33 +++++++++++++++++++ 3 files changed, 59 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d79a899ba..6de68ca7a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 > Use [this search for a list of all CHANGELOG.md files in this repo](https://github.com/search?q=repo%3Aopen-telemetry%2Fopentelemetry-python-contrib+path%3A**%2FCHANGELOG.md&type=code). ## Unreleased +- `opentelemetry-instrumentation-fastapi` Support for Middleware Wrapped FastAPI Application [#4041](https://github.com/open-telemetry/opentelemetry-python-contrib/issues/4031) ## Version 1.39.0/0.60b0 (2025-12-03) diff --git a/instrumentation/opentelemetry-instrumentation-fastapi/src/opentelemetry/instrumentation/fastapi/__init__.py b/instrumentation/opentelemetry-instrumentation-fastapi/src/opentelemetry/instrumentation/fastapi/__init__.py index 7de11cab8d..952ae152dc 100644 --- a/instrumentation/opentelemetry-instrumentation-fastapi/src/opentelemetry/instrumentation/fastapi/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-fastapi/src/opentelemetry/instrumentation/fastapi/__init__.py @@ -265,6 +265,8 @@ def instrument_app( http_capture_headers_sanitize_fields: Optional list of HTTP headers to sanitize. exclude_spans: Optionally exclude HTTP `send` and/or `receive` spans from the trace. """ + # unwraps any middleware to get to the FastAPI or Starlette app + app = _unwrap_middleware(app) if not hasattr(app, "_is_instrumented_by_opentelemetry"): app._is_instrumented_by_opentelemetry = False @@ -391,6 +393,12 @@ async def __call__( app=otel_middleware, ) + # add check if the app object has build_middleware_stack method + if not hasattr(app, "build_middleware_stack"): + _logger.error( + "Skipping FastAPI instrumentation due to missing build_middleware_stack method on app object." + ) + return app._original_build_middleware_stack = app.build_middleware_stack app.build_middleware_stack = types.MethodType( functools.wraps(app.build_middleware_stack)( @@ -409,6 +417,9 @@ async def __call__( @staticmethod def uninstrument_app(app: fastapi.FastAPI): + # Unwraps any middleware to get to the FastAPI or Starlette app + app = _unwrap_middleware(app) + original_build_middleware_stack = getattr( app, "_original_build_middleware_stack", None ) @@ -514,3 +525,17 @@ def _get_default_span_details(scope): else: # fallback span_name = method return span_name, attributes + + +def _unwrap_middleware(app): + """ + Unwraps the middleware stack to find the underlying FastAPI or Starlette app. + + Args: + app: The ASGI application potentially wrapped in middleware. + Returns: + The unwrapped FastAPI or Starlette application. + """ + while hasattr(app, "app"): + app = app.app + return app diff --git a/instrumentation/opentelemetry-instrumentation-fastapi/tests/test_fastapi_instrumentation.py b/instrumentation/opentelemetry-instrumentation-fastapi/tests/test_fastapi_instrumentation.py index d60b169fec..2ca2cfb8ce 100644 --- a/instrumentation/opentelemetry-instrumentation-fastapi/tests/test_fastapi_instrumentation.py +++ b/instrumentation/opentelemetry-instrumentation-fastapi/tests/test_fastapi_instrumentation.py @@ -25,6 +25,7 @@ import fastapi import pytest +from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.httpsredirect import HTTPSRedirectMiddleware from fastapi.responses import JSONResponse, PlainTextResponse from fastapi.routing import APIRoute @@ -1487,6 +1488,38 @@ def test_mark_span_internal_in_presence_of_span_from_other_framework(self): ) +class TestMiddlewareWrappedApplication(TestBase): + def setUp(self): + super().setUp() + self.fastapi_app = fastapi.FastAPI() + + @self.fastapi_app.get("/foobar") + async def _(): + return {"message": "hello world"} + + self.app = CORSMiddleware(self.fastapi_app, allow_origins=["*"]) + + otel_fastapi.FastAPIInstrumentor().instrument_app(self.app) + self.client = TestClient(self.app) + + def tearDown(self) -> None: + super().tearDown() + with self.disable_logging(): + otel_fastapi.FastAPIInstrumentor().uninstrument_app(self.app) + + def test_instrumentation_with_existing_middleware(self): + resp = self.client.get("/foobar") + self.assertEqual(200, resp.status_code) + + span_list = self.memory_exporter.get_finished_spans() + self.assertEqual(len(span_list), 3) + + server_span = [ + span for span in span_list if span.kind == trace.SpanKind.SERVER + ][0] + self.assertEqual(server_span.name, "GET /foobar") + + class TestFastAPIGarbageCollection(unittest.TestCase): def test_fastapi_app_is_collected_after_instrument(self): app = fastapi.FastAPI()