From c9b4831063287bd7704f095337c3ca843de190d4 Mon Sep 17 00:00:00 2001 From: "Anthony T. Lannutti" Date: Tue, 5 Aug 2025 16:58:21 -0500 Subject: [PATCH 1/3] fix(fastapi): issue with APIRoute subclasses Fixes: #3671 When an APIRoute subclass would overwrite the matches method with an implementation that depended on non-standard fields existing on the HTTP connection scope, this would cause a failure when the OpenTelemetryMiddleware tried to get the default span details for the incoming request. This has been fixed by using the matches implementation on the Route class for any subclass of Route. This should be sufficient since the only information we are trying to get from that method is the path for the request. --- .../src/opentelemetry/instrumentation/fastapi/__init__.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 8ba83985c6..f9925349f9 100644 --- a/instrumentation/opentelemetry-instrumentation-fastapi/src/opentelemetry/instrumentation/fastapi/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-fastapi/src/opentelemetry/instrumentation/fastapi/__init__.py @@ -190,7 +190,7 @@ def client_response_hook(span: Span, scope: dict[str, Any], message: dict[str, A import fastapi from starlette.applications import Starlette from starlette.middleware.errors import ServerErrorMiddleware -from starlette.routing import Match +from starlette.routing import Match, Route from starlette.types import ASGIApp from opentelemetry.instrumentation._semconv import ( @@ -448,7 +448,11 @@ def _get_route_details(scope): route = None for starlette_route in app.routes: - match, _ = starlette_route.matches(scope) + match, _ = ( + Route.matches(starlette_route, scope) + if isinstance(starlette_route, Route) + else starlette_route.matches(scope) + ) if match == Match.FULL: route = starlette_route.path break From 8878698b149475f973265432a1c0ac64987465a8 Mon Sep 17 00:00:00 2001 From: "Anthony T. Lannutti" Date: Tue, 5 Aug 2025 17:45:05 -0500 Subject: [PATCH 2/3] test(fastapi): added tests for custom api route implementation fix This commit adds tests that illustrate the original issue that was being experienced for custom api route implementations when they depended on non-standard fields existing on the ASGI HTTP connection scope. Before the fix was implemented, the inclusion of a custom API route in the FastAPI application would cause an exception to be raised inside the OpenTelemetryMiddleware since the non-standard fields do not exist on the ASGI HTTP connection scope until after the subsequent middleware runs and adds the expected fields. --- .../tests/test_fastapi_instrumentation.py | 47 +++++++++++++++++-- 1 file changed, 44 insertions(+), 3 deletions(-) diff --git a/instrumentation/opentelemetry-instrumentation-fastapi/tests/test_fastapi_instrumentation.py b/instrumentation/opentelemetry-instrumentation-fastapi/tests/test_fastapi_instrumentation.py index 523c165f85..8a107089c7 100644 --- a/instrumentation/opentelemetry-instrumentation-fastapi/tests/test_fastapi_instrumentation.py +++ b/instrumentation/opentelemetry-instrumentation-fastapi/tests/test_fastapi_instrumentation.py @@ -22,7 +22,10 @@ import fastapi from fastapi.middleware.httpsredirect import HTTPSRedirectMiddleware from fastapi.responses import JSONResponse +from fastapi.routing import APIRoute from fastapi.testclient import TestClient +from starlette.routing import Match +from starlette.types import Receive, Scope, Send import opentelemetry.instrumentation.fastapi as otel_fastapi from opentelemetry import trace @@ -38,9 +41,7 @@ from opentelemetry.instrumentation.auto_instrumentation._load import ( _load_instrumentors, ) -from opentelemetry.instrumentation.dependencies import ( - DependencyConflict, -) +from opentelemetry.instrumentation.dependencies import DependencyConflict from opentelemetry.sdk.metrics.export import ( HistogramDataPoint, NumberDataPoint, @@ -123,6 +124,23 @@ ) +class CustomMiddleware: + def __init__(self, app: fastapi.FastAPI) -> None: + self.app = app + + async def __call__( + self, scope: Scope, receive: Receive, send: Send + ) -> None: + scope["nonstandard_field"] = "here" + await self.app(scope, receive, send) + + +class CustomRoute(APIRoute): + def matches(self, scope: Scope) -> tuple[Match, Scope]: + assert "nonstandard_field" in scope + return super().matches(scope) + + class TestBaseFastAPI(TestBase): def _create_app(self): app = self._create_fastapi_app() @@ -183,6 +201,7 @@ def setUp(self): self._instrumentor = otel_fastapi.FastAPIInstrumentor() self._app = self._create_app() self._app.add_middleware(HTTPSRedirectMiddleware) + self._app.add_middleware(CustomMiddleware) self._client = TestClient(self._app, base_url="https://testserver:443") # run the lifespan, initialize the middleware stack # this is more in-line with what happens in a real application when the server starts up @@ -202,6 +221,7 @@ def tearDown(self): def _create_fastapi_app(): app = fastapi.FastAPI() sub_app = fastapi.FastAPI() + custom_router = fastapi.APIRouter(route_class=CustomRoute) @sub_app.get("/home") async def _(): @@ -227,6 +247,12 @@ async def _(): async def _(): raise UnhandledException("This is an unhandled exception") + @custom_router.get("/success") + async def _(): + return None + + app.include_router(custom_router, prefix="/custom-router") + app.mount("/sub", app=sub_app) return app @@ -304,6 +330,14 @@ def test_sub_app_fastapi_call(self): span.attributes[HTTP_URL], ) + def test_custom_api_router(self): + """ + This test is to ensure that custom API routers the OpenTelemetryMiddleware does not cause issues with + custom API routers that depend on non-standard fields on the ASGI scope. + """ + resp = self._client.get("/custom-router/success") + self.assertEqual(resp.status_code, 200) + class TestBaseAutoFastAPI(TestBaseFastAPI): @classmethod @@ -988,6 +1022,7 @@ def test_metric_uninstrument(self): def _create_fastapi_app(): app = fastapi.FastAPI() sub_app = fastapi.FastAPI() + custom_router = fastapi.APIRouter(route_class=CustomRoute) @sub_app.get("/home") async def _(): @@ -1013,6 +1048,12 @@ async def _(): async def _(): raise UnhandledException("This is an unhandled exception") + @custom_router.get("/success") + async def _(): + return None + + app.include_router(custom_router, prefix="/custom-router") + app.mount("/sub", app=sub_app) return app From fcca86cfd99b4351b0a9d76fea871558bada2c06 Mon Sep 17 00:00:00 2001 From: "Anthony T. Lannutti" Date: Wed, 1 Oct 2025 22:25:37 -0500 Subject: [PATCH 3/3] test(fastapi): added span assertions for custom api route tests --- .../instrumentation/fastapi/__init__.py | 2 -- .../tests/test_fastapi_instrumentation.py | 22 ++++++++++++++----- 2 files changed, 17 insertions(+), 7 deletions(-) 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 9eff081e4a..23f6470ef7 100644 --- a/instrumentation/opentelemetry-instrumentation-fastapi/src/opentelemetry/instrumentation/fastapi/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-fastapi/src/opentelemetry/instrumentation/fastapi/__init__.py @@ -191,11 +191,9 @@ def client_response_hook(span: Span, scope: dict[str, Any], message: dict[str, A import fastapi from starlette.applications import Starlette from starlette.middleware.errors import ServerErrorMiddleware - from starlette.routing import Match, Route from starlette.types import ASGIApp, Receive, Scope, Send - from opentelemetry.instrumentation._semconv import ( _get_schema_url, _OpenTelemetrySemanticConventionStability, diff --git a/instrumentation/opentelemetry-instrumentation-fastapi/tests/test_fastapi_instrumentation.py b/instrumentation/opentelemetry-instrumentation-fastapi/tests/test_fastapi_instrumentation.py index 9d8892b3fc..661c7097cd 100644 --- a/instrumentation/opentelemetry-instrumentation-fastapi/tests/test_fastapi_instrumentation.py +++ b/instrumentation/opentelemetry-instrumentation-fastapi/tests/test_fastapi_instrumentation.py @@ -20,7 +20,7 @@ import weakref as _weakref from contextlib import ExitStack from timeit import default_timer -from typing import Any, cast +from typing import Any, Final, cast from unittest.mock import Mock, call, patch import fastapi @@ -341,15 +341,27 @@ def test_sub_app_fastapi_call(self): span.attributes[HTTP_URL], ) - def test_custom_api_router(self): """ This test is to ensure that custom API routers the OpenTelemetryMiddleware does not cause issues with custom API routers that depend on non-standard fields on the ASGI scope. """ - resp = self._client.get("/custom-router/success") - self.assertEqual(resp.status_code, 200) - + resp: Final = self._client.get("/custom-router/success") + spans: Final = self.memory_exporter.get_finished_spans() + spans_with_http_attributes = [ + span + for span in spans + if (HTTP_URL in span.attributes or HTTP_TARGET in span.attributes) + ] + self.assertEqual(200, resp.status_code) + for span in spans_with_http_attributes: + self.assertEqual( + "/custom-router/success", span.attributes[HTTP_TARGET] + ) + self.assertEqual( + "https://testserver/custom-router/success", + span.attributes[HTTP_URL], + ) def test_host_fastapi_call(self): client = TestClient(self._app, base_url="https://testserver2:443")