[do not merge] feat: Span streaming & new span API #5317
26 issues
High
AttributeError when calling set_transaction_name with NoOpStreamedSpan on scope - `sentry_sdk/scope.py:829-831`
The code calls self._span.segment.set_name(name) when self._span is an instance of StreamedSpan. However, NoOpStreamedSpan is a subclass of StreamedSpan that sets self.segment = None in its constructor. When self._span is a NoOpStreamedSpan, accessing self._span.segment.set_name(name) will raise AttributeError: 'NoneType' object has no attribute 'set_name'. The code should either check isinstance(self._span, NoOpStreamedSpan) first or use a null check on segment before calling methods on it.
Also found at:
sentry_sdk/traces.py:527-528
UnboundLocalError crash when exception occurs inside capture_internal_exceptions block - `sentry_sdk/integrations/celery/__init__.py:324-365`
The variable span_ctx is declared at line 324 but only assigned inside the capture_internal_exceptions() block (lines 337/360). If an exception occurs after transaction is assigned (e.g., at line 333-335 or 349-359) but before span_ctx is assigned, the exception is suppressed but span_ctx remains unbound. The check at line 362 only verifies transaction is not None, then line 365 with span_ctx: will raise UnboundLocalError, crashing the Celery task execution.
StreamedSpan imported under TYPE_CHECKING but used at runtime with isinstance() - `sentry_sdk/integrations/graphene.py:30`
StreamedSpan is imported inside the if TYPE_CHECKING: block (line 30), but it's used in an isinstance() check at runtime (line 167 in graphql_span). When span streaming is enabled and the finally block executes, a NameError: name 'StreamedSpan' is not defined will be raised because the import is not available at runtime.
Also found at:
sentry_sdk/integrations/stdlib.py:127
AttributeError when calling set_transaction_name with NoOpStreamedSpan as current span - `sentry_sdk/scope.py:828-831`
In set_transaction_name, the code checks isinstance(self._span, StreamedSpan) and then accesses self._span.segment.set_name(name). However, NoOpStreamedSpan (a subclass of StreamedSpan) sets segment = None in its constructor. Since isinstance() returns True for subclasses, when the current span is a NoOpStreamedSpan, accessing .segment.set_name() will raise AttributeError: 'NoneType' object has no attribute 'set_name'. This can occur when spans are filtered out via is_ignored_span() or when a span has no name.
Medium
_running_size not updated when MAX_BEFORE_FLUSH triggers flush - `sentry_sdk/_span_batcher.py:68-70`
When size + 1 >= MAX_BEFORE_FLUSH on line 68, the code returns early without updating _running_size[span.trace_id]. The span has already been added to _span_buffer (line 66), but its size is never tracked. If the flush event doesn't clear all buffers immediately (e.g., due to threading), subsequent byte-based flush decisions will underestimate the actual buffer size, potentially causing memory growth beyond the intended limit.
Also found at:
sentry_sdk/integrations/redis/utils.py:152-160
StreamedSpan status hardcoded to ERROR, ignoring actual status parameter - `sentry_sdk/integrations/celery/__init__.py:104-107`
When scope.span is a StreamedSpan, the code always sets SpanStatus.ERROR regardless of the actual status parameter passed to _set_status. This loses the distinction between "aborted" (for control flow exceptions like Retry, Ignore, Reject) and "internal_error" (for actual failures). Since StreamedSpan.set_status() accepts Union[SpanStatus, str] and handles both types correctly, the status parameter should be passed through.
Also found at:
sentry_sdk/integrations/strawberry.py:314-320
UnboundLocalError possible when span_streaming is enabled and span setup fails - `sentry_sdk/integrations/celery/__init__.py:324-337`
In _wrap_tracer, the span_ctx variable is declared with a type annotation but not initialized. In the span_streaming branch, if start_span() succeeds but any subsequent setter method (set_origin, set_source, set_op) throws an exception, the exception is suppressed by capture_internal_exceptions() but span_ctx is never assigned. Since transaction is not None, the code proceeds to with span_ctx: (line 365), causing an UnboundLocalError that crashes the Celery task.
Missing test coverage for httpx integration with span streaming mode - `sentry_sdk/integrations/httpx.py:64-67`
The httpx integration now supports both legacy tracing and the new span streaming mode (trace_lifecycle: stream), but the existing tests in tests/integrations/httpx/test_httpx.py only test the legacy mode using start_transaction(). The new StreamedSpan code paths (lines 64-67, 80-88, 145-148, 161-168) are not covered by any integration tests, meaning bugs in span attribute setting or the streaming workflow won't be caught.
Spans leak when Redis command raises exception - `sentry_sdk/integrations/redis/_async_common.py:135`
In _sentry_execute_command, spans are opened via __enter__() but closed via __exit__(None, None, None) without a try/finally block. If await old_execute_command() raises an exception, both db_span and cache_span will never have __exit__ called, leaving them unclosed. This can cause memory leaks and incorrect tracing data (spans that are never finished).
Also found at:
sentry_sdk/integrations/redis/_sync_common.py:135-146
Missing parent_span in on_parse causes orphaned span in streaming mode - `sentry_sdk/integrations/strawberry.py:261-262`
In on_parse() for streaming mode (line 261-262), sentry_sdk.traces.start_span() is called without the parent_span=self.graphql_span argument, unlike on_validate() (line 239-240) which correctly passes it. This inconsistency means the parsing span will be parented to whatever span is currently active on the scope rather than explicitly to self.graphql_span, potentially causing incorrect span hierarchy in the trace.
...and 16 more
4 skills analyzed
| Skill | Findings | Duration | Cost |
|---|---|---|---|
| code-review | 11 | 32m 58s | $16.33 |
| find-bugs | 15 | 17m 57s | $28.60 |
| skill-scanner | 0 | 41m 48s | $4.12 |
| security-review | 0 | 37m | $9.25 |
Duration: 129m 42s · Tokens: 45.4M in / 453.2k out · Cost: $58.39 (+extraction: $0.06, +merge: $0.01, +dedup: $0.03)