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/aiohttp.py b/sentry_sdk/integrations/aiohttp.py index 776d9eef0e..24efdd2b48 100644 --- a/sentry_sdk/integrations/aiohttp.py +++ b/sentry_sdk/integrations/aiohttp.py @@ -29,6 +29,7 @@ SegmentNameSource, SpanStatus, StreamedSpan, + get_current_span, ) from sentry_sdk.tracing import ( BAGGAGE_HEADER_NAME, @@ -327,6 +328,7 @@ async def sentry_urldispatcher_resolve( return rv name = None + http_route = None try: if integration.transaction_style == "handler_name": @@ -335,11 +337,12 @@ async def sentry_urldispatcher_resolve( route_info = rv.get_info() pattern = route_info.get("path") or route_info.get("formatter") name = "{} {}".format(request.method, pattern) + http_route = pattern except Exception: pass if name is not None: - current_span = sentry_sdk.get_current_span() + current_span = get_current_span() if isinstance(current_span, StreamedSpan) and not isinstance( current_span, NoOpStreamedSpan ): @@ -348,6 +351,10 @@ async def sentry_urldispatcher_resolve( "sentry.segment.name.source", SEGMENT_SOURCE_FOR_STYLE[integration.transaction_style].value, ) + if http_route is not None: + current_span._segment.set_attribute( + SPANDATA.HTTP_ROUTE, http_route + ) else: current_scope = sentry_sdk.get_current_scope() current_scope.set_transaction_name( 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/bottle.py b/sentry_sdk/integrations/bottle.py index 50f6ca2e1d..a26cc6651d 100644 --- a/sentry_sdk/integrations/bottle.py +++ b/sentry_sdk/integrations/bottle.py @@ -2,6 +2,7 @@ from typing import TYPE_CHECKING import sentry_sdk +from sentry_sdk.consts import SPANDATA from sentry_sdk.integrations import ( _DEFAULT_FAILED_REQUEST_STATUS_CODES, DidNotEnable, @@ -10,8 +11,15 @@ ) from sentry_sdk.integrations._wsgi_common import RequestExtractor from sentry_sdk.integrations.wsgi import SentryWsgiMiddleware -from sentry_sdk.traces import SOURCE_FOR_STYLE as SEGMENT_SOURCE_FOR_STYLE -from sentry_sdk.tracing import SOURCE_FOR_STYLE as TRANSACTION_SOURCE_FOR_STYLE +from sentry_sdk.traces import ( + SOURCE_FOR_STYLE as SEGMENT_SOURCE_FOR_STYLE, +) +from sentry_sdk.traces import ( + SegmentNameSource, +) +from sentry_sdk.tracing import ( + SOURCE_FOR_STYLE as TRANSACTION_SOURCE_FOR_STYLE, +) from sentry_sdk.tracing_utils import has_span_streaming_enabled from sentry_sdk.utils import ( capture_internal_exceptions, @@ -181,9 +189,15 @@ def _set_segment_name_and_source(transaction_style: str) -> None: or "bottle request" ) + source = SEGMENT_SOURCE_FOR_STYLE[transaction_style] + if source == SegmentNameSource.ROUTE: + sentry_sdk.get_current_scope().set_segment_attribute( + SPANDATA.HTTP_ROUTE, name + ) + sentry_sdk.get_current_scope().set_transaction_name( name, - source=SEGMENT_SOURCE_FOR_STYLE[transaction_style], + source=source, ) except RuntimeError: pass diff --git a/sentry_sdk/integrations/django/__init__.py b/sentry_sdk/integrations/django/__init__.py index b8b1bc8f57..320cbd28b8 100644 --- a/sentry_sdk/integrations/django/__init__.py +++ b/sentry_sdk/integrations/django/__init__.py @@ -472,6 +472,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/falcon.py b/sentry_sdk/integrations/falcon.py index 7a595bcf2a..59c1a6f336 100644 --- a/sentry_sdk/integrations/falcon.py +++ b/sentry_sdk/integrations/falcon.py @@ -1,9 +1,11 @@ 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 RequestExtractor from sentry_sdk.integrations.wsgi import SentryWsgiMiddleware +from sentry_sdk.traces import SegmentNameSource from sentry_sdk.tracing import SOURCE_FOR_STYLE from sentry_sdk.tracing_utils import has_span_streaming_enabled from sentry_sdk.utils import ( @@ -122,7 +124,10 @@ def process_resource( } name = name_for_style[integration.transaction_style] source = sentry_sdk.traces.SOURCE_FOR_STYLE[integration.transaction_style] - sentry_sdk.set_transaction_name(name, source) + scope = sentry_sdk.get_current_scope() + if source == SegmentNameSource.ROUTE: + scope.set_segment_attribute(SPANDATA.HTTP_ROUTE, name) + scope.set_transaction_name(name, source) TRANSACTION_STYLE_VALUES = ("uri_template", "path") diff --git a/sentry_sdk/integrations/fastapi.py b/sentry_sdk/integrations/fastapi.py index dc408797e3..d749994a9f 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/pyramid.py b/sentry_sdk/integrations/pyramid.py index 5ab6ef90e9..68b99b5e80 100644 --- a/sentry_sdk/integrations/pyramid.py +++ b/sentry_sdk/integrations/pyramid.py @@ -4,11 +4,17 @@ import weakref import sentry_sdk +from sentry_sdk.consts import SPANDATA from sentry_sdk.integrations import DidNotEnable, Integration from sentry_sdk.integrations._wsgi_common import RequestExtractor from sentry_sdk.integrations.wsgi import SentryWsgiMiddleware from sentry_sdk.scope import should_send_default_pii -from sentry_sdk.traces import SOURCE_FOR_STYLE as SEGMENT_SOURCE_FOR_STYLE +from sentry_sdk.traces import ( + SOURCE_FOR_STYLE as SEGMENT_SOURCE_FOR_STYLE, +) +from sentry_sdk.traces import ( + SegmentNameSource, +) from sentry_sdk.tracing import SOURCE_FOR_STYLE as TRANSACTION_SOURCE_FOR_STYLE from sentry_sdk.tracing_utils import has_span_streaming_enabled from sentry_sdk.utils import ( @@ -182,8 +188,12 @@ def _set_transaction_name_and_source( if is_span_streaming_enabled else TRANSACTION_SOURCE_FOR_STYLE[transaction_style] ) + name = name_for_style[transaction_style] + if source == SegmentNameSource.ROUTE: + scope.set_segment_attribute(SPANDATA.HTTP_ROUTE, name) + scope.set_transaction_name( - name_for_style[transaction_style], + name, source=source, ) except Exception: diff --git a/sentry_sdk/integrations/quart.py b/sentry_sdk/integrations/quart.py index 444ef93fa5..ea8c79bac7 100644 --- a/sentry_sdk/integrations/quart.py +++ b/sentry_sdk/integrations/quart.py @@ -5,14 +5,20 @@ from typing import TYPE_CHECKING import sentry_sdk +from sentry_sdk.consts import SPANDATA from sentry_sdk.data_collection import _apply_data_collection_filtering_to_query_string from sentry_sdk.integrations import DidNotEnable, Integration from sentry_sdk.integrations._wsgi_common import _filter_headers from sentry_sdk.integrations.asgi import SentryAsgiMiddleware from sentry_sdk.scope import should_send_default_pii from sentry_sdk.traces import SOURCE_FOR_STYLE as SEGMENT_SOURCE_FOR_STYLE -from sentry_sdk.traces import StreamedSpan, get_current_span -from sentry_sdk.tracing import SOURCE_FOR_STYLE as TRANSACTION_SOURCE_FOR_STYLE +from sentry_sdk.traces import SegmentNameSource, StreamedSpan, get_current_span +from sentry_sdk.tracing import ( + SOURCE_FOR_STYLE as TRANSACTION_SOURCE_FOR_STYLE, +) +from sentry_sdk.tracing import ( + TransactionSource, +) from sentry_sdk.tracing_utils import has_span_streaming_enabled from sentry_sdk.utils import ( capture_internal_exceptions, @@ -163,8 +169,12 @@ def _set_transaction_name_and_source( else TRANSACTION_SOURCE_FOR_STYLE[transaction_style] ) + name = name_for_style[transaction_style] + if source in (TransactionSource.ROUTE, SegmentNameSource.ROUTE): + scope.set_segment_attribute(SPANDATA.HTTP_ROUTE, name) + scope.set_transaction_name( - name=name_for_style[transaction_style], + name=name, source=source, ) except Exception: diff --git a/sentry_sdk/integrations/starlette.py b/sentry_sdk/integrations/starlette.py index 418badb680..6791181da3 100644 --- a/sentry_sdk/integrations/starlette.py +++ b/sentry_sdk/integrations/starlette.py @@ -880,6 +880,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..7340635309 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -2053,6 +2053,20 @@ 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 span (the root of the trace in + this service). + + 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. + + This method 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/aiohttp/test_aiohttp.py b/tests/integrations/aiohttp/test_aiohttp.py index 04e7312b80..fa5f03ec88 100644 --- a/tests/integrations/aiohttp/test_aiohttp.py +++ b/tests/integrations/aiohttp/test_aiohttp.py @@ -1877,6 +1877,10 @@ async def hello(request): assert server_segment["name"] == expected_name assert server_segment["is_segment"] assert server_segment["attributes"]["sentry.segment.name.source"] == expected_source + if expected_source == "route": + assert server_segment["attributes"]["http.route"] == "/{var}" + else: + assert "http.route" not in server_segment["attributes"] @pytest.mark.asyncio 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/bottle/test_bottle.py b/tests/integrations/bottle/test_bottle.py index d670dde32b..3487a77e42 100644 --- a/tests/integrations/bottle/test_bottle.py +++ b/tests/integrations/bottle/test_bottle.py @@ -660,6 +660,10 @@ def named_hi(): assert segment["name"].endswith(expected_name) assert segment["attributes"]["sentry.segment.name.source"] == expected_source + if expected_source == "route": + assert segment["attributes"]["http.route"] == expected_name + else: + assert "http.route" not in segment["attributes"] def test_span_streaming_with_error(sentry_init, capture_items): diff --git a/tests/integrations/django/test_basic.py b/tests/integrations/django/test_basic.py index e94231beef..7d2c658c97 100644 --- a/tests/integrations/django/test_basic.py +++ b/tests/integrations/django/test_basic.py @@ -1083,6 +1083,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/falcon/test_falcon.py b/tests/integrations/falcon/test_falcon.py index 8bf22707e1..62dc28bc53 100644 --- a/tests/integrations/falcon/test_falcon.py +++ b/tests/integrations/falcon/test_falcon.py @@ -140,6 +140,10 @@ def test_transaction_style( spans = [span for span in spans if span["name"] == expected_transaction] assert len(spans) == 1 assert spans[0]["attributes"]["sentry.segment.name.source"] == expected_source + if expected_source == "route": + assert spans[0]["attributes"]["http.route"] == expected_transaction + else: + assert "http.route" not in spans[0]["attributes"] else: events = capture_events() 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() diff --git a/tests/integrations/flask/test_flask.py b/tests/integrations/flask/test_flask.py index 5586f9f276..4e2d9c928b 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/integrations/pyramid/test_pyramid.py b/tests/integrations/pyramid/test_pyramid.py index 5290de6357..8a8307cb7c 100644 --- a/tests/integrations/pyramid/test_pyramid.py +++ b/tests/integrations/pyramid/test_pyramid.py @@ -171,6 +171,10 @@ def test_transaction_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: (_, transaction_event) = events assert transaction_event["transaction"] == expected_transaction diff --git a/tests/integrations/quart/test_quart.py b/tests/integrations/quart/test_quart.py index c93a6f3186..0841f96604 100644 --- a/tests/integrations/quart/test_quart.py +++ b/tests/integrations/quart/test_quart.py @@ -771,6 +771,10 @@ async def test_span_streaming_transaction_style( assert segment["is_segment"] is True assert segment["name"] == expected_name assert segment["attributes"]["sentry.segment.name.source"] == expected_source + if expected_source == "route": + assert segment["attributes"]["http.route"] == expected_name + else: + assert "http.route" not in segment["attributes"] @pytest.mark.asyncio diff --git a/tests/integrations/tornado/test_tornado.py b/tests/integrations/tornado/test_tornado.py index b8582ae964..010ea27eec 100644 --- a/tests/integrations/tornado/test_tornado.py +++ b/tests/integrations/tornado/test_tornado.py @@ -687,6 +687,7 @@ def test_transactions( ) assert server_segment["name"] == expected_handler assert server_segment["attributes"]["sentry.segment.name.source"] == "component" + assert "http.route" not in server_segment["attributes"] assert server_segment["attributes"]["http.request.method"] == "POST" assert server_segment["attributes"]["http.request.body.data"] == "heyoo" assert server_segment["attributes"]["http.response.status_code"] == code 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,