From 8e7a6ab12733aa10c902b2d3afc209e4579c074f Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Tue, 21 Apr 2026 08:58:31 -0400 Subject: [PATCH 1/2] feat(fastapi): Support span streaming in active thread tracking When the span streaming experiment is enabled, the current scope has a StreamedSpan instead of a legacy transaction. Detect this case and call the segment's _update_active_thread() so the profiler's active thread id is recorded on the segment in span-first mode. Also parametrize existing tests over streaming/static modes and add a dedicated test asserting the active thread id is attached to the segment when streaming. Refs PY-2322 Co-Authored-By: Claude Opus 4.7 --- sentry_sdk/integrations/fastapi.py | 8 ++- tests/integrations/fastapi/test_fastapi.py | 68 +++++++++++++++++++--- 2 files changed, 67 insertions(+), 9 deletions(-) diff --git a/sentry_sdk/integrations/fastapi.py b/sentry_sdk/integrations/fastapi.py index 66f73ea4e0..c6181d4312 100644 --- a/sentry_sdk/integrations/fastapi.py +++ b/sentry_sdk/integrations/fastapi.py @@ -5,6 +5,7 @@ import sentry_sdk from sentry_sdk.integrations import DidNotEnable from sentry_sdk.scope import should_send_default_pii +from sentry_sdk.traces import StreamedSpan from sentry_sdk.tracing import SOURCE_FOR_STYLE, TransactionSource from sentry_sdk.utils import transaction_from_function @@ -80,7 +81,12 @@ def _sentry_get_request_handler(*args: "Any", **kwargs: "Any") -> "Any": @wraps(old_call) def _sentry_call(*args: "Any", **kwargs: "Any") -> "Any": current_scope = sentry_sdk.get_current_scope() - if current_scope.transaction is not None: + current_span = current_scope.span + + if isinstance(current_span, StreamedSpan): + segment = current_span._segment + segment._update_active_thread() + elif current_scope.transaction is not None: current_scope.transaction.update_active_thread() sentry_scope = sentry_sdk.get_isolation_scope() diff --git a/tests/integrations/fastapi/test_fastapi.py b/tests/integrations/fastapi/test_fastapi.py index 005189f00c..d321db993c 100644 --- a/tests/integrations/fastapi/test_fastapi.py +++ b/tests/integrations/fastapi/test_fastapi.py @@ -220,11 +220,43 @@ def test_active_thread_id(sentry_init, capture_envelopes, teardown_profiling, en assert str(data["active"]) == trace_context["data"]["thread.id"] +@pytest.mark.parametrize("endpoint", ["/sync/thread_ids", "/async/thread_ids"]) +def test_active_thread_id_span_streaming(sentry_init, capture_items, endpoint): + sentry_init( + auto_enabling_integrations=False, # Ensure httpx is not auto-enabled; its legacy start_span interferes with streaming mode + integrations=[StarletteIntegration(), FastApiIntegration()], + traces_sample_rate=1.0, + _experiments={"trace_lifecycle": "stream"}, + ) + app = fastapi_app_factory() + + items = capture_items("span") + + client = TestClient(app) + response = client.get(endpoint) + assert response.status_code == 200 + + data = json.loads(response.content) + + sentry_sdk.flush() + + segments = [item.payload for item in items if item.payload.get("is_segment")] + assert len(segments) == 1 + assert str(data["active"]) == segments[0]["attributes"]["thread.id"] + + +@pytest.mark.parametrize("span_streaming", [True, False]) @pytest.mark.asyncio -async def test_original_request_not_scrubbed(sentry_init, capture_events): +async def test_original_request_not_scrubbed( + sentry_init, capture_events, span_streaming +): sentry_init( + auto_enabling_integrations=False, # Ensure httpx is not auto-enabled; its legacy start_span interferes with streaming mode integrations=[StarletteIntegration(), FastApiIntegration()], traces_sample_rate=1.0, + _experiments={ + "trace_lifecycle": "stream" if span_streaming else "static", + }, ) app = FastAPI() @@ -344,6 +376,7 @@ def test_response_status_code_not_found_in_transaction_context( assert transaction["contexts"]["response"]["status_code"] == 404 +@pytest.mark.parametrize("span_streaming", [True, False]) @pytest.mark.parametrize( "request_url,transaction_style,expected_transaction_name,expected_transaction_source", [ @@ -368,6 +401,8 @@ def test_transaction_name( expected_transaction_name, expected_transaction_source, capture_envelopes, + capture_items, + span_streaming, ): """ Tests that the transaction name is something meaningful. @@ -379,22 +414,39 @@ def test_transaction_name( FastApiIntegration(transaction_style=transaction_style), ], traces_sample_rate=1.0, + _experiments={ + "trace_lifecycle": "stream" if span_streaming else "static", + }, ) - envelopes = capture_envelopes() + if span_streaming: + items = capture_items("span") + else: + envelopes = capture_envelopes() app = fastapi_app_factory() client = TestClient(app) client.get(request_url) - (_, transaction_envelope) = envelopes - transaction_event = transaction_envelope.get_transaction_event() + if span_streaming: + sentry_sdk.flush() + segments = [item.payload for item in items if item.payload.get("is_segment")] + assert len(segments) == 1 + segment = segments[0] + assert segment["name"] == expected_transaction_name + assert ( + segment["attributes"]["sentry.span.source"] == expected_transaction_source + ) + else: + (_, transaction_envelope) = envelopes + transaction_event = transaction_envelope.get_transaction_event() - assert transaction_event["transaction"] == expected_transaction_name - assert ( - transaction_event["transaction_info"]["source"] == expected_transaction_source - ) + assert transaction_event["transaction"] == expected_transaction_name + assert ( + transaction_event["transaction_info"]["source"] + == expected_transaction_source + ) def test_route_endpoint_equal_dependant_call(sentry_init): From 24d4a6ff738d78ea2b0b1510b495dc1bd3ed1210 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Tue, 21 Apr 2026 09:41:46 -0400 Subject: [PATCH 2/2] Add check for NoOpStreamedSpan --- sentry_sdk/integrations/fastapi.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/sentry_sdk/integrations/fastapi.py b/sentry_sdk/integrations/fastapi.py index c6181d4312..3572b1c07f 100644 --- a/sentry_sdk/integrations/fastapi.py +++ b/sentry_sdk/integrations/fastapi.py @@ -5,7 +5,7 @@ import sentry_sdk from sentry_sdk.integrations import DidNotEnable from sentry_sdk.scope import should_send_default_pii -from sentry_sdk.traces import StreamedSpan +from sentry_sdk.traces import NoOpStreamedSpan, StreamedSpan from sentry_sdk.tracing import SOURCE_FOR_STYLE, TransactionSource from sentry_sdk.utils import transaction_from_function @@ -83,7 +83,9 @@ def _sentry_call(*args: "Any", **kwargs: "Any") -> "Any": current_scope = sentry_sdk.get_current_scope() current_span = current_scope.span - if isinstance(current_span, StreamedSpan): + if isinstance(current_span, StreamedSpan) and not isinstance( + current_span, NoOpStreamedSpan + ): segment = current_span._segment segment._update_active_thread() elif current_scope.transaction is not None: