From b1363a59e0b70fc7c9dd6260173559c8d5bb37ff Mon Sep 17 00:00:00 2001 From: Matt Quinn Date: Wed, 12 Aug 2026 13:15:32 -0400 Subject: [PATCH] fix(spans): Add `http.route` attribute to HTTP server spans --- sentry_sdk/consts.py | 6 ++++ sentry_sdk/integrations/asgi.py | 19 ++++++++++-- sentry_sdk/integrations/django/__init__.py | 2 ++ sentry_sdk/integrations/fastapi.py | 2 ++ sentry_sdk/integrations/flask.py | 12 ++++---- sentry_sdk/integrations/starlette.py | 2 ++ sentry_sdk/scope.py | 11 +++++++ tests/integrations/asgi/test_asgi.py | 35 ++++++++++++++++++++++ tests/integrations/django/test_basic.py | 4 +++ tests/integrations/fastapi/test_fastapi.py | 1 + tests/integrations/flask/test_flask.py | 4 +++ tests/tracing/test_span_streaming.py | 24 +++++++++++++++ 12 files changed, 114 insertions(+), 8 deletions(-) diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index b01fd32e15..61b28d8bcb 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..084d1133fa 100644 --- a/sentry_sdk/integrations/asgi.py +++ b/sentry_sdk/integrations/asgi.py @@ -246,13 +246,19 @@ async def _run_app( span_ctx: "ContextManager[Union[Span, StreamedSpan, None]]" if span_streaming: segment: "Optional[StreamedSpan]" = None + segment_source = getattr( + transaction_source, "value", transaction_source + ) attributes: "Attributes" = { - "sentry.segment.name.source": getattr( - transaction_source, "value", transaction_source - ), + "sentry.segment.name.source": segment_source, "sentry.origin": self.span_origin, "network.protocol.name": ty, } + if ( + segment_source == SegmentNameSource.ROUTE.value + and transaction_name != _DEFAULT_TRANSACTION_NAME + ): + attributes[SPANDATA.HTTP_ROUTE] = transaction_name if scope.get("client"): client_options = sentry_sdk.get_client().options @@ -412,6 +418,13 @@ async def _sentry_wrapped_send( span.set_attribute( "sentry.segment.name.source", source ) + if ( + source == SegmentNameSource.ROUTE.value + and name != _DEFAULT_TRANSACTION_NAME + ): + span.set_attribute( + SPANDATA.HTTP_ROUTE, name + ) finally: _asgi_middleware_applied.set(False) diff --git a/sentry_sdk/integrations/django/__init__.py b/sentry_sdk/integrations/django/__init__.py index 6068df2587..9cb7c57f24 100644 --- a/sentry_sdk/integrations/django/__init__.py +++ b/sentry_sdk/integrations/django/__init__.py @@ -448,6 +448,8 @@ def _set_transaction_name_and_source( source = TransactionSource.URL else: source = SOURCE_FOR_STYLE[transaction_style] + if source == TransactionSource.ROUTE: + scope.set_segment_attribute(SPANDATA.HTTP_ROUTE, transaction_name) scope.set_transaction_name( transaction_name, diff --git a/sentry_sdk/integrations/fastapi.py b/sentry_sdk/integrations/fastapi.py index 0b048e11ee..36e6dab1b6 100644 --- a/sentry_sdk/integrations/fastapi.py +++ b/sentry_sdk/integrations/fastapi.py @@ -84,6 +84,8 @@ def _set_transaction_name_and_source( source = TransactionSource.ROUTE else: source = SOURCE_FOR_STYLE[transaction_style] + if source == TransactionSource.ROUTE: + scope.set_segment_attribute(SPANDATA.HTTP_ROUTE, name) scope.set_transaction_name(name, source=source) diff --git a/sentry_sdk/integrations/flask.py b/sentry_sdk/integrations/flask.py index 4baa65b183..e1964b0ea1 100644 --- a/sentry_sdk/integrations/flask.py +++ b/sentry_sdk/integrations/flask.py @@ -1,6 +1,7 @@ from typing import TYPE_CHECKING import sentry_sdk +from sentry_sdk.consts import SPANDATA from sentry_sdk.integrations import DidNotEnable, Integration, _check_minimum_version from sentry_sdk.integrations._wsgi_common import ( DEFAULT_HTTP_METHODS_TO_CAPTURE, @@ -8,7 +9,7 @@ ) from sentry_sdk.integrations.wsgi import SentryWsgiMiddleware from sentry_sdk.scope import should_send_default_pii -from sentry_sdk.tracing import SOURCE_FOR_STYLE +from sentry_sdk.tracing import SOURCE_FOR_STYLE, TransactionSource from sentry_sdk.utils import ( capture_internal_exceptions, ensure_integration_enabled, @@ -134,10 +135,11 @@ def _set_transaction_name_and_source( "url": request.url_rule.rule, "endpoint": request.url_rule.endpoint, } - scope.set_transaction_name( - name_for_style[transaction_style], - source=SOURCE_FOR_STYLE[transaction_style], - ) + name = name_for_style[transaction_style] + source = SOURCE_FOR_STYLE[transaction_style] + if source == TransactionSource.ROUTE: + scope.set_segment_attribute(SPANDATA.HTTP_ROUTE, name) + scope.set_transaction_name(name, source=source) except Exception: pass diff --git a/sentry_sdk/integrations/starlette.py b/sentry_sdk/integrations/starlette.py index 7579c70c11..9fc6f2fe8c 100644 --- a/sentry_sdk/integrations/starlette.py +++ b/sentry_sdk/integrations/starlette.py @@ -861,6 +861,8 @@ def _set_transaction_name_and_source( if name is None: name = _DEFAULT_TRANSACTION_NAME source = TransactionSource.ROUTE + elif source == TransactionSource.ROUTE: + scope.set_segment_attribute(SPANDATA.HTTP_ROUTE, name) scope.set_transaction_name(name, source=source) diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index 9587ed84fd..7dceb3a11e 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -2053,6 +2053,17 @@ def set_attributes(self, attributes: "dict[str, AttributeValue]") -> None: for attribute, value in attributes.items(): self.set_attribute(attribute, value) + def set_segment_attribute(self, key: str, value: "AttributeValue") -> None: + """ + Set an attribute on the active segment (the root span of the trace). + + Unlike :py:meth:`set_attribute`, which applies to all telemetry captured + while the scope is active, this sets the attribute on the segment span + only. It has no effect outside of span streaming mode. + """ + if isinstance(self._span, StreamedSpan): + self._span._segment.set_attribute(key, value) + def remove_attribute(self, attribute: str) -> None: """Remove an attribute if set on the scope. No-op if there is no such attribute.""" try: diff --git a/tests/integrations/asgi/test_asgi.py b/tests/integrations/asgi/test_asgi.py index 7f1924128e..ca6c209e96 100644 --- a/tests/integrations/asgi/test_asgi.py +++ b/tests/integrations/asgi/test_asgi.py @@ -670,6 +670,7 @@ async def test_transaction_style( assert span["name"] == expected_transaction assert span["attributes"]["sentry.segment.name.source"] == expected_source + assert "http.route" not in span["attributes"] else: (transaction_event,) = events @@ -678,6 +679,40 @@ async def test_transaction_style( assert transaction_event["transaction_info"] == {"source": expected_source} +@pytest.mark.asyncio +async def test_http_route_set_for_route_segment_name( + sentry_init, + asgi3_app, + capture_items, +): + sentry_init( + traces_sample_rate=1.0, + trace_lifecycle="stream", + ) + app = SentryAsgiMiddleware(asgi3_app, transaction_style="url") + + class Route: + path = "/message/{message_id}" + + scope = { + "endpoint": asgi3_app, + "route": Route(), + "client": ("127.0.0.1", 60457), + } + + async with TestClient(app, scope=scope) as client: + items = capture_items("span") + await client.get("/message/123456") + + sentry_sdk.flush() + + assert len(items) == 1 + span = items[0].payload + assert span["name"] == "/message/{message_id}" + assert span["attributes"]["sentry.segment.name.source"] == "route" + assert span["attributes"]["http.route"] == "/message/{message_id}" + + def mock_asgi2_app(): pass diff --git a/tests/integrations/django/test_basic.py b/tests/integrations/django/test_basic.py index fa385285a9..cbec84bef3 100644 --- a/tests/integrations/django/test_basic.py +++ b/tests/integrations/django/test_basic.py @@ -1276,6 +1276,10 @@ def test_transaction_style( assert spans[2]["is_segment"] is True assert spans[2]["attributes"]["sentry.segment.name.source"] == expected_source + if expected_source == "route": + assert spans[2]["attributes"]["http.route"] == expected_transaction + else: + assert "http.route" not in spans[2]["attributes"] (event,) = (item.payload for item in items if item.type == "event") else: diff --git a/tests/integrations/fastapi/test_fastapi.py b/tests/integrations/fastapi/test_fastapi.py index 913396f356..9c769487a8 100644 --- a/tests/integrations/fastapi/test_fastapi.py +++ b/tests/integrations/fastapi/test_fastapi.py @@ -770,6 +770,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() diff --git a/tests/integrations/flask/test_flask.py b/tests/integrations/flask/test_flask.py index 1252673b34..2d344bd982 100644 --- a/tests/integrations/flask/test_flask.py +++ b/tests/integrations/flask/test_flask.py @@ -135,6 +135,10 @@ def test_transaction_or_segment_style( (segment,) = spans assert segment["name"] == expected_transaction assert segment["attributes"]["sentry.segment.name.source"] == expected_source + if expected_source == "route": + assert segment["attributes"]["http.route"] == expected_transaction + else: + assert "http.route" not in segment["attributes"] else: (_, event) = events assert event["transaction"] == expected_transaction diff --git a/tests/tracing/test_span_streaming.py b/tests/tracing/test_span_streaming.py index 8aef2f7d17..3df3babf4d 100644 --- a/tests/tracing/test_span_streaming.py +++ b/tests/tracing/test_span_streaming.py @@ -60,6 +60,30 @@ def test_start_span(sentry_init, capture_items): assert segment["status"] == "ok" +def test_set_segment_attribute(sentry_init, capture_items): + sentry_init( + traces_sample_rate=1.0, + trace_lifecycle="stream", + ) + + items = capture_items("span") + + with sentry_sdk.traces.start_span(name="segment"): + with sentry_sdk.traces.start_span(name="child"): + # Set from within a child span to prove it targets the segment, + # not the active span. + sentry_sdk.get_current_scope().set_segment_attribute( + "http.route", "/users/{id}" + ) + + sentry_sdk.get_client().flush() + spans = {item.payload["name"]: item.payload for item in items} + + assert spans["segment"]["attributes"]["http.route"] == "/users/{id}" + # Unlike scope-wide set_attribute, the child span must not inherit it. + assert "http.route" not in spans["child"]["attributes"] + + def test_start_span_no_context_manager(sentry_init, capture_items): sentry_init( traces_sample_rate=1.0,