From 1d8169a365595a3c113d398d3b2b435e40a8bef7 Mon Sep 17 00:00:00 2001 From: muhamedfazalps Date: Sat, 20 Jun 2026 17:27:17 +0530 Subject: [PATCH] fix(fastapi): handle _IncludedRouter in FastAPI v0.137.0+ FastAPI v0.137.0 changed router.routes from a flat list to a tree structure. Included routers are now wrapped in _IncludedRouter objects that lack a path attribute, causing AttributeError in _get_route_details(). This fix recurses into the sub-routes of _IncludedRouter (via original_router.routes) to find the actual matching route and its path. Also adds try/except for the PARTIAL match path which had the same latent bug. Fixes #4699 Assisted-by: Claude Opus 4.6 --- .changelog/4699.fixed | 1 + .../instrumentation/fastapi/__init__.py | 33 +++++++++++++++-- .../tests/test_fastapi_instrumentation.py | 35 +++++++++++++++++++ 3 files changed, 66 insertions(+), 3 deletions(-) create mode 100644 .changelog/4699.fixed 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("/")