From 24da80f957b2a00ac8e946e4c995eb270f77df82 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Mon, 13 Jul 2026 15:54:56 +0200 Subject: [PATCH 1/2] fix(tracing): Skip child span creation in streaming path when no current span (web frameworks) When span streaming is enabled and there is no current span, web framework 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: starlette, starlite, litestar, graphene, strawberry, aiohttp. Co-Authored-By: Claude Opus 4.6 --- sentry_sdk/integrations/aiohttp.py | 6 +++++- sentry_sdk/integrations/graphene.py | 4 ++++ sentry_sdk/integrations/litestar.py | 6 ++++++ sentry_sdk/integrations/starlette.py | 3 +++ sentry_sdk/integrations/starlite.py | 3 +++ sentry_sdk/integrations/strawberry.py | 18 ++++++++++++++++++ 6 files changed, 39 insertions(+), 1 deletion(-) diff --git a/sentry_sdk/integrations/aiohttp.py b/sentry_sdk/integrations/aiohttp.py index d22f3a745b..f262f1330f 100644 --- a/sentry_sdk/integrations/aiohttp.py +++ b/sentry_sdk/integrations/aiohttp.py @@ -351,8 +351,12 @@ async def on_request_start( parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE, ) - span: "Union[Span, StreamedSpan]" + span: "Union[Span, StreamedSpan, None]" if has_span_streaming_enabled(client.options): + if sentry_sdk.traces.get_current_span() is None: + trace_config_ctx.span = None + return + attributes: "Attributes" = { "sentry.op": OP.HTTP_CLIENT, "sentry.origin": AioHttpIntegration.origin, diff --git a/sentry_sdk/integrations/graphene.py b/sentry_sdk/integrations/graphene.py index 4938a5c0f3..ce2b545241 100644 --- a/sentry_sdk/integrations/graphene.py +++ b/sentry_sdk/integrations/graphene.py @@ -149,6 +149,10 @@ def graphql_span( ) if is_span_streaming_enabled: + if sentry_sdk.traces.get_current_span() is None: + yield + return + additional_attributes = {} if should_send_default_pii(): additional_attributes["graphql.document"] = source diff --git a/sentry_sdk/integrations/litestar.py b/sentry_sdk/integrations/litestar.py index f0c90a7921..cb7aa87ae9 100644 --- a/sentry_sdk/integrations/litestar.py +++ b/sentry_sdk/integrations/litestar.py @@ -164,6 +164,8 @@ async def _create_span_call( middleware_name = self.__class__.__name__ if has_span_streaming_enabled(client.options): + if sentry_sdk.traces.get_current_span() is None: + return await old_call(self, scope, receive, send) with sentry_sdk.traces.start_span( name=middleware_name, attributes={ @@ -179,6 +181,8 @@ async def _sentry_receive( ) -> "Union[HTTPReceiveMessage, WebSocketReceiveMessage]": if client.get_integration(LitestarIntegration) is None: return await receive(*args, **kwargs) + if sentry_sdk.traces.get_current_span() is None: + return await receive(*args, **kwargs) with sentry_sdk.traces.start_span( name=getattr(receive, "__qualname__", str(receive)), attributes={ @@ -197,6 +201,8 @@ async def _sentry_receive( async def _sentry_send(message: "Message") -> None: if client.get_integration(LitestarIntegration) is None: return await send(message) + if sentry_sdk.traces.get_current_span() is None: + return await send(message) with sentry_sdk.traces.start_span( name=getattr(send, "__qualname__", str(send)), attributes={ diff --git a/sentry_sdk/integrations/starlette.py b/sentry_sdk/integrations/starlette.py index 3f9fbf4d8e..74effb1516 100644 --- a/sentry_sdk/integrations/starlette.py +++ b/sentry_sdk/integrations/starlette.py @@ -35,6 +35,7 @@ capture_internal_exceptions, ensure_integration_enabled, event_from_exception, + nullcontext, parse_version, transaction_from_function, ) @@ -198,6 +199,8 @@ async def _create_span_call( def _start_middleware_span(op: str, name: str) -> "Any": if is_span_streaming_enabled: + if sentry_sdk.traces.get_current_span() is None: + return nullcontext() return sentry_sdk.traces.start_span( name=name, attributes={ diff --git a/sentry_sdk/integrations/starlite.py b/sentry_sdk/integrations/starlite.py index 1eebd37e84..3b759a62b7 100644 --- a/sentry_sdk/integrations/starlite.py +++ b/sentry_sdk/integrations/starlite.py @@ -10,6 +10,7 @@ from sentry_sdk.utils import ( ensure_integration_enabled, event_from_exception, + nullcontext, transaction_from_function, ) @@ -151,6 +152,8 @@ async def _create_span_call( def _start_middleware_span(op: str, name: str) -> "Any": if is_span_streaming_enabled: + if sentry_sdk.traces.get_current_span() is None: + return nullcontext() return sentry_sdk.traces.start_span( name=name, attributes={ diff --git a/sentry_sdk/integrations/strawberry.py b/sentry_sdk/integrations/strawberry.py index 5f00e8bf6d..4e41133f3a 100644 --- a/sentry_sdk/integrations/strawberry.py +++ b/sentry_sdk/integrations/strawberry.py @@ -188,6 +188,10 @@ def on_operation(self) -> "Generator[None, None, None]": client = sentry_sdk.get_client() is_span_streaming_enabled = has_span_streaming_enabled(client.options) if is_span_streaming_enabled: + if sentry_sdk.traces.get_current_span() is None: + yield + return + additional_attributes: "dict[str, Any]" = {} if should_send_default_pii(): @@ -244,6 +248,10 @@ def on_validate(self) -> "Generator[None, None, None]": is_span_streaming_enabled = has_span_streaming_enabled(client.options) if is_span_streaming_enabled: + if sentry_sdk.traces.get_current_span() is None: + yield + return + validation_span = sentry_sdk.traces.start_span( name="validation", attributes={ @@ -272,6 +280,10 @@ def on_parse(self) -> "Generator[None, None, None]": is_span_streaming_enabled = has_span_streaming_enabled(client.options) if is_span_streaming_enabled: + if sentry_sdk.traces.get_current_span() is None: + yield + return + parsing_span = sentry_sdk.traces.start_span( name="parsing", attributes={ @@ -333,6 +345,9 @@ async def resolve( client = sentry_sdk.get_client() is_span_streaming_enabled = has_span_streaming_enabled(client.options) if is_span_streaming_enabled: + if sentry_sdk.traces.get_current_span() is None: + return await self._resolve(_next, root, info, *args, **kwargs) + with sentry_sdk.traces.start_span( name=f"resolving {field_path}", attributes={ @@ -372,6 +387,9 @@ def resolve( client = sentry_sdk.get_client() is_span_streaming_enabled = has_span_streaming_enabled(client.options) if is_span_streaming_enabled: + if sentry_sdk.traces.get_current_span() is None: + return _next(root, info, *args, **kwargs) + with sentry_sdk.traces.start_span( name=f"resolving {field_path}", attributes={ From dc9ec115c455b28a5635d25f9b55026c9d01e7e6 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Mon, 13 Jul 2026 17:08:13 +0200 Subject: [PATCH 2/2] fix(tests): Update aiohttp span_streaming tests for no-current-span guards The outgoing http.client span from the test fixture is now suppressed when there is no active span, reducing span counts in tests. Co-Authored-By: Claude Opus 4.6 --- tests/integrations/aiohttp/test_aiohttp.py | 88 ++++++---------------- 1 file changed, 25 insertions(+), 63 deletions(-) diff --git a/tests/integrations/aiohttp/test_aiohttp.py b/tests/integrations/aiohttp/test_aiohttp.py index 583e7a50b6..4a7c663d0e 100644 --- a/tests/integrations/aiohttp/test_aiohttp.py +++ b/tests/integrations/aiohttp/test_aiohttp.py @@ -1162,16 +1162,12 @@ async def hello(request): sentry_sdk.flush() - # The aiohttp_client fixture is itself sentry-instrumented and emits the - # first http.client segment; the server-side http.server span is the other - # segment. Asserting the exact length confirms no other spans leak in. - assert len(items) == 2 - - server_span, client_span = [item.payload for item in items] + # The server-side http.server span is the only segment. The aiohttp_client + # fixture's outgoing http.client span is suppressed because there is no + # active span when the test client makes the request. + assert len(items) == 1 - assert client_span["is_segment"] is True - assert client_span["attributes"]["sentry.op"] == "http.client" - assert client_span["name"].startswith("GET http://127.0.0.1:") + (server_span,) = [item.payload for item in items] assert server_span["is_segment"] is True assert ( @@ -1242,7 +1238,7 @@ async def hello(request): sentry_sdk.flush() - server_span, _client_segment = [item.payload for item in items] + (server_span,) = [item.payload for item in items] # send_default_pii defaults to False, so _filter_headers substitutes # sensitive headers with SENSITIVE_DATA_SUBSTITUTE ("[Filtered]"). The @@ -1282,7 +1278,7 @@ async def hello(request): sentry_sdk.flush() - server_span, _client_segment = [item.payload for item in items] + (server_span,) = [item.payload for item in items] # With send_default_pii=True, _filter_headers is a no-op and the original # value reaches the span attribute. @@ -1321,8 +1317,8 @@ async def hello(request): sentry_sdk.flush() - assert len(items) == 2 - server_segment, client_segment = [item.payload for item in items] + assert len(items) == 1 + (server_segment,) = [item.payload for item in items] if send_pii: assert server_segment["attributes"]["url.query"] == "foo=bar&baz=qux" @@ -1378,8 +1374,8 @@ async def hello(request): sentry_sdk.flush() - assert len(items) == 2 - server_segment, client_segment = [item.payload for item in items] + assert len(items) == 1 + (server_segment,) = [item.payload for item in items] assert server_segment["name"] == expected_name assert server_segment["is_segment"] @@ -1408,21 +1404,14 @@ async def hello(request): sentry_sdk.flush() - # 1 error event + 2 spans (server http.server, test client http.client segment) - assert len(items) == 3 + # 1 error event + 1 span (server http.server) + assert len(items) == 2 error_event = items[0] assert error_event.type == "event" assert error_event.payload["exception"]["values"][0]["type"] == "ZeroDivisionError" - server_span, segment = [item.payload for item in items[1:]] - - assert segment["is_segment"] is True - assert segment["attributes"]["sentry.op"] == "http.client" - # The test client receives the 500 response that aiohttp's outer error - # handler synthesizes from the unhandled exception. - assert segment["attributes"]["http.response.status_code"] == 500 - assert segment["status"] == "error" + server_span = items[1].payload # The integration's generic Exception path reraises without recording # http.response.status_code on the server span. StreamedSpan.__exit__ @@ -1456,13 +1445,8 @@ async def hello(request): sentry_sdk.flush() - assert len(items) == 2 - server_span, segment = [item.payload for item in items] - - assert segment["is_segment"] is True - assert segment["attributes"]["sentry.op"] == "http.client" - assert segment["attributes"]["http.response.status_code"] == 403 - assert segment["status"] == "error" + assert len(items) == 1 + (server_span,) = [item.payload for item in items] assert server_span["attributes"]["sentry.op"] == "http.server" assert server_span["attributes"]["http.response.status_code"] == 403 @@ -1493,13 +1477,8 @@ async def hello(request): sentry_sdk.flush() - assert len(items) == 2 - server_span, segment = [item.payload for item in items] - - assert segment["is_segment"] is True - assert segment["attributes"]["sentry.op"] == "http.client" - assert segment["attributes"]["http.response.status_code"] == 302 - assert segment["status"] == "ok" + assert len(items) == 1 + (server_span,) = [item.payload for item in items] assert server_span["attributes"]["sentry.op"] == "http.server" assert server_span["attributes"]["http.response.status_code"] == 302 @@ -1538,18 +1517,13 @@ async def hello(request): sentry_sdk.flush() - # 3 spans, finished inner-first: + # 2 spans, finished inner-first: # #0 inner http.client (server -> raw_server) # #1 server http.server - # #2 outer http.client segment (test client -> server) - assert len(items) == 3 + assert len(items) == 2 inner_client_span = items[0].payload server_span = items[1].payload - segment = items[2].payload - - assert segment["is_segment"] is True - assert segment["attributes"]["sentry.op"] == "http.client" assert server_span["attributes"]["sentry.op"] == "http.server" @@ -1592,25 +1566,13 @@ async def handler(request): items = capture_items("span") client = await aiohttp_client(raw_server) - resp = await client.get("/") + await client.get("/") sentry_sdk.flush() - # raw_server bypasses Application._handle, so only the test client's - # outgoing http.client segment is emitted. - assert len(items) == 1 - client_span = items[0].payload - - assert client_span["is_segment"] is True - assert client_span["attributes"]["sentry.op"] == "http.client" - assert client_span["name"].startswith("GET http://127.0.0.1:") - assert resp.request_info.headers[ - "sentry-trace" - ] == "{trace_id}-{span_id}-{sampled}".format( - trace_id=client_span["trace_id"], - span_id=client_span["span_id"], - sampled=1, - ) + # The outgoing http.client span is suppressed because there is no active + # span when the test client makes the request. + assert len(items) == 0 @pytest.mark.asyncio @@ -1640,7 +1602,7 @@ async def hello(request): sentry_sdk.flush() - child_span, server_span, client_span = [item.payload for item in items] + child_span, server_span = [item.payload for item in items] if send_default_pii: assert server_span["attributes"]["user.ip_address"] == "127.0.0.1"