Skip to content

Commit f50e1ab

Browse files
ericapisaniclaude
andcommitted
feat(starlette): Support span streaming
Add span-streaming support to the Starlette integration so middleware spans and active-thread tracking work under the `trace_lifecycle: "stream"` experiment, while preserving the legacy transaction-based behavior. When span streaming is enabled, `_enable_span_for_middleware` starts middleware spans via `sentry_sdk.traces.start_span` with `sentry.op`, `sentry.origin`, and `starlette.middleware_name` attributes instead of the legacy `start_span(op=..., origin=...)` + tag pattern. In `patch_request_response`, when the current scope holds a `StreamedSpan` (and not a `NoOpStreamedSpan`), the profiler hook now calls `_segment._update_active_thread()`; otherwise the legacy `current_scope.transaction.update_active_thread()` path is preserved. Tests are parametrized across streaming and static modes for `test_middleware_spans`, `test_middleware_spans_disabled`, `test_middleware_callback_spans`, and `test_span_origin`. A new `test_active_thread_id_span_streaming` verifies the segment's `thread.id` attribute under streaming. `auto_enabling_integrations` is disabled in tests where auto-instrumented spans would leak into the captured span stream. Refs PY-2362 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent e882029 commit f50e1ab

2 files changed

Lines changed: 197 additions & 46 deletions

File tree

sentry_sdk/integrations/starlette.py

Lines changed: 38 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,12 @@
2020
)
2121
from sentry_sdk.integrations.asgi import SentryAsgiMiddleware
2222
from sentry_sdk.scope import should_send_default_pii
23+
from sentry_sdk.traces import NoOpStreamedSpan, StreamedSpan
2324
from sentry_sdk.tracing import (
2425
SOURCE_FOR_STYLE,
2526
TransactionSource,
2627
)
28+
from sentry_sdk.tracing_utils import has_span_streaming_enabled
2729
from sentry_sdk.utils import (
2830
AnnotatedValue,
2931
capture_internal_exceptions,
@@ -147,7 +149,8 @@ async def _create_span_call(
147149
send: "Callable[[Dict[str, Any]], Awaitable[None]]",
148150
**kwargs: "Any",
149151
) -> None:
150-
integration = sentry_sdk.get_client().get_integration(StarletteIntegration)
152+
client = sentry_sdk.get_client()
153+
integration = client.get_integration(StarletteIntegration)
151154
if integration is None:
152155
return await old_call(app, scope, receive, send, **kwargs)
153156

@@ -164,22 +167,38 @@ async def _create_span_call(
164167
return await old_call(app, scope, receive, send, **kwargs)
165168

166169
middleware_name = app.__class__.__name__
170+
is_span_streaming_enabled = has_span_streaming_enabled(client.options)
171+
172+
def _start_middleware_span(op: str, name: str) -> "Any":
173+
if is_span_streaming_enabled:
174+
return sentry_sdk.traces.start_span(
175+
name=name,
176+
attributes={
177+
"sentry.op": op,
178+
"sentry.origin": StarletteIntegration.origin,
179+
"starlette.middleware_name": middleware_name,
180+
},
181+
)
182+
return sentry_sdk.start_span(
183+
op=op,
184+
name=name,
185+
origin=StarletteIntegration.origin,
186+
)
167187

168-
with sentry_sdk.start_span(
169-
op=OP.MIDDLEWARE_STARLETTE,
170-
name=middleware_name,
171-
origin=StarletteIntegration.origin,
188+
with _start_middleware_span(
189+
op=OP.MIDDLEWARE_STARLETTE, name=middleware_name
172190
) as middleware_span:
173-
middleware_span.set_tag("starlette.middleware_name", middleware_name)
191+
if not is_span_streaming_enabled:
192+
middleware_span.set_tag("starlette.middleware_name", middleware_name)
174193

175194
# Creating spans for the "receive" callback
176195
async def _sentry_receive(*args: "Any", **kwargs: "Any") -> "Any":
177-
with sentry_sdk.start_span(
196+
with _start_middleware_span(
178197
op=OP.MIDDLEWARE_STARLETTE_RECEIVE,
179198
name=getattr(receive, "__qualname__", str(receive)),
180-
origin=StarletteIntegration.origin,
181199
) as span:
182-
span.set_tag("starlette.middleware_name", middleware_name)
200+
if not is_span_streaming_enabled:
201+
span.set_tag("starlette.middleware_name", middleware_name)
183202
return await receive(*args, **kwargs)
184203

185204
receive_name = getattr(receive, "__name__", str(receive))
@@ -188,12 +207,12 @@ async def _sentry_receive(*args: "Any", **kwargs: "Any") -> "Any":
188207

189208
# Creating spans for the "send" callback
190209
async def _sentry_send(*args: "Any", **kwargs: "Any") -> "Any":
191-
with sentry_sdk.start_span(
210+
with _start_middleware_span(
192211
op=OP.MIDDLEWARE_STARLETTE_SEND,
193212
name=getattr(send, "__qualname__", str(send)),
194-
origin=StarletteIntegration.origin,
195213
) as span:
196-
span.set_tag("starlette.middleware_name", middleware_name)
214+
if not is_span_streaming_enabled:
215+
span.set_tag("starlette.middleware_name", middleware_name)
197216
return await send(*args, **kwargs)
198217

199218
send_name = getattr(send, "__name__", str(send))
@@ -496,7 +515,13 @@ def _sentry_sync_func(*args: "Any", **kwargs: "Any") -> "Any":
496515
return old_func(*args, **kwargs)
497516

498517
current_scope = sentry_sdk.get_current_scope()
499-
if current_scope.transaction is not None:
518+
current_span = current_scope.span
519+
520+
if isinstance(current_span, StreamedSpan) and not isinstance(
521+
current_span, NoOpStreamedSpan
522+
):
523+
current_span._segment._update_active_thread()
524+
elif current_scope.transaction is not None:
500525
current_scope.transaction.update_active_thread()
501526

502527
sentry_scope = sentry_sdk.get_isolation_scope()

tests/integrations/starlette/test_starlette.py

Lines changed: 159 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
import pytest
1313

14+
import sentry_sdk
1415
from sentry_sdk import capture_message, get_baggage, get_traceparent
1516
from sentry_sdk.integrations.asgi import SentryAsgiMiddleware
1617
from sentry_sdk.integrations.starlette import (
@@ -648,24 +649,30 @@ def test_user_information_transaction_no_pii(sentry_init, capture_events):
648649
assert "user" not in transaction_event
649650

650651

651-
def test_middleware_spans(sentry_init, capture_events):
652+
@pytest.mark.parametrize("span_streaming", [True, False])
653+
def test_middleware_spans(sentry_init, capture_events, capture_items, span_streaming):
652654
sentry_init(
653655
traces_sample_rate=1.0,
654656
integrations=[StarletteIntegration(middleware_spans=True)],
657+
_experiments={
658+
"trace_lifecycle": "stream" if span_streaming else "static",
659+
},
655660
)
656661
starlette_app = starlette_app_factory(
657662
middleware=[Middleware(AuthenticationMiddleware, backend=BasicAuthBackend())]
658663
)
659-
events = capture_events()
664+
665+
if span_streaming:
666+
items = capture_items("span")
667+
else:
668+
events = capture_events()
660669

661670
client = TestClient(starlette_app, raise_server_exceptions=False)
662671
try:
663672
client.get("/message", auth=("Gabriela", "hello123"))
664673
except Exception:
665674
pass
666675

667-
(_, transaction_event) = events
668-
669676
expected_middleware_spans = [
670677
"ServerErrorMiddleware",
671678
"AuthenticationMiddleware",
@@ -676,55 +683,108 @@ def test_middleware_spans(sentry_init, capture_events):
676683
"ServerErrorMiddleware", # 'op': 'middleware.starlette.send'
677684
]
678685

679-
assert len(transaction_event["spans"]) == len(expected_middleware_spans)
686+
if span_streaming:
687+
sentry_sdk.flush()
688+
689+
middleware_spans = sorted(
690+
[
691+
item.payload
692+
for item in items
693+
if item.payload.get("attributes", {})
694+
.get("sentry.op", "")
695+
.startswith("middleware.starlette")
696+
],
697+
key=lambda s: s["start_timestamp"],
698+
)
680699

681-
idx = 0
682-
for span in transaction_event["spans"]:
683-
if span["op"].startswith("middleware.starlette"):
700+
assert len(middleware_spans) == len(expected_middleware_spans)
701+
702+
for idx, span in enumerate(middleware_spans):
684703
assert (
685-
span["tags"]["starlette.middleware_name"]
704+
span["attributes"]["starlette.middleware_name"]
686705
== expected_middleware_spans[idx]
687706
)
688-
idx += 1
707+
else:
708+
(_, transaction_event) = events
709+
710+
assert len(transaction_event["spans"]) == len(expected_middleware_spans)
689711

712+
idx = 0
713+
for span in transaction_event["spans"]:
714+
if span["op"].startswith("middleware.starlette"):
715+
assert (
716+
span["tags"]["starlette.middleware_name"]
717+
== expected_middleware_spans[idx]
718+
)
719+
idx += 1
690720

691-
def test_middleware_spans_disabled(sentry_init, capture_events):
721+
722+
@pytest.mark.parametrize("span_streaming", [True, False])
723+
def test_middleware_spans_disabled(
724+
sentry_init, capture_events, capture_items, span_streaming
725+
):
692726
sentry_init(
693727
traces_sample_rate=1.0,
694728
integrations=[StarletteIntegration(middleware_spans=False)],
729+
_experiments={
730+
"trace_lifecycle": "stream" if span_streaming else "static",
731+
},
695732
)
696733
starlette_app = starlette_app_factory(
697734
middleware=[Middleware(AuthenticationMiddleware, backend=BasicAuthBackend())]
698735
)
699-
events = capture_events()
736+
737+
if span_streaming:
738+
items = capture_items("span")
739+
else:
740+
events = capture_events()
700741

701742
client = TestClient(starlette_app, raise_server_exceptions=False)
702743
try:
703744
client.get("/message", auth=("Gabriela", "hello123"))
704745
except Exception:
705746
pass
706747

707-
(_, transaction_event) = events
708-
709-
assert len(transaction_event["spans"]) == 0
748+
if span_streaming:
749+
sentry_sdk.flush()
750+
751+
middleware_spans = [
752+
item.payload
753+
for item in items
754+
if item.payload.get("attributes", {})
755+
.get("sentry.op", "")
756+
.startswith("middleware.starlette")
757+
]
758+
assert len(middleware_spans) == 0
759+
else:
760+
(_, transaction_event) = events
761+
assert len(transaction_event["spans"]) == 0
710762

711763

712-
def test_middleware_callback_spans(sentry_init, capture_events):
764+
@pytest.mark.parametrize("span_streaming", [True, False])
765+
def test_middleware_callback_spans(
766+
sentry_init, capture_events, capture_items, span_streaming
767+
):
713768
sentry_init(
714769
traces_sample_rate=1.0,
715-
integrations=[StarletteIntegration()],
770+
integrations=[StarletteIntegration(middleware_spans=True)],
771+
_experiments={
772+
"trace_lifecycle": "stream" if span_streaming else "static",
773+
},
716774
)
717775
starlette_app = starlette_app_factory(middleware=[Middleware(SampleMiddleware)])
718-
events = capture_events()
776+
777+
if span_streaming:
778+
items = capture_items("span")
779+
else:
780+
events = capture_events()
719781

720782
client = TestClient(starlette_app, raise_server_exceptions=False)
721783
try:
722784
client.get("/message", auth=("Gabriela", "hello123"))
723785
except Exception:
724786
pass
725787

726-
(_, transaction_event) = events
727-
728788
expected = [
729789
{
730790
"op": "middleware.starlette",
@@ -773,12 +833,37 @@ def test_middleware_callback_spans(sentry_init, capture_events):
773833
},
774834
]
775835

776-
idx = 0
777-
for span in transaction_event["spans"]:
778-
assert span["op"] == expected[idx]["op"]
779-
assert span["description"] == expected[idx]["description"]
780-
assert span["tags"] == expected[idx]["tags"]
781-
idx += 1
836+
if span_streaming:
837+
sentry_sdk.flush()
838+
839+
middleware_spans = sorted(
840+
[
841+
item.payload
842+
for item in items
843+
if item.payload.get("attributes", {})
844+
.get("sentry.op", "")
845+
.startswith("middleware.starlette")
846+
],
847+
key=lambda s: s["start_timestamp"],
848+
)
849+
850+
assert len(middleware_spans) == len(expected)
851+
for span, exp in zip(middleware_spans, expected):
852+
assert span["attributes"]["sentry.op"] == exp["op"]
853+
assert span["name"] == exp["description"]
854+
assert (
855+
span["attributes"]["starlette.middleware_name"]
856+
== exp["tags"]["starlette.middleware_name"]
857+
)
858+
else:
859+
(_, transaction_event) = events
860+
861+
idx = 0
862+
for span in transaction_event["spans"]:
863+
assert span["op"] == expected[idx]["op"]
864+
assert span["description"] == expected[idx]["description"]
865+
assert span["tags"] == expected[idx]["tags"]
866+
idx += 1
782867

783868

784869
def test_middleware_receive_send(sentry_init, capture_events):
@@ -946,6 +1031,31 @@ def test_active_thread_id(sentry_init, capture_envelopes, teardown_profiling, en
9461031
assert str(data["active"]) == trace_context["data"]["thread.id"]
9471032

9481033

1034+
@pytest.mark.parametrize("endpoint", ["/sync/thread_ids", "/async/thread_ids"])
1035+
def test_active_thread_id_span_streaming(sentry_init, capture_items, endpoint):
1036+
sentry_init(
1037+
auto_enabling_integrations=False, # avoid legacy spans from auto-enabled integrations leaking into streaming mode
1038+
integrations=[StarletteIntegration()],
1039+
traces_sample_rate=1.0,
1040+
_experiments={"trace_lifecycle": "stream"},
1041+
)
1042+
app = starlette_app_factory()
1043+
1044+
items = capture_items("span")
1045+
1046+
client = TestClient(app)
1047+
response = client.get(endpoint)
1048+
assert response.status_code == 200
1049+
1050+
data = json.loads(response.content)
1051+
1052+
sentry_sdk.flush()
1053+
1054+
segments = [item.payload for item in items if item.payload.get("is_segment")]
1055+
assert len(segments) == 1
1056+
assert str(data["active"]) == segments[0]["attributes"]["thread.id"]
1057+
1058+
9491059
def test_original_request_not_scrubbed(sentry_init, capture_events):
9501060
sentry_init(integrations=[StarletteIntegration()])
9511061

@@ -1167,27 +1277,43 @@ def test_transaction_name_in_middleware(
11671277
)
11681278

11691279

1170-
def test_span_origin(sentry_init, capture_events):
1280+
@pytest.mark.parametrize("span_streaming", [True, False])
1281+
def test_span_origin(sentry_init, capture_events, capture_items, span_streaming):
11711282
sentry_init(
1172-
integrations=[StarletteIntegration()],
1283+
auto_enabling_integrations=False, # avoid httpx auto-instrumentation leaking spans
1284+
integrations=[StarletteIntegration(middleware_spans=True)],
11731285
traces_sample_rate=1.0,
1286+
_experiments={
1287+
"trace_lifecycle": "stream" if span_streaming else "static",
1288+
},
11741289
)
11751290
starlette_app = starlette_app_factory(
11761291
middleware=[Middleware(AuthenticationMiddleware, backend=BasicAuthBackend())]
11771292
)
1178-
events = capture_events()
1293+
1294+
if span_streaming:
1295+
items = capture_items("span")
1296+
else:
1297+
events = capture_events()
11791298

11801299
client = TestClient(starlette_app, raise_server_exceptions=False)
11811300
try:
11821301
client.get("/message", auth=("Gabriela", "hello123"))
11831302
except Exception:
11841303
pass
11851304

1186-
(_, event) = events
1305+
if span_streaming:
1306+
sentry_sdk.flush()
1307+
1308+
assert len(items) > 0
1309+
for item in items:
1310+
assert item.payload["attributes"]["sentry.origin"] == "auto.http.starlette"
1311+
else:
1312+
(_, event) = events
11871313

1188-
assert event["contexts"]["trace"]["origin"] == "auto.http.starlette"
1189-
for span in event["spans"]:
1190-
assert span["origin"] == "auto.http.starlette"
1314+
assert event["contexts"]["trace"]["origin"] == "auto.http.starlette"
1315+
for span in event["spans"]:
1316+
assert span["origin"] == "auto.http.starlette"
11911317

11921318

11931319
class NonIterableContainer:

0 commit comments

Comments
 (0)