diff --git a/sentry_sdk/integrations/_asgi_common.py b/sentry_sdk/integrations/_asgi_common.py index a8022c6bb1..a4ce86d5e3 100644 --- a/sentry_sdk/integrations/_asgi_common.py +++ b/sentry_sdk/integrations/_asgi_common.py @@ -12,6 +12,7 @@ from typing import Union from typing_extensions import Literal + from sentry_sdk._types import Attributes from sentry_sdk.utils import AnnotatedValue @@ -105,3 +106,31 @@ def _get_request_data(asgi_scope: "Any") -> "Dict[str, Any]": request_data["env"] = {"REMOTE_ADDR": _get_ip(asgi_scope)} return request_data + + +def _get_request_attributes(asgi_scope: "Any") -> "dict[str, Any]": + """ + Return attributes related to the HTTP request from the ASGI scope. + """ + attributes: "Attributes" = {} + + ty = asgi_scope["type"] + if ty in ("http", "websocket"): + if asgi_scope.get("method"): + attributes["http.request.method"] = asgi_scope["method"].upper() + + headers = _filter_headers(_get_headers(asgi_scope)) + for header, value in headers.items(): + attributes[f"http.request.header.{header.lower()}"] = value + + attributes["http.query"] = _get_query(asgi_scope) + + attributes["url.full"] = _get_url( + asgi_scope, "http" if ty == "http" else "ws", headers.get("host") + ) + + client = asgi_scope.get("client") + if client and should_send_default_pii(): + attributes["client.address"] = _get_ip(asgi_scope) + + return attributes diff --git a/sentry_sdk/integrations/asgi.py b/sentry_sdk/integrations/asgi.py index 2294781f05..3fb732e387 100644 --- a/sentry_sdk/integrations/asgi.py +++ b/sentry_sdk/integrations/asgi.py @@ -15,6 +15,7 @@ from sentry_sdk.consts import OP from sentry_sdk.integrations._asgi_common import ( _get_headers, + _get_request_attributes, _get_request_data, _get_url, ) @@ -23,7 +24,11 @@ nullcontext, ) from sentry_sdk.sessions import track_session -from sentry_sdk.traces import StreamedSpan +from sentry_sdk.traces import ( + StreamedSpan, + SegmentSource, + SOURCE_FOR_STYLE as SEGMENT_SOURCE_FOR_STYLE, +) from sentry_sdk.tracing import ( SOURCE_FOR_STYLE, Transaction, @@ -40,6 +45,7 @@ _get_installed_modules, reraise, capture_internal_exceptions, + qualname_from_function, ) from typing import TYPE_CHECKING @@ -235,7 +241,7 @@ async def _run_app( transaction_source, "value", transaction_source ), "sentry.origin": self.span_origin, - "asgi.type": ty, + "network.protocol.name": ty, } if ty in ("http", "websocket"): @@ -301,6 +307,9 @@ async def _run_app( else nullcontext() ) + for attribute, value in _get_request_attributes(scope).items(): + sentry_scope.set_attribute(attribute, value) + with span_ctx as span: try: @@ -336,6 +345,7 @@ async def _sentry_wrapped_send( return await self.app( scope, receive, _sentry_wrapped_send ) + except Exception as exc: suppress_chained_exceptions = ( sentry_sdk.get_client() @@ -350,6 +360,15 @@ async def _sentry_wrapped_send( with capture_internal_exceptions(): self._capture_request_exception(exc) reraise(*exc_info) + + finally: + with capture_internal_exceptions(): + name, source = self._get_segment_name_and_source( + self.transaction_style, scope + ) + if isinstance(span, StreamedSpan): + span.name = name + span.set_attribute("sentry.span.source", source) finally: _asgi_middleware_applied.set(False) @@ -424,3 +443,40 @@ def _get_transaction_name_and_source( return name, source return name, source + + def _get_segment_name_and_source( + self: "SentryAsgiMiddleware", segment_style: str, asgi_scope: "Any" + ) -> "Tuple[str, str]": + name = None + source = SEGMENT_SOURCE_FOR_STYLE[segment_style].value + ty = asgi_scope.get("type") + + if segment_style == "endpoint": + endpoint = asgi_scope.get("endpoint") + # Webframeworks like Starlette mutate the ASGI env once routing is + # done, which is sometime after the request has started. If we have + # an endpoint, overwrite our generic transaction name. + if endpoint: + name = qualname_from_function(endpoint) or "" + else: + name = _get_url(asgi_scope, "http" if ty == "http" else "ws", host=None) + source = SegmentSource.URL.value + + elif segment_style == "url": + # FastAPI includes the route object in the scope to let Sentry extract the + # path from it for the transaction name + route = asgi_scope.get("route") + if route: + path = getattr(route, "path", None) + if path is not None: + name = path + else: + name = _get_url(asgi_scope, "http" if ty == "http" else "ws", host=None) + source = SegmentSource.URL.value + + if name is None: + name = _DEFAULT_TRANSACTION_NAME + source = SegmentSource.ROUTE.value + return name, source + + return name, source diff --git a/tests/conftest.py b/tests/conftest.py index 71f2431aac..c723229ed7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1212,6 +1212,29 @@ def werkzeug_set_cookie(client, servername, key, value): client.set_cookie(key, value) +def envelopes_to_spans(envelopes): + res: "list[dict[str, Any]]" = [] + for envelope in envelopes: + for item in envelope.items: + if item.type == "span": + for span_json in item.payload.json["items"]: + span = { + "start_timestamp": span_json["start_timestamp"], + "end_timestamp": span_json.get("end_timestamp"), + "trace_id": span_json["trace_id"], + "span_id": span_json["span_id"], + "name": span_json["name"], + "status": span_json["status"], + "is_segment": span_json["is_segment"], + "parent_span_id": span_json.get("parent_span_id"), + "attributes": { + k: v["value"] for (k, v) in span_json["attributes"].items() + }, + } + res.append(span) + return res + + @contextmanager def patch_start_tracing_child( fake_transaction_is_none: bool = False, diff --git a/tests/integrations/asgi/test_asgi.py b/tests/integrations/asgi/test_asgi.py index ec2796c140..e117db12ba 100644 --- a/tests/integrations/asgi/test_asgi.py +++ b/tests/integrations/asgi/test_asgi.py @@ -6,6 +6,7 @@ from sentry_sdk.tracing import TransactionSource from sentry_sdk.integrations._asgi_common import _get_ip, _get_headers from sentry_sdk.integrations.asgi import SentryAsgiMiddleware, _looks_like_asgi3 +from tests.conftest import envelopes_to_spans from async_asgi_testclient import TestClient @@ -164,34 +165,72 @@ def test_invalid_transaction_style(asgi3_app): @pytest.mark.asyncio +@pytest.mark.parametrize( + ("span_streaming"), + (True, False), +) async def test_capture_transaction( sentry_init, asgi3_app, capture_events, + capture_envelopes, + span_streaming, ): - sentry_init(send_default_pii=True, traces_sample_rate=1.0) + sentry_init( + traces_sample_rate=1.0, + _experiments={ + "trace_lifecycle": "stream" if span_streaming else "static", + }, + ) app = SentryAsgiMiddleware(asgi3_app) async with TestClient(app) as client: - events = capture_events() + if span_streaming: + envelopes = capture_envelopes() + else: + events = capture_events() await client.get("/some_url?somevalue=123") - (transaction_event,) = events + sentry_sdk.flush() - assert transaction_event["type"] == "transaction" - assert transaction_event["transaction"] == "/some_url" - assert transaction_event["transaction_info"] == {"source": "url"} - assert transaction_event["contexts"]["trace"]["op"] == "http.server" - assert transaction_event["request"] == { - "headers": { - "host": "localhost", - "remote-addr": "127.0.0.1", - "user-agent": "ASGI-Test-Client", - }, - "method": "GET", - "query_string": "somevalue=123", - "url": "http://localhost/some_url", - } + if span_streaming: + spans = envelopes_to_spans(envelopes) + assert len(spans) == 1 + (span,) = spans + + assert span["is_segment"] is True + assert span["name"] == "/some_url" + + assert span["attributes"]["sentry.span.source"] == "url" + assert span["attributes"]["sentry.op"] == "http.server" + + assert span["attributes"]["url.full"] == "http://localhost/some_url" + assert span["attributes"]["network.protocol.name"] == "http" + assert span["attributes"]["http.request.method"] == "GET" + assert span["attributes"]["http.query"] == "somevalue=123" + assert span["attributes"]["http.request.header.host"] == "localhost" + assert span["attributes"]["http.request.header.remote-addr"] == "127.0.0.1" + assert ( + span["attributes"]["http.request.header.user-agent"] == "ASGI-Test-Client" + ) + + else: + (transaction_event,) = events + + assert transaction_event["type"] == "transaction" + assert transaction_event["transaction"] == "/some_url" + assert transaction_event["transaction_info"] == {"source": "url"} + assert transaction_event["contexts"]["trace"]["op"] == "http.server" + assert transaction_event["request"] == { + "headers": { + "host": "localhost", + "remote-addr": "127.0.0.1", + "user-agent": "ASGI-Test-Client", + }, + "method": "GET", + "query_string": "somevalue=123", + "url": "http://localhost/some_url", + } @pytest.mark.asyncio diff --git a/tests/tracing/test_span_streaming.py b/tests/tracing/test_span_streaming.py index 21c3d26ea3..458c486e3f 100644 --- a/tests/tracing/test_span_streaming.py +++ b/tests/tracing/test_span_streaming.py @@ -2,7 +2,6 @@ import re import sys import time -from typing import Any from unittest import mock import pytest @@ -10,35 +9,14 @@ import sentry_sdk from sentry_sdk.profiler.continuous_profiler import get_profiler_id from sentry_sdk.traces import NoOpStreamedSpan, SpanStatus, StreamedSpan +from tests.conftest import envelopes_to_spans + minimum_python_38 = pytest.mark.skipif( sys.version_info < (3, 8), reason="Asyncio tests need Python >= 3.8" ) -def envelopes_to_spans(envelopes): - res: "list[dict[str, Any]]" = [] - for envelope in envelopes: - for item in envelope.items: - if item.type == "span": - for span_json in item.payload.json["items"]: - span = { - "start_timestamp": span_json["start_timestamp"], - "end_timestamp": span_json.get("end_timestamp"), - "trace_id": span_json["trace_id"], - "span_id": span_json["span_id"], - "name": span_json["name"], - "status": span_json["status"], - "is_segment": span_json["is_segment"], - "parent_span_id": span_json.get("parent_span_id"), - "attributes": { - k: v["value"] for (k, v) in span_json["attributes"].items() - }, - } - res.append(span) - return res - - def test_start_span(sentry_init, capture_envelopes): sentry_init( traces_sample_rate=1.0,