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 97854830c2..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,7 +191,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, Receive, Scope, Send from opentelemetry.instrumentation._semconv import ( @@ -474,7 +474,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: try: route = starlette_route.path diff --git a/instrumentation/opentelemetry-instrumentation-fastapi/tests/test_fastapi_instrumentation.py b/instrumentation/opentelemetry-instrumentation-fastapi/tests/test_fastapi_instrumentation.py index a97697f582..661c7097cd 100644 --- a/instrumentation/opentelemetry-instrumentation-fastapi/tests/test_fastapi_instrumentation.py +++ b/instrumentation/opentelemetry-instrumentation-fastapi/tests/test_fastapi_instrumentation.py @@ -20,14 +20,17 @@ 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 import pytest from fastapi.middleware.httpsredirect import HTTPSRedirectMiddleware from fastapi.responses import JSONResponse, PlainTextResponse +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 @@ -131,6 +134,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() @@ -191,6 +211,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 @@ -210,6 +231,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 _(): @@ -235,6 +257,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) app.host("testserver2", sub_app) @@ -313,6 +341,28 @@ 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: 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") client.get("/") @@ -1017,6 +1067,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 _(): @@ -1042,6 +1093,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