From 9811845f3a7af2bcb1d564a413b3cacecad8cfbe Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Tue, 1 Sep 2026 09:15:41 +0200 Subject: [PATCH 1/6] feat(fastapi): Add http.route attribute --- sentry_sdk/consts.py | 6 ++++++ sentry_sdk/integrations/asgi.py | 3 +++ sentry_sdk/integrations/fastapi.py | 2 ++ sentry_sdk/scope.py | 1 + 4 files changed, 12 insertions(+) diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index 82a9f27ec8..c9323623d0 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -878,6 +878,12 @@ class SPANDATA: Example: GET """ + HTTP_ROUTE = "http.route" + """ + The matched route, that is, the path template used to match the request. + Example: /users/{id} + """ + HTTP_QUERY = "http.query" """ The Query string present in the URL. diff --git a/sentry_sdk/integrations/asgi.py b/sentry_sdk/integrations/asgi.py index d594c3504d..a0f1c4d819 100644 --- a/sentry_sdk/integrations/asgi.py +++ b/sentry_sdk/integrations/asgi.py @@ -412,6 +412,9 @@ async def _sentry_wrapped_send( span.set_attribute( "sentry.segment.name.source", source ) + http_route = getattr(sentry_sdk.get_current_scope(), "_http_route", None) + if http_route is not None: + span.set_attribute(SPANDATA.HTTP_ROUTE, http_route) finally: _asgi_middleware_applied.set(False) diff --git a/sentry_sdk/integrations/fastapi.py b/sentry_sdk/integrations/fastapi.py index dc408797e3..d40ce76c77 100644 --- a/sentry_sdk/integrations/fastapi.py +++ b/sentry_sdk/integrations/fastapi.py @@ -79,6 +79,8 @@ def _set_transaction_name_and_source( if path is not None: name = path + scope._http_route = route + if not name: name = _DEFAULT_TRANSACTION_NAME source = TransactionSource.ROUTE diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index 9587ed84fd..7ce13a093f 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -240,6 +240,7 @@ class Scope: "_last_event_id", "_flags", "_attributes", + "_http_route", ) def __init__( From f900aee9090553be7f8f92bfb535da58c104ff21 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Tue, 1 Sep 2026 09:35:02 +0200 Subject: [PATCH 2/6] . --- sentry_sdk/integrations/asgi.py | 2 +- sentry_sdk/integrations/fastapi.py | 2 +- sentry_sdk/scope.py | 2 ++ tests/integrations/fastapi/test_fastapi.py | 1 + 4 files changed, 5 insertions(+), 2 deletions(-) diff --git a/sentry_sdk/integrations/asgi.py b/sentry_sdk/integrations/asgi.py index a0f1c4d819..3236f1db4c 100644 --- a/sentry_sdk/integrations/asgi.py +++ b/sentry_sdk/integrations/asgi.py @@ -412,7 +412,7 @@ async def _sentry_wrapped_send( span.set_attribute( "sentry.segment.name.source", source ) - http_route = getattr(sentry_sdk.get_current_scope(), "_http_route", None) + http_route = sentry_scope._http_route if http_route is not None: span.set_attribute(SPANDATA.HTTP_ROUTE, http_route) finally: diff --git a/sentry_sdk/integrations/fastapi.py b/sentry_sdk/integrations/fastapi.py index d40ce76c77..5281d803d8 100644 --- a/sentry_sdk/integrations/fastapi.py +++ b/sentry_sdk/integrations/fastapi.py @@ -79,7 +79,7 @@ def _set_transaction_name_and_source( if path is not None: name = path - scope._http_route = route + sentry_sdk.get_isolation_scope()._http_route = name if not name: name = _DEFAULT_TRANSACTION_NAME diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index 7ce13a093f..071a3f865b 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -254,6 +254,7 @@ def __init__( self._error_processors: "List[ErrorProcessor]" = [] self._name: "Optional[str]" = None + self._http_route: "Optional[str]" = None self._propagation_context: "Optional[PropagationContext]" = None self._n_breadcrumbs_truncated: int = 0 self._gen_ai_original_message_count: "Dict[str, int]" = {} @@ -279,6 +280,7 @@ def __copy__(self) -> "Scope": rv.client = self.client rv._level = self._level rv._name = self._name + rv._http_route = self._http_route rv._fingerprint = self._fingerprint rv._transaction = self._transaction rv._transaction_info = self._transaction_info.copy() diff --git a/tests/integrations/fastapi/test_fastapi.py b/tests/integrations/fastapi/test_fastapi.py index 4a1df70d7c..8e5d7f5ca9 100644 --- a/tests/integrations/fastapi/test_fastapi.py +++ b/tests/integrations/fastapi/test_fastapi.py @@ -883,6 +883,7 @@ async def get_user(user_id: int): segment = segments[0] assert segment["name"] == "/api/users/{user_id}" assert segment["attributes"]["sentry.segment.name.source"] == "route" + assert segment["attributes"]["http.route"] == "/api/users/{user_id}" else: (transaction_envelope,) = envelopes transaction_event = transaction_envelope.get_transaction_event() From 04559a4a9309ea4f929792cdb8a25ae4b98feae7 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Tue, 1 Sep 2026 09:35:51 +0200 Subject: [PATCH 3/6] . --- sentry_sdk/scope.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index 071a3f865b..c572782de5 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -254,7 +254,7 @@ def __init__( self._error_processors: "List[ErrorProcessor]" = [] self._name: "Optional[str]" = None - self._http_route: "Optional[str]" = None + self._http_route: "Optional[str]" = None self._propagation_context: "Optional[PropagationContext]" = None self._n_breadcrumbs_truncated: int = 0 self._gen_ai_original_message_count: "Dict[str, int]" = {} From e149db8ab827d53317fc44e8bab57d45ce0903a4 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Tue, 1 Sep 2026 13:55:40 +0200 Subject: [PATCH 4/6] store span on scope instead of attribute --- sentry_sdk/integrations/asgi.py | 5 ++--- sentry_sdk/integrations/fastapi.py | 4 +++- sentry_sdk/scope.py | 7 ++++--- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/sentry_sdk/integrations/asgi.py b/sentry_sdk/integrations/asgi.py index 3236f1db4c..32940004ce 100644 --- a/sentry_sdk/integrations/asgi.py +++ b/sentry_sdk/integrations/asgi.py @@ -281,6 +281,7 @@ async def _run_app( attributes=attributes, parent_span=None, ) + sentry_scope.get_current_scope()._server_segment_span = segment else: sentry_sdk.traces.new_trace() @@ -292,6 +293,7 @@ async def _run_app( attributes=attributes, parent_span=None, ) + sentry_scope.get_current_scope()._server_segment_span = segment span_ctx = segment or nullcontext() @@ -412,9 +414,6 @@ async def _sentry_wrapped_send( span.set_attribute( "sentry.segment.name.source", source ) - http_route = sentry_scope._http_route - if http_route is not None: - span.set_attribute(SPANDATA.HTTP_ROUTE, http_route) finally: _asgi_middleware_applied.set(False) diff --git a/sentry_sdk/integrations/fastapi.py b/sentry_sdk/integrations/fastapi.py index 5281d803d8..6f43f515b3 100644 --- a/sentry_sdk/integrations/fastapi.py +++ b/sentry_sdk/integrations/fastapi.py @@ -79,7 +79,9 @@ def _set_transaction_name_and_source( if path is not None: name = path - sentry_sdk.get_isolation_scope()._http_route = name + server_span = sentry_sdk.get_current_scope()._server_segment_span + if server_span is not None: + server_span.set_attribute(SPANDATA.HTTP_ROUTE, name) if not name: name = _DEFAULT_TRANSACTION_NAME diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index c572782de5..4d547a3f48 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -230,6 +230,7 @@ class Scope: "_error_processors", "_should_capture", "_span", + "_server_segment_span", "_session", "_attachments", "_force_auto_session_tracking", @@ -240,7 +241,6 @@ class Scope: "_last_event_id", "_flags", "_attributes", - "_http_route", ) def __init__( @@ -254,11 +254,12 @@ def __init__( self._error_processors: "List[ErrorProcessor]" = [] self._name: "Optional[str]" = None - self._http_route: "Optional[str]" = None self._propagation_context: "Optional[PropagationContext]" = None self._n_breadcrumbs_truncated: int = 0 self._gen_ai_original_message_count: "Dict[str, int]" = {} + self._server_segment_span: "Optional[StreamedSpan]" = None + self.client: "sentry_sdk.client.BaseClient" = NonRecordingClient() if client is not None: @@ -280,7 +281,6 @@ def __copy__(self) -> "Scope": rv.client = self.client rv._level = self._level rv._name = self._name - rv._http_route = self._http_route rv._fingerprint = self._fingerprint rv._transaction = self._transaction rv._transaction_info = self._transaction_info.copy() @@ -299,6 +299,7 @@ def __copy__(self) -> "Scope": rv._should_capture = self._should_capture rv._span = self._span + rv._server_segment_span = self._server_segment_span rv._session = self._session rv._force_auto_session_tracking = self._force_auto_session_tracking rv._attachments = self._attachments.copy() From f633dc4e3e89514b991ab856f1178122e8fd83f8 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Tue, 1 Sep 2026 13:57:09 +0200 Subject: [PATCH 5/6] run pre-commit --- sentry_sdk/integrations/asgi.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/sentry_sdk/integrations/asgi.py b/sentry_sdk/integrations/asgi.py index 32940004ce..17ffbaacf7 100644 --- a/sentry_sdk/integrations/asgi.py +++ b/sentry_sdk/integrations/asgi.py @@ -293,7 +293,9 @@ async def _run_app( attributes=attributes, parent_span=None, ) - sentry_scope.get_current_scope()._server_segment_span = segment + sentry_scope.get_current_scope()._server_segment_span = ( + segment + ) span_ctx = segment or nullcontext() From 9fdf50346b73b080950f61dcd37af99283f019a3 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Wed, 2 Sep 2026 10:13:21 +0200 Subject: [PATCH 6/6] adapt _set_transaction_name_and_source and rename test --- sentry_sdk/integrations/fastapi.py | 69 ++++++++++++---------- tests/integrations/fastapi/test_fastapi.py | 2 +- 2 files changed, 39 insertions(+), 32 deletions(-) diff --git a/sentry_sdk/integrations/fastapi.py b/sentry_sdk/integrations/fastapi.py index 6f43f515b3..6a7c543c8c 100644 --- a/sentry_sdk/integrations/fastapi.py +++ b/sentry_sdk/integrations/fastapi.py @@ -12,7 +12,7 @@ from sentry_sdk.utils import has_data_collection_enabled, transaction_from_function if TYPE_CHECKING: - from typing import Any, Awaitable, Callable, Dict + from typing import Any, Awaitable, Callable, Dict, Optional from sentry_sdk._types import Event @@ -50,38 +50,18 @@ def setup_once() -> None: def _set_transaction_name_and_source( - scope: "sentry_sdk.Scope", transaction_style: str, request: "Any" + scope: "sentry_sdk.Scope", + transaction_style: str, + endpoint: "Optional[Callable[..., Any]]", + route_path: "Optional[str]", ) -> None: name = "" - if transaction_style == "endpoint": - endpoint = request.scope.get("endpoint") - if endpoint: - name = transaction_from_function(endpoint) or "" - - elif transaction_style == "url": - route = request.scope.get("route") - - if route: - # FastAPI >= 0.137 stores the prefix-resolved path on an - # effective_route_context in scope["fastapi"], while - # scope["route"].path holds the unprefixed original. - # Prefer the effective context path when available. - effective_route_context = request.scope.get("fastapi", {}).get( - "effective_route_context" - ) - context_path = getattr(effective_route_context, "path", None) - - if context_path: - name = context_path - else: - path = getattr(route, "path", None) - if path is not None: - name = path - - server_span = sentry_sdk.get_current_scope()._server_segment_span - if server_span is not None: - server_span.set_attribute(SPANDATA.HTTP_ROUTE, name) + if transaction_style == "endpoint" and endpoint: + name = transaction_from_function(endpoint) or "" + + elif transaction_style == "url" and route_path is not None: + name = route_path if not name: name = _DEFAULT_TRANSACTION_NAME @@ -107,8 +87,35 @@ async def _wrap_async_handler( request = args[0] + route = request.scope.get("route") + + route_path = None + if route: + # FastAPI >= 0.137 stores the prefix-resolved path on an + # effective_route_context in scope["fastapi"], while + # scope["route"].path holds the unprefixed original. + # Prefer the effective context path when available. + effective_route_context = request.scope.get("fastapi", {}).get( + "effective_route_context" + ) + context_path = getattr(effective_route_context, "path", None) + + if context_path: + route_path = context_path + else: + path = getattr(route, "path", None) + if path is not None: + route_path = path + + server_span = sentry_sdk.get_current_scope()._server_segment_span + if server_span is not None and route_path is not None: + server_span.set_attribute(SPANDATA.HTTP_ROUTE, route_path) + _set_transaction_name_and_source( - sentry_sdk.get_current_scope(), integration.transaction_style, request + sentry_sdk.get_current_scope(), + integration.transaction_style, + endpoint=request.scope.get("endpoint"), + route_path=route_path, ) sentry_scope = sentry_sdk.get_isolation_scope() extractor = StarletteRequestExtractor(request) diff --git a/tests/integrations/fastapi/test_fastapi.py b/tests/integrations/fastapi/test_fastapi.py index 8e5d7f5ca9..7bf319d9f6 100644 --- a/tests/integrations/fastapi/test_fastapi.py +++ b/tests/integrations/fastapi/test_fastapi.py @@ -843,7 +843,7 @@ def test_transaction_name( @pytest.mark.parametrize("span_streaming", [True, False]) -def test_transaction_name_with_prefix( +def test_http_route_with_prefix( sentry_init, capture_envelopes, capture_items,