From 282ba352292a7e7187ed1a3f428e9fb3a3971b04 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Mon, 30 Mar 2026 14:56:20 +0200 Subject: [PATCH 01/26] feat(asgi): Migrate away from event processor in span first --- sentry_sdk/integrations/_asgi_common.py | 30 ++++++++++++ sentry_sdk/integrations/asgi.py | 65 +++++++++++++++++++++++-- 2 files changed, 91 insertions(+), 4 deletions(-) diff --git a/sentry_sdk/integrations/_asgi_common.py b/sentry_sdk/integrations/_asgi_common.py index a8022c6bb1..dc36119f60 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,32 @@ 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)) + # TODO[span-first]: Correctly merge headers if duplicate + for header, value in headers.items(): + attributes[f"http.request.headers.{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"] = {"REMOTE_ADDR": _get_ip(asgi_scope)} + + return attributes diff --git a/sentry_sdk/integrations/asgi.py b/sentry_sdk/integrations/asgi.py index 2294781f05..a85450f937 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): + sentry_scope.set_attribute(attribute, value) + with span_ctx as span: try: @@ -329,13 +338,24 @@ async def _sentry_wrapped_send( return await send(event) if asgi_version == 2: - return await self.app(scope)( + result = await self.app(scope)( receive, _sentry_wrapped_send ) else: - return await self.app( + result = await self.app( scope, receive, _sentry_wrapped_send ) + + 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) + + return result + except Exception as exc: suppress_chained_exceptions = ( sentry_sdk.get_client() @@ -424,3 +444,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] + 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 From e2484bd32ba035f674e7185ab2d3e224fba67cf4 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Mon, 30 Mar 2026 15:13:07 +0200 Subject: [PATCH 02/26] fixes --- sentry_sdk/integrations/_asgi_common.py | 2 +- sentry_sdk/integrations/asgi.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/sentry_sdk/integrations/_asgi_common.py b/sentry_sdk/integrations/_asgi_common.py index dc36119f60..4c84b691a5 100644 --- a/sentry_sdk/integrations/_asgi_common.py +++ b/sentry_sdk/integrations/_asgi_common.py @@ -132,6 +132,6 @@ def _get_request_attributes(asgi_scope: "Any") -> "dict[str, Any]": client = asgi_scope.get("client") if client and should_send_default_pii(): - attributes["client.address"] = {"REMOTE_ADDR": _get_ip(asgi_scope)} + attributes["client.address"] = _get_ip(asgi_scope) return attributes diff --git a/sentry_sdk/integrations/asgi.py b/sentry_sdk/integrations/asgi.py index a85450f937..e942995ac2 100644 --- a/sentry_sdk/integrations/asgi.py +++ b/sentry_sdk/integrations/asgi.py @@ -307,7 +307,7 @@ async def _run_app( else nullcontext() ) - for attribute, value in _get_request_attributes(scope): + for attribute, value in _get_request_attributes(scope).items(): sentry_scope.set_attribute(attribute, value) with span_ctx as span: @@ -449,7 +449,7 @@ 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] + source = SEGMENT_SOURCE_FOR_STYLE[segment_style].value ty = asgi_scope.get("type") if segment_style == "endpoint": From 5c3174f0bc94c5bd25ebe27361fc594b7cda73b5 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Mon, 30 Mar 2026 15:26:38 +0200 Subject: [PATCH 03/26] . --- sentry_sdk/integrations/asgi.py | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/sentry_sdk/integrations/asgi.py b/sentry_sdk/integrations/asgi.py index e942995ac2..3fb732e387 100644 --- a/sentry_sdk/integrations/asgi.py +++ b/sentry_sdk/integrations/asgi.py @@ -338,24 +338,14 @@ async def _sentry_wrapped_send( return await send(event) if asgi_version == 2: - result = await self.app(scope)( + return await self.app(scope)( receive, _sentry_wrapped_send ) else: - result = await self.app( + return await self.app( scope, receive, _sentry_wrapped_send ) - 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) - - return result - except Exception as exc: suppress_chained_exceptions = ( sentry_sdk.get_client() @@ -370,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) From b270e5ab28588111511a7dcbefb825c71adfe294 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Wed, 1 Apr 2026 11:27:09 +0200 Subject: [PATCH 04/26] . --- sentry_sdk/integrations/_asgi_common.py | 3 +- tests/conftest.py | 23 +++++++ tests/integrations/asgi/test_asgi.py | 80 +++++++++++++++++++------ tests/tracing/test_span_streaming.py | 26 +------- 4 files changed, 89 insertions(+), 43 deletions(-) diff --git a/sentry_sdk/integrations/_asgi_common.py b/sentry_sdk/integrations/_asgi_common.py index 4c84b691a5..a4ce86d5e3 100644 --- a/sentry_sdk/integrations/_asgi_common.py +++ b/sentry_sdk/integrations/_asgi_common.py @@ -120,9 +120,8 @@ def _get_request_attributes(asgi_scope: "Any") -> "dict[str, Any]": attributes["http.request.method"] = asgi_scope["method"].upper() headers = _filter_headers(_get_headers(asgi_scope)) - # TODO[span-first]: Correctly merge headers if duplicate for header, value in headers.items(): - attributes[f"http.request.headers.{header.lower()}"] = [value] + attributes[f"http.request.header.{header.lower()}"] = value attributes["http.query"] = _get_query(asgi_scope) 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..76e8824c60 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,79 @@ def test_invalid_transaction_style(asgi3_app): @pytest.mark.asyncio +@pytest.mark.parametrize( + ("span_streaming", "send_default_pii"), + [[False, True], [False, True]], +) async def test_capture_transaction( sentry_init, asgi3_app, capture_events, + capture_envelopes, + span_streaming, + send_default_pii, ): - sentry_init(send_default_pii=True, traces_sample_rate=1.0) + sentry_init( + send_default_pii=send_default_pii, + 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" + + if send_default_pii: + assert span["attributes"]["client.address"] == "127.0.0.1" + else: + assert "client.address" not in span["attributes"] + + assert span["attributes"]["http.request.method"] == "GET" + assert span["attributes"]["url.full"] == "http://localhost/some_url" + assert span["attributes"]["http.query"] == "somevalue=123" + assert span["attributes"]["http.request.protocol.name"] == "http" + 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, From e93fd1ba7ebb8a79869211093e95e88d2418c794 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Wed, 1 Apr 2026 12:31:30 +0200 Subject: [PATCH 05/26] . --- tests/integrations/asgi/test_asgi.py | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/tests/integrations/asgi/test_asgi.py b/tests/integrations/asgi/test_asgi.py index 76e8824c60..e117db12ba 100644 --- a/tests/integrations/asgi/test_asgi.py +++ b/tests/integrations/asgi/test_asgi.py @@ -166,8 +166,8 @@ def test_invalid_transaction_style(asgi3_app): @pytest.mark.asyncio @pytest.mark.parametrize( - ("span_streaming", "send_default_pii"), - [[False, True], [False, True]], + ("span_streaming"), + (True, False), ) async def test_capture_transaction( sentry_init, @@ -175,10 +175,8 @@ async def test_capture_transaction( capture_events, capture_envelopes, span_streaming, - send_default_pii, ): sentry_init( - send_default_pii=send_default_pii, traces_sample_rate=1.0, _experiments={ "trace_lifecycle": "stream" if span_streaming else "static", @@ -206,15 +204,10 @@ async def test_capture_transaction( assert span["attributes"]["sentry.span.source"] == "url" assert span["attributes"]["sentry.op"] == "http.server" - if send_default_pii: - assert span["attributes"]["client.address"] == "127.0.0.1" - else: - assert "client.address" not in span["attributes"] - - assert span["attributes"]["http.request.method"] == "GET" 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.protocol.name"] == "http" assert span["attributes"]["http.request.header.host"] == "localhost" assert span["attributes"]["http.request.header.remote-addr"] == "127.0.0.1" assert ( From 7cde97355f13e14da40c122a12fe37067d77ecf7 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 2 Apr 2026 13:30:55 +0200 Subject: [PATCH 06/26] capture_items --- tests/conftest.py | 42 ++++++++++++++++ tests/integrations/asgi/test_asgi.py | 74 ++++++++++++++++++++-------- 2 files changed, 95 insertions(+), 21 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index c723229ed7..85de20737b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,6 +7,7 @@ import brotli import gzip import io +from dataclasses import dataclass from threading import Thread from contextlib import contextmanager from http.server import BaseHTTPRequestHandler, HTTPServer @@ -320,6 +321,47 @@ def append_envelope(envelope): return inner +@dataclass +class UnwrappedItem: + type: str + payload: dict + + +@pytest.fixture +def capture_items(monkeypatch): + """Capture envelope payload, unfurling individual items.""" + + def inner(types=None): + telemetry = [] + test_client = sentry_sdk.get_client() + old_capture_envelope = test_client.transport.capture_envelope + + def append_envelope(envelope): + for item in envelope: + if types is None or item.type not in types: + continue + + if item.type in ("metric", "log", "span"): + for json in item.payload.json["items"]: + t = {k: v for k, v in json.items() if k != "attributes"} + t["attributes"] = { + k: v["value"] for k, v in json["attributes"].items() + } + telemetry.append(UnwrappedItem(type=item.type, payload=t)) + else: + telemetry.append( + UnwrappedItem(type=item.type, payload=item.payload.json) + ) + + return old_capture_envelope(envelope) + + monkeypatch.setattr(test_client.transport, "capture_envelope", append_envelope) + + return telemetry + + return inner + + @pytest.fixture def capture_record_lost_event_calls(monkeypatch): def inner(): diff --git a/tests/integrations/asgi/test_asgi.py b/tests/integrations/asgi/test_asgi.py index e117db12ba..b5f13a5aad 100644 --- a/tests/integrations/asgi/test_asgi.py +++ b/tests/integrations/asgi/test_asgi.py @@ -6,7 +6,6 @@ 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 @@ -166,17 +165,18 @@ def test_invalid_transaction_style(asgi3_app): @pytest.mark.asyncio @pytest.mark.parametrize( - ("span_streaming"), - (True, False), + "span_streaming", + [True, False], ) async def test_capture_transaction( sentry_init, asgi3_app, capture_events, - capture_envelopes, + capture_items, span_streaming, ): sentry_init( + send_default_pii=True, traces_sample_rate=1.0, _experiments={ "trace_lifecycle": "stream" if span_streaming else "static", @@ -186,7 +186,7 @@ async def test_capture_transaction( async with TestClient(app) as client: if span_streaming: - envelopes = capture_envelopes() + items = capture_items(["span"]) else: events = capture_events() await client.get("/some_url?somevalue=123") @@ -194,9 +194,8 @@ async def test_capture_transaction( sentry_sdk.flush() if span_streaming: - spans = envelopes_to_spans(envelopes) - assert len(spans) == 1 - (span,) = spans + assert len(items) == 1 + span = items[0].payload assert span["is_segment"] is True assert span["name"] == "/some_url" @@ -234,24 +233,48 @@ async def test_capture_transaction( @pytest.mark.asyncio +@pytest.mark.parametrize( + "span_streaming", + [True, False], +) async def test_capture_transaction_with_error( sentry_init, asgi3_app_with_error, capture_events, + capture_items, DictionaryContaining, # noqa: N803 + span_streaming, ): - sentry_init(send_default_pii=True, traces_sample_rate=1.0) + sentry_init( + send_default_pii=True, + traces_sample_rate=1.0, + _experiments={ + "trace_lifecycle": "stream" if span_streaming else "static", + }, + ) + app = SentryAsgiMiddleware(asgi3_app_with_error) - events = capture_events() + if span_streaming: + items = capture_items(["event", "span"]) + else: + events = capture_events() + with pytest.raises(ZeroDivisionError): async with TestClient(app) as client: await client.get("/some_url") - ( - error_event, - transaction_event, - ) = events + sentry_sdk.flush() + + if span_streaming: + assert len(items) == 2 + assert items[0].type == "event" + assert items[1].type == "span" + + error_event = items[0].payload + span_item = items[1].payload + else: + (error_event, transaction_event) = events assert error_event["transaction"] == "/some_url" assert error_event["transaction_info"] == {"source": "url"} @@ -261,13 +284,22 @@ async def test_capture_transaction_with_error( assert error_event["exception"]["values"][0]["mechanism"]["handled"] is False assert error_event["exception"]["values"][0]["mechanism"]["type"] == "asgi" - assert transaction_event["type"] == "transaction" - assert transaction_event["contexts"]["trace"] == DictionaryContaining( - error_event["contexts"]["trace"] - ) - assert transaction_event["contexts"]["trace"]["status"] == "internal_error" - assert transaction_event["transaction"] == error_event["transaction"] - assert transaction_event["request"] == error_event["request"] + if span_streaming: + assert span_item["trace_id"] == error_event["contexts"]["trace"]["trace_id"] + assert span_item["span_id"] == error_event["contexts"]["trace"]["span_id"] + assert span_item.get("parent_span_id") == error_event["contexts"]["trace"].get( + "parent_span_id" + ) + assert span_item["status"] == "error" + + else: + assert transaction_event["type"] == "transaction" + assert transaction_event["contexts"]["trace"] == DictionaryContaining( + error_event["contexts"]["trace"] + ) + assert transaction_event["contexts"]["trace"]["status"] == "internal_error" + assert transaction_event["transaction"] == error_event["transaction"] + assert transaction_event["request"] == error_event["request"] @pytest.mark.asyncio From df3f3af5751fff053464b85018c36be41d99ac8f Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 2 Apr 2026 13:37:22 +0200 Subject: [PATCH 07/26] . --- tests/conftest.py | 25 +------------------------ tests/integrations/asgi/test_asgi.py | 4 ++-- tests/tracing/test_span_streaming.py | 25 ++++++++++++++++++++++++- 3 files changed, 27 insertions(+), 27 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 85de20737b..1d16cec5fb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -331,7 +331,7 @@ class UnwrappedItem: def capture_items(monkeypatch): """Capture envelope payload, unfurling individual items.""" - def inner(types=None): + def inner(*types): telemetry = [] test_client = sentry_sdk.get_client() old_capture_envelope = test_client.transport.capture_envelope @@ -1254,29 +1254,6 @@ 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 b5f13a5aad..c8bc3cc1ca 100644 --- a/tests/integrations/asgi/test_asgi.py +++ b/tests/integrations/asgi/test_asgi.py @@ -186,7 +186,7 @@ async def test_capture_transaction( async with TestClient(app) as client: if span_streaming: - items = capture_items(["span"]) + items = capture_items("span") else: events = capture_events() await client.get("/some_url?somevalue=123") @@ -256,7 +256,7 @@ async def test_capture_transaction_with_error( app = SentryAsgiMiddleware(asgi3_app_with_error) if span_streaming: - items = capture_items(["event", "span"]) + items = capture_items("event", "span") else: events = capture_events() diff --git a/tests/tracing/test_span_streaming.py b/tests/tracing/test_span_streaming.py index 458c486e3f..445c8cfb99 100644 --- a/tests/tracing/test_span_streaming.py +++ b/tests/tracing/test_span_streaming.py @@ -3,13 +3,13 @@ import sys import time from unittest import mock +from typing import Any import pytest 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( @@ -17,6 +17,29 @@ ) +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, From babb3bdcee89ab267f298ecd52722a5e687ea4f7 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 2 Apr 2026 13:38:56 +0200 Subject: [PATCH 08/26] . --- tests/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 1d16cec5fb..c052ddcd35 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -338,7 +338,7 @@ def inner(*types): def append_envelope(envelope): for item in envelope: - if types is None or item.type not in types: + if types and item.type not in types: continue if item.type in ("metric", "log", "span"): From 75429dc9df629c4dd6fb6a7de7ff7ac5ad2788c9 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 2 Apr 2026 13:50:05 +0200 Subject: [PATCH 09/26] no annotatedvalues --- sentry_sdk/integrations/_asgi_common.py | 2 +- sentry_sdk/integrations/_wsgi_common.py | 13 ++++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/sentry_sdk/integrations/_asgi_common.py b/sentry_sdk/integrations/_asgi_common.py index a4ce86d5e3..24d550d09a 100644 --- a/sentry_sdk/integrations/_asgi_common.py +++ b/sentry_sdk/integrations/_asgi_common.py @@ -119,7 +119,7 @@ def _get_request_attributes(asgi_scope: "Any") -> "dict[str, Any]": if asgi_scope.get("method"): attributes["http.request.method"] = asgi_scope["method"].upper() - headers = _filter_headers(_get_headers(asgi_scope)) + headers = _filter_headers(_get_headers(asgi_scope), use_annotated_value=False) for header, value in headers.items(): attributes[f"http.request.header.{header.lower()}"] = value diff --git a/sentry_sdk/integrations/_wsgi_common.py b/sentry_sdk/integrations/_wsgi_common.py index 688e965be4..8a23f77e83 100644 --- a/sentry_sdk/integrations/_wsgi_common.py +++ b/sentry_sdk/integrations/_wsgi_common.py @@ -3,6 +3,7 @@ from copy import deepcopy import sentry_sdk +from sentry_sdk._types import SENSITIVE_DATA_SUBSTITUTE from sentry_sdk.scope import should_send_default_pii from sentry_sdk.utils import AnnotatedValue, logger @@ -211,16 +212,18 @@ def _is_json_content_type(ct: "Optional[str]") -> bool: def _filter_headers( headers: "Mapping[str, str]", + use_annotated_value: bool = True, ) -> "Mapping[str, Union[AnnotatedValue, str]]": if should_send_default_pii(): return headers + if use_annotated_value: + substitute = AnnotatedValue.removed_because_over_size_limit() + else: + substitute = SENSITIVE_DATA_SUBSTITUTE + return { - k: ( - v - if k.upper().replace("-", "_") not in SENSITIVE_HEADERS - else AnnotatedValue.removed_because_over_size_limit() - ) + k: (v if k.upper().replace("-", "_") not in SENSITIVE_HEADERS else substitute) for k, v in headers.items() } From d9d76745e9d5f05d5449b601aa84ad047086127a Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 2 Apr 2026 14:50:13 +0200 Subject: [PATCH 10/26] . --- tests/integrations/asgi/test_asgi.py | 71 ++++++++++++++++++++++------ 1 file changed, 57 insertions(+), 14 deletions(-) diff --git a/tests/integrations/asgi/test_asgi.py b/tests/integrations/asgi/test_asgi.py index c8bc3cc1ca..286e95e0cc 100644 --- a/tests/integrations/asgi/test_asgi.py +++ b/tests/integrations/asgi/test_asgi.py @@ -303,35 +303,78 @@ async def test_capture_transaction_with_error( @pytest.mark.asyncio +@pytest.mark.parametrize( + "span_streaming", + [True, False], +) async def test_has_trace_if_performance_enabled( sentry_init, asgi3_app_with_error_and_msg, capture_events, + capture_items, + span_streaming, ): - sentry_init(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_with_error_and_msg) with pytest.raises(ZeroDivisionError): async with TestClient(app) as client: - events = capture_events() + if span_streaming: + items = capture_items("event", "span") + else: + events = capture_events() await client.get("/") - msg_event, error_event, transaction_event = events + sentry_sdk.flush() - assert msg_event["contexts"]["trace"] - assert "trace_id" in msg_event["contexts"]["trace"] + if span_streaming: + for item in items: + print(item) + print() + msg_event, error_event, span = items - assert error_event["contexts"]["trace"] - assert "trace_id" in error_event["contexts"]["trace"] + assert msg_event.type == "event" + msg_event = msg_event.payload + assert msg_event["contexts"]["trace"] + assert "trace_id" in msg_event["contexts"]["trace"] - assert transaction_event["contexts"]["trace"] - assert "trace_id" in transaction_event["contexts"]["trace"] + assert error_event.type == "event" + error_event = error_event.payload + assert error_event["contexts"]["trace"] + assert "trace_id" in error_event["contexts"]["trace"] - assert ( - error_event["contexts"]["trace"]["trace_id"] - == transaction_event["contexts"]["trace"]["trace_id"] - == msg_event["contexts"]["trace"]["trace_id"] - ) + assert span.type == "span" + span = span.payload + assert span["trace_id"] is not None + + assert ( + error_event["contexts"]["trace"]["trace_id"] + == msg_event["contexts"]["trace"]["trace_id"] + == span["trace_id"] + ) + + else: + msg_event, error_event, transaction_event = events + + assert msg_event["contexts"]["trace"] + assert "trace_id" in msg_event["contexts"]["trace"] + + assert error_event["contexts"]["trace"] + assert "trace_id" in error_event["contexts"]["trace"] + + assert transaction_event["contexts"]["trace"] + assert "trace_id" in transaction_event["contexts"]["trace"] + + assert ( + error_event["contexts"]["trace"]["trace_id"] + == transaction_event["contexts"]["trace"]["trace_id"] + == msg_event["contexts"]["trace"]["trace_id"] + ) @pytest.mark.asyncio From 32e4191c85434261beadeb882bfc42748eb021c8 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 2 Apr 2026 14:54:58 +0200 Subject: [PATCH 11/26] . --- tests/integrations/asgi/test_asgi.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/integrations/asgi/test_asgi.py b/tests/integrations/asgi/test_asgi.py index 286e95e0cc..d27395e0b6 100644 --- a/tests/integrations/asgi/test_asgi.py +++ b/tests/integrations/asgi/test_asgi.py @@ -333,9 +333,6 @@ async def test_has_trace_if_performance_enabled( sentry_sdk.flush() if span_streaming: - for item in items: - print(item) - print() msg_event, error_event, span = items assert msg_event.type == "event" From 5cecf9782dd54f28236f7c206f4b2be38e1804a3 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 2 Apr 2026 15:14:44 +0200 Subject: [PATCH 12/26] more tests --- tests/integrations/asgi/test_asgi.py | 274 ++++++++++++++++++++++----- 1 file changed, 222 insertions(+), 52 deletions(-) diff --git a/tests/integrations/asgi/test_asgi.py b/tests/integrations/asgi/test_asgi.py index d27395e0b6..c59243fc7c 100644 --- a/tests/integrations/asgi/test_asgi.py +++ b/tests/integrations/asgi/test_asgi.py @@ -397,13 +397,24 @@ async def test_has_trace_if_performance_disabled( assert "trace_id" in error_event["contexts"]["trace"] +@pytest.mark.parametrize( + "span_streaming", + [True, False], +) @pytest.mark.asyncio async def test_trace_from_headers_if_performance_enabled( sentry_init, asgi3_app_with_error_and_msg, capture_events, + capture_items, + span_streaming, ): - sentry_init(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_with_error_and_msg) trace_id = "582b43a4192642f0b136d5159a501701" @@ -411,23 +422,50 @@ async def test_trace_from_headers_if_performance_enabled( with pytest.raises(ZeroDivisionError): async with TestClient(app) as client: - events = capture_events() + if span_streaming: + items = capture_items("event", "span") + else: + events = capture_events() await client.get("/", headers={"sentry-trace": sentry_trace_header}) - msg_event, error_event, transaction_event = events + sentry_sdk.flush() - assert msg_event["contexts"]["trace"] - assert "trace_id" in msg_event["contexts"]["trace"] + if span_streaming: + msg_event, error_event, span = items - assert error_event["contexts"]["trace"] - assert "trace_id" in error_event["contexts"]["trace"] + assert msg_event.type == "event" + msg_event = msg_event.payload + assert msg_event["contexts"]["trace"] + assert "trace_id" in msg_event["contexts"]["trace"] - assert transaction_event["contexts"]["trace"] - assert "trace_id" in transaction_event["contexts"]["trace"] + assert error_event.type == "event" + error_event = error_event.payload + assert error_event["contexts"]["trace"] + assert "trace_id" in error_event["contexts"]["trace"] - assert msg_event["contexts"]["trace"]["trace_id"] == trace_id - assert error_event["contexts"]["trace"]["trace_id"] == trace_id - assert transaction_event["contexts"]["trace"]["trace_id"] == trace_id + assert span.type == "span" + span = span.payload + assert span["trace_id"] is not None + + assert msg_event["contexts"]["trace"]["trace_id"] == trace_id + assert error_event["contexts"]["trace"]["trace_id"] == trace_id + assert span["trace_id"] == trace_id + + else: + msg_event, error_event, transaction_event = events + + assert msg_event["contexts"]["trace"] + assert "trace_id" in msg_event["contexts"]["trace"] + + assert error_event["contexts"]["trace"] + assert "trace_id" in error_event["contexts"]["trace"] + + assert transaction_event["contexts"]["trace"] + assert "trace_id" in transaction_event["contexts"]["trace"] + + assert msg_event["contexts"]["trace"]["trace_id"] == trace_id + assert error_event["contexts"]["trace"]["trace_id"] == trace_id + assert transaction_event["contexts"]["trace"]["trace_id"] == trace_id @pytest.mark.asyncio @@ -459,10 +497,25 @@ async def test_trace_from_headers_if_performance_disabled( @pytest.mark.asyncio -async def test_websocket(sentry_init, asgi3_ws_app, capture_events, request): - sentry_init(send_default_pii=True, traces_sample_rate=1.0) - - events = capture_events() +@pytest.mark.parametrize( + "span_streaming", + [True, False], +) +async def test_websocket( + sentry_init, + asgi3_ws_app, + capture_events, + capture_items, + request, + span_streaming, +): + sentry_init( + send_default_pii=True, + traces_sample_rate=1.0, + _experiments={ + "trace_lifecycle": "stream" if span_streaming else "static", + }, + ) asgi3_ws_app = SentryAsgiMiddleware(asgi3_ws_app) @@ -470,21 +523,48 @@ async def test_websocket(sentry_init, asgi3_ws_app, capture_events, request): with pytest.raises(ValueError): client = TestClient(asgi3_ws_app) + if span_streaming: + items = capture_items("event", "span") + else: + events = capture_events() async with client.websocket_connect(request_url) as ws: await ws.receive_text() - msg_event, error_event, transaction_event = events + sentry_sdk.flush() + + if span_streaming: + msg_event, error_event, span = items + + assert msg_event.type == "event" + msg_event = msg_event.payload + assert msg_event["transaction"] == request_url + assert msg_event["transaction_info"] == {"source": "url"} + assert msg_event["message"] == "Some message to the world!" + + assert error_event.type == "event" + error_event = error_event.payload + (exc,) = error_event["exception"]["values"] + assert exc["type"] == "ValueError" + assert exc["value"] == "Oh no" + + assert span.type == "span" + span = span.payload + assert span["name"] == request_url + assert span["attributes"]["sentry.span.source"] == "url" + + else: + msg_event, error_event, transaction_event = events - assert msg_event["transaction"] == request_url - assert msg_event["transaction_info"] == {"source": "url"} - assert msg_event["message"] == "Some message to the world!" + assert msg_event["transaction"] == request_url + assert msg_event["transaction_info"] == {"source": "url"} + assert msg_event["message"] == "Some message to the world!" - (exc,) = error_event["exception"]["values"] - assert exc["type"] == "ValueError" - assert exc["value"] == "Oh no" + (exc,) = error_event["exception"]["values"] + assert exc["type"] == "ValueError" + assert exc["value"] == "Oh no" - assert transaction_event["transaction"] == request_url - assert transaction_event["transaction_info"] == {"source": "url"} + assert transaction_event["transaction"] == request_url + assert transaction_event["transaction_info"] == {"source": "url"} @pytest.mark.asyncio @@ -542,17 +622,29 @@ async def test_auto_session_tracking_with_aggregates( ), ], ) +@pytest.mark.parametrize( + "span_streaming", + [True, False], +) @pytest.mark.asyncio async def test_transaction_style( sentry_init, asgi3_app, capture_events, + capture_items, url, transaction_style, expected_transaction, expected_source, + span_streaming, ): - sentry_init(send_default_pii=True, traces_sample_rate=1.0) + sentry_init( + send_default_pii=True, + traces_sample_rate=1.0, + _experiments={ + "trace_lifecycle": "stream" if span_streaming else "static", + }, + ) app = SentryAsgiMiddleware(asgi3_app, transaction_style=transaction_style) scope = { @@ -562,13 +654,26 @@ async def test_transaction_style( } async with TestClient(app, scope=scope) as client: - events = capture_events() + if span_streaming: + items = capture_items("span") + else: + events = capture_events() await client.get(url) - (transaction_event,) = events + sentry_sdk.flush() + + if span_streaming: + assert len(items) == 1 + span = items[0].payload + + assert span["name"] == expected_transaction + assert span["attributes"]["sentry.span.source"] == expected_source + + else: + (transaction_event,) = events - assert transaction_event["transaction"] == expected_transaction - assert transaction_event["transaction_info"] == {"source": expected_source} + assert transaction_event["transaction"] == expected_transaction + assert transaction_event["transaction_info"] == {"source": expected_source} def mock_asgi2_app(): @@ -733,6 +838,10 @@ def test_get_headers(): ), ], ) +@pytest.mark.parametrize( + "span_streaming", + [True, False], +) async def test_transaction_name( sentry_init, request_url, @@ -741,28 +850,46 @@ async def test_transaction_name( expected_transaction_source, asgi3_app, capture_envelopes, + capture_items, + span_streaming, ): """ Tests that the transaction name is something meaningful. """ sentry_init( traces_sample_rate=1.0, + _experiments={ + "trace_lifecycle": "stream" if span_streaming else "static", + }, ) - envelopes = capture_envelopes() - app = SentryAsgiMiddleware(asgi3_app, transaction_style=transaction_style) async with TestClient(app) as client: + if span_streaming: + items = capture_items("span") + else: + envelopes = capture_envelopes() await client.get(request_url) - (transaction_envelope,) = envelopes - transaction_event = transaction_envelope.get_transaction_event() + sentry_sdk.flush() + + if span_streaming: + assert len(items) == 1 + span = items[0].payload - assert transaction_event["transaction"] == expected_transaction_name - assert ( - transaction_event["transaction_info"]["source"] == expected_transaction_source - ) + assert span["name"] == expected_transaction_name + assert span["attributes"]["sentry.span.source"] == expected_transaction_source + + else: + (transaction_envelope,) = envelopes + transaction_event = transaction_envelope.get_transaction_event() + + assert transaction_event["transaction"] == expected_transaction_name + assert ( + transaction_event["transaction_info"]["source"] + == expected_transaction_source + ) @pytest.mark.asyncio @@ -783,6 +910,10 @@ async def test_transaction_name( ), ], ) +@pytest.mark.parametrize( + "span_streaming", + [True, False], +) async def test_transaction_name_in_traces_sampler( sentry_init, request_url, @@ -790,6 +921,7 @@ async def test_transaction_name_in_traces_sampler( expected_transaction_name, expected_transaction_source, asgi3_app, + span_streaming, ): """ Tests that a custom traces_sampler has a meaningful transaction name. @@ -797,17 +929,28 @@ async def test_transaction_name_in_traces_sampler( """ def dummy_traces_sampler(sampling_context): - assert ( - sampling_context["transaction_context"]["name"] == expected_transaction_name - ) - assert ( - sampling_context["transaction_context"]["source"] - == expected_transaction_source - ) + if span_streaming: + assert sampling_context["span_context"]["name"] == expected_transaction_name + assert ( + sampling_context["span_context"]["attributes"]["sentry.span.source"] + == expected_transaction_source + ) + else: + assert ( + sampling_context["transaction_context"]["name"] + == expected_transaction_name + ) + assert ( + sampling_context["transaction_context"]["source"] + == expected_transaction_source + ) sentry_init( traces_sampler=dummy_traces_sampler, traces_sample_rate=1.0, + _experiments={ + "trace_lifecycle": "stream" if span_streaming else "static", + }, ) app = SentryAsgiMiddleware(asgi3_app, transaction_style=transaction_style) @@ -817,17 +960,44 @@ def dummy_traces_sampler(sampling_context): @pytest.mark.asyncio +@pytest.mark.parametrize( + "span_streaming", + [True, False], +) async def test_custom_transaction_name( - sentry_init, asgi3_custom_transaction_app, capture_events + sentry_init, + asgi3_custom_transaction_app, + capture_events, + capture_items, + span_streaming, ): - sentry_init(traces_sample_rate=1.0) - events = capture_events() + sentry_init( + traces_sample_rate=1.0, + _experiments={ + "trace_lifecycle": "stream" if span_streaming else "static", + }, + ) app = SentryAsgiMiddleware(asgi3_custom_transaction_app) async with TestClient(app) as client: + if span_streaming: + items = capture_items("span") + else: + events = capture_events() await client.get("/test") - (transaction_event,) = events - assert transaction_event["type"] == "transaction" - assert transaction_event["transaction"] == "foobar" - assert transaction_event["transaction_info"] == {"source": "custom"} + sentry_sdk.flush() + + if span_streaming: + assert len(items) == 1 + span = items[0].payload + + assert span["is_segment"] is True + assert span["name"] == "foobar" + assert span["attributes"]["sentry.span.source"] == "custom" + + else: + (transaction_event,) = events + assert transaction_event["type"] == "transaction" + assert transaction_event["transaction"] == "foobar" + assert transaction_event["transaction_info"] == {"source": "custom"} From 7fb48630b9bd462303d6d5c316540ed80039fb79 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 2 Apr 2026 18:24:14 +0200 Subject: [PATCH 13/26] . --- sentry_sdk/integrations/asgi.py | 23 +++++++++++++++++------ sentry_sdk/traces.py | 2 +- tests/conftest.py | 7 ++++++- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/sentry_sdk/integrations/asgi.py b/sentry_sdk/integrations/asgi.py index 3fb732e387..e8287bc55c 100644 --- a/sentry_sdk/integrations/asgi.py +++ b/sentry_sdk/integrations/asgi.py @@ -362,13 +362,24 @@ async def _sentry_wrapped_send( reraise(*exc_info) finally: - with capture_internal_exceptions(): - name, source = self._get_segment_name_and_source( - self.transaction_style, scope + if isinstance(span, StreamedSpan): + already_set = ( + span is not None + and span.name != _DEFAULT_TRANSACTION_NAME + and span.get_attributes().get("sentry.span.source") + in [ + SegmentSource.COMPONENT.value, + SegmentSource.ROUTE.value, + SegmentSource.CUSTOM.value, + ] ) - if isinstance(span, StreamedSpan): - span.name = name - span.set_attribute("sentry.span.source", source) + with capture_internal_exceptions(): + if not already_set: + name, source = self._get_segment_name_and_source( + self.transaction_style, scope + ) + span.name = name + span.set_attribute("sentry.span.source", source) finally: _asgi_middleware_applied.set(False) diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index 944e17e5d7..f44ef71f5b 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -259,6 +259,7 @@ def __init__( self._name: str = name self._active: bool = active self._attributes: "Attributes" = {} + if attributes: for attribute, value in attributes.items(): self.set_attribute(attribute, value) @@ -287,7 +288,6 @@ def __init__( self._span_id: "Optional[str]" = None self._status = SpanStatus.OK.value - self.set_attribute("sentry.span.source", SegmentSource.CUSTOM.value) self._update_active_thread() diff --git a/tests/conftest.py b/tests/conftest.py index c052ddcd35..2828dbc733 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -329,7 +329,12 @@ class UnwrappedItem: @pytest.fixture def capture_items(monkeypatch): - """Capture envelope payload, unfurling individual items.""" + """ + Capture envelope payload, unfurling individual items. + + Makes it easier to work with both events and attribute-based telemetry in + one test. + """ def inner(*types): telemetry = [] From 7490fe3a0dbb2566350a75f41e9908c517a782c0 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 2 Apr 2026 18:38:21 +0200 Subject: [PATCH 14/26] . --- tests/integrations/asgi/test_asgi.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/integrations/asgi/test_asgi.py b/tests/integrations/asgi/test_asgi.py index c59243fc7c..7f44c9d00a 100644 --- a/tests/integrations/asgi/test_asgi.py +++ b/tests/integrations/asgi/test_asgi.py @@ -863,18 +863,19 @@ async def test_transaction_name( }, ) + if span_streaming: + items = capture_items("span") + else: + envelopes = capture_envelopes() + app = SentryAsgiMiddleware(asgi3_app, transaction_style=transaction_style) async with TestClient(app) as client: - if span_streaming: - items = capture_items("span") - else: - envelopes = capture_envelopes() await client.get(request_url) - sentry_sdk.flush() - if span_streaming: + sentry_sdk.flush() + assert len(items) == 1 span = items[0].payload From c11e8f16d8388bbc2ede14de51f5219cc2cd47e8 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 2 Apr 2026 18:41:21 +0200 Subject: [PATCH 15/26] . --- sentry_sdk/integrations/_asgi_common.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/sentry_sdk/integrations/_asgi_common.py b/sentry_sdk/integrations/_asgi_common.py index 24d550d09a..6e9d9d2881 100644 --- a/sentry_sdk/integrations/_asgi_common.py +++ b/sentry_sdk/integrations/_asgi_common.py @@ -123,7 +123,9 @@ def _get_request_attributes(asgi_scope: "Any") -> "dict[str, Any]": for header, value in headers.items(): attributes[f"http.request.header.{header.lower()}"] = value - attributes["http.query"] = _get_query(asgi_scope) + query = _get_query(asgi_scope) + if query: + attributes["http.query"] = query attributes["url.full"] = _get_url( asgi_scope, "http" if ty == "http" else "ws", headers.get("host") From 10bf7c966dbbc34cab87ec60bf32112874e0e6b8 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Mon, 13 Apr 2026 09:23:39 +0200 Subject: [PATCH 16/26] ruff --- sentry_sdk/integrations/asgi.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/sentry_sdk/integrations/asgi.py b/sentry_sdk/integrations/asgi.py index e8287bc55c..a27c302113 100644 --- a/sentry_sdk/integrations/asgi.py +++ b/sentry_sdk/integrations/asgi.py @@ -375,8 +375,10 @@ async def _sentry_wrapped_send( ) with capture_internal_exceptions(): if not already_set: - name, source = self._get_segment_name_and_source( - self.transaction_style, scope + name, source = ( + self._get_segment_name_and_source( + self.transaction_style, scope + ) ) span.name = name span.set_attribute("sentry.span.source", source) From 87d00e6e8cfbee0c00ff8b153e55bf675a0a0b99 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Mon, 13 Apr 2026 09:29:19 +0200 Subject: [PATCH 17/26] source is not default anymore --- tests/tracing/test_span_streaming.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/tracing/test_span_streaming.py b/tests/tracing/test_span_streaming.py index 445c8cfb99..44f504cc26 100644 --- a/tests/tracing/test_span_streaming.py +++ b/tests/tracing/test_span_streaming.py @@ -1540,7 +1540,6 @@ def test_transport_format(sentry_init, capture_envelopes): "start_timestamp": mock.ANY, "end_timestamp": mock.ANY, "attributes": { - "sentry.span.source": {"value": "custom", "type": "string"}, "thread.id": {"value": mock.ANY, "type": "string"}, "thread.name": {"value": "MainThread", "type": "string"}, "sentry.segment.id": {"value": mock.ANY, "type": "string"}, From eebc3859dd8f46ad492f22ce5aadbc99ced86098 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Mon, 13 Apr 2026 09:40:47 +0200 Subject: [PATCH 18/26] mypy --- sentry_sdk/integrations/_asgi_common.py | 3 +-- sentry_sdk/integrations/_wsgi_common.py | 1 + 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/sentry_sdk/integrations/_asgi_common.py b/sentry_sdk/integrations/_asgi_common.py index 6e9d9d2881..d871eac4ab 100644 --- a/sentry_sdk/integrations/_asgi_common.py +++ b/sentry_sdk/integrations/_asgi_common.py @@ -12,7 +12,6 @@ from typing import Union from typing_extensions import Literal - from sentry_sdk._types import Attributes from sentry_sdk.utils import AnnotatedValue @@ -112,7 +111,7 @@ def _get_request_attributes(asgi_scope: "Any") -> "dict[str, Any]": """ Return attributes related to the HTTP request from the ASGI scope. """ - attributes: "Attributes" = {} + attributes: "dict[str, Any]" = {} ty = asgi_scope["type"] if ty in ("http", "websocket"): diff --git a/sentry_sdk/integrations/_wsgi_common.py b/sentry_sdk/integrations/_wsgi_common.py index 8a23f77e83..9f1b1399f0 100644 --- a/sentry_sdk/integrations/_wsgi_common.py +++ b/sentry_sdk/integrations/_wsgi_common.py @@ -217,6 +217,7 @@ def _filter_headers( if should_send_default_pii(): return headers + substitute: "Union[AnnotatedValue, str]" if use_annotated_value: substitute = AnnotatedValue.removed_because_over_size_limit() else: From e38890112860918e5a2c29a8940054d8443cf1da Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Mon, 13 Apr 2026 09:42:39 +0200 Subject: [PATCH 19/26] var name --- tests/conftest.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 2828dbc733..1a19bad7cb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -347,10 +347,10 @@ def append_envelope(envelope): continue if item.type in ("metric", "log", "span"): - for json in item.payload.json["items"]: - t = {k: v for k, v in json.items() if k != "attributes"} + for i in item.payload.json["items"]: + t = {k: v for k, v in i.items() if k != "attributes"} t["attributes"] = { - k: v["value"] for k, v in json["attributes"].items() + k: v["value"] for k, v in i["attributes"].items() } telemetry.append(UnwrappedItem(type=item.type, payload=t)) else: From f33e40714f912336dbeb2c33bc635e50adc441e7 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Mon, 13 Apr 2026 11:11:20 +0200 Subject: [PATCH 20/26] feat(wsgi): Migrate WSGI integration to span first --- sentry_sdk/integrations/_asgi_common.py | 4 +- sentry_sdk/integrations/_wsgi_common.py | 47 +++++++++++ sentry_sdk/integrations/wsgi.py | 107 ++++++++++++++++++------ tests/integrations/wsgi/test_wsgi.py | 53 +++++++++--- 4 files changed, 174 insertions(+), 37 deletions(-) diff --git a/sentry_sdk/integrations/_asgi_common.py b/sentry_sdk/integrations/_asgi_common.py index d871eac4ab..525ca4b5b5 100644 --- a/sentry_sdk/integrations/_asgi_common.py +++ b/sentry_sdk/integrations/_asgi_common.py @@ -132,6 +132,8 @@ def _get_request_attributes(asgi_scope: "Any") -> "dict[str, Any]": client = asgi_scope.get("client") if client and should_send_default_pii(): - attributes["client.address"] = _get_ip(asgi_scope) + ip = _get_ip(asgi_scope) + attributes["client.address"] = ip + attributes["user.ip_address"] = ip return attributes diff --git a/sentry_sdk/integrations/_wsgi_common.py b/sentry_sdk/integrations/_wsgi_common.py index 9f1b1399f0..2dcb2d8dbe 100644 --- a/sentry_sdk/integrations/_wsgi_common.py +++ b/sentry_sdk/integrations/_wsgi_common.py @@ -249,6 +249,53 @@ def _in_http_status_code_range( return False +def _get_request_attributes( + environ: "Dict[str, str]", + use_x_forwarded_for: bool = False, +) -> "Dict[str, Any]": + """ + Return span attributes related to the HTTP request from the WSGI environ. + """ + from sentry_sdk._werkzeug import _get_headers + from sentry_sdk.integrations.wsgi import get_client_ip, get_request_url + + attributes: "Dict[str, Any]" = {} + + method = environ.get("REQUEST_METHOD") + if method: + attributes["http.request.method"] = method.upper() + + headers = _filter_headers(dict(_get_headers(environ)), use_annotated_value=False) + for header, value in headers.items(): + attributes[f"http.request.header.{header.lower()}"] = value + + query_string = environ.get("QUERY_STRING") + if query_string: + attributes["http.query"] = query_string + + attributes["url.full"] = get_request_url(environ, use_x_forwarded_for) + + url_scheme = environ.get("wsgi.url_scheme") + if url_scheme: + attributes["network.protocol.name"] = url_scheme + + server_name = environ.get("SERVER_NAME") + if server_name: + attributes["server.address"] = server_name + + server_port = environ.get("SERVER_PORT") + if server_port: + attributes["server.port"] = server_port + + if should_send_default_pii(): + client_ip = get_client_ip(environ) + if client_ip: + attributes["client.address"] = client_ip + attributes["user.ip_address"] = client_ip + + return attributes + + class HttpCodeRangeContainer: """ Wrapper to make it possible to use list[HttpStatusCodeRange] as a Container[int]. diff --git a/sentry_sdk/integrations/wsgi.py b/sentry_sdk/integrations/wsgi.py index eebd7eb4c3..4cdd018765 100644 --- a/sentry_sdk/integrations/wsgi.py +++ b/sentry_sdk/integrations/wsgi.py @@ -9,11 +9,14 @@ from sentry_sdk.integrations._wsgi_common import ( DEFAULT_HTTP_METHODS_TO_CAPTURE, _filter_headers, + _get_request_attributes, nullcontext, ) from sentry_sdk.scope import should_send_default_pii, use_isolation_scope from sentry_sdk.sessions import track_session +from sentry_sdk.traces import StreamedSpan, SegmentSource from sentry_sdk.tracing import Transaction, TransactionSource +from sentry_sdk.tracing_utils import has_span_streaming_enabled from sentry_sdk.utils import ( ContextVar, capture_internal_exceptions, @@ -22,7 +25,18 @@ ) if TYPE_CHECKING: - from typing import Any, Callable, Dict, Iterator, Optional, Protocol, Tuple, TypeVar + from typing import ( + Any, + Callable, + ContextManager, + Dict, + Iterator, + Optional, + Protocol, + Tuple, + TypeVar, + Union, + ) from sentry_sdk._types import Event, EventProcessor from sentry_sdk.utils import ExcInfo @@ -42,6 +56,7 @@ def __call__( _wsgi_middleware_applied = ContextVar("sentry_wsgi_middleware_applied") +_DEFAULT_TRANSACTION_NAME = "generic WSGI request" def wsgi_decoding_dance(s: str, charset: str = "utf-8", errors: str = "replace") -> str: @@ -94,6 +109,9 @@ def __call__( if _wsgi_middleware_applied.get(False): return self.app(environ, start_response) + client = sentry_sdk.get_client() + span_streaming = has_span_streaming_enabled(client.options) + _wsgi_middleware_applied.set(True) try: with sentry_sdk.isolation_scope() as scope: @@ -108,34 +126,71 @@ def __call__( ) method = environ.get("REQUEST_METHOD", "").upper() - transaction = None + + span_ctx: "ContextManager[Union[Transaction, StreamedSpan, None]]" = None if method in self.http_methods_to_capture: - transaction = continue_trace( - environ, - op=OP.HTTP_SERVER, - name="generic WSGI request", - source=TransactionSource.ROUTE, - origin=self.span_origin, - ) + if span_streaming: + sentry_sdk.traces.continue_trace( + dict(_get_headers(environ)) + ) + scope.set_custom_sampling_context({"wsgi_environ": scope}) + + span_ctx = sentry_sdk.traces.start_span( + name=_DEFAULT_TRANSACTION_NAME, + attributes={ + "sentry.span.source": SegmentSource.ROUTE, + "sentry.origin": self.span_origin, + "sentry.op": OP.HTTP_SERVER, + }, + ) + else: + transaction = continue_trace( + environ, + op=OP.HTTP_SERVER, + name=_DEFAULT_TRANSACTION_NAME, + source=TransactionSource.ROUTE, + origin=self.span_origin, + ) - transaction_context = ( - sentry_sdk.start_transaction( - transaction, - custom_sampling_context={"wsgi_environ": environ}, - ) - if transaction is not None - else nullcontext() - ) - with transaction_context: + span_ctx = sentry_sdk.start_transaction( + transaction, + custom_sampling_context={"wsgi_environ": environ}, + ) + + with capture_internal_exceptions(): + for attr, value in _get_request_attributes( + environ, self.use_x_forwarded_for + ).items(): + scope.set_attribute(attr, value) + + span_ctx = span_ctx or nullcontext() + + with span_ctx as span: try: response = self.app( environ, - partial( - _sentry_start_response, start_response, transaction - ), + partial(_sentry_start_response, start_response, span), ) except BaseException: reraise(*_capture_exception()) + finally: + if isinstance(span, StreamedSpan): + already_set = ( + span.name != _DEFAULT_TRANSACTION_NAME + and span.get_attributes().get("sentry.span.source") + in [ + SegmentSource.COMPONENT.value, + SegmentSource.ROUTE.value, + SegmentSource.CUSTOM.value, + ] + ) + if not already_set: + with capture_internal_exceptions(): + span.name = _DEFAULT_TRANSACTION_NAME + span.set_attribute( + "sentry.span.source", + SegmentSource.ROUTE.value, + ) finally: _wsgi_middleware_applied.set(False) @@ -167,15 +222,19 @@ def __call__( def _sentry_start_response( old_start_response: "StartResponse", - transaction: "Optional[Transaction]", + span: "Optional[Union[Transaction, StreamedSpan]]", status: str, response_headers: "WsgiResponseHeaders", exc_info: "Optional[WsgiExcInfo]" = None, ) -> "WsgiResponseIter": # type: ignore[type-var] with capture_internal_exceptions(): status_int = int(status.split(" ", 1)[0]) - if transaction is not None: - transaction.set_http_status(status_int) + if span is not None: + if isinstance(span, StreamedSpan): + span.status = "error" if status_int >= 400 else "ok" + span.set_attribute("http.response.status_code", status_int) + else: + span.set_http_status(status_int) if exc_info is None: # The Django Rest Framework WSGI test client, and likely other diff --git a/tests/integrations/wsgi/test_wsgi.py b/tests/integrations/wsgi/test_wsgi.py index 48b3b4349b..6a322620b1 100644 --- a/tests/integrations/wsgi/test_wsgi.py +++ b/tests/integrations/wsgi/test_wsgi.py @@ -139,26 +139,50 @@ def test_keyboard_interrupt_is_captured(sentry_init, capture_events): assert event["level"] == "error" +@pytest.mark.parametrize("span_streaming", [True, False]) def test_transaction_with_error( sentry_init, crashing_app, capture_events, + capture_items, DictionaryContaining, # noqa:N803 + span_streaming, ): def dogpark(environ, start_response): raise ValueError("Fetch aborted. The ball was not returned.") - sentry_init(send_default_pii=True, traces_sample_rate=1.0) + sentry_init( + send_default_pii=True, + traces_sample_rate=1.0, + _experiments={ + "trace_lifecycle": "stream" if span_streaming else "static", + }, + ) app = SentryWsgiMiddleware(dogpark) client = Client(app) - events = capture_events() + + if span_streaming: + items = capture_items("event", "span") + else: + events = capture_events() with pytest.raises(ValueError): client.get("http://dogs.are.great/sit/stay/rollover/") - error_event, envelope = events + sentry_sdk.flush() + + if span_streaming: + assert len(items) == 2 + assert items[0].type == "event" + assert items[1].type == "span" + + error_event = items[0].payload + span_item = items[1].payload + else: + error_event, envelope = events + + assert error_event["transaction"] == "generic WSGI request" - assert error_event["transaction"] == "generic WSGI request" assert error_event["contexts"]["trace"]["op"] == "http.server" assert error_event["exception"]["values"][0]["type"] == "ValueError" assert error_event["exception"]["values"][0]["mechanism"]["type"] == "wsgi" @@ -168,15 +192,20 @@ def dogpark(environ, start_response): == "Fetch aborted. The ball was not returned." ) - assert envelope["type"] == "transaction" + if span_streaming: + assert span_item["trace_id"] == error_event["contexts"]["trace"]["trace_id"] + assert span_item["span_id"] == error_event["contexts"]["trace"]["span_id"] + assert span_item["status"] == "error" + else: + assert envelope["type"] == "transaction" - # event trace context is a subset of envelope trace context - assert envelope["contexts"]["trace"] == DictionaryContaining( - error_event["contexts"]["trace"] - ) - assert envelope["contexts"]["trace"]["status"] == "internal_error" - assert envelope["transaction"] == error_event["transaction"] - assert envelope["request"] == error_event["request"] + # event trace context is a subset of envelope trace context + assert envelope["contexts"]["trace"] == DictionaryContaining( + error_event["contexts"]["trace"] + ) + assert envelope["contexts"]["trace"]["status"] == "internal_error" + assert envelope["transaction"] == error_event["transaction"] + assert envelope["request"] == error_event["request"] def test_transaction_no_error( From a2ff75c48c1bdb0fce284d52bbe990f920bc50d7 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Mon, 13 Apr 2026 13:28:33 +0200 Subject: [PATCH 21/26] fixes, more tests --- sentry_sdk/integrations/wsgi.py | 8 +- sentry_sdk/scope.py | 2 +- tests/integrations/wsgi/test_wsgi.py | 302 +++++++++++++++++++++------ 3 files changed, 241 insertions(+), 71 deletions(-) diff --git a/sentry_sdk/integrations/wsgi.py b/sentry_sdk/integrations/wsgi.py index 4cdd018765..146e84585f 100644 --- a/sentry_sdk/integrations/wsgi.py +++ b/sentry_sdk/integrations/wsgi.py @@ -15,7 +15,7 @@ from sentry_sdk.scope import should_send_default_pii, use_isolation_scope from sentry_sdk.sessions import track_session from sentry_sdk.traces import StreamedSpan, SegmentSource -from sentry_sdk.tracing import Transaction, TransactionSource +from sentry_sdk.tracing import Span, TransactionSource from sentry_sdk.tracing_utils import has_span_streaming_enabled from sentry_sdk.utils import ( ContextVar, @@ -127,13 +127,13 @@ def __call__( method = environ.get("REQUEST_METHOD", "").upper() - span_ctx: "ContextManager[Union[Transaction, StreamedSpan, None]]" = None + span_ctx: "Optional[ContextManager[Union[Span, StreamedSpan, None]]]" = None if method in self.http_methods_to_capture: if span_streaming: sentry_sdk.traces.continue_trace( dict(_get_headers(environ)) ) - scope.set_custom_sampling_context({"wsgi_environ": scope}) + scope.set_custom_sampling_context({"wsgi_environ": environ}) span_ctx = sentry_sdk.traces.start_span( name=_DEFAULT_TRANSACTION_NAME, @@ -222,7 +222,7 @@ def __call__( def _sentry_start_response( old_start_response: "StartResponse", - span: "Optional[Union[Transaction, StreamedSpan]]", + span: "Optional[Union[Span, StreamedSpan]]", status: str, response_headers: "WsgiResponseHeaders", exc_info: "Optional[WsgiExcInfo]" = None, diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index e92c0bf7fc..750e602127 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -714,7 +714,7 @@ def get_active_propagation_context(self) -> "PropagationContext": def set_custom_sampling_context( self, custom_sampling_context: "dict[str, Any]" ) -> None: - self.get_active_propagation_context()._set_custom_sampling_context( + self.get_current_scope().get_active_propagation_context()._set_custom_sampling_context( custom_sampling_context ) diff --git a/tests/integrations/wsgi/test_wsgi.py b/tests/integrations/wsgi/test_wsgi.py index 6a322620b1..a95a1d63fa 100644 --- a/tests/integrations/wsgi/test_wsgi.py +++ b/tests/integrations/wsgi/test_wsgi.py @@ -208,64 +208,129 @@ def dogpark(environ, start_response): assert envelope["request"] == error_event["request"] +@pytest.mark.parametrize("span_streaming", [True, False]) def test_transaction_no_error( sentry_init, capture_events, + capture_items, DictionaryContaining, # noqa:N803 + span_streaming, ): def dogpark(environ, start_response): start_response("200 OK", []) return ["Go get the ball! Good dog!"] - sentry_init(send_default_pii=True, traces_sample_rate=1.0) + sentry_init( + send_default_pii=True, + traces_sample_rate=1.0, + _experiments={ + "trace_lifecycle": "stream" if span_streaming else "static", + }, + ) app = SentryWsgiMiddleware(dogpark) client = Client(app) - events = capture_events() + + if span_streaming: + items = capture_items("span") + else: + events = capture_events() client.get("/dogs/are/great/") - envelope = events[0] + sentry_sdk.flush() - assert envelope["type"] == "transaction" - assert envelope["transaction"] == "generic WSGI request" - assert envelope["contexts"]["trace"]["op"] == "http.server" - assert envelope["request"] == DictionaryContaining( - {"method": "GET", "url": "http://localhost/dogs/are/great/"} - ) + if span_streaming: + assert len(items) == 1 + span = items[0].payload + + assert span["is_segment"] is True + assert span["name"] == "generic WSGI request" + assert span["attributes"]["sentry.op"] == "http.server" + assert span["attributes"]["sentry.span.source"] == "route" + assert span["attributes"]["http.request.method"] == "GET" + assert span["attributes"]["url.full"] == "http://localhost/dogs/are/great/" + assert span["attributes"]["http.response.status_code"] == 200 + assert span["status"] == "ok" + else: + envelope = events[0] + assert envelope["type"] == "transaction" + assert envelope["transaction"] == "generic WSGI request" + assert envelope["contexts"]["trace"]["op"] == "http.server" + assert envelope["request"] == DictionaryContaining( + {"method": "GET", "url": "http://localhost/dogs/are/great/"} + ) + +@pytest.mark.parametrize("span_streaming", [True, False]) def test_has_trace_if_performance_enabled( sentry_init, capture_events, + capture_items, + span_streaming, ): def dogpark(environ, start_response): capture_message("Attempting to fetch the ball") raise ValueError("Fetch aborted. The ball was not returned.") - sentry_init(traces_sample_rate=1.0) + sentry_init( + traces_sample_rate=1.0, + _experiments={ + "trace_lifecycle": "stream" if span_streaming else "static", + }, + ) app = SentryWsgiMiddleware(dogpark) client = Client(app) - events = capture_events() + + if span_streaming: + items = capture_items("event", "span") + else: + events = capture_events() with pytest.raises(ValueError): client.get("http://dogs.are.great/sit/stay/rollover/") - msg_event, error_event, transaction_event = events + sentry_sdk.flush() - assert msg_event["contexts"]["trace"] - assert "trace_id" in msg_event["contexts"]["trace"] + if span_streaming: + msg_event, error_event, span_item = items + + assert msg_event.type == "event" + msg_event = msg_event.payload + assert msg_event["contexts"]["trace"] + assert "trace_id" in msg_event["contexts"]["trace"] + + assert error_event.type == "event" + error_event = error_event.payload + assert error_event["contexts"]["trace"] + assert "trace_id" in error_event["contexts"]["trace"] + + assert span_item.type == "span" + span_item = span_item.payload + assert span_item["trace_id"] is not None + + assert ( + msg_event["contexts"]["trace"]["trace_id"] + == error_event["contexts"]["trace"]["trace_id"] + == span_item["trace_id"] + ) + else: + msg_event, error_event, transaction_event = events - assert error_event["contexts"]["trace"] - assert "trace_id" in error_event["contexts"]["trace"] + assert msg_event["contexts"]["trace"] + assert "trace_id" in msg_event["contexts"]["trace"] - assert transaction_event["contexts"]["trace"] - assert "trace_id" in transaction_event["contexts"]["trace"] + assert error_event["contexts"]["trace"] + assert "trace_id" in error_event["contexts"]["trace"] - assert ( - msg_event["contexts"]["trace"]["trace_id"] - == error_event["contexts"]["trace"]["trace_id"] - == transaction_event["contexts"]["trace"]["trace_id"] - ) + assert transaction_event["contexts"]["trace"] + assert "trace_id" in transaction_event["contexts"]["trace"] + + assert ( + msg_event["contexts"]["trace"]["trace_id"] + == error_event["contexts"]["trace"]["trace_id"] + == transaction_event["contexts"]["trace"]["trace_id"] + ) def test_has_trace_if_performance_disabled( @@ -293,18 +358,30 @@ def dogpark(environ, start_response): assert "trace_id" in error_event["contexts"]["trace"] +@pytest.mark.parametrize("span_streaming", [True, False]) def test_trace_from_headers_if_performance_enabled( sentry_init, capture_events, + capture_items, + span_streaming, ): def dogpark(environ, start_response): capture_message("Attempting to fetch the ball") raise ValueError("Fetch aborted. The ball was not returned.") - sentry_init(traces_sample_rate=1.0) + sentry_init( + traces_sample_rate=1.0, + _experiments={ + "trace_lifecycle": "stream" if span_streaming else "static", + }, + ) app = SentryWsgiMiddleware(dogpark) client = Client(app) - events = capture_events() + + if span_streaming: + items = capture_items("event", "span") + else: + events = capture_events() trace_id = "582b43a4192642f0b136d5159a501701" sentry_trace_header = "{}-{}-{}".format(trace_id, "6e8f22c393e68f19", 1) @@ -315,20 +392,29 @@ def dogpark(environ, start_response): headers={"sentry-trace": sentry_trace_header}, ) - msg_event, error_event, transaction_event = events + sentry_sdk.flush() - assert msg_event["contexts"]["trace"] - assert "trace_id" in msg_event["contexts"]["trace"] + if span_streaming: + msg_event, error_event, span_item = items - assert error_event["contexts"]["trace"] - assert "trace_id" in error_event["contexts"]["trace"] + assert msg_event.payload["contexts"]["trace"]["trace_id"] == trace_id + assert error_event.payload["contexts"]["trace"]["trace_id"] == trace_id + assert span_item.payload["trace_id"] == trace_id + else: + msg_event, error_event, transaction_event = events - assert transaction_event["contexts"]["trace"] - assert "trace_id" in transaction_event["contexts"]["trace"] + assert msg_event["contexts"]["trace"] + assert "trace_id" in msg_event["contexts"]["trace"] - assert msg_event["contexts"]["trace"]["trace_id"] == trace_id - assert error_event["contexts"]["trace"]["trace_id"] == trace_id - assert transaction_event["contexts"]["trace"]["trace_id"] == trace_id + assert error_event["contexts"]["trace"] + assert "trace_id" in error_event["contexts"]["trace"] + + assert transaction_event["contexts"]["trace"] + assert "trace_id" in transaction_event["contexts"]["trace"] + + assert msg_event["contexts"]["trace"]["trace_id"] == trace_id + assert error_event["contexts"]["trace"]["trace_id"] == trace_id + assert transaction_event["contexts"]["trace"]["trace_id"] == trace_id def test_trace_from_headers_if_performance_disabled( @@ -364,37 +450,65 @@ def dogpark(environ, start_response): assert error_event["contexts"]["trace"]["trace_id"] == trace_id +@pytest.mark.parametrize("span_streaming", [True, False]) def test_traces_sampler_gets_correct_values_in_sampling_context( sentry_init, DictionaryContaining, # noqa:N803 + span_streaming, ): def app(environ, start_response): start_response("200 OK", []) return ["Go get the ball! Good dog!"] traces_sampler = mock.Mock(return_value=True) - sentry_init(send_default_pii=True, traces_sampler=traces_sampler) + sentry_init( + send_default_pii=True, + traces_sampler=traces_sampler, + _experiments={ + "trace_lifecycle": "stream" if span_streaming else "static", + }, + ) app = SentryWsgiMiddleware(app) client = Client(app) client.get("/dogs/are/great/") - traces_sampler.assert_any_call( - DictionaryContaining( - { - "wsgi_environ": DictionaryContaining( - { - "PATH_INFO": "/dogs/are/great/", - "REQUEST_METHOD": "GET", - }, - ), - } + if span_streaming: + traces_sampler.assert_any_call( + DictionaryContaining( + { + "span_context": DictionaryContaining( + { + "name": "generic WSGI request", + }, + ), + "wsgi_environ": DictionaryContaining( + { + "PATH_INFO": "/dogs/are/great/", + "REQUEST_METHOD": "GET", + }, + ), + } + ) + ) + else: + traces_sampler.assert_any_call( + DictionaryContaining( + { + "wsgi_environ": DictionaryContaining( + { + "PATH_INFO": "/dogs/are/great/", + "REQUEST_METHOD": "GET", + }, + ), + } + ) ) - ) +@pytest.mark.parametrize("span_streaming", [True, False]) def test_session_mode_defaults_to_request_mode_in_wsgi_handler( - capture_envelopes, sentry_init + capture_envelopes, sentry_init, span_streaming ): """ Test that ensures that even though the default `session_mode` for @@ -407,7 +521,13 @@ def app(environ, start_response): return ["Go get the ball! Good dog!"] traces_sampler = mock.Mock(return_value=True) - sentry_init(send_default_pii=True, traces_sampler=traces_sampler) + sentry_init( + send_default_pii=True, + traces_sampler=traces_sampler, + _experiments={ + "trace_lifecycle": "stream" if span_streaming else "static", + }, + ) app = SentryWsgiMiddleware(app) envelopes = capture_envelopes() @@ -417,7 +537,11 @@ def app(environ, start_response): sentry_sdk.flush() - sess = envelopes[1] + session_envelopes = [ + e for e in envelopes if any(item.type == "sessions" for item in e.items) + ] + assert len(session_envelopes) == 1 + sess = session_envelopes[0] assert len(sess.items) == 1 sess_event = sess.items[0].payload.json @@ -426,7 +550,10 @@ def app(environ, start_response): assert aggregates[0]["exited"] == 1 -def test_auto_session_tracking_with_aggregates(sentry_init, capture_envelopes): +@pytest.mark.parametrize("span_streaming", [True, False]) +def test_auto_session_tracking_with_aggregates( + sentry_init, capture_envelopes, span_streaming +): """ Test for correct session aggregates in auto session tracking. """ @@ -439,7 +566,13 @@ def sample_app(environ, start_response): return ["Go get the ball! Good dog!"] traces_sampler = mock.Mock(return_value=True) - sentry_init(send_default_pii=True, traces_sampler=traces_sampler) + sentry_init( + send_default_pii=True, + traces_sampler=traces_sampler, + _experiments={ + "trace_lifecycle": "stream" if span_streaming else "static", + }, + ) app = SentryWsgiMiddleware(sample_app) envelopes = capture_envelopes() assert len(envelopes) == 0 @@ -456,14 +589,21 @@ def sample_app(environ, start_response): count_item_types = Counter() for envelope in envelopes: - count_item_types[envelope.items[0].type] += 1 + for item in envelope.items: + count_item_types[item.type] += 1 - assert count_item_types["transaction"] == 3 + if span_streaming: + assert count_item_types["span"] == 3 + else: + assert count_item_types["transaction"] == 3 assert count_item_types["event"] == 1 assert count_item_types["sessions"] == 1 - assert len(envelopes) == 5 - session_aggregates = envelopes[-1].items[0].payload.json["aggregates"] + session_envelopes = [ + e for e in envelopes if any(item.type == "sessions" for item in e.items) + ] + assert len(session_envelopes) == 1 + session_aggregates = session_envelopes[0].items[0].payload.json["aggregates"] assert session_aggregates[0]["exited"] == 2 assert session_aggregates[0]["crashed"] == 1 assert len(session_aggregates) == 1 @@ -496,43 +636,73 @@ def test_app(environ, start_response): assert len(profiles) == 1 -def test_span_origin_manual(sentry_init, capture_events): +@pytest.mark.parametrize("span_streaming", [True, False]) +def test_span_origin_manual(sentry_init, capture_events, capture_items, span_streaming): def dogpark(environ, start_response): start_response("200 OK", []) return ["Go get the ball! Good dog!"] - sentry_init(send_default_pii=True, traces_sample_rate=1.0) + sentry_init( + send_default_pii=True, + traces_sample_rate=1.0, + _experiments={ + "trace_lifecycle": "stream" if span_streaming else "static", + }, + ) app = SentryWsgiMiddleware(dogpark) - events = capture_events() + if span_streaming: + items = capture_items("span") + else: + events = capture_events() client = Client(app) client.get("/dogs/are/great/") - (event,) = events + sentry_sdk.flush() - assert event["contexts"]["trace"]["origin"] == "manual" + if span_streaming: + assert len(items) == 1 + assert items[0].payload["attributes"]["sentry.origin"] == "manual" + else: + (event,) = events + assert event["contexts"]["trace"]["origin"] == "manual" -def test_span_origin_custom(sentry_init, capture_events): +@pytest.mark.parametrize("span_streaming", [True, False]) +def test_span_origin_custom(sentry_init, capture_events, capture_items, span_streaming): def dogpark(environ, start_response): start_response("200 OK", []) return ["Go get the ball! Good dog!"] - sentry_init(send_default_pii=True, traces_sample_rate=1.0) + sentry_init( + send_default_pii=True, + traces_sample_rate=1.0, + _experiments={ + "trace_lifecycle": "stream" if span_streaming else "static", + }, + ) app = SentryWsgiMiddleware( dogpark, span_origin="auto.dogpark.deluxe", ) - events = capture_events() + if span_streaming: + items = capture_items("span") + else: + events = capture_events() client = Client(app) client.get("/dogs/are/great/") - (event,) = events + sentry_sdk.flush() - assert event["contexts"]["trace"]["origin"] == "auto.dogpark.deluxe" + if span_streaming: + assert len(items) == 1 + assert items[0].payload["attributes"]["sentry.origin"] == "auto.dogpark.deluxe" + else: + (event,) = events + assert event["contexts"]["trace"]["origin"] == "auto.dogpark.deluxe" @pytest.mark.parametrize( From 86fd48a1cf6011657a69ebcccbb63df05469dab5 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Mon, 13 Apr 2026 16:15:59 +0200 Subject: [PATCH 22/26] dont set on scope --- sentry_sdk/integrations/asgi.py | 3 --- sentry_sdk/integrations/wsgi.py | 12 ++++++------ 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/sentry_sdk/integrations/asgi.py b/sentry_sdk/integrations/asgi.py index 01b72c12a3..64dc3cc554 100644 --- a/sentry_sdk/integrations/asgi.py +++ b/sentry_sdk/integrations/asgi.py @@ -307,9 +307,6 @@ 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: if isinstance(span, StreamedSpan): for attribute, value in _get_request_attributes( diff --git a/sentry_sdk/integrations/wsgi.py b/sentry_sdk/integrations/wsgi.py index 146e84585f..f294cb7e8d 100644 --- a/sentry_sdk/integrations/wsgi.py +++ b/sentry_sdk/integrations/wsgi.py @@ -157,15 +157,15 @@ def __call__( custom_sampling_context={"wsgi_environ": environ}, ) - with capture_internal_exceptions(): - for attr, value in _get_request_attributes( - environ, self.use_x_forwarded_for - ).items(): - scope.set_attribute(attr, value) - span_ctx = span_ctx or nullcontext() with span_ctx as span: + with capture_internal_exceptions(): + for attr, value in _get_request_attributes( + environ, self.use_x_forwarded_for + ).items(): + span.set_attribute(attr, value) + try: response = self.app( environ, From 96480221ead9084f9759bfaaea7c5147028fb4d0 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Mon, 13 Apr 2026 16:18:09 +0200 Subject: [PATCH 23/26] move imports --- sentry_sdk/integrations/_wsgi_common.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sentry_sdk/integrations/_wsgi_common.py b/sentry_sdk/integrations/_wsgi_common.py index 2dcb2d8dbe..2ce28ae99e 100644 --- a/sentry_sdk/integrations/_wsgi_common.py +++ b/sentry_sdk/integrations/_wsgi_common.py @@ -4,6 +4,8 @@ import sentry_sdk from sentry_sdk._types import SENSITIVE_DATA_SUBSTITUTE +from sentry_sdk._werkzeug import _get_headers +from sentry_sdk.integrations.wsgi import get_client_ip, get_request_url from sentry_sdk.scope import should_send_default_pii from sentry_sdk.utils import AnnotatedValue, logger @@ -256,8 +258,6 @@ def _get_request_attributes( """ Return span attributes related to the HTTP request from the WSGI environ. """ - from sentry_sdk._werkzeug import _get_headers - from sentry_sdk.integrations.wsgi import get_client_ip, get_request_url attributes: "Dict[str, Any]" = {} From 0d85002c62bd4bbdb5f81562024f3a979a61a3cf Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Tue, 14 Apr 2026 11:16:40 +0200 Subject: [PATCH 24/26] . --- sentry_sdk/integrations/_wsgi_common.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/sentry_sdk/integrations/_wsgi_common.py b/sentry_sdk/integrations/_wsgi_common.py index 2ce28ae99e..bbaef06c73 100644 --- a/sentry_sdk/integrations/_wsgi_common.py +++ b/sentry_sdk/integrations/_wsgi_common.py @@ -258,8 +258,7 @@ def _get_request_attributes( """ Return span attributes related to the HTTP request from the WSGI environ. """ - - attributes: "Dict[str, Any]" = {} + attributes: "dict[str, Any]" = {} method = environ.get("REQUEST_METHOD") if method: From 54064a5ba73c9d4c5a9f4a22675a59148bdd1e8a Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Tue, 14 Apr 2026 11:32:19 +0200 Subject: [PATCH 25/26] fix --- sentry_sdk/integrations/_wsgi_common.py | 46 -------------------- sentry_sdk/integrations/wsgi.py | 56 ++++++++++++++++++++++--- 2 files changed, 50 insertions(+), 52 deletions(-) diff --git a/sentry_sdk/integrations/_wsgi_common.py b/sentry_sdk/integrations/_wsgi_common.py index bbaef06c73..9f1b1399f0 100644 --- a/sentry_sdk/integrations/_wsgi_common.py +++ b/sentry_sdk/integrations/_wsgi_common.py @@ -4,8 +4,6 @@ import sentry_sdk from sentry_sdk._types import SENSITIVE_DATA_SUBSTITUTE -from sentry_sdk._werkzeug import _get_headers -from sentry_sdk.integrations.wsgi import get_client_ip, get_request_url from sentry_sdk.scope import should_send_default_pii from sentry_sdk.utils import AnnotatedValue, logger @@ -251,50 +249,6 @@ def _in_http_status_code_range( return False -def _get_request_attributes( - environ: "Dict[str, str]", - use_x_forwarded_for: bool = False, -) -> "Dict[str, Any]": - """ - Return span attributes related to the HTTP request from the WSGI environ. - """ - attributes: "dict[str, Any]" = {} - - method = environ.get("REQUEST_METHOD") - if method: - attributes["http.request.method"] = method.upper() - - headers = _filter_headers(dict(_get_headers(environ)), use_annotated_value=False) - for header, value in headers.items(): - attributes[f"http.request.header.{header.lower()}"] = value - - query_string = environ.get("QUERY_STRING") - if query_string: - attributes["http.query"] = query_string - - attributes["url.full"] = get_request_url(environ, use_x_forwarded_for) - - url_scheme = environ.get("wsgi.url_scheme") - if url_scheme: - attributes["network.protocol.name"] = url_scheme - - server_name = environ.get("SERVER_NAME") - if server_name: - attributes["server.address"] = server_name - - server_port = environ.get("SERVER_PORT") - if server_port: - attributes["server.port"] = server_port - - if should_send_default_pii(): - client_ip = get_client_ip(environ) - if client_ip: - attributes["client.address"] = client_ip - attributes["user.ip_address"] = client_ip - - return attributes - - class HttpCodeRangeContainer: """ Wrapper to make it possible to use list[HttpStatusCodeRange] as a Container[int]. diff --git a/sentry_sdk/integrations/wsgi.py b/sentry_sdk/integrations/wsgi.py index f294cb7e8d..a1e05bb833 100644 --- a/sentry_sdk/integrations/wsgi.py +++ b/sentry_sdk/integrations/wsgi.py @@ -9,7 +9,6 @@ from sentry_sdk.integrations._wsgi_common import ( DEFAULT_HTTP_METHODS_TO_CAPTURE, _filter_headers, - _get_request_attributes, nullcontext, ) from sentry_sdk.scope import should_send_default_pii, use_isolation_scope @@ -160,11 +159,12 @@ def __call__( span_ctx = span_ctx or nullcontext() with span_ctx as span: - with capture_internal_exceptions(): - for attr, value in _get_request_attributes( - environ, self.use_x_forwarded_for - ).items(): - span.set_attribute(attr, value) + if isinstance(span, StreamedSpan): + with capture_internal_exceptions(): + for attr, value in _get_request_attributes( + environ, self.use_x_forwarded_for + ).items(): + span.set_attribute(attr, value) try: response = self.app( @@ -385,3 +385,47 @@ def event_processor(event: "Event", hint: "Dict[str, Any]") -> "Event": return event return event_processor + + +def _get_request_attributes( + environ: "Dict[str, str]", + use_x_forwarded_for: bool = False, +) -> "Dict[str, Any]": + """ + Return span attributes related to the HTTP request from the WSGI environ. + """ + attributes: "dict[str, Any]" = {} + + method = environ.get("REQUEST_METHOD") + if method: + attributes["http.request.method"] = method.upper() + + headers = _filter_headers(dict(_get_headers(environ)), use_annotated_value=False) + for header, value in headers.items(): + attributes[f"http.request.header.{header.lower()}"] = value + + query_string = environ.get("QUERY_STRING") + if query_string: + attributes["http.query"] = query_string + + attributes["url.full"] = get_request_url(environ, use_x_forwarded_for) + + url_scheme = environ.get("wsgi.url_scheme") + if url_scheme: + attributes["network.protocol.name"] = url_scheme + + server_name = environ.get("SERVER_NAME") + if server_name: + attributes["server.address"] = server_name + + server_port = environ.get("SERVER_PORT") + if server_port: + attributes["server.port"] = server_port + + if should_send_default_pii(): + client_ip = get_client_ip(environ) + if client_ip: + attributes["client.address"] = client_ip + attributes["user.ip_address"] = client_ip + + return attributes From 94532f6c74ab95ad421949c64e088d34f4ac19ef Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Tue, 14 Apr 2026 12:23:26 +0200 Subject: [PATCH 26/26] set server.port as int --- sentry_sdk/integrations/wsgi.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/sentry_sdk/integrations/wsgi.py b/sentry_sdk/integrations/wsgi.py index a1e05bb833..8814a82858 100644 --- a/sentry_sdk/integrations/wsgi.py +++ b/sentry_sdk/integrations/wsgi.py @@ -420,7 +420,10 @@ def _get_request_attributes( server_port = environ.get("SERVER_PORT") if server_port: - attributes["server.port"] = server_port + try: + attributes["server.port"] = int(server_port) + except ValueError: + pass if should_send_default_pii(): client_ip = get_client_ip(environ)