From 86d26ce1b8d24299bcc17a074744bbe19d812732 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Voron?= Date: Fri, 29 Aug 2025 17:15:57 +0200 Subject: [PATCH] starlette/fastapi: fix error on host-based routing (#3507) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * starlette/fastapi: fix error on host-based routing Fix #3506 * Update CHANGELOG.md * Update instrumentation/opentelemetry-instrumentation-starlette/tests/test_starlette_instrumentation.py * Update CHANGELOG.md --------- Co-authored-by: Riccardo Magliocchetti Co-authored-by: Emídio Neto <9735060+emdneto@users.noreply.github.com> --- CHANGELOG.md | 2 ++ .../instrumentation/fastapi/__init__.py | 6 +++++- .../tests/test_fastapi_instrumentation.py | 21 +++++++++++++++++++ .../instrumentation/starlette/__init__.py | 6 +++++- .../tests/test_starlette_instrumentation.py | 21 ++++++++++++++++++- 5 files changed, 53 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b2102a59..0c0f78819 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ([#3679](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3679)) - `opentelemetry-instrumentation`: Avoid calls to `context.detach` with `None` token. ([#3673](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3673)) +- `opentelemetry-instrumentation-starlette`/`opentelemetry-instrumentation-fastapi`: Fixes a crash when host-based routing is used + ([#3507](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3507)) ### Added 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 c5c87f987..87ab1b2e7 100644 --- a/instrumentation/opentelemetry-instrumentation-fastapi/src/opentelemetry/instrumentation/fastapi/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-fastapi/src/opentelemetry/instrumentation/fastapi/__init__.py @@ -516,7 +516,11 @@ def _get_route_details(scope): for starlette_route in app.routes: match, _ = starlette_route.matches(scope) if match == Match.FULL: - route = starlette_route.path + try: + route = starlette_route.path + except AttributeError: + # routes added via host routing won't have a path attribute + route = scope.get("path") break if match == Match.PARTIAL: 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 08a4668f5..ffc85b35b 100644 --- a/instrumentation/opentelemetry-instrumentation-fastapi/tests/test_fastapi_instrumentation.py +++ b/instrumentation/opentelemetry-instrumentation-fastapi/tests/test_fastapi_instrumentation.py @@ -234,6 +234,7 @@ class TestBaseFastAPI(TestBase): raise UnhandledException("This is an unhandled exception") app.mount("/sub", app=sub_app) + app.host("testserver2", sub_app) return app @@ -310,6 +311,26 @@ class TestBaseManualFastAPI(TestBaseFastAPI): span.attributes[HTTP_URL], ) + def test_host_fastapi_call(self): + client = TestClient(self._app, base_url="https://testserver2") + client.get("/") + spans = 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(1, len(spans_with_http_attributes)) + + for span in spans_with_http_attributes: + self.assertEqual("/", span.attributes[HTTP_TARGET]) + self.assertEqual( + "https://testserver2:443/", + span.attributes[HTTP_URL], + ) + class TestBaseAutoFastAPI(TestBaseFastAPI): @classmethod diff --git a/instrumentation/opentelemetry-instrumentation-starlette/src/opentelemetry/instrumentation/starlette/__init__.py b/instrumentation/opentelemetry-instrumentation-starlette/src/opentelemetry/instrumentation/starlette/__init__.py index 3a88582ec..93f242432 100644 --- a/instrumentation/opentelemetry-instrumentation-starlette/src/opentelemetry/instrumentation/starlette/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-starlette/src/opentelemetry/instrumentation/starlette/__init__.py @@ -354,7 +354,11 @@ def _get_route_details(scope: dict[str, Any]) -> str | None: for starlette_route in app.routes: match, _ = starlette_route.matches(scope) if match == Match.FULL: - route = starlette_route.path + try: + route = starlette_route.path + except AttributeError: + # routes added via host routing won't have a path attribute + route = scope.get("path") break if match == Match.PARTIAL: route = starlette_route.path diff --git a/instrumentation/opentelemetry-instrumentation-starlette/tests/test_starlette_instrumentation.py b/instrumentation/opentelemetry-instrumentation-starlette/tests/test_starlette_instrumentation.py index 17f79073a..8c326e83b 100644 --- a/instrumentation/opentelemetry-instrumentation-starlette/tests/test_starlette_instrumentation.py +++ b/instrumentation/opentelemetry-instrumentation-starlette/tests/test_starlette_instrumentation.py @@ -18,7 +18,7 @@ from unittest.mock import patch from starlette import applications from starlette.responses import PlainTextResponse -from starlette.routing import Mount, Route +from starlette.routing import Host, Mount, Route from starlette.testclient import TestClient from starlette.websockets import WebSocket @@ -140,6 +140,24 @@ class TestStarletteManualInstrumentation(TestBase): span.attributes[HTTP_URL], ) + def test_host_starlette_call(self): + client = TestClient(self._app, base_url="http://testserver2") + client.get("/home") + spans = 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) + ] + + for span in spans_with_http_attributes: + self.assertEqual("/home", span.attributes[HTTP_TARGET]) + self.assertEqual( + "http://testserver2/home", + span.attributes[HTTP_URL], + ) + def test_starlette_route_attribute_added(self): """Ensure that starlette routes are used as the span name.""" self._client.get("/user/123") @@ -294,6 +312,7 @@ class TestStarletteManualInstrumentation(TestBase): Route("/user/{username}", home), Route("/healthzz", health), Mount("/sub", app=sub_app), + Host("testserver2", sub_app), ], )