From 563ea47d075849eac934fe506f0bbc3b12514d50 Mon Sep 17 00:00:00 2001 From: Matt Quinn Date: Wed, 26 Aug 2026 16:37:00 -0400 Subject: [PATCH] fix(starlette): Use source `url` when route matching fails --- sentry_sdk/integrations/starlette.py | 20 ++++++++------- .../integrations/starlette/test_starlette.py | 25 +++++++++++++++++++ 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/sentry_sdk/integrations/starlette.py b/sentry_sdk/integrations/starlette.py index 2eff9d2d2a..418badb680 100644 --- a/sentry_sdk/integrations/starlette.py +++ b/sentry_sdk/integrations/starlette.py @@ -843,21 +843,24 @@ async def json(self: "StarletteRequestExtractor") -> "Optional[Dict[str, Any]]": return None -def _transaction_name_from_router(scope: "StarletteScope") -> "Optional[str]": +def _transaction_name_and_source_from_router( + scope: "StarletteScope", +) -> "Tuple[Optional[str], TransactionSource]": router = scope.get("router") if not router: - return None + return None, TransactionSource.ROUTE for route in router.routes: match = route.matches(scope) if match[0] == Match.FULL: try: - return route.path + return route.path, TransactionSource.ROUTE except AttributeError: - # routes added via app.host() won't have a path attribute - return scope.get("path") + # Host routes have no path template, so fall back to the + # concrete request path and classify it as a URL. + return scope.get("path"), TransactionSource.URL - return None + return None, TransactionSource.ROUTE def _set_transaction_name_and_source( @@ -872,7 +875,7 @@ def _set_transaction_name_and_source( name = transaction_from_function(endpoint) or None elif transaction_style == "url": - name = _transaction_name_from_router(request.scope) + name, source = _transaction_name_and_source_from_router(request.scope) if name is None: name = _DEFAULT_TRANSACTION_NAME @@ -891,7 +894,6 @@ def _get_transaction_from_middleware( name = transaction_from_function(app.__class__) source = TransactionSource.COMPONENT elif integration.transaction_style == "url": - name = _transaction_name_from_router(asgi_scope) - source = TransactionSource.ROUTE + name, source = _transaction_name_and_source_from_router(asgi_scope) return name, source diff --git a/tests/integrations/starlette/test_starlette.py b/tests/integrations/starlette/test_starlette.py index 2b1cedf1bb..88339a8c2f 100644 --- a/tests/integrations/starlette/test_starlette.py +++ b/tests/integrations/starlette/test_starlette.py @@ -1051,6 +1051,31 @@ def test_transaction_style( assert event["transaction_info"] == {"source": expected_source} +def test_host_route_path_has_url_source(sentry_init, capture_events): + sentry_init( + integrations=[StarletteIntegration(transaction_style="url")], + traces_sample_rate=1.0, + ) + + async def hosted_endpoint(request): + return starlette.responses.JSONResponse({"status": "ok"}) + + subapp = starlette.applications.Starlette( + routes=[starlette.routing.Route("/users/{user_id}", hosted_endpoint)] + ) + app = starlette.applications.Starlette( + routes=[starlette.routing.Host("subapp", subapp)] + ) + + events = capture_events() + client = TestClient(app) + client.get("/users/123456", headers={"Host": "subapp"}) + + (event,) = events + assert event["transaction"].endswith("/users/123456") + assert event["transaction_info"] == {"source": "url"} + + @pytest.mark.parametrize( "test_url,expected_error,expected_message", [