From 41192b7a7c207a2ba613859be5a2ba8a40eed725 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Mon, 13 Jul 2026 15:55:17 +0200 Subject: [PATCH 01/16] fix(tracing): Skip child span creation in streaming path when no current span (HTTP clients) When span streaming is enabled and there is no current span, HTTP client integrations should not create new root segments for child-span operations. This adds a guard check using `sentry_sdk.traces.get_current_span()` before creating spans in the streaming path. Affected integrations: httpx, httpx2, pyreqwest, boto3, stdlib. Co-Authored-By: Claude Opus 4.6 --- sentry_sdk/integrations/boto3.py | 2 ++ sentry_sdk/integrations/httpx.py | 6 +++++ sentry_sdk/integrations/httpx2.py | 6 +++++ sentry_sdk/integrations/pyreqwest.py | 38 ++++++++++++++++------------ sentry_sdk/integrations/stdlib.py | 10 ++++++++ 5 files changed, 46 insertions(+), 16 deletions(-) diff --git a/sentry_sdk/integrations/boto3.py b/sentry_sdk/integrations/boto3.py index a7fdd99b21..69deefc7b7 100644 --- a/sentry_sdk/integrations/boto3.py +++ b/sentry_sdk/integrations/boto3.py @@ -67,6 +67,8 @@ def _sentry_request_created( is_span_streaming_enabled = has_span_streaming_enabled(client.options) span: "Union[Span, StreamedSpan]" if is_span_streaming_enabled: + if sentry_sdk.traces.get_current_span() is None: + return span = sentry_sdk.traces.start_span( name=description, attributes={ diff --git a/sentry_sdk/integrations/httpx.py b/sentry_sdk/integrations/httpx.py index a68f20b299..3322de564e 100644 --- a/sentry_sdk/integrations/httpx.py +++ b/sentry_sdk/integrations/httpx.py @@ -60,6 +60,9 @@ def send(self: "Client", request: "Request", **kwargs: "Any") -> "Response": parsed_url = parse_url(str(request.url), sanitize=False) if is_span_streaming_enabled: + if sentry_sdk.traces.get_current_span() is None: + return real_send(self, request, **kwargs) + with sentry_sdk.traces.start_span( name="%s %s" % ( @@ -170,6 +173,9 @@ async def send( parsed_url = parse_url(str(request.url), sanitize=False) if is_span_streaming_enabled: + if sentry_sdk.traces.get_current_span() is None: + return await real_send(self, request, **kwargs) + with sentry_sdk.traces.start_span( name="%s %s" % ( diff --git a/sentry_sdk/integrations/httpx2.py b/sentry_sdk/integrations/httpx2.py index 25062aaa11..d327e320f6 100644 --- a/sentry_sdk/integrations/httpx2.py +++ b/sentry_sdk/integrations/httpx2.py @@ -60,6 +60,9 @@ def send(self: "Client", request: "Request", **kwargs: "Any") -> "Response": parsed_url = parse_url(str(request.url), sanitize=False) if is_span_streaming_enabled: + if sentry_sdk.traces.get_current_span() is None: + return real_send(self, request, **kwargs) + with sentry_sdk.traces.start_span( name="%s %s" % ( @@ -170,6 +173,9 @@ async def send( parsed_url = parse_url(str(request.url), sanitize=False) if is_span_streaming_enabled: + if sentry_sdk.traces.get_current_span() is None: + return await real_send(self, request, **kwargs) + with sentry_sdk.traces.start_span( name="%s %s" % ( diff --git a/sentry_sdk/integrations/pyreqwest.py b/sentry_sdk/integrations/pyreqwest.py index aae68c4c10..7ca73b370a 100644 --- a/sentry_sdk/integrations/pyreqwest.py +++ b/sentry_sdk/integrations/pyreqwest.py @@ -18,6 +18,7 @@ SENSITIVE_DATA_SUBSTITUTE, capture_internal_exceptions, logger, + nullcontext, parse_url, ) @@ -88,18 +89,22 @@ def _sentry_pyreqwest_span(request: "Request") -> "Generator[Any, None, None]": span_streaming = has_span_streaming_enabled(sentry_sdk.get_client().options) if span_streaming: - with sentry_sdk.traces.start_span( - name=f"{request.method} {parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE}", - attributes={ - "sentry.op": OP.HTTP_CLIENT, - "sentry.origin": PyreqwestIntegration.origin, - SPANDATA.HTTP_REQUEST_METHOD: request.method, - }, - ) as span: - if parsed_url is not None and should_send_default_pii(): - span.set_attribute(SPANDATA.URL_FULL, parsed_url.url) - span.set_attribute(SPANDATA.URL_QUERY, parsed_url.query) - span.set_attribute(SPANDATA.URL_FRAGMENT, parsed_url.fragment) + span_ctx = nullcontext() + if sentry_sdk.traces.get_current_span() is not None: + span_ctx = sentry_sdk.traces.start_span( + name=f"{request.method} {parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE}", + attributes={ + "sentry.op": OP.HTTP_CLIENT, + "sentry.origin": PyreqwestIntegration.origin, + SPANDATA.HTTP_REQUEST_METHOD: request.method, + }, + ) + with span_ctx as span: + if span is not None: + if parsed_url is not None and should_send_default_pii(): + span.set_attribute(SPANDATA.URL_FULL, parsed_url.url) + span.set_attribute(SPANDATA.URL_QUERY, parsed_url.query) + span.set_attribute(SPANDATA.URL_FRAGMENT, parsed_url.fragment) if should_propagate_trace(sentry_sdk.get_client(), str(request.url)): for ( @@ -119,8 +124,9 @@ def _sentry_pyreqwest_span(request: "Request") -> "Generator[Any, None, None]": yield span - with capture_internal_exceptions(): - add_http_request_source(span) + if span is not None: + with capture_internal_exceptions(): + add_http_request_source(span) return @@ -171,7 +177,7 @@ async def sentry_async_middleware( SPANDATA.HTTP_STATUS_CODE, response.status, ) - else: + elif span is not None: span.set_http_status(response.status) return response @@ -191,7 +197,7 @@ def sentry_sync_middleware( SPANDATA.HTTP_STATUS_CODE, response.status, ) - else: + elif span is not None: span.set_http_status(response.status) return response diff --git a/sentry_sdk/integrations/stdlib.py b/sentry_sdk/integrations/stdlib.py index 82f30f2dda..1cb1b62a5f 100644 --- a/sentry_sdk/integrations/stdlib.py +++ b/sentry_sdk/integrations/stdlib.py @@ -114,6 +114,9 @@ def putrequest( span_streaming = has_span_streaming_enabled(client.options) span: "Union[Span, StreamedSpan]" if span_streaming: + if sentry_sdk.traces.get_current_span() is None: + return real_putrequest(self, method, url, *args, **kwargs) + span = sentry_sdk.traces.start_span( name="%s %s" % (method, parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE), @@ -303,6 +306,9 @@ def sentry_patched_popen_init( span_streaming = has_span_streaming_enabled(sentry_sdk.get_client().options) span: "Union[Span, StreamedSpan]" if span_streaming: + if sentry_sdk.traces.get_current_span() is None: + return old_popen_init(self, *a, **kw) + span = sentry_sdk.traces.start_span( name=description, attributes={ @@ -353,6 +359,8 @@ def sentry_patched_popen_wait( ) -> "Any": span_streaming = has_span_streaming_enabled(sentry_sdk.get_client().options) if span_streaming: + if sentry_sdk.traces.get_current_span() is None: + return old_popen_wait(self, *a, **kw) with sentry_sdk.traces.start_span( name=OP.SUBPROCESS_WAIT, attributes={ @@ -380,6 +388,8 @@ def sentry_patched_popen_communicate( ) -> "Any": span_streaming = has_span_streaming_enabled(sentry_sdk.get_client().options) if span_streaming: + if sentry_sdk.traces.get_current_span() is None: + return old_popen_communicate(self, *a, **kw) with sentry_sdk.traces.start_span( name=OP.SUBPROCESS_COMMUNICATE, attributes={ From fcade98d64c77ab5a012e06c62f655bafd46a702 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Mon, 13 Jul 2026 17:17:24 +0200 Subject: [PATCH 02/16] fix(tests): Update httpx2 and strawberry tests for streaming child span guards httpx2: Wrap HTTP calls in parent span context so child spans are created. strawberry: Remove async/sync branching since both paths now produce 5 spans. Co-Authored-By: Claude Opus 4.6 --- tests/integrations/httpx2/test_httpx2.py | 116 ++++++++++-------- .../strawberry/test_strawberry.py | 60 +++------ 2 files changed, 78 insertions(+), 98 deletions(-) diff --git a/tests/integrations/httpx2/test_httpx2.py b/tests/integrations/httpx2/test_httpx2.py index 15d50513ae..dd41e3de12 100644 --- a/tests/integrations/httpx2/test_httpx2.py +++ b/tests/integrations/httpx2/test_httpx2.py @@ -733,10 +733,11 @@ def test_outgoing_trace_headers_span_streaming( items = capture_items("span") - if asyncio.iscoroutinefunction(httpx2_client.get): - response = asyncio.run(httpx2_client.get(url)) - else: - response = httpx2_client.get(url) + with sentry_sdk.traces.start_span(name="test"): + if asyncio.iscoroutinefunction(httpx2_client.get): + response = asyncio.run(httpx2_client.get(url)) + else: + response = httpx2_client.get(url) sentry_sdk.flush() @@ -775,12 +776,13 @@ def test_outgoing_trace_headers_append_to_baggage_span_streaming( items = capture_items("span") with mock.patch("sentry_sdk.tracing_utils.Random.randrange", return_value=500000): - if asyncio.iscoroutinefunction(httpx2_client.get): - response = asyncio.run( - httpx2_client.get(url, headers={"baGGage": "custom=data"}) - ) - else: - response = httpx2_client.get(url, headers={"baGGage": "custom=data"}) + with sentry_sdk.traces.start_span(name="test"): + if asyncio.iscoroutinefunction(httpx2_client.get): + response = asyncio.run( + httpx2_client.get(url, headers={"baGGage": "custom=data"}) + ) + else: + response = httpx2_client.get(url, headers={"baGGage": "custom=data"}) sentry_sdk.flush() @@ -814,10 +816,11 @@ def test_request_source_disabled_span_streaming( url = "http://example.com/" - if asyncio.iscoroutinefunction(httpx2_client.get): - asyncio.run(httpx2_client.get(url)) - else: - httpx2_client.get(url) + with sentry_sdk.traces.start_span(name="test"): + if asyncio.iscoroutinefunction(httpx2_client.get): + asyncio.run(httpx2_client.get(url)) + else: + httpx2_client.get(url) sentry_sdk.flush() @@ -858,10 +861,11 @@ def test_request_source_enabled_span_streaming( url = "http://example.com/" - if asyncio.iscoroutinefunction(httpx2_client.get): - asyncio.run(httpx2_client.get(url)) - else: - httpx2_client.get(url) + with sentry_sdk.traces.start_span(name="test"): + if asyncio.iscoroutinefunction(httpx2_client.get): + asyncio.run(httpx2_client.get(url)) + else: + httpx2_client.get(url) sentry_sdk.flush() @@ -894,10 +898,11 @@ def test_request_source_span_streaming( url = "http://example.com/" - if asyncio.iscoroutinefunction(httpx2_client.get): - asyncio.run(httpx2_client.get(url)) - else: - httpx2_client.get(url) + with sentry_sdk.traces.start_span(name="test"): + if asyncio.iscoroutinefunction(httpx2_client.get): + asyncio.run(httpx2_client.get(url)) + else: + httpx2_client.get(url) sentry_sdk.flush() @@ -951,14 +956,15 @@ def test_request_source_with_module_in_search_path_span_streaming( url = "http://example.com/" - if asyncio.iscoroutinefunction(httpx2_client.get): - from httpx2_helpers.helpers import async_get_request_with_client + with sentry_sdk.traces.start_span(name="test"): + if asyncio.iscoroutinefunction(httpx2_client.get): + from httpx2_helpers.helpers import async_get_request_with_client - asyncio.run(async_get_request_with_client(httpx2_client, url)) - else: - from httpx2_helpers.helpers import get_request_with_client + asyncio.run(async_get_request_with_client(httpx2_client, url)) + else: + from httpx2_helpers.helpers import get_request_with_client - get_request_with_client(httpx2_client, url) + get_request_with_client(httpx2_client, url) sentry_sdk.flush() @@ -1010,10 +1016,11 @@ def test_no_request_source_if_duration_too_short_span_streaming( url = "http://example.com/" - if asyncio.iscoroutinefunction(httpx2_client.get): - asyncio.run(httpx2_client.get(url)) - else: - httpx2_client.get(url) + with sentry_sdk.traces.start_span(name="test"): + if asyncio.iscoroutinefunction(httpx2_client.get): + asyncio.run(httpx2_client.get(url)) + else: + httpx2_client.get(url) sentry_sdk.flush() @@ -1047,10 +1054,11 @@ def test_request_source_if_duration_over_threshold_span_streaming( url = "http://example.com/" - if asyncio.iscoroutinefunction(httpx2_client.get): - asyncio.run(httpx2_client.get(url)) - else: - httpx2_client.get(url) + with sentry_sdk.traces.start_span(name="test"): + if asyncio.iscoroutinefunction(httpx2_client.get): + asyncio.run(httpx2_client.get(url)) + else: + httpx2_client.get(url) sentry_sdk.flush() @@ -1099,10 +1107,11 @@ def test_span_origin_span_streaming( url = "http://example.com/" - if asyncio.iscoroutinefunction(httpx2_client.get): - asyncio.run(httpx2_client.get(url)) - else: - httpx2_client.get(url) + with sentry_sdk.traces.start_span(name="test"): + if asyncio.iscoroutinefunction(httpx2_client.get): + asyncio.run(httpx2_client.get(url)) + else: + httpx2_client.get(url) sentry_sdk.flush() @@ -1131,10 +1140,11 @@ def test_http_url_attributes_span_streaming( url = "http://example.com/?foo=bar#frag" - if asyncio.iscoroutinefunction(httpx2_client.get): - asyncio.run(httpx2_client.get(url)) - else: - httpx2_client.get(url) + with sentry_sdk.traces.start_span(name="test"): + if asyncio.iscoroutinefunction(httpx2_client.get): + asyncio.run(httpx2_client.get(url)) + else: + httpx2_client.get(url) sentry_sdk.flush() @@ -1167,10 +1177,11 @@ def test_http_url_attributes_no_query_or_fragment_span_streaming( url = "http://example.com/" - if asyncio.iscoroutinefunction(httpx2_client.get): - asyncio.run(httpx2_client.get(url)) - else: - httpx2_client.get(url) + with sentry_sdk.traces.start_span(name="test"): + if asyncio.iscoroutinefunction(httpx2_client.get): + asyncio.run(httpx2_client.get(url)) + else: + httpx2_client.get(url) sentry_sdk.flush() @@ -1202,10 +1213,11 @@ def test_http_url_attributes_pii_disabled_span_streaming( url = "http://example.com/?foo=bar#frag" - if asyncio.iscoroutinefunction(httpx2_client.get): - asyncio.run(httpx2_client.get(url)) - else: - httpx2_client.get(url) + with sentry_sdk.traces.start_span(name="test"): + if asyncio.iscoroutinefunction(httpx2_client.get): + asyncio.run(httpx2_client.get(url)) + else: + httpx2_client.get(url) sentry_sdk.flush() diff --git a/tests/integrations/strawberry/test_strawberry.py b/tests/integrations/strawberry/test_strawberry.py index 16e15142ff..99802a3a86 100644 --- a/tests/integrations/strawberry/test_strawberry.py +++ b/tests/integrations/strawberry/test_strawberry.py @@ -331,14 +331,8 @@ def test_capture_transaction_on_error( assert len(error_events) == 1 - if async_execution: - # When FastAPI is run, there's an extra span from the httpx client - # so we need to account for that - assert len(spans) == 6 - parse_span, validate_span, resolve_span, query_span, segment, _ = spans - else: - assert len(spans) == 5 - parse_span, validate_span, resolve_span, query_span, segment = spans + assert len(spans) == 5 + parse_span, validate_span, resolve_span, query_span, segment = spans assert segment["is_segment"] is True assert segment["name"] == "ErrorQuery" @@ -473,12 +467,8 @@ def test_capture_transaction_on_success( sentry_sdk.flush() spans = [i.payload for i in items] - if async_execution: - assert len(spans) == 6 - parse_span, validate_span, resolve_span, query_span, segment, _ = spans - else: - assert len(spans) == 5 - parse_span, validate_span, resolve_span, query_span, segment = spans + assert len(spans) == 5 + parse_span, validate_span, resolve_span, query_span, segment = spans assert segment["is_segment"] is True assert segment["name"] == "GreetingQuery" @@ -613,12 +603,8 @@ def test_transaction_no_operation_name( sentry_sdk.flush() spans = [i.payload for i in items] - if async_execution: - assert len(spans) == 6 - parse_span, validate_span, resolve_span, query_span, segment, _ = spans - else: - assert len(spans) == 5 - parse_span, validate_span, resolve_span, query_span, segment = spans + assert len(spans) == 5 + parse_span, validate_span, resolve_span, query_span, segment = spans assert segment["is_segment"] is True if async_execution: @@ -758,12 +744,8 @@ def test_transaction_mutation( sentry_sdk.flush() spans = [i.payload for i in items] - if async_execution: - assert len(spans) == 6 - parse_span, validate_span, resolve_span, mutation_span, segment, _ = spans - else: - assert len(spans) == 5 - parse_span, validate_span, resolve_span, mutation_span, segment = spans + assert len(spans) == 5 + parse_span, validate_span, resolve_span, mutation_span, segment = spans assert segment["is_segment"] is True assert segment["name"] == "Change" @@ -924,12 +906,8 @@ def test_span_origin( sentry_sdk.flush() spans = [i.payload for i in items] - if async_execution: - assert len(spans) == 6 - parse_span, validate_span, resolve_span, mutation_span, segment, _ = spans - else: - assert len(spans) == 5 - parse_span, validate_span, resolve_span, mutation_span, segment = spans + assert len(spans) == 5 + parse_span, validate_span, resolve_span, mutation_span, segment = spans assert segment["is_segment"] is True if is_flask: @@ -995,12 +973,8 @@ def test_span_origin2( sentry_sdk.flush() spans = [i.payload for i in items] - if async_execution: - assert len(spans) == 6 - parse_span, validate_span, resolve_span, query_span, segment, _ = spans - else: - assert len(spans) == 5 - parse_span, validate_span, resolve_span, query_span, segment = spans + assert len(spans) == 5 + parse_span, validate_span, resolve_span, query_span, segment = spans assert segment["is_segment"] is True if is_flask: @@ -1066,14 +1040,8 @@ def test_span_origin3( sentry_sdk.flush() spans = [i.payload for i in items] - if async_execution: - assert len(spans) == 6 - parse_span, validate_span, resolve_span, subscription_span, segment, _ = ( - spans - ) - else: - assert len(spans) == 5 - parse_span, validate_span, resolve_span, subscription_span, segment = spans + assert len(spans) == 5 + parse_span, validate_span, resolve_span, subscription_span, segment = spans assert segment["is_segment"] is True if is_flask: From 6ea870fda4d47cdbf50bd8e3e8e83710da02d340 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Tue, 14 Jul 2026 10:42:10 -0400 Subject: [PATCH 03/16] test(httpx): Wrap streaming tests in parent span The streaming child-span guard added for HTTP client integrations skips child-span creation when there is no current span. The httpx2 and strawberry tests were updated for this, but the equivalent httpx streaming tests were missed, so no HTTP client span was produced and the tests failed with StopIteration. Wrap each streaming test's request in a parent span so a child HTTP client span is created, mirroring the httpx2 test updates. Co-Authored-By: Claude Opus 4.6 --- tests/integrations/httpx/test_httpx.py | 113 ++++++++++++++----------- 1 file changed, 63 insertions(+), 50 deletions(-) diff --git a/tests/integrations/httpx/test_httpx.py b/tests/integrations/httpx/test_httpx.py index 4ca18c8edc..580076207a 100644 --- a/tests/integrations/httpx/test_httpx.py +++ b/tests/integrations/httpx/test_httpx.py @@ -739,10 +739,13 @@ def test_outgoing_trace_headers_span_streaming( items = capture_items("span") - if asyncio.iscoroutinefunction(httpx_client.get): - response = asyncio.get_event_loop().run_until_complete(httpx_client.get(url)) - else: - response = httpx_client.get(url) + with sentry_sdk.traces.start_span(name="test"): + if asyncio.iscoroutinefunction(httpx_client.get): + response = asyncio.get_event_loop().run_until_complete( + httpx_client.get(url) + ) + else: + response = httpx_client.get(url) sentry_sdk.flush() @@ -781,12 +784,13 @@ def test_outgoing_trace_headers_append_to_baggage_span_streaming( items = capture_items("span") with mock.patch("sentry_sdk.tracing_utils.Random.randrange", return_value=500000): - if asyncio.iscoroutinefunction(httpx_client.get): - response = asyncio.get_event_loop().run_until_complete( - httpx_client.get(url, headers={"baGGage": "custom=data"}) - ) - else: - response = httpx_client.get(url, headers={"baGGage": "custom=data"}) + with sentry_sdk.traces.start_span(name="test"): + if asyncio.iscoroutinefunction(httpx_client.get): + response = asyncio.get_event_loop().run_until_complete( + httpx_client.get(url, headers={"baGGage": "custom=data"}) + ) + else: + response = httpx_client.get(url, headers={"baGGage": "custom=data"}) sentry_sdk.flush() @@ -820,10 +824,11 @@ def test_request_source_disabled_span_streaming( url = "http://example.com/" - if asyncio.iscoroutinefunction(httpx_client.get): - asyncio.get_event_loop().run_until_complete(httpx_client.get(url)) - else: - httpx_client.get(url) + with sentry_sdk.traces.start_span(name="test"): + if asyncio.iscoroutinefunction(httpx_client.get): + asyncio.get_event_loop().run_until_complete(httpx_client.get(url)) + else: + httpx_client.get(url) sentry_sdk.flush() @@ -864,10 +869,11 @@ def test_request_source_enabled_span_streaming( url = "http://example.com/" - if asyncio.iscoroutinefunction(httpx_client.get): - asyncio.get_event_loop().run_until_complete(httpx_client.get(url)) - else: - httpx_client.get(url) + with sentry_sdk.traces.start_span(name="test"): + if asyncio.iscoroutinefunction(httpx_client.get): + asyncio.get_event_loop().run_until_complete(httpx_client.get(url)) + else: + httpx_client.get(url) sentry_sdk.flush() @@ -900,10 +906,11 @@ def test_request_source_span_streaming( url = "http://example.com/" - if asyncio.iscoroutinefunction(httpx_client.get): - asyncio.get_event_loop().run_until_complete(httpx_client.get(url)) - else: - httpx_client.get(url) + with sentry_sdk.traces.start_span(name="test"): + if asyncio.iscoroutinefunction(httpx_client.get): + asyncio.get_event_loop().run_until_complete(httpx_client.get(url)) + else: + httpx_client.get(url) sentry_sdk.flush() @@ -957,16 +964,17 @@ def test_request_source_with_module_in_search_path_span_streaming( url = "http://example.com/" - if asyncio.iscoroutinefunction(httpx_client.get): - from httpx_helpers.helpers import async_get_request_with_client + with sentry_sdk.traces.start_span(name="test"): + if asyncio.iscoroutinefunction(httpx_client.get): + from httpx_helpers.helpers import async_get_request_with_client - asyncio.get_event_loop().run_until_complete( - async_get_request_with_client(httpx_client, url) - ) - else: - from httpx_helpers.helpers import get_request_with_client + asyncio.get_event_loop().run_until_complete( + async_get_request_with_client(httpx_client, url) + ) + else: + from httpx_helpers.helpers import get_request_with_client - get_request_with_client(httpx_client, url) + get_request_with_client(httpx_client, url) sentry_sdk.flush() @@ -1018,10 +1026,11 @@ def test_no_request_source_if_duration_too_short_span_streaming( url = "http://example.com/" - if asyncio.iscoroutinefunction(httpx_client.get): - asyncio.get_event_loop().run_until_complete(httpx_client.get(url)) - else: - httpx_client.get(url) + with sentry_sdk.traces.start_span(name="test"): + if asyncio.iscoroutinefunction(httpx_client.get): + asyncio.get_event_loop().run_until_complete(httpx_client.get(url)) + else: + httpx_client.get(url) sentry_sdk.flush() @@ -1055,10 +1064,11 @@ def test_request_source_if_duration_over_threshold_span_streaming( url = "http://example.com/" - if asyncio.iscoroutinefunction(httpx_client.get): - asyncio.get_event_loop().run_until_complete(httpx_client.get(url)) - else: - httpx_client.get(url) + with sentry_sdk.traces.start_span(name="test"): + if asyncio.iscoroutinefunction(httpx_client.get): + asyncio.get_event_loop().run_until_complete(httpx_client.get(url)) + else: + httpx_client.get(url) sentry_sdk.flush() @@ -1107,10 +1117,11 @@ def test_span_origin_span_streaming( url = "http://example.com/" - if asyncio.iscoroutinefunction(httpx_client.get): - asyncio.get_event_loop().run_until_complete(httpx_client.get(url)) - else: - httpx_client.get(url) + with sentry_sdk.traces.start_span(name="test"): + if asyncio.iscoroutinefunction(httpx_client.get): + asyncio.get_event_loop().run_until_complete(httpx_client.get(url)) + else: + httpx_client.get(url) sentry_sdk.flush() @@ -1140,10 +1151,11 @@ def test_http_url_attributes_span_streaming( url = "http://example.com/?foo=bar#frag" - if asyncio.iscoroutinefunction(httpx_client.get): - asyncio.get_event_loop().run_until_complete(httpx_client.get(url)) - else: - httpx_client.get(url) + with sentry_sdk.traces.start_span(name="test"): + if asyncio.iscoroutinefunction(httpx_client.get): + asyncio.get_event_loop().run_until_complete(httpx_client.get(url)) + else: + httpx_client.get(url) sentry_sdk.flush() @@ -1183,10 +1195,11 @@ def test_http_url_attributes_no_query_or_fragment_span_streaming( url = "http://example.com/" - if asyncio.iscoroutinefunction(httpx_client.get): - asyncio.get_event_loop().run_until_complete(httpx_client.get(url)) - else: - httpx_client.get(url) + with sentry_sdk.traces.start_span(name="test"): + if asyncio.iscoroutinefunction(httpx_client.get): + asyncio.get_event_loop().run_until_complete(httpx_client.get(url)) + else: + httpx_client.get(url) sentry_sdk.flush() From 877be03eaa135148bb24281a592bb387683335f1 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Wed, 15 Jul 2026 12:44:22 -0400 Subject: [PATCH 04/16] fix(tracing): Propagate trace headers in streaming path when no current span (HTTP clients) Previously, when span streaming was enabled and there was no active span, httpx/httpx2/httplib skipped trace header propagation entirely by returning early. This diverged from the legacy behavior and dropped distributed tracing continuity for outgoing requests made outside of a span. Extract the shared header-propagation logic into `propagate_trace_headers()` in tracing_utils.py and call it from the no-current-span early-return branches so headers are still attached from the scope's propagation context. --- sentry_sdk/integrations/httpx.py | 68 ++----------------- sentry_sdk/integrations/httpx2.py | 72 +++----------------- sentry_sdk/integrations/stdlib.py | 40 +++++------ sentry_sdk/tracing_utils.py | 25 +++++++ tests/integrations/httpx2/test_httpx2.py | 83 +++++++++++++++++++++++ tests/integrations/stdlib/test_httplib.py | 68 +++++++++++++++++++ 6 files changed, 211 insertions(+), 145 deletions(-) diff --git a/sentry_sdk/integrations/httpx.py b/sentry_sdk/integrations/httpx.py index 3322de564e..7f5d5a64fa 100644 --- a/sentry_sdk/integrations/httpx.py +++ b/sentry_sdk/integrations/httpx.py @@ -4,18 +4,15 @@ from sentry_sdk.consts import OP, SPANDATA from sentry_sdk.integrations import DidNotEnable, Integration from sentry_sdk.scope import should_send_default_pii -from sentry_sdk.tracing import BAGGAGE_HEADER_NAME from sentry_sdk.tracing_utils import ( add_http_request_source, - add_sentry_baggage_to_headers, has_span_streaming_enabled, - should_propagate_trace, + propagate_trace_headers, ) from sentry_sdk.utils import ( SENSITIVE_DATA_SUBSTITUTE, capture_internal_exceptions, ensure_integration_enabled, - logger, parse_url, ) @@ -84,21 +81,7 @@ def send(self: "Client", request: "Request", **kwargs: "Any") -> "Response": if parsed_url.fragment: attributes["url.fragment"] = parsed_url.fragment - if should_propagate_trace(client, str(request.url)): - for ( - key, - value, - ) in ( - sentry_sdk.get_current_scope().iter_trace_propagation_headers() - ): - logger.debug( - f"[Tracing] Adding `{key}` header {value} to outgoing request to {request.url}." - ) - - if key == BAGGAGE_HEADER_NAME: - add_sentry_baggage_to_headers(request.headers, value) - else: - request.headers[key] = value + propagate_trace_headers(client, request) try: rv = real_send(self, request, **kwargs) @@ -128,21 +111,7 @@ def send(self: "Client", request: "Request", **kwargs: "Any") -> "Response": span.set_data(SPANDATA.HTTP_QUERY, parsed_url.query) span.set_data(SPANDATA.HTTP_FRAGMENT, parsed_url.fragment) - if should_propagate_trace(client, str(request.url)): - for ( - key, - value, - ) in ( - sentry_sdk.get_current_scope().iter_trace_propagation_headers() - ): - logger.debug( - f"[Tracing] Adding `{key}` header {value} to outgoing request to {request.url}." - ) - - if key == BAGGAGE_HEADER_NAME: - add_sentry_baggage_to_headers(request.headers, value) - else: - request.headers[key] = value + propagate_trace_headers(client, request) rv = real_send(self, request, **kwargs) @@ -197,21 +166,7 @@ async def send( if parsed_url.fragment: attributes["url.fragment"] = parsed_url.fragment - if should_propagate_trace(client, str(request.url)): - for ( - key, - value, - ) in ( - sentry_sdk.get_current_scope().iter_trace_propagation_headers() - ): - logger.debug( - f"[Tracing] Adding `{key}` header {value} to outgoing request to {request.url}." - ) - - if key == BAGGAGE_HEADER_NAME: - add_sentry_baggage_to_headers(request.headers, value) - else: - request.headers[key] = value + propagate_trace_headers(client, request) try: rv = await real_send(self, request, **kwargs) @@ -241,20 +196,7 @@ async def send( span.set_data(SPANDATA.HTTP_QUERY, parsed_url.query) span.set_data(SPANDATA.HTTP_FRAGMENT, parsed_url.fragment) - if should_propagate_trace(client, str(request.url)): - for ( - key, - value, - ) in ( - sentry_sdk.get_current_scope().iter_trace_propagation_headers() - ): - logger.debug( - f"[Tracing] Adding `{key}` header {value} to outgoing request to {request.url}." - ) - if key == BAGGAGE_HEADER_NAME: - add_sentry_baggage_to_headers(request.headers, value) - else: - request.headers[key] = value + propagate_trace_headers(client, request) rv = await real_send(self, request, **kwargs) diff --git a/sentry_sdk/integrations/httpx2.py b/sentry_sdk/integrations/httpx2.py index d327e320f6..1ce3e93524 100644 --- a/sentry_sdk/integrations/httpx2.py +++ b/sentry_sdk/integrations/httpx2.py @@ -4,18 +4,15 @@ from sentry_sdk.consts import OP, SPANDATA from sentry_sdk.integrations import DidNotEnable, Integration from sentry_sdk.scope import should_send_default_pii -from sentry_sdk.tracing import BAGGAGE_HEADER_NAME from sentry_sdk.tracing_utils import ( add_http_request_source, - add_sentry_baggage_to_headers, has_span_streaming_enabled, - should_propagate_trace, + propagate_trace_headers, ) from sentry_sdk.utils import ( SENSITIVE_DATA_SUBSTITUTE, capture_internal_exceptions, ensure_integration_enabled, - logger, parse_url, ) @@ -61,6 +58,8 @@ def send(self: "Client", request: "Request", **kwargs: "Any") -> "Response": if is_span_streaming_enabled: if sentry_sdk.traces.get_current_span() is None: + propagate_trace_headers(client, request) + return real_send(self, request, **kwargs) with sentry_sdk.traces.start_span( @@ -84,21 +83,7 @@ def send(self: "Client", request: "Request", **kwargs: "Any") -> "Response": if parsed_url.fragment: attributes["url.fragment"] = parsed_url.fragment - if should_propagate_trace(client, str(request.url)): - for ( - key, - value, - ) in ( - sentry_sdk.get_current_scope().iter_trace_propagation_headers() - ): - logger.debug( - f"[Tracing] Adding `{key}` header {value} to outgoing request to {request.url}." - ) - - if key == BAGGAGE_HEADER_NAME: - add_sentry_baggage_to_headers(request.headers, value) - else: - request.headers[key] = value + propagate_trace_headers(client, request) try: rv = real_send(self, request, **kwargs) @@ -128,21 +113,7 @@ def send(self: "Client", request: "Request", **kwargs: "Any") -> "Response": span.set_data(SPANDATA.HTTP_QUERY, parsed_url.query) span.set_data(SPANDATA.HTTP_FRAGMENT, parsed_url.fragment) - if should_propagate_trace(client, str(request.url)): - for ( - key, - value, - ) in ( - sentry_sdk.get_current_scope().iter_trace_propagation_headers() - ): - logger.debug( - f"[Tracing] Adding `{key}` header {value} to outgoing request to {request.url}." - ) - - if key == BAGGAGE_HEADER_NAME: - add_sentry_baggage_to_headers(request.headers, value) - else: - request.headers[key] = value + propagate_trace_headers(client, request) rv = real_send(self, request, **kwargs) @@ -174,6 +145,8 @@ async def send( if is_span_streaming_enabled: if sentry_sdk.traces.get_current_span() is None: + propagate_trace_headers(client, request) + return await real_send(self, request, **kwargs) with sentry_sdk.traces.start_span( @@ -197,21 +170,7 @@ async def send( if parsed_url.fragment: attributes["url.fragment"] = parsed_url.fragment - if should_propagate_trace(client, str(request.url)): - for ( - key, - value, - ) in ( - sentry_sdk.get_current_scope().iter_trace_propagation_headers() - ): - logger.debug( - f"[Tracing] Adding `{key}` header {value} to outgoing request to {request.url}." - ) - - if key == BAGGAGE_HEADER_NAME: - add_sentry_baggage_to_headers(request.headers, value) - else: - request.headers[key] = value + propagate_trace_headers(client, request) try: rv = await real_send(self, request, **kwargs) @@ -241,20 +200,7 @@ async def send( span.set_data(SPANDATA.HTTP_QUERY, parsed_url.query) span.set_data(SPANDATA.HTTP_FRAGMENT, parsed_url.fragment) - if should_propagate_trace(client, str(request.url)): - for ( - key, - value, - ) in ( - sentry_sdk.get_current_scope().iter_trace_propagation_headers() - ): - logger.debug( - f"[Tracing] Adding `{key}` header {value} to outgoing request to {request.url}." - ) - if key == BAGGAGE_HEADER_NAME: - add_sentry_baggage_to_headers(request.headers, value) - else: - request.headers[key] = value + propagate_trace_headers(client, request) rv = await real_send(self, request, **kwargs) diff --git a/sentry_sdk/integrations/stdlib.py b/sentry_sdk/integrations/stdlib.py index 1cb1b62a5f..4de3819a77 100644 --- a/sentry_sdk/integrations/stdlib.py +++ b/sentry_sdk/integrations/stdlib.py @@ -112,28 +112,30 @@ def putrequest( parsed_url = parse_url(real_url, sanitize=False) span_streaming = has_span_streaming_enabled(client.options) - span: "Union[Span, StreamedSpan]" + span: "Union[Span, StreamedSpan, None]" if span_streaming: if sentry_sdk.traces.get_current_span() is None: - return real_putrequest(self, method, url, *args, **kwargs) - - span = sentry_sdk.traces.start_span( - name="%s %s" - % (method, parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE), - attributes={ - "sentry.origin": "auto.http.stdlib.httplib", - "sentry.op": OP.HTTP_CLIENT, - SPANDATA.HTTP_REQUEST_METHOD: method, - }, - ) - - if parsed_url is not None and should_send_default_pii(): - span.set_attribute(SPANDATA.URL_FRAGMENT, parsed_url.fragment) - span.set_attribute(SPANDATA.URL_FULL, parsed_url.url) - span.set_attribute(SPANDATA.URL_QUERY, parsed_url.query) + span = None + else: + span = sentry_sdk.traces.start_span( + name="%s %s" + % ( + method, + parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE, + ), + attributes={ + "sentry.origin": "auto.http.stdlib.httplib", + "sentry.op": OP.HTTP_CLIENT, + SPANDATA.HTTP_REQUEST_METHOD: method, + }, + ) - set_on_span = span.set_attribute + if parsed_url is not None and should_send_default_pii(): + span.set_attribute(SPANDATA.URL_FRAGMENT, parsed_url.fragment) + span.set_attribute(SPANDATA.URL_FULL, parsed_url.url) + span.set_attribute(SPANDATA.URL_QUERY, parsed_url.query) + set_on_span = span.set_attribute else: span = sentry_sdk.start_span( op=OP.HTTP_CLIENT, @@ -151,7 +153,7 @@ def putrequest( set_on_span = span.set_data # for proxies, these point to the proxy host/port - if tunnel_host: + if span and tunnel_host: set_on_span(SPANDATA.NETWORK_PEER_ADDRESS, self.host) set_on_span(SPANDATA.NETWORK_PEER_PORT, self.port) diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index 8cb1cbe3ae..ce5e83324c 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -973,6 +973,31 @@ def should_propagate_trace(client: "sentry_sdk.client.BaseClient", url: str) -> return match_regex_list(url, trace_propagation_targets, substring_matching=True) +def propagate_trace_headers( + client: "sentry_sdk.client.BaseClient", request: "Any" +) -> None: + """ + Attach Sentry trace propagation headers (``sentry-trace``/``baggage``) from the + current scope's propagation context to an outgoing request, if the request's + URL matches the configured ``trace_propagation_targets``. + + ``request`` is expected to expose ``url`` and a mutable ``headers`` mapping + (e.g. an ``httpx``/``httpx2`` ``Request``). + """ + if not should_propagate_trace(client, str(request.url)): + return + + for key, value in sentry_sdk.get_current_scope().iter_trace_propagation_headers(): + logger.debug( + f"[Tracing] Adding `{key}` header {value} to outgoing request to {request.url}." + ) + + if key == BAGGAGE_HEADER_NAME: + add_sentry_baggage_to_headers(request.headers, value) + else: + request.headers[key] = value + + def normalize_incoming_data(incoming_data: "Dict[str, Any]") -> "Dict[str, Any]": """ Normalizes incoming data so the keys are all lowercase with dashes instead of underscores and stripped from known prefixes. diff --git a/tests/integrations/httpx2/test_httpx2.py b/tests/integrations/httpx2/test_httpx2.py index dd41e3de12..d66c62849b 100644 --- a/tests/integrations/httpx2/test_httpx2.py +++ b/tests/integrations/httpx2/test_httpx2.py @@ -795,6 +795,89 @@ def test_outgoing_trace_headers_append_to_baggage_span_streaming( assert "sentry-sampled=true" in baggage +def test_outgoing_trace_headers_span_streaming_no_current_span( + sentry_init, httpx2_mock +): + """ + Even when there is no active span, trace propagation headers should still + be attached to outgoing requests when span streaming is enabled. + + This is deliberately different from the legacy (transaction-based) approach, + which does not propagate outside of a transaction (see + ``test_do_not_propagate_outside_transaction``). The streamed approach + propagates from the current scope's propagation context regardless of + whether a span is active. + """ + httpx2_mock.add_response() + + sentry_init( + traces_sample_rate=1.0, + trace_propagation_targets=[MATCH_ALL], + integrations=[Httpx2Integration()], + _experiments={"trace_lifecycle": "stream"}, + ) + + url = "http://example.com/" + + httpx2_client = httpx2.Client() + + # No start_span / start_transaction -> get_current_span() is None + assert sentry_sdk.traces.get_current_span() is None + + response = httpx2_client.get(url) + + assert response.status_code == 200 + + # Trace is still propagated from the scope's propagation context + request_headers = httpx2_mock.get_request().headers + assert "sentry-trace" in request_headers + assert "baggage" in request_headers + + # The propagated headers describe a single, coherent trace: the trace_id in + # sentry-trace matches the one carried in baggage. + trace_id = request_headers["sentry-trace"].split("-")[0] + assert f"sentry-trace_id={trace_id}" in request_headers["baggage"] + + +def test_outgoing_trace_headers_span_streaming_no_current_span_async( + sentry_init, httpx2_mock +): + """ + The async client must match the sync client: trace propagation headers are + attached to outgoing requests even when there is no active span and span + streaming is enabled. + """ + httpx2_mock.add_response() + + sentry_init( + traces_sample_rate=1.0, + trace_propagation_targets=[MATCH_ALL], + integrations=[Httpx2Integration()], + _experiments={"trace_lifecycle": "stream"}, + ) + + url = "http://example.com/" + + httpx2_client = httpx2.AsyncClient() + + # No start_span / start_transaction -> get_current_span() is None + assert sentry_sdk.traces.get_current_span() is None + + response = asyncio.run(httpx2_client.get(url)) + + assert response.status_code == 200 + + # Trace is still propagated from the scope's propagation context + request_headers = httpx2_mock.get_request().headers + assert "sentry-trace" in request_headers + assert "baggage" in request_headers + + # The propagated headers describe a single, coherent trace: the trace_id in + # sentry-trace matches the one carried in baggage. + trace_id = request_headers["sentry-trace"].split("-")[0] + assert f"sentry-trace_id={trace_id}" in request_headers["baggage"] + + @pytest.mark.parametrize( "httpx2_client", (httpx2.Client(), httpx2.AsyncClient()), diff --git a/tests/integrations/stdlib/test_httplib.py b/tests/integrations/stdlib/test_httplib.py index 64e059fc4c..f780109ab6 100644 --- a/tests/integrations/stdlib/test_httplib.py +++ b/tests/integrations/stdlib/test_httplib.py @@ -460,6 +460,74 @@ def getresponse(self, *args, **kwargs): assert request_headers["baggage"] == expected_outgoing_baggage +def test_outgoing_trace_headers_span_streaming_no_current_span(sentry_init): + """ + With span streaming enabled and no active span, trace propagation headers + should still be attached to outgoing requests, propagated from the scope's + propagation context. + """ + sentry_init( + traces_sample_rate=1.0, + _experiments={"trace_lifecycle": "stream"}, + ) + + already_patched_getresponse = HTTPSConnection.getresponse + request_headers = {} + + class HTTPSConnectionRecordingRequestHeaders(HTTPSConnection): + def send(self, *args, **kwargs) -> None: + request_str = args[0] + for line in request_str.decode("utf-8").split("\r\n")[1:]: + if line: + key, val = line.split(": ") + request_headers[key] = val + + server_sock, client_sock = socket.socketpair() + server_sock.sendall(b"HTTP/1.1 200 OK\r\n\r\n") + server_sock.close() + self.sock = client_sock + + def getresponse(self, *args, **kwargs): + return already_patched_getresponse(self, *args, **kwargs) + + headers = { + "sentry-trace": "771a43a4192642f0b136d5159a501700-1234567890abcdef-1", + "baggage": ( + "sentry-trace_id=771a43a4192642f0b136d5159a501700," + "sentry-public_key=49d0f7386ad645858ae85020e393bef3," + "sentry-sample_rate=0.01337," + "sentry-sample_rand=0.000005," + "sentry-sampled=true" + ), + } + + # Seed the scope's propagation context, but do NOT start a span. + sentry_sdk.traces.continue_trace(headers) + assert sentry_sdk.traces.get_current_span() is None + + connection = HTTPSConnectionRecordingRequestHeaders("localhost", port=PORT) + connection.request("GET", "/top-chasers") + connection.getresponse() + + # Trace is still propagated, carrying the trace_id from the propagation + # context. The span id segment is generated for the propagation context + # (there is no active span), so we assert the stable trace_id prefix. + assert request_headers["sentry-trace"].startswith( + "771a43a4192642f0b136d5159a501700-" + ) + + # The outgoing baggage is fully deterministic: it is the frozen incoming + # baggage from the propagation context. + expected_outgoing_baggage = ( + "sentry-trace_id=771a43a4192642f0b136d5159a501700," + "sentry-public_key=49d0f7386ad645858ae85020e393bef3," + "sentry-sample_rate=0.01337," + "sentry-sample_rand=0.000005," + "sentry-sampled=true" + ) + assert request_headers["baggage"] == expected_outgoing_baggage + + @pytest.mark.parametrize( "trace_propagation_targets,host,path,trace_propagated", [ From c8c992431ac7fd4376fb74fd899540f956927819 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Wed, 15 Jul 2026 12:53:08 -0400 Subject: [PATCH 05/16] fix(tracing): Propagate trace headers for httpx when no current span The httpx sync and async streaming paths returned early without propagating trace headers when there was no active span, dropping distributed tracing continuity for outgoing requests made outside of a span. Call propagate_trace_headers() in both no-current-span branches so headers are attached from the scope's propagation context, matching httpx2 and httplib. Add sync and async tests covering trace propagation with span streaming enabled and no active span. --- sentry_sdk/integrations/httpx.py | 2 + tests/integrations/httpx/test_httpx.py | 81 ++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/sentry_sdk/integrations/httpx.py b/sentry_sdk/integrations/httpx.py index 7f5d5a64fa..779302a238 100644 --- a/sentry_sdk/integrations/httpx.py +++ b/sentry_sdk/integrations/httpx.py @@ -58,6 +58,7 @@ def send(self: "Client", request: "Request", **kwargs: "Any") -> "Response": if is_span_streaming_enabled: if sentry_sdk.traces.get_current_span() is None: + propagate_trace_headers(client, request) return real_send(self, request, **kwargs) with sentry_sdk.traces.start_span( @@ -143,6 +144,7 @@ async def send( if is_span_streaming_enabled: if sentry_sdk.traces.get_current_span() is None: + propagate_trace_headers(client, request) return await real_send(self, request, **kwargs) with sentry_sdk.traces.start_span( diff --git a/tests/integrations/httpx/test_httpx.py b/tests/integrations/httpx/test_httpx.py index 580076207a..c0a0ea049c 100644 --- a/tests/integrations/httpx/test_httpx.py +++ b/tests/integrations/httpx/test_httpx.py @@ -803,6 +803,87 @@ def test_outgoing_trace_headers_append_to_baggage_span_streaming( assert "sentry-sampled=true" in baggage +def test_outgoing_trace_headers_span_streaming_no_current_span(sentry_init, httpx_mock): + """ + Even when there is no active span, trace propagation headers should still + be attached to outgoing requests when span streaming is enabled. + + This is deliberately different from the legacy (transaction-based) approach, + which does not propagate outside of a transaction (see + ``test_do_not_propagate_outside_transaction``). The streamed approach + propagates from the current scope's propagation context regardless of + whether a span is active. + """ + httpx_mock.add_response() + + sentry_init( + traces_sample_rate=1.0, + trace_propagation_targets=[MATCH_ALL], + integrations=[HttpxIntegration()], + _experiments={"trace_lifecycle": "stream"}, + ) + + url = "http://example.com/" + + httpx_client = httpx.Client() + + # No start_span / start_transaction -> get_current_span() is None + assert sentry_sdk.traces.get_current_span() is None + + response = httpx_client.get(url) + + assert response.status_code == 200 + + # Trace is still propagated from the scope's propagation context + request_headers = httpx_mock.get_request().headers + assert "sentry-trace" in request_headers + assert "baggage" in request_headers + + # The propagated headers describe a single, coherent trace: the trace_id in + # sentry-trace matches the one carried in baggage. + trace_id = request_headers["sentry-trace"].split("-")[0] + assert f"sentry-trace_id={trace_id}" in request_headers["baggage"] + + +def test_outgoing_trace_headers_span_streaming_no_current_span_async( + sentry_init, httpx_mock +): + """ + The async client must match the sync client: trace propagation headers are + attached to outgoing requests even when there is no active span and span + streaming is enabled. + """ + httpx_mock.add_response() + + sentry_init( + traces_sample_rate=1.0, + trace_propagation_targets=[MATCH_ALL], + integrations=[HttpxIntegration()], + _experiments={"trace_lifecycle": "stream"}, + ) + + url = "http://example.com/" + + httpx_client = httpx.AsyncClient() + + # No start_span / start_transaction -> get_current_span() is None + assert sentry_sdk.traces.get_current_span() is None + + response = asyncio.get_event_loop().run_until_complete(httpx_client.get(url)) + + assert response.status_code == 200 + + # Trace is still propagated from the scope's propagation context + request_headers = httpx_mock.get_request().headers + assert "sentry-trace" in request_headers + assert "baggage" in request_headers + + # The propagated headers describe a single, coherent trace: the trace_id in + # sentry-trace matches the one carried in baggage. + trace_id = request_headers["sentry-trace"].split("-")[0] + assert f"sentry-trace_id={trace_id}" in request_headers["baggage"] + + @pytest.mark.parametrize( "httpx_client", (httpx.Client(), httpx.AsyncClient()), From 655bd41059946246027869aabe0567dcff32b6d1 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Mon, 13 Jul 2026 15:55:17 +0200 Subject: [PATCH 06/16] fix(tracing): Skip child span creation in streaming path when no current span (HTTP clients) When span streaming is enabled and there is no current span, HTTP client integrations should not create new root segments for child-span operations. This adds a guard check using `sentry_sdk.traces.get_current_span()` before creating spans in the streaming path. Affected integrations: httpx, httpx2, pyreqwest, boto3, stdlib. Co-Authored-By: Claude Opus 4.6 --- sentry_sdk/integrations/boto3.py | 2 ++ sentry_sdk/integrations/httpx.py | 6 +++++ sentry_sdk/integrations/httpx2.py | 6 +++++ sentry_sdk/integrations/pyreqwest.py | 38 ++++++++++++++++------------ sentry_sdk/integrations/stdlib.py | 10 ++++++++ 5 files changed, 46 insertions(+), 16 deletions(-) diff --git a/sentry_sdk/integrations/boto3.py b/sentry_sdk/integrations/boto3.py index a7fdd99b21..69deefc7b7 100644 --- a/sentry_sdk/integrations/boto3.py +++ b/sentry_sdk/integrations/boto3.py @@ -67,6 +67,8 @@ def _sentry_request_created( is_span_streaming_enabled = has_span_streaming_enabled(client.options) span: "Union[Span, StreamedSpan]" if is_span_streaming_enabled: + if sentry_sdk.traces.get_current_span() is None: + return span = sentry_sdk.traces.start_span( name=description, attributes={ diff --git a/sentry_sdk/integrations/httpx.py b/sentry_sdk/integrations/httpx.py index a68f20b299..3322de564e 100644 --- a/sentry_sdk/integrations/httpx.py +++ b/sentry_sdk/integrations/httpx.py @@ -60,6 +60,9 @@ def send(self: "Client", request: "Request", **kwargs: "Any") -> "Response": parsed_url = parse_url(str(request.url), sanitize=False) if is_span_streaming_enabled: + if sentry_sdk.traces.get_current_span() is None: + return real_send(self, request, **kwargs) + with sentry_sdk.traces.start_span( name="%s %s" % ( @@ -170,6 +173,9 @@ async def send( parsed_url = parse_url(str(request.url), sanitize=False) if is_span_streaming_enabled: + if sentry_sdk.traces.get_current_span() is None: + return await real_send(self, request, **kwargs) + with sentry_sdk.traces.start_span( name="%s %s" % ( diff --git a/sentry_sdk/integrations/httpx2.py b/sentry_sdk/integrations/httpx2.py index 25062aaa11..d327e320f6 100644 --- a/sentry_sdk/integrations/httpx2.py +++ b/sentry_sdk/integrations/httpx2.py @@ -60,6 +60,9 @@ def send(self: "Client", request: "Request", **kwargs: "Any") -> "Response": parsed_url = parse_url(str(request.url), sanitize=False) if is_span_streaming_enabled: + if sentry_sdk.traces.get_current_span() is None: + return real_send(self, request, **kwargs) + with sentry_sdk.traces.start_span( name="%s %s" % ( @@ -170,6 +173,9 @@ async def send( parsed_url = parse_url(str(request.url), sanitize=False) if is_span_streaming_enabled: + if sentry_sdk.traces.get_current_span() is None: + return await real_send(self, request, **kwargs) + with sentry_sdk.traces.start_span( name="%s %s" % ( diff --git a/sentry_sdk/integrations/pyreqwest.py b/sentry_sdk/integrations/pyreqwest.py index aae68c4c10..7ca73b370a 100644 --- a/sentry_sdk/integrations/pyreqwest.py +++ b/sentry_sdk/integrations/pyreqwest.py @@ -18,6 +18,7 @@ SENSITIVE_DATA_SUBSTITUTE, capture_internal_exceptions, logger, + nullcontext, parse_url, ) @@ -88,18 +89,22 @@ def _sentry_pyreqwest_span(request: "Request") -> "Generator[Any, None, None]": span_streaming = has_span_streaming_enabled(sentry_sdk.get_client().options) if span_streaming: - with sentry_sdk.traces.start_span( - name=f"{request.method} {parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE}", - attributes={ - "sentry.op": OP.HTTP_CLIENT, - "sentry.origin": PyreqwestIntegration.origin, - SPANDATA.HTTP_REQUEST_METHOD: request.method, - }, - ) as span: - if parsed_url is not None and should_send_default_pii(): - span.set_attribute(SPANDATA.URL_FULL, parsed_url.url) - span.set_attribute(SPANDATA.URL_QUERY, parsed_url.query) - span.set_attribute(SPANDATA.URL_FRAGMENT, parsed_url.fragment) + span_ctx = nullcontext() + if sentry_sdk.traces.get_current_span() is not None: + span_ctx = sentry_sdk.traces.start_span( + name=f"{request.method} {parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE}", + attributes={ + "sentry.op": OP.HTTP_CLIENT, + "sentry.origin": PyreqwestIntegration.origin, + SPANDATA.HTTP_REQUEST_METHOD: request.method, + }, + ) + with span_ctx as span: + if span is not None: + if parsed_url is not None and should_send_default_pii(): + span.set_attribute(SPANDATA.URL_FULL, parsed_url.url) + span.set_attribute(SPANDATA.URL_QUERY, parsed_url.query) + span.set_attribute(SPANDATA.URL_FRAGMENT, parsed_url.fragment) if should_propagate_trace(sentry_sdk.get_client(), str(request.url)): for ( @@ -119,8 +124,9 @@ def _sentry_pyreqwest_span(request: "Request") -> "Generator[Any, None, None]": yield span - with capture_internal_exceptions(): - add_http_request_source(span) + if span is not None: + with capture_internal_exceptions(): + add_http_request_source(span) return @@ -171,7 +177,7 @@ async def sentry_async_middleware( SPANDATA.HTTP_STATUS_CODE, response.status, ) - else: + elif span is not None: span.set_http_status(response.status) return response @@ -191,7 +197,7 @@ def sentry_sync_middleware( SPANDATA.HTTP_STATUS_CODE, response.status, ) - else: + elif span is not None: span.set_http_status(response.status) return response diff --git a/sentry_sdk/integrations/stdlib.py b/sentry_sdk/integrations/stdlib.py index 82f30f2dda..1cb1b62a5f 100644 --- a/sentry_sdk/integrations/stdlib.py +++ b/sentry_sdk/integrations/stdlib.py @@ -114,6 +114,9 @@ def putrequest( span_streaming = has_span_streaming_enabled(client.options) span: "Union[Span, StreamedSpan]" if span_streaming: + if sentry_sdk.traces.get_current_span() is None: + return real_putrequest(self, method, url, *args, **kwargs) + span = sentry_sdk.traces.start_span( name="%s %s" % (method, parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE), @@ -303,6 +306,9 @@ def sentry_patched_popen_init( span_streaming = has_span_streaming_enabled(sentry_sdk.get_client().options) span: "Union[Span, StreamedSpan]" if span_streaming: + if sentry_sdk.traces.get_current_span() is None: + return old_popen_init(self, *a, **kw) + span = sentry_sdk.traces.start_span( name=description, attributes={ @@ -353,6 +359,8 @@ def sentry_patched_popen_wait( ) -> "Any": span_streaming = has_span_streaming_enabled(sentry_sdk.get_client().options) if span_streaming: + if sentry_sdk.traces.get_current_span() is None: + return old_popen_wait(self, *a, **kw) with sentry_sdk.traces.start_span( name=OP.SUBPROCESS_WAIT, attributes={ @@ -380,6 +388,8 @@ def sentry_patched_popen_communicate( ) -> "Any": span_streaming = has_span_streaming_enabled(sentry_sdk.get_client().options) if span_streaming: + if sentry_sdk.traces.get_current_span() is None: + return old_popen_communicate(self, *a, **kw) with sentry_sdk.traces.start_span( name=OP.SUBPROCESS_COMMUNICATE, attributes={ From a8cf0f4ce45835446a988280fe3dddae46009e9c Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Mon, 13 Jul 2026 17:17:24 +0200 Subject: [PATCH 07/16] fix(tests): Update httpx2 and strawberry tests for streaming child span guards httpx2: Wrap HTTP calls in parent span context so child spans are created. strawberry: Remove async/sync branching since both paths now produce 5 spans. Co-Authored-By: Claude Opus 4.6 --- tests/integrations/httpx2/test_httpx2.py | 116 ++++++++++-------- .../strawberry/test_strawberry.py | 60 +++------ 2 files changed, 78 insertions(+), 98 deletions(-) diff --git a/tests/integrations/httpx2/test_httpx2.py b/tests/integrations/httpx2/test_httpx2.py index 15d50513ae..dd41e3de12 100644 --- a/tests/integrations/httpx2/test_httpx2.py +++ b/tests/integrations/httpx2/test_httpx2.py @@ -733,10 +733,11 @@ def test_outgoing_trace_headers_span_streaming( items = capture_items("span") - if asyncio.iscoroutinefunction(httpx2_client.get): - response = asyncio.run(httpx2_client.get(url)) - else: - response = httpx2_client.get(url) + with sentry_sdk.traces.start_span(name="test"): + if asyncio.iscoroutinefunction(httpx2_client.get): + response = asyncio.run(httpx2_client.get(url)) + else: + response = httpx2_client.get(url) sentry_sdk.flush() @@ -775,12 +776,13 @@ def test_outgoing_trace_headers_append_to_baggage_span_streaming( items = capture_items("span") with mock.patch("sentry_sdk.tracing_utils.Random.randrange", return_value=500000): - if asyncio.iscoroutinefunction(httpx2_client.get): - response = asyncio.run( - httpx2_client.get(url, headers={"baGGage": "custom=data"}) - ) - else: - response = httpx2_client.get(url, headers={"baGGage": "custom=data"}) + with sentry_sdk.traces.start_span(name="test"): + if asyncio.iscoroutinefunction(httpx2_client.get): + response = asyncio.run( + httpx2_client.get(url, headers={"baGGage": "custom=data"}) + ) + else: + response = httpx2_client.get(url, headers={"baGGage": "custom=data"}) sentry_sdk.flush() @@ -814,10 +816,11 @@ def test_request_source_disabled_span_streaming( url = "http://example.com/" - if asyncio.iscoroutinefunction(httpx2_client.get): - asyncio.run(httpx2_client.get(url)) - else: - httpx2_client.get(url) + with sentry_sdk.traces.start_span(name="test"): + if asyncio.iscoroutinefunction(httpx2_client.get): + asyncio.run(httpx2_client.get(url)) + else: + httpx2_client.get(url) sentry_sdk.flush() @@ -858,10 +861,11 @@ def test_request_source_enabled_span_streaming( url = "http://example.com/" - if asyncio.iscoroutinefunction(httpx2_client.get): - asyncio.run(httpx2_client.get(url)) - else: - httpx2_client.get(url) + with sentry_sdk.traces.start_span(name="test"): + if asyncio.iscoroutinefunction(httpx2_client.get): + asyncio.run(httpx2_client.get(url)) + else: + httpx2_client.get(url) sentry_sdk.flush() @@ -894,10 +898,11 @@ def test_request_source_span_streaming( url = "http://example.com/" - if asyncio.iscoroutinefunction(httpx2_client.get): - asyncio.run(httpx2_client.get(url)) - else: - httpx2_client.get(url) + with sentry_sdk.traces.start_span(name="test"): + if asyncio.iscoroutinefunction(httpx2_client.get): + asyncio.run(httpx2_client.get(url)) + else: + httpx2_client.get(url) sentry_sdk.flush() @@ -951,14 +956,15 @@ def test_request_source_with_module_in_search_path_span_streaming( url = "http://example.com/" - if asyncio.iscoroutinefunction(httpx2_client.get): - from httpx2_helpers.helpers import async_get_request_with_client + with sentry_sdk.traces.start_span(name="test"): + if asyncio.iscoroutinefunction(httpx2_client.get): + from httpx2_helpers.helpers import async_get_request_with_client - asyncio.run(async_get_request_with_client(httpx2_client, url)) - else: - from httpx2_helpers.helpers import get_request_with_client + asyncio.run(async_get_request_with_client(httpx2_client, url)) + else: + from httpx2_helpers.helpers import get_request_with_client - get_request_with_client(httpx2_client, url) + get_request_with_client(httpx2_client, url) sentry_sdk.flush() @@ -1010,10 +1016,11 @@ def test_no_request_source_if_duration_too_short_span_streaming( url = "http://example.com/" - if asyncio.iscoroutinefunction(httpx2_client.get): - asyncio.run(httpx2_client.get(url)) - else: - httpx2_client.get(url) + with sentry_sdk.traces.start_span(name="test"): + if asyncio.iscoroutinefunction(httpx2_client.get): + asyncio.run(httpx2_client.get(url)) + else: + httpx2_client.get(url) sentry_sdk.flush() @@ -1047,10 +1054,11 @@ def test_request_source_if_duration_over_threshold_span_streaming( url = "http://example.com/" - if asyncio.iscoroutinefunction(httpx2_client.get): - asyncio.run(httpx2_client.get(url)) - else: - httpx2_client.get(url) + with sentry_sdk.traces.start_span(name="test"): + if asyncio.iscoroutinefunction(httpx2_client.get): + asyncio.run(httpx2_client.get(url)) + else: + httpx2_client.get(url) sentry_sdk.flush() @@ -1099,10 +1107,11 @@ def test_span_origin_span_streaming( url = "http://example.com/" - if asyncio.iscoroutinefunction(httpx2_client.get): - asyncio.run(httpx2_client.get(url)) - else: - httpx2_client.get(url) + with sentry_sdk.traces.start_span(name="test"): + if asyncio.iscoroutinefunction(httpx2_client.get): + asyncio.run(httpx2_client.get(url)) + else: + httpx2_client.get(url) sentry_sdk.flush() @@ -1131,10 +1140,11 @@ def test_http_url_attributes_span_streaming( url = "http://example.com/?foo=bar#frag" - if asyncio.iscoroutinefunction(httpx2_client.get): - asyncio.run(httpx2_client.get(url)) - else: - httpx2_client.get(url) + with sentry_sdk.traces.start_span(name="test"): + if asyncio.iscoroutinefunction(httpx2_client.get): + asyncio.run(httpx2_client.get(url)) + else: + httpx2_client.get(url) sentry_sdk.flush() @@ -1167,10 +1177,11 @@ def test_http_url_attributes_no_query_or_fragment_span_streaming( url = "http://example.com/" - if asyncio.iscoroutinefunction(httpx2_client.get): - asyncio.run(httpx2_client.get(url)) - else: - httpx2_client.get(url) + with sentry_sdk.traces.start_span(name="test"): + if asyncio.iscoroutinefunction(httpx2_client.get): + asyncio.run(httpx2_client.get(url)) + else: + httpx2_client.get(url) sentry_sdk.flush() @@ -1202,10 +1213,11 @@ def test_http_url_attributes_pii_disabled_span_streaming( url = "http://example.com/?foo=bar#frag" - if asyncio.iscoroutinefunction(httpx2_client.get): - asyncio.run(httpx2_client.get(url)) - else: - httpx2_client.get(url) + with sentry_sdk.traces.start_span(name="test"): + if asyncio.iscoroutinefunction(httpx2_client.get): + asyncio.run(httpx2_client.get(url)) + else: + httpx2_client.get(url) sentry_sdk.flush() diff --git a/tests/integrations/strawberry/test_strawberry.py b/tests/integrations/strawberry/test_strawberry.py index 16e15142ff..99802a3a86 100644 --- a/tests/integrations/strawberry/test_strawberry.py +++ b/tests/integrations/strawberry/test_strawberry.py @@ -331,14 +331,8 @@ def test_capture_transaction_on_error( assert len(error_events) == 1 - if async_execution: - # When FastAPI is run, there's an extra span from the httpx client - # so we need to account for that - assert len(spans) == 6 - parse_span, validate_span, resolve_span, query_span, segment, _ = spans - else: - assert len(spans) == 5 - parse_span, validate_span, resolve_span, query_span, segment = spans + assert len(spans) == 5 + parse_span, validate_span, resolve_span, query_span, segment = spans assert segment["is_segment"] is True assert segment["name"] == "ErrorQuery" @@ -473,12 +467,8 @@ def test_capture_transaction_on_success( sentry_sdk.flush() spans = [i.payload for i in items] - if async_execution: - assert len(spans) == 6 - parse_span, validate_span, resolve_span, query_span, segment, _ = spans - else: - assert len(spans) == 5 - parse_span, validate_span, resolve_span, query_span, segment = spans + assert len(spans) == 5 + parse_span, validate_span, resolve_span, query_span, segment = spans assert segment["is_segment"] is True assert segment["name"] == "GreetingQuery" @@ -613,12 +603,8 @@ def test_transaction_no_operation_name( sentry_sdk.flush() spans = [i.payload for i in items] - if async_execution: - assert len(spans) == 6 - parse_span, validate_span, resolve_span, query_span, segment, _ = spans - else: - assert len(spans) == 5 - parse_span, validate_span, resolve_span, query_span, segment = spans + assert len(spans) == 5 + parse_span, validate_span, resolve_span, query_span, segment = spans assert segment["is_segment"] is True if async_execution: @@ -758,12 +744,8 @@ def test_transaction_mutation( sentry_sdk.flush() spans = [i.payload for i in items] - if async_execution: - assert len(spans) == 6 - parse_span, validate_span, resolve_span, mutation_span, segment, _ = spans - else: - assert len(spans) == 5 - parse_span, validate_span, resolve_span, mutation_span, segment = spans + assert len(spans) == 5 + parse_span, validate_span, resolve_span, mutation_span, segment = spans assert segment["is_segment"] is True assert segment["name"] == "Change" @@ -924,12 +906,8 @@ def test_span_origin( sentry_sdk.flush() spans = [i.payload for i in items] - if async_execution: - assert len(spans) == 6 - parse_span, validate_span, resolve_span, mutation_span, segment, _ = spans - else: - assert len(spans) == 5 - parse_span, validate_span, resolve_span, mutation_span, segment = spans + assert len(spans) == 5 + parse_span, validate_span, resolve_span, mutation_span, segment = spans assert segment["is_segment"] is True if is_flask: @@ -995,12 +973,8 @@ def test_span_origin2( sentry_sdk.flush() spans = [i.payload for i in items] - if async_execution: - assert len(spans) == 6 - parse_span, validate_span, resolve_span, query_span, segment, _ = spans - else: - assert len(spans) == 5 - parse_span, validate_span, resolve_span, query_span, segment = spans + assert len(spans) == 5 + parse_span, validate_span, resolve_span, query_span, segment = spans assert segment["is_segment"] is True if is_flask: @@ -1066,14 +1040,8 @@ def test_span_origin3( sentry_sdk.flush() spans = [i.payload for i in items] - if async_execution: - assert len(spans) == 6 - parse_span, validate_span, resolve_span, subscription_span, segment, _ = ( - spans - ) - else: - assert len(spans) == 5 - parse_span, validate_span, resolve_span, subscription_span, segment = spans + assert len(spans) == 5 + parse_span, validate_span, resolve_span, subscription_span, segment = spans assert segment["is_segment"] is True if is_flask: From 1e9a5d7da4be36f2aeaf10db9a77f510323eb9de Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Tue, 14 Jul 2026 10:42:10 -0400 Subject: [PATCH 08/16] test(httpx): Wrap streaming tests in parent span The streaming child-span guard added for HTTP client integrations skips child-span creation when there is no current span. The httpx2 and strawberry tests were updated for this, but the equivalent httpx streaming tests were missed, so no HTTP client span was produced and the tests failed with StopIteration. Wrap each streaming test's request in a parent span so a child HTTP client span is created, mirroring the httpx2 test updates. Co-Authored-By: Claude Opus 4.6 --- tests/integrations/httpx/test_httpx.py | 113 ++++++++++++++----------- 1 file changed, 63 insertions(+), 50 deletions(-) diff --git a/tests/integrations/httpx/test_httpx.py b/tests/integrations/httpx/test_httpx.py index 4ca18c8edc..580076207a 100644 --- a/tests/integrations/httpx/test_httpx.py +++ b/tests/integrations/httpx/test_httpx.py @@ -739,10 +739,13 @@ def test_outgoing_trace_headers_span_streaming( items = capture_items("span") - if asyncio.iscoroutinefunction(httpx_client.get): - response = asyncio.get_event_loop().run_until_complete(httpx_client.get(url)) - else: - response = httpx_client.get(url) + with sentry_sdk.traces.start_span(name="test"): + if asyncio.iscoroutinefunction(httpx_client.get): + response = asyncio.get_event_loop().run_until_complete( + httpx_client.get(url) + ) + else: + response = httpx_client.get(url) sentry_sdk.flush() @@ -781,12 +784,13 @@ def test_outgoing_trace_headers_append_to_baggage_span_streaming( items = capture_items("span") with mock.patch("sentry_sdk.tracing_utils.Random.randrange", return_value=500000): - if asyncio.iscoroutinefunction(httpx_client.get): - response = asyncio.get_event_loop().run_until_complete( - httpx_client.get(url, headers={"baGGage": "custom=data"}) - ) - else: - response = httpx_client.get(url, headers={"baGGage": "custom=data"}) + with sentry_sdk.traces.start_span(name="test"): + if asyncio.iscoroutinefunction(httpx_client.get): + response = asyncio.get_event_loop().run_until_complete( + httpx_client.get(url, headers={"baGGage": "custom=data"}) + ) + else: + response = httpx_client.get(url, headers={"baGGage": "custom=data"}) sentry_sdk.flush() @@ -820,10 +824,11 @@ def test_request_source_disabled_span_streaming( url = "http://example.com/" - if asyncio.iscoroutinefunction(httpx_client.get): - asyncio.get_event_loop().run_until_complete(httpx_client.get(url)) - else: - httpx_client.get(url) + with sentry_sdk.traces.start_span(name="test"): + if asyncio.iscoroutinefunction(httpx_client.get): + asyncio.get_event_loop().run_until_complete(httpx_client.get(url)) + else: + httpx_client.get(url) sentry_sdk.flush() @@ -864,10 +869,11 @@ def test_request_source_enabled_span_streaming( url = "http://example.com/" - if asyncio.iscoroutinefunction(httpx_client.get): - asyncio.get_event_loop().run_until_complete(httpx_client.get(url)) - else: - httpx_client.get(url) + with sentry_sdk.traces.start_span(name="test"): + if asyncio.iscoroutinefunction(httpx_client.get): + asyncio.get_event_loop().run_until_complete(httpx_client.get(url)) + else: + httpx_client.get(url) sentry_sdk.flush() @@ -900,10 +906,11 @@ def test_request_source_span_streaming( url = "http://example.com/" - if asyncio.iscoroutinefunction(httpx_client.get): - asyncio.get_event_loop().run_until_complete(httpx_client.get(url)) - else: - httpx_client.get(url) + with sentry_sdk.traces.start_span(name="test"): + if asyncio.iscoroutinefunction(httpx_client.get): + asyncio.get_event_loop().run_until_complete(httpx_client.get(url)) + else: + httpx_client.get(url) sentry_sdk.flush() @@ -957,16 +964,17 @@ def test_request_source_with_module_in_search_path_span_streaming( url = "http://example.com/" - if asyncio.iscoroutinefunction(httpx_client.get): - from httpx_helpers.helpers import async_get_request_with_client + with sentry_sdk.traces.start_span(name="test"): + if asyncio.iscoroutinefunction(httpx_client.get): + from httpx_helpers.helpers import async_get_request_with_client - asyncio.get_event_loop().run_until_complete( - async_get_request_with_client(httpx_client, url) - ) - else: - from httpx_helpers.helpers import get_request_with_client + asyncio.get_event_loop().run_until_complete( + async_get_request_with_client(httpx_client, url) + ) + else: + from httpx_helpers.helpers import get_request_with_client - get_request_with_client(httpx_client, url) + get_request_with_client(httpx_client, url) sentry_sdk.flush() @@ -1018,10 +1026,11 @@ def test_no_request_source_if_duration_too_short_span_streaming( url = "http://example.com/" - if asyncio.iscoroutinefunction(httpx_client.get): - asyncio.get_event_loop().run_until_complete(httpx_client.get(url)) - else: - httpx_client.get(url) + with sentry_sdk.traces.start_span(name="test"): + if asyncio.iscoroutinefunction(httpx_client.get): + asyncio.get_event_loop().run_until_complete(httpx_client.get(url)) + else: + httpx_client.get(url) sentry_sdk.flush() @@ -1055,10 +1064,11 @@ def test_request_source_if_duration_over_threshold_span_streaming( url = "http://example.com/" - if asyncio.iscoroutinefunction(httpx_client.get): - asyncio.get_event_loop().run_until_complete(httpx_client.get(url)) - else: - httpx_client.get(url) + with sentry_sdk.traces.start_span(name="test"): + if asyncio.iscoroutinefunction(httpx_client.get): + asyncio.get_event_loop().run_until_complete(httpx_client.get(url)) + else: + httpx_client.get(url) sentry_sdk.flush() @@ -1107,10 +1117,11 @@ def test_span_origin_span_streaming( url = "http://example.com/" - if asyncio.iscoroutinefunction(httpx_client.get): - asyncio.get_event_loop().run_until_complete(httpx_client.get(url)) - else: - httpx_client.get(url) + with sentry_sdk.traces.start_span(name="test"): + if asyncio.iscoroutinefunction(httpx_client.get): + asyncio.get_event_loop().run_until_complete(httpx_client.get(url)) + else: + httpx_client.get(url) sentry_sdk.flush() @@ -1140,10 +1151,11 @@ def test_http_url_attributes_span_streaming( url = "http://example.com/?foo=bar#frag" - if asyncio.iscoroutinefunction(httpx_client.get): - asyncio.get_event_loop().run_until_complete(httpx_client.get(url)) - else: - httpx_client.get(url) + with sentry_sdk.traces.start_span(name="test"): + if asyncio.iscoroutinefunction(httpx_client.get): + asyncio.get_event_loop().run_until_complete(httpx_client.get(url)) + else: + httpx_client.get(url) sentry_sdk.flush() @@ -1183,10 +1195,11 @@ def test_http_url_attributes_no_query_or_fragment_span_streaming( url = "http://example.com/" - if asyncio.iscoroutinefunction(httpx_client.get): - asyncio.get_event_loop().run_until_complete(httpx_client.get(url)) - else: - httpx_client.get(url) + with sentry_sdk.traces.start_span(name="test"): + if asyncio.iscoroutinefunction(httpx_client.get): + asyncio.get_event_loop().run_until_complete(httpx_client.get(url)) + else: + httpx_client.get(url) sentry_sdk.flush() From aeb7bd8e625a44a616db532af825919e1587f02f Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Wed, 15 Jul 2026 12:44:22 -0400 Subject: [PATCH 09/16] fix(tracing): Propagate trace headers in streaming path when no current span (HTTP clients) Previously, when span streaming was enabled and there was no active span, httpx/httpx2/httplib skipped trace header propagation entirely by returning early. This diverged from the legacy behavior and dropped distributed tracing continuity for outgoing requests made outside of a span. Extract the shared header-propagation logic into `propagate_trace_headers()` in tracing_utils.py and call it from the no-current-span early-return branches so headers are still attached from the scope's propagation context. --- sentry_sdk/integrations/httpx.py | 68 ++----------------- sentry_sdk/integrations/httpx2.py | 72 +++----------------- sentry_sdk/integrations/stdlib.py | 40 +++++------ sentry_sdk/tracing_utils.py | 25 +++++++ tests/integrations/httpx2/test_httpx2.py | 83 +++++++++++++++++++++++ tests/integrations/stdlib/test_httplib.py | 68 +++++++++++++++++++ 6 files changed, 211 insertions(+), 145 deletions(-) diff --git a/sentry_sdk/integrations/httpx.py b/sentry_sdk/integrations/httpx.py index 3322de564e..7f5d5a64fa 100644 --- a/sentry_sdk/integrations/httpx.py +++ b/sentry_sdk/integrations/httpx.py @@ -4,18 +4,15 @@ from sentry_sdk.consts import OP, SPANDATA from sentry_sdk.integrations import DidNotEnable, Integration from sentry_sdk.scope import should_send_default_pii -from sentry_sdk.tracing import BAGGAGE_HEADER_NAME from sentry_sdk.tracing_utils import ( add_http_request_source, - add_sentry_baggage_to_headers, has_span_streaming_enabled, - should_propagate_trace, + propagate_trace_headers, ) from sentry_sdk.utils import ( SENSITIVE_DATA_SUBSTITUTE, capture_internal_exceptions, ensure_integration_enabled, - logger, parse_url, ) @@ -84,21 +81,7 @@ def send(self: "Client", request: "Request", **kwargs: "Any") -> "Response": if parsed_url.fragment: attributes["url.fragment"] = parsed_url.fragment - if should_propagate_trace(client, str(request.url)): - for ( - key, - value, - ) in ( - sentry_sdk.get_current_scope().iter_trace_propagation_headers() - ): - logger.debug( - f"[Tracing] Adding `{key}` header {value} to outgoing request to {request.url}." - ) - - if key == BAGGAGE_HEADER_NAME: - add_sentry_baggage_to_headers(request.headers, value) - else: - request.headers[key] = value + propagate_trace_headers(client, request) try: rv = real_send(self, request, **kwargs) @@ -128,21 +111,7 @@ def send(self: "Client", request: "Request", **kwargs: "Any") -> "Response": span.set_data(SPANDATA.HTTP_QUERY, parsed_url.query) span.set_data(SPANDATA.HTTP_FRAGMENT, parsed_url.fragment) - if should_propagate_trace(client, str(request.url)): - for ( - key, - value, - ) in ( - sentry_sdk.get_current_scope().iter_trace_propagation_headers() - ): - logger.debug( - f"[Tracing] Adding `{key}` header {value} to outgoing request to {request.url}." - ) - - if key == BAGGAGE_HEADER_NAME: - add_sentry_baggage_to_headers(request.headers, value) - else: - request.headers[key] = value + propagate_trace_headers(client, request) rv = real_send(self, request, **kwargs) @@ -197,21 +166,7 @@ async def send( if parsed_url.fragment: attributes["url.fragment"] = parsed_url.fragment - if should_propagate_trace(client, str(request.url)): - for ( - key, - value, - ) in ( - sentry_sdk.get_current_scope().iter_trace_propagation_headers() - ): - logger.debug( - f"[Tracing] Adding `{key}` header {value} to outgoing request to {request.url}." - ) - - if key == BAGGAGE_HEADER_NAME: - add_sentry_baggage_to_headers(request.headers, value) - else: - request.headers[key] = value + propagate_trace_headers(client, request) try: rv = await real_send(self, request, **kwargs) @@ -241,20 +196,7 @@ async def send( span.set_data(SPANDATA.HTTP_QUERY, parsed_url.query) span.set_data(SPANDATA.HTTP_FRAGMENT, parsed_url.fragment) - if should_propagate_trace(client, str(request.url)): - for ( - key, - value, - ) in ( - sentry_sdk.get_current_scope().iter_trace_propagation_headers() - ): - logger.debug( - f"[Tracing] Adding `{key}` header {value} to outgoing request to {request.url}." - ) - if key == BAGGAGE_HEADER_NAME: - add_sentry_baggage_to_headers(request.headers, value) - else: - request.headers[key] = value + propagate_trace_headers(client, request) rv = await real_send(self, request, **kwargs) diff --git a/sentry_sdk/integrations/httpx2.py b/sentry_sdk/integrations/httpx2.py index d327e320f6..1ce3e93524 100644 --- a/sentry_sdk/integrations/httpx2.py +++ b/sentry_sdk/integrations/httpx2.py @@ -4,18 +4,15 @@ from sentry_sdk.consts import OP, SPANDATA from sentry_sdk.integrations import DidNotEnable, Integration from sentry_sdk.scope import should_send_default_pii -from sentry_sdk.tracing import BAGGAGE_HEADER_NAME from sentry_sdk.tracing_utils import ( add_http_request_source, - add_sentry_baggage_to_headers, has_span_streaming_enabled, - should_propagate_trace, + propagate_trace_headers, ) from sentry_sdk.utils import ( SENSITIVE_DATA_SUBSTITUTE, capture_internal_exceptions, ensure_integration_enabled, - logger, parse_url, ) @@ -61,6 +58,8 @@ def send(self: "Client", request: "Request", **kwargs: "Any") -> "Response": if is_span_streaming_enabled: if sentry_sdk.traces.get_current_span() is None: + propagate_trace_headers(client, request) + return real_send(self, request, **kwargs) with sentry_sdk.traces.start_span( @@ -84,21 +83,7 @@ def send(self: "Client", request: "Request", **kwargs: "Any") -> "Response": if parsed_url.fragment: attributes["url.fragment"] = parsed_url.fragment - if should_propagate_trace(client, str(request.url)): - for ( - key, - value, - ) in ( - sentry_sdk.get_current_scope().iter_trace_propagation_headers() - ): - logger.debug( - f"[Tracing] Adding `{key}` header {value} to outgoing request to {request.url}." - ) - - if key == BAGGAGE_HEADER_NAME: - add_sentry_baggage_to_headers(request.headers, value) - else: - request.headers[key] = value + propagate_trace_headers(client, request) try: rv = real_send(self, request, **kwargs) @@ -128,21 +113,7 @@ def send(self: "Client", request: "Request", **kwargs: "Any") -> "Response": span.set_data(SPANDATA.HTTP_QUERY, parsed_url.query) span.set_data(SPANDATA.HTTP_FRAGMENT, parsed_url.fragment) - if should_propagate_trace(client, str(request.url)): - for ( - key, - value, - ) in ( - sentry_sdk.get_current_scope().iter_trace_propagation_headers() - ): - logger.debug( - f"[Tracing] Adding `{key}` header {value} to outgoing request to {request.url}." - ) - - if key == BAGGAGE_HEADER_NAME: - add_sentry_baggage_to_headers(request.headers, value) - else: - request.headers[key] = value + propagate_trace_headers(client, request) rv = real_send(self, request, **kwargs) @@ -174,6 +145,8 @@ async def send( if is_span_streaming_enabled: if sentry_sdk.traces.get_current_span() is None: + propagate_trace_headers(client, request) + return await real_send(self, request, **kwargs) with sentry_sdk.traces.start_span( @@ -197,21 +170,7 @@ async def send( if parsed_url.fragment: attributes["url.fragment"] = parsed_url.fragment - if should_propagate_trace(client, str(request.url)): - for ( - key, - value, - ) in ( - sentry_sdk.get_current_scope().iter_trace_propagation_headers() - ): - logger.debug( - f"[Tracing] Adding `{key}` header {value} to outgoing request to {request.url}." - ) - - if key == BAGGAGE_HEADER_NAME: - add_sentry_baggage_to_headers(request.headers, value) - else: - request.headers[key] = value + propagate_trace_headers(client, request) try: rv = await real_send(self, request, **kwargs) @@ -241,20 +200,7 @@ async def send( span.set_data(SPANDATA.HTTP_QUERY, parsed_url.query) span.set_data(SPANDATA.HTTP_FRAGMENT, parsed_url.fragment) - if should_propagate_trace(client, str(request.url)): - for ( - key, - value, - ) in ( - sentry_sdk.get_current_scope().iter_trace_propagation_headers() - ): - logger.debug( - f"[Tracing] Adding `{key}` header {value} to outgoing request to {request.url}." - ) - if key == BAGGAGE_HEADER_NAME: - add_sentry_baggage_to_headers(request.headers, value) - else: - request.headers[key] = value + propagate_trace_headers(client, request) rv = await real_send(self, request, **kwargs) diff --git a/sentry_sdk/integrations/stdlib.py b/sentry_sdk/integrations/stdlib.py index 1cb1b62a5f..4de3819a77 100644 --- a/sentry_sdk/integrations/stdlib.py +++ b/sentry_sdk/integrations/stdlib.py @@ -112,28 +112,30 @@ def putrequest( parsed_url = parse_url(real_url, sanitize=False) span_streaming = has_span_streaming_enabled(client.options) - span: "Union[Span, StreamedSpan]" + span: "Union[Span, StreamedSpan, None]" if span_streaming: if sentry_sdk.traces.get_current_span() is None: - return real_putrequest(self, method, url, *args, **kwargs) - - span = sentry_sdk.traces.start_span( - name="%s %s" - % (method, parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE), - attributes={ - "sentry.origin": "auto.http.stdlib.httplib", - "sentry.op": OP.HTTP_CLIENT, - SPANDATA.HTTP_REQUEST_METHOD: method, - }, - ) - - if parsed_url is not None and should_send_default_pii(): - span.set_attribute(SPANDATA.URL_FRAGMENT, parsed_url.fragment) - span.set_attribute(SPANDATA.URL_FULL, parsed_url.url) - span.set_attribute(SPANDATA.URL_QUERY, parsed_url.query) + span = None + else: + span = sentry_sdk.traces.start_span( + name="%s %s" + % ( + method, + parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE, + ), + attributes={ + "sentry.origin": "auto.http.stdlib.httplib", + "sentry.op": OP.HTTP_CLIENT, + SPANDATA.HTTP_REQUEST_METHOD: method, + }, + ) - set_on_span = span.set_attribute + if parsed_url is not None and should_send_default_pii(): + span.set_attribute(SPANDATA.URL_FRAGMENT, parsed_url.fragment) + span.set_attribute(SPANDATA.URL_FULL, parsed_url.url) + span.set_attribute(SPANDATA.URL_QUERY, parsed_url.query) + set_on_span = span.set_attribute else: span = sentry_sdk.start_span( op=OP.HTTP_CLIENT, @@ -151,7 +153,7 @@ def putrequest( set_on_span = span.set_data # for proxies, these point to the proxy host/port - if tunnel_host: + if span and tunnel_host: set_on_span(SPANDATA.NETWORK_PEER_ADDRESS, self.host) set_on_span(SPANDATA.NETWORK_PEER_PORT, self.port) diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index 8cb1cbe3ae..ce5e83324c 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -973,6 +973,31 @@ def should_propagate_trace(client: "sentry_sdk.client.BaseClient", url: str) -> return match_regex_list(url, trace_propagation_targets, substring_matching=True) +def propagate_trace_headers( + client: "sentry_sdk.client.BaseClient", request: "Any" +) -> None: + """ + Attach Sentry trace propagation headers (``sentry-trace``/``baggage``) from the + current scope's propagation context to an outgoing request, if the request's + URL matches the configured ``trace_propagation_targets``. + + ``request`` is expected to expose ``url`` and a mutable ``headers`` mapping + (e.g. an ``httpx``/``httpx2`` ``Request``). + """ + if not should_propagate_trace(client, str(request.url)): + return + + for key, value in sentry_sdk.get_current_scope().iter_trace_propagation_headers(): + logger.debug( + f"[Tracing] Adding `{key}` header {value} to outgoing request to {request.url}." + ) + + if key == BAGGAGE_HEADER_NAME: + add_sentry_baggage_to_headers(request.headers, value) + else: + request.headers[key] = value + + def normalize_incoming_data(incoming_data: "Dict[str, Any]") -> "Dict[str, Any]": """ Normalizes incoming data so the keys are all lowercase with dashes instead of underscores and stripped from known prefixes. diff --git a/tests/integrations/httpx2/test_httpx2.py b/tests/integrations/httpx2/test_httpx2.py index dd41e3de12..d66c62849b 100644 --- a/tests/integrations/httpx2/test_httpx2.py +++ b/tests/integrations/httpx2/test_httpx2.py @@ -795,6 +795,89 @@ def test_outgoing_trace_headers_append_to_baggage_span_streaming( assert "sentry-sampled=true" in baggage +def test_outgoing_trace_headers_span_streaming_no_current_span( + sentry_init, httpx2_mock +): + """ + Even when there is no active span, trace propagation headers should still + be attached to outgoing requests when span streaming is enabled. + + This is deliberately different from the legacy (transaction-based) approach, + which does not propagate outside of a transaction (see + ``test_do_not_propagate_outside_transaction``). The streamed approach + propagates from the current scope's propagation context regardless of + whether a span is active. + """ + httpx2_mock.add_response() + + sentry_init( + traces_sample_rate=1.0, + trace_propagation_targets=[MATCH_ALL], + integrations=[Httpx2Integration()], + _experiments={"trace_lifecycle": "stream"}, + ) + + url = "http://example.com/" + + httpx2_client = httpx2.Client() + + # No start_span / start_transaction -> get_current_span() is None + assert sentry_sdk.traces.get_current_span() is None + + response = httpx2_client.get(url) + + assert response.status_code == 200 + + # Trace is still propagated from the scope's propagation context + request_headers = httpx2_mock.get_request().headers + assert "sentry-trace" in request_headers + assert "baggage" in request_headers + + # The propagated headers describe a single, coherent trace: the trace_id in + # sentry-trace matches the one carried in baggage. + trace_id = request_headers["sentry-trace"].split("-")[0] + assert f"sentry-trace_id={trace_id}" in request_headers["baggage"] + + +def test_outgoing_trace_headers_span_streaming_no_current_span_async( + sentry_init, httpx2_mock +): + """ + The async client must match the sync client: trace propagation headers are + attached to outgoing requests even when there is no active span and span + streaming is enabled. + """ + httpx2_mock.add_response() + + sentry_init( + traces_sample_rate=1.0, + trace_propagation_targets=[MATCH_ALL], + integrations=[Httpx2Integration()], + _experiments={"trace_lifecycle": "stream"}, + ) + + url = "http://example.com/" + + httpx2_client = httpx2.AsyncClient() + + # No start_span / start_transaction -> get_current_span() is None + assert sentry_sdk.traces.get_current_span() is None + + response = asyncio.run(httpx2_client.get(url)) + + assert response.status_code == 200 + + # Trace is still propagated from the scope's propagation context + request_headers = httpx2_mock.get_request().headers + assert "sentry-trace" in request_headers + assert "baggage" in request_headers + + # The propagated headers describe a single, coherent trace: the trace_id in + # sentry-trace matches the one carried in baggage. + trace_id = request_headers["sentry-trace"].split("-")[0] + assert f"sentry-trace_id={trace_id}" in request_headers["baggage"] + + @pytest.mark.parametrize( "httpx2_client", (httpx2.Client(), httpx2.AsyncClient()), diff --git a/tests/integrations/stdlib/test_httplib.py b/tests/integrations/stdlib/test_httplib.py index 64e059fc4c..f780109ab6 100644 --- a/tests/integrations/stdlib/test_httplib.py +++ b/tests/integrations/stdlib/test_httplib.py @@ -460,6 +460,74 @@ def getresponse(self, *args, **kwargs): assert request_headers["baggage"] == expected_outgoing_baggage +def test_outgoing_trace_headers_span_streaming_no_current_span(sentry_init): + """ + With span streaming enabled and no active span, trace propagation headers + should still be attached to outgoing requests, propagated from the scope's + propagation context. + """ + sentry_init( + traces_sample_rate=1.0, + _experiments={"trace_lifecycle": "stream"}, + ) + + already_patched_getresponse = HTTPSConnection.getresponse + request_headers = {} + + class HTTPSConnectionRecordingRequestHeaders(HTTPSConnection): + def send(self, *args, **kwargs) -> None: + request_str = args[0] + for line in request_str.decode("utf-8").split("\r\n")[1:]: + if line: + key, val = line.split(": ") + request_headers[key] = val + + server_sock, client_sock = socket.socketpair() + server_sock.sendall(b"HTTP/1.1 200 OK\r\n\r\n") + server_sock.close() + self.sock = client_sock + + def getresponse(self, *args, **kwargs): + return already_patched_getresponse(self, *args, **kwargs) + + headers = { + "sentry-trace": "771a43a4192642f0b136d5159a501700-1234567890abcdef-1", + "baggage": ( + "sentry-trace_id=771a43a4192642f0b136d5159a501700," + "sentry-public_key=49d0f7386ad645858ae85020e393bef3," + "sentry-sample_rate=0.01337," + "sentry-sample_rand=0.000005," + "sentry-sampled=true" + ), + } + + # Seed the scope's propagation context, but do NOT start a span. + sentry_sdk.traces.continue_trace(headers) + assert sentry_sdk.traces.get_current_span() is None + + connection = HTTPSConnectionRecordingRequestHeaders("localhost", port=PORT) + connection.request("GET", "/top-chasers") + connection.getresponse() + + # Trace is still propagated, carrying the trace_id from the propagation + # context. The span id segment is generated for the propagation context + # (there is no active span), so we assert the stable trace_id prefix. + assert request_headers["sentry-trace"].startswith( + "771a43a4192642f0b136d5159a501700-" + ) + + # The outgoing baggage is fully deterministic: it is the frozen incoming + # baggage from the propagation context. + expected_outgoing_baggage = ( + "sentry-trace_id=771a43a4192642f0b136d5159a501700," + "sentry-public_key=49d0f7386ad645858ae85020e393bef3," + "sentry-sample_rate=0.01337," + "sentry-sample_rand=0.000005," + "sentry-sampled=true" + ) + assert request_headers["baggage"] == expected_outgoing_baggage + + @pytest.mark.parametrize( "trace_propagation_targets,host,path,trace_propagated", [ From 2b599427339f04208951b4e6ca37abd5a7cda8a5 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Wed, 15 Jul 2026 12:53:08 -0400 Subject: [PATCH 10/16] fix(tracing): Propagate trace headers for httpx when no current span The httpx sync and async streaming paths returned early without propagating trace headers when there was no active span, dropping distributed tracing continuity for outgoing requests made outside of a span. Call propagate_trace_headers() in both no-current-span branches so headers are attached from the scope's propagation context, matching httpx2 and httplib. Add sync and async tests covering trace propagation with span streaming enabled and no active span. --- sentry_sdk/integrations/httpx.py | 2 + tests/integrations/httpx/test_httpx.py | 81 ++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/sentry_sdk/integrations/httpx.py b/sentry_sdk/integrations/httpx.py index 7f5d5a64fa..779302a238 100644 --- a/sentry_sdk/integrations/httpx.py +++ b/sentry_sdk/integrations/httpx.py @@ -58,6 +58,7 @@ def send(self: "Client", request: "Request", **kwargs: "Any") -> "Response": if is_span_streaming_enabled: if sentry_sdk.traces.get_current_span() is None: + propagate_trace_headers(client, request) return real_send(self, request, **kwargs) with sentry_sdk.traces.start_span( @@ -143,6 +144,7 @@ async def send( if is_span_streaming_enabled: if sentry_sdk.traces.get_current_span() is None: + propagate_trace_headers(client, request) return await real_send(self, request, **kwargs) with sentry_sdk.traces.start_span( diff --git a/tests/integrations/httpx/test_httpx.py b/tests/integrations/httpx/test_httpx.py index 580076207a..c0a0ea049c 100644 --- a/tests/integrations/httpx/test_httpx.py +++ b/tests/integrations/httpx/test_httpx.py @@ -803,6 +803,87 @@ def test_outgoing_trace_headers_append_to_baggage_span_streaming( assert "sentry-sampled=true" in baggage +def test_outgoing_trace_headers_span_streaming_no_current_span(sentry_init, httpx_mock): + """ + Even when there is no active span, trace propagation headers should still + be attached to outgoing requests when span streaming is enabled. + + This is deliberately different from the legacy (transaction-based) approach, + which does not propagate outside of a transaction (see + ``test_do_not_propagate_outside_transaction``). The streamed approach + propagates from the current scope's propagation context regardless of + whether a span is active. + """ + httpx_mock.add_response() + + sentry_init( + traces_sample_rate=1.0, + trace_propagation_targets=[MATCH_ALL], + integrations=[HttpxIntegration()], + _experiments={"trace_lifecycle": "stream"}, + ) + + url = "http://example.com/" + + httpx_client = httpx.Client() + + # No start_span / start_transaction -> get_current_span() is None + assert sentry_sdk.traces.get_current_span() is None + + response = httpx_client.get(url) + + assert response.status_code == 200 + + # Trace is still propagated from the scope's propagation context + request_headers = httpx_mock.get_request().headers + assert "sentry-trace" in request_headers + assert "baggage" in request_headers + + # The propagated headers describe a single, coherent trace: the trace_id in + # sentry-trace matches the one carried in baggage. + trace_id = request_headers["sentry-trace"].split("-")[0] + assert f"sentry-trace_id={trace_id}" in request_headers["baggage"] + + +def test_outgoing_trace_headers_span_streaming_no_current_span_async( + sentry_init, httpx_mock +): + """ + The async client must match the sync client: trace propagation headers are + attached to outgoing requests even when there is no active span and span + streaming is enabled. + """ + httpx_mock.add_response() + + sentry_init( + traces_sample_rate=1.0, + trace_propagation_targets=[MATCH_ALL], + integrations=[HttpxIntegration()], + _experiments={"trace_lifecycle": "stream"}, + ) + + url = "http://example.com/" + + httpx_client = httpx.AsyncClient() + + # No start_span / start_transaction -> get_current_span() is None + assert sentry_sdk.traces.get_current_span() is None + + response = asyncio.get_event_loop().run_until_complete(httpx_client.get(url)) + + assert response.status_code == 200 + + # Trace is still propagated from the scope's propagation context + request_headers = httpx_mock.get_request().headers + assert "sentry-trace" in request_headers + assert "baggage" in request_headers + + # The propagated headers describe a single, coherent trace: the trace_id in + # sentry-trace matches the one carried in baggage. + trace_id = request_headers["sentry-trace"].split("-")[0] + assert f"sentry-trace_id={trace_id}" in request_headers["baggage"] + + @pytest.mark.parametrize( "httpx_client", (httpx.Client(), httpx.AsyncClient()), From 4afedf112f0ab2e8879a4bb09642329e48ee2f9c Mon Sep 17 00:00:00 2001 From: Neel Shah Date: Thu, 16 Jul 2026 13:47:53 +0200 Subject: [PATCH 11/16] neel fixes --- sentry_sdk/integrations/pyreqwest.py | 38 +++++++++++++--------------- sentry_sdk/tracing_utils.py | 3 +++ 2 files changed, 21 insertions(+), 20 deletions(-) diff --git a/sentry_sdk/integrations/pyreqwest.py b/sentry_sdk/integrations/pyreqwest.py index 7ca73b370a..d45dcb466e 100644 --- a/sentry_sdk/integrations/pyreqwest.py +++ b/sentry_sdk/integrations/pyreqwest.py @@ -18,7 +18,6 @@ SENSITIVE_DATA_SUBSTITUTE, capture_internal_exceptions, logger, - nullcontext, parse_url, ) @@ -89,22 +88,22 @@ def _sentry_pyreqwest_span(request: "Request") -> "Generator[Any, None, None]": span_streaming = has_span_streaming_enabled(sentry_sdk.get_client().options) if span_streaming: - span_ctx = nullcontext() - if sentry_sdk.traces.get_current_span() is not None: - span_ctx = sentry_sdk.traces.start_span( - name=f"{request.method} {parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE}", - attributes={ - "sentry.op": OP.HTTP_CLIENT, - "sentry.origin": PyreqwestIntegration.origin, - SPANDATA.HTTP_REQUEST_METHOD: request.method, - }, - ) - with span_ctx as span: - if span is not None: - if parsed_url is not None and should_send_default_pii(): - span.set_attribute(SPANDATA.URL_FULL, parsed_url.url) - span.set_attribute(SPANDATA.URL_QUERY, parsed_url.query) - span.set_attribute(SPANDATA.URL_FRAGMENT, parsed_url.fragment) + if sentry_sdk.traces.get_current_span() is None: + yield None + return + + with sentry_sdk.traces.start_span( + name=f"{request.method} {parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE}", + attributes={ + "sentry.op": OP.HTTP_CLIENT, + "sentry.origin": PyreqwestIntegration.origin, + SPANDATA.HTTP_REQUEST_METHOD: request.method, + }, + ) as span: + if parsed_url is not None and should_send_default_pii(): + span.set_attribute(SPANDATA.URL_FULL, parsed_url.url) + span.set_attribute(SPANDATA.URL_QUERY, parsed_url.query) + span.set_attribute(SPANDATA.URL_FRAGMENT, parsed_url.fragment) if should_propagate_trace(sentry_sdk.get_client(), str(request.url)): for ( @@ -124,9 +123,8 @@ def _sentry_pyreqwest_span(request: "Request") -> "Generator[Any, None, None]": yield span - if span is not None: - with capture_internal_exceptions(): - add_http_request_source(span) + with capture_internal_exceptions(): + add_http_request_source(span) return diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index ce5e83324c..f3f9012d83 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -984,6 +984,9 @@ def propagate_trace_headers( ``request`` is expected to expose ``url`` and a mutable ``headers`` mapping (e.g. an ``httpx``/``httpx2`` ``Request``). """ + if not hasattr(request, "url") or not hasattr(request, "headers"): + return + if not should_propagate_trace(client, str(request.url)): return From a6f5f5ada8eab37d9156f499f196f552f0f1624a Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Thu, 16 Jul 2026 07:49:20 -0400 Subject: [PATCH 12/16] update pyreqwest to use trace propagation helper method --- sentry_sdk/integrations/pyreqwest.py | 17 ++--------------- sentry_sdk/tracing_utils.py | 6 ++++++ 2 files changed, 8 insertions(+), 15 deletions(-) diff --git a/sentry_sdk/integrations/pyreqwest.py b/sentry_sdk/integrations/pyreqwest.py index 7ca73b370a..0c7d6eca40 100644 --- a/sentry_sdk/integrations/pyreqwest.py +++ b/sentry_sdk/integrations/pyreqwest.py @@ -12,6 +12,7 @@ add_http_request_source, add_sentry_baggage_to_headers, has_span_streaming_enabled, + propagate_trace_headers, should_propagate_trace, ) from sentry_sdk.utils import ( @@ -106,21 +107,7 @@ def _sentry_pyreqwest_span(request: "Request") -> "Generator[Any, None, None]": span.set_attribute(SPANDATA.URL_QUERY, parsed_url.query) span.set_attribute(SPANDATA.URL_FRAGMENT, parsed_url.fragment) - if should_propagate_trace(sentry_sdk.get_client(), str(request.url)): - for ( - key, - value, - ) in sentry_sdk.get_current_scope().iter_trace_propagation_headers(): - logger.debug( - "[Tracing] Adding `{key}` header {value} to outgoing request to {url}.".format( - key=key, value=value, url=request.url - ) - ) - - if key == BAGGAGE_HEADER_NAME: - add_sentry_baggage_to_headers(request.headers, value) - else: - request.headers[key] = value + propagate_trace_headers(client=sentry_sdk.get_client(), request=request) yield span diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index ce5e83324c..8cf11d01f7 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -984,6 +984,12 @@ def propagate_trace_headers( ``request`` is expected to expose ``url`` and a mutable ``headers`` mapping (e.g. an ``httpx``/``httpx2`` ``Request``). """ + if not hasattr(request, "url") or not hasattr(request, "headers"): + logger.warning( + "Unable to propagate trace headers in request - missing url or headers attributes" + ) + return + if not should_propagate_trace(client, str(request.url)): return From ac2936827bd5273330e3c945e07203c5dbaf58b3 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Thu, 16 Jul 2026 07:52:05 -0400 Subject: [PATCH 13/16] add trace propagation --- sentry_sdk/integrations/pyreqwest.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sentry_sdk/integrations/pyreqwest.py b/sentry_sdk/integrations/pyreqwest.py index 23c5b9f40e..dff1c07ff1 100644 --- a/sentry_sdk/integrations/pyreqwest.py +++ b/sentry_sdk/integrations/pyreqwest.py @@ -108,6 +108,8 @@ def _sentry_pyreqwest_span(request: "Request") -> "Generator[Any, None, None]": span.set_attribute(SPANDATA.URL_QUERY, parsed_url.query) span.set_attribute(SPANDATA.URL_FRAGMENT, parsed_url.fragment) + propagate_trace_headers(client=sentry_sdk.get_client(), request=request) + yield span if span is not None: From 94e5b1f0155cebb540f36a8d5de2ebd7b8f7f23c Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Thu, 16 Jul 2026 07:54:04 -0400 Subject: [PATCH 14/16] bad merge --- sentry_sdk/tracing_utils.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index 06ce83345b..8cf11d01f7 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -985,12 +985,9 @@ def propagate_trace_headers( (e.g. an ``httpx``/``httpx2`` ``Request``). """ if not hasattr(request, "url") or not hasattr(request, "headers"): -<<<<<<< HEAD logger.warning( "Unable to propagate trace headers in request - missing url or headers attributes" ) -======= ->>>>>>> 4afedf112f0ab2e8879a4bb09642329e48ee2f9c return if not should_propagate_trace(client, str(request.url)): From a2f1bd3ce9802840844fc17a5342ad49d5368e43 Mon Sep 17 00:00:00 2001 From: Neel Shah Date: Thu, 16 Jul 2026 13:57:29 +0200 Subject: [PATCH 15/16] remove nullcontext --- sentry_sdk/integrations/pyreqwest.py | 1 - 1 file changed, 1 deletion(-) diff --git a/sentry_sdk/integrations/pyreqwest.py b/sentry_sdk/integrations/pyreqwest.py index dff1c07ff1..d25d03f470 100644 --- a/sentry_sdk/integrations/pyreqwest.py +++ b/sentry_sdk/integrations/pyreqwest.py @@ -19,7 +19,6 @@ SENSITIVE_DATA_SUBSTITUTE, capture_internal_exceptions, logger, - nullcontext, parse_url, ) From 94a8d6265d1e80949ad49344140ca4459d961ff7 Mon Sep 17 00:00:00 2001 From: Neel Shah Date: Thu, 16 Jul 2026 14:04:51 +0200 Subject: [PATCH 16/16] fix arq tests --- tests/integrations/arq/test_arq.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/tests/integrations/arq/test_arq.py b/tests/integrations/arq/test_arq.py index 013ea83167..d962bf61c7 100644 --- a/tests/integrations/arq/test_arq.py +++ b/tests/integrations/arq/test_arq.py @@ -248,19 +248,19 @@ async def retry_job(ctx): # The retry re-enqueue happens without an active span, so no producer # (queue.submit.arq) span is created for it; only the consumer segment # is emitted. The consumer segment is preceded by the redis spans for - # the re-enqueue, so it lands at index 3. - assert spans[3]["attributes"]["sentry.op"] == "queue.task.arq" - assert spans[3]["status"] == "ok" - assert spans[3]["name"] == "retry_job" + # the re-enqueue, so it lands at index 2. + assert spans[2]["attributes"]["sentry.op"] == "queue.task.arq" + assert spans[2]["status"] == "ok" + assert spans[2]["name"] == "retry_job" await worker.run_job(job.job_id, timestamp_ms()) sentry_sdk.flush() spans = [item.payload for item in items] - assert spans[6]["attributes"]["sentry.op"] == "queue.task.arq" - assert spans[6]["status"] == "ok" - assert spans[6]["name"] == "retry_job" + assert spans[5]["attributes"]["sentry.op"] == "queue.task.arq" + assert spans[5]["status"] == "ok" + assert spans[5]["name"] == "retry_job" else: events = capture_events() @@ -589,7 +589,7 @@ async def job(ctx): pool, worker = init_fixture_method(span_streaming, [job]) if span_streaming: - job = await pool.enqueue_job("retry_job") + job = await pool.enqueue_job("job") items = capture_items("span") @@ -600,13 +600,13 @@ async def job(ctx): # No producer (queue.submit.arq) span is created for the re-enqueue # triggered by the retry, since it happens without an active span, so - # the consumer segment lands at index 3. - assert spans[3]["attributes"]["sentry.op"] == "queue.task.arq" - assert spans[3]["attributes"]["sentry.origin"] == "auto.queue.arq" - assert spans[2]["attributes"]["sentry.origin"] == "auto.db.redis" + # the consumer segment lands at index 2. + assert spans[2]["attributes"]["sentry.op"] == "queue.task.arq" + assert spans[2]["attributes"]["sentry.origin"] == "auto.queue.arq" assert spans[1]["attributes"]["sentry.origin"] == "auto.db.redis" + assert spans[0]["attributes"]["sentry.origin"] == "auto.db.redis" else: - job = await pool.enqueue_job("retry_job") + job = await pool.enqueue_job("job") events = capture_events()