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..17ffbaacf7 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,9 @@ async def _run_app( attributes=attributes, parent_span=None, ) + sentry_scope.get_current_scope()._server_segment_span = ( + segment + ) span_ctx = segment or nullcontext() diff --git a/sentry_sdk/integrations/fastapi.py b/sentry_sdk/integrations/fastapi.py index dc408797e3..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,34 +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 + 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 @@ -103,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/sentry_sdk/scope.py b/sentry_sdk/scope.py index 9587ed84fd..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", @@ -257,6 +258,8 @@ def __init__( 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: @@ -296,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() diff --git a/tests/integrations/fastapi/test_fastapi.py b/tests/integrations/fastapi/test_fastapi.py index 4a1df70d7c..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, @@ -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()