diff --git a/.changelog/4699.fixed b/.changelog/4699.fixed new file mode 100644 index 0000000000..d23f900187 --- /dev/null +++ b/.changelog/4699.fixed @@ -0,0 +1 @@ +`opentelemetry-instrumentation-fastapi`: fix ``AttributeError`` for ``_IncludedRouter`` in FastAPI v0.137.0+ 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 ab936f0ed5..a3e3212c45 100644 --- a/instrumentation/opentelemetry-instrumentation-fastapi/src/opentelemetry/instrumentation/fastapi/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-fastapi/src/opentelemetry/instrumentation/fastapi/__init__.py @@ -488,11 +488,38 @@ def _get_route_details(scope): try: route = starlette_route.path except AttributeError: - # routes added via host routing won't have a path attribute - route = scope.get("path") + # FastAPI v0.137.0+ wraps included routers in + # _IncludedRouter objects that lack a path attribute. + # Recurse into the sub-routes to find the actual route. + sub_routes = getattr(starlette_route, "routes", None) + if sub_routes is None and hasattr( + starlette_route, "original_router" + ): + sub_routes = getattr( + starlette_route.original_router, "routes", [] + ) + if sub_routes: + for sub in sub_routes: + sub_match, _ = ( + Route.matches(sub, scope) + if isinstance(sub, Route) + else sub.matches(scope) + ) + if sub_match == Match.FULL: + try: + route = sub.path + except AttributeError: + route = scope.get("path") + break + else: + # routes added via host routing won't have a path attribute + route = scope.get("path") break if match == Match.PARTIAL: - route = starlette_route.path + try: + route = starlette_route.path + except AttributeError: + route = scope.get("path") return route diff --git a/instrumentation/opentelemetry-instrumentation-fastapi/tests/test_fastapi_instrumentation.py b/instrumentation/opentelemetry-instrumentation-fastapi/tests/test_fastapi_instrumentation.py index 991aec902f..7d38e77248 100644 --- a/instrumentation/opentelemetry-instrumentation-fastapi/tests/test_fastapi_instrumentation.py +++ b/instrumentation/opentelemetry-instrumentation-fastapi/tests/test_fastapi_instrumentation.py @@ -358,6 +358,41 @@ def test_custom_api_router(self): span.attributes[HTTP_URL], ) + def test_included_router_route_attribute(self): + """Ensure that routes from included routers resolve the correct HTTP_ROUTE. + + FastAPI v0.137.0+ wraps included routers in _IncludedRouter objects + that do not have a path attribute. The instrumentation must recurse + into the sub-routes to find the actual route path. + See: https://github.com/open-telemetry/opentelemetry-python-contrib/issues/4699 + """ + from fastapi import APIRouter + + app = fastapi.FastAPI() + router = APIRouter(prefix="/items") + + @router.get("/{item_id}") + async def get_item(item_id: int): + return {"item_id": item_id} + + app.include_router(router) + otel_fastapi.FastAPIInstrumentor.instrument_app(app) + + client = TestClient(app) + resp = client.get("/items/42") + self.assertEqual(resp.status_code, 200) + + spans = self.memory_exporter.get_finished_spans() + server_spans = [ + span + for span in spans + if HTTP_ROUTE in span.attributes + ] + self.assertTrue(len(server_spans) > 0) + self.assertEqual( + server_spans[-1].attributes[HTTP_ROUTE], "/items/{item_id}" + ) + def test_host_fastapi_call(self): client = TestClient(self._app, base_url="https://testserver2:443") client.get("/")