[do not merge] feat: Span streaming & new span API #5317
15 issues
find-bugs: Found 15 issues (3 high, 6 medium, 6 low)
High
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
Async httpx client doesn't strip existing Sentry baggage before appending, causing duplicate entries - `sentry_sdk/integrations/httpx.py:186-192`
The async client (lines 186-192) handles baggage headers differently from the sync client. The sync client uses add_sentry_baggage_to_headers() which strips existing Sentry items before appending new ones. The async client simply appends the new baggage without stripping, potentially causing duplicate or conflicting Sentry baggage items in outgoing requests. This inconsistency could lead to trace propagation issues and confusing debugging scenarios.
Spans never closed when Redis command throws exception - `sentry_sdk/integrations/redis/_async_common.py:135-137`
In _sentry_execute_command, db_span.__enter__() and optionally cache_span.__enter__() are called manually, but if await old_execute_command() raises an exception, __exit__() is never called on either span. This causes spans to leak (never be finished/sent to Sentry) and prevents exception status from being recorded on the spans. The proper context manager protocol requires a try/finally block when using manual __enter__/__exit__ calls.
Also found at:
sentry_sdk/integrations/redis/_sync_common.py:135-136
Missing parent_span in on_parse causes incorrect span parenting in streaming mode - `sentry_sdk/integrations/strawberry.py:261-265`
In on_parse(), when span streaming is enabled, sentry_sdk.traces.start_span() is called without the parent_span argument (line 261-262). This is inconsistent with on_validate() which correctly passes parent_span=self.graphql_span. Without the explicit parent, the parsing span will be parented to whatever span is currently active on the scope, which may not be self.graphql_span, resulting in incorrect trace hierarchies.
Async resolve() missing set_op() and set_origin() for StreamedSpan - `sentry_sdk/integrations/strawberry.py:314-320`
The async SentryAsyncExtension.resolve() method does not call set_op(OP.GRAPHQL_RESOLVE) or set_origin(StrawberryIntegration.origin) when creating a StreamedSpan, while the sync SentrySyncExtension.resolve() method does. This inconsistency means async GraphQL field resolution spans will lack operation type and origin metadata, making them harder to identify and filter in Sentry's UI.
Span data silently lost when end() called without start() - `sentry_sdk/traces.py:376`
The end() method (line 376) calls __exit__() which accesses self._context_manager_state (line 347). This attribute is only set during __enter__() (called by start()). If a user creates a span and calls end() without first calling start(), an AttributeError is raised but silently swallowed by capture_internal_exceptions(). The span is never captured and no error is reported to the user, causing silent data loss.
NoOpStreamedSpan.dynamic_sampling_context() will crash with AttributeError - `sentry_sdk/traces.py:527-528`
The dynamic_sampling_context() method is inherited from StreamedSpan but not overridden in NoOpStreamedSpan. When called, it executes self.segment.get_baggage().dynamic_sampling_context(). Since NoOpStreamedSpan.__init__ sets self.segment = None, this will raise AttributeError: 'NoneType' object has no attribute 'get_baggage'. Any code that obtains a span and calls dynamic_sampling_context() without checking the span type will crash.
Also found at:
sentry_sdk/traces.py:576-586
Low
Unused import: json module imported but never used - `sentry_sdk/_span_batcher.py:1`
The json module is imported at line 1 but is never used in the file. The only occurrences of 'json' in the file are in content-type strings and as a keyword argument name to PayloadRef, neither of which require the json module. This is dead code that should be removed.
Wrong warning type used for unsupported API in streaming mode - `sentry_sdk/api.py:508-514`
The code uses DeprecationWarning when update_current_span is called in streaming mode, but the API is not deprecated - it's simply unsupported in that mode. DeprecationWarning is semantically incorrect and will be hidden by default in Python's main interpreter (only shown in __main__ and when using -W flags). A UserWarning or RuntimeWarning would be more appropriate and would actually be visible to users.
redis.is_cluster attribute not set when command name is empty - `sentry_sdk/integrations/redis/utils.py:152-160`
In the refactored _set_client_data function, the redis.is_cluster tag/attribute was moved inside the if name: block. Previously, redis.is_cluster was set unconditionally at the start of the function. Now, if name is empty or falsy, redis.is_cluster will not be set on the span for either StreamedSpan (using set_attribute) or legacy Span (using set_tag). This is a behavioral regression that could cause missing telemetry data.
Unreachable code in NoOpStreamedSpan.__enter__ method - `sentry_sdk/traces.py:694`
In NoOpStreamedSpan.__enter__, line 694 contains scope = self._scope or sentry_sdk.get_current_scope(). The or sentry_sdk.get_current_scope() clause is unreachable because line 691-692 returns early if self._scope is None. This makes the fallback behavior dead code and suggests a potential logic error in scope handling.
Missing truthy check for segment name before adding to baggage - `sentry_sdk/tracing_utils.py:816-820`
The populate_from_segment method does not check if segment._name is truthy before adding it to sentry_items['transaction']. The analogous populate_from_transaction method at line 768-772 checks if (transaction.name and ...) before setting the transaction. If segment._name is an empty string, this will add an empty 'transaction' key to the baggage, which could cause issues with downstream processing.
Unused import 'should_send_default_pii' in create_streaming_span_decorator - `sentry_sdk/tracing_utils.py:1058`
The function create_streaming_span_decorator imports should_send_default_pii from sentry_sdk.scope on line 1058 but never uses it. This appears to be leftover code from copying the pattern from create_span_decorator (which does use this import on lines 941 and 971/1016). While this is not a security vulnerability, it adds unnecessary import overhead.
Duration: 17m 57s · Tokens: 22.4M in / 186.1k out · Cost: $28.64 (+extraction: $0.04, +merge: $0.01)
Annotations
Check failure on line 365 in sentry_sdk/integrations/celery/__init__.py
github-actions / warden: find-bugs
UnboundLocalError crash when exception occurs inside capture_internal_exceptions block
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.
Check failure on line 30 in sentry_sdk/integrations/graphene.py
github-actions / warden: find-bugs
StreamedSpan imported under TYPE_CHECKING but used at runtime with isinstance()
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.
Check failure on line 127 in sentry_sdk/integrations/stdlib.py
github-actions / warden: find-bugs
[K2Y-Z8F] StreamedSpan imported under TYPE_CHECKING but used at runtime with isinstance() (additional location)
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.
Check failure on line 831 in sentry_sdk/scope.py
github-actions / warden: find-bugs
AttributeError when calling set_transaction_name with NoOpStreamedSpan as current span
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.
Check warning on line 192 in sentry_sdk/integrations/httpx.py
github-actions / warden: find-bugs
Async httpx client doesn't strip existing Sentry baggage before appending, causing duplicate entries
The async client (lines 186-192) handles baggage headers differently from the sync client. The sync client uses `add_sentry_baggage_to_headers()` which strips existing Sentry items before appending new ones. The async client simply appends the new baggage without stripping, potentially causing duplicate or conflicting Sentry baggage items in outgoing requests. This inconsistency could lead to trace propagation issues and confusing debugging scenarios.
Check warning on line 137 in sentry_sdk/integrations/redis/_async_common.py
github-actions / warden: find-bugs
Spans never closed when Redis command throws exception
In `_sentry_execute_command`, `db_span.__enter__()` and optionally `cache_span.__enter__()` are called manually, but if `await old_execute_command()` raises an exception, `__exit__()` is never called on either span. This causes spans to leak (never be finished/sent to Sentry) and prevents exception status from being recorded on the spans. The proper context manager protocol requires a try/finally block when using manual `__enter__`/`__exit__` calls.
Check warning on line 136 in sentry_sdk/integrations/redis/_sync_common.py
github-actions / warden: find-bugs
[5Y2-CP9] Spans never closed when Redis command throws exception (additional location)
In `_sentry_execute_command`, `db_span.__enter__()` and optionally `cache_span.__enter__()` are called manually, but if `await old_execute_command()` raises an exception, `__exit__()` is never called on either span. This causes spans to leak (never be finished/sent to Sentry) and prevents exception status from being recorded on the spans. The proper context manager protocol requires a try/finally block when using manual `__enter__`/`__exit__` calls.
Check warning on line 265 in sentry_sdk/integrations/strawberry.py
github-actions / warden: find-bugs
Missing parent_span in on_parse causes incorrect span parenting in streaming mode
In `on_parse()`, when span streaming is enabled, `sentry_sdk.traces.start_span()` is called without the `parent_span` argument (line 261-262). This is inconsistent with `on_validate()` which correctly passes `parent_span=self.graphql_span`. Without the explicit parent, the parsing span will be parented to whatever span is currently active on the scope, which may not be `self.graphql_span`, resulting in incorrect trace hierarchies.
Check warning on line 320 in sentry_sdk/integrations/strawberry.py
github-actions / warden: find-bugs
Async resolve() missing set_op() and set_origin() for StreamedSpan
The async `SentryAsyncExtension.resolve()` method does not call `set_op(OP.GRAPHQL_RESOLVE)` or `set_origin(StrawberryIntegration.origin)` when creating a StreamedSpan, while the sync `SentrySyncExtension.resolve()` method does. This inconsistency means async GraphQL field resolution spans will lack operation type and origin metadata, making them harder to identify and filter in Sentry's UI.
Check warning on line 376 in sentry_sdk/traces.py
github-actions / warden: find-bugs
Span data silently lost when end() called without start()
The `end()` method (line 376) calls `__exit__()` which accesses `self._context_manager_state` (line 347). This attribute is only set during `__enter__()` (called by `start()`). If a user creates a span and calls `end()` without first calling `start()`, an AttributeError is raised but silently swallowed by `capture_internal_exceptions()`. The span is never captured and no error is reported to the user, causing silent data loss.
Check warning on line 528 in sentry_sdk/traces.py
github-actions / warden: find-bugs
NoOpStreamedSpan.dynamic_sampling_context() will crash with AttributeError
The `dynamic_sampling_context()` method is inherited from `StreamedSpan` but not overridden in `NoOpStreamedSpan`. When called, it executes `self.segment.get_baggage().dynamic_sampling_context()`. Since `NoOpStreamedSpan.__init__` sets `self.segment = None`, this will raise `AttributeError: 'NoneType' object has no attribute 'get_baggage'`. Any code that obtains a span and calls `dynamic_sampling_context()` without checking the span type will crash.
Check warning on line 586 in sentry_sdk/traces.py
github-actions / warden: find-bugs
[EVH-QF4] NoOpStreamedSpan.dynamic_sampling_context() will crash with AttributeError (additional location)
The `dynamic_sampling_context()` method is inherited from `StreamedSpan` but not overridden in `NoOpStreamedSpan`. When called, it executes `self.segment.get_baggage().dynamic_sampling_context()`. Since `NoOpStreamedSpan.__init__` sets `self.segment = None`, this will raise `AttributeError: 'NoneType' object has no attribute 'get_baggage'`. Any code that obtains a span and calls `dynamic_sampling_context()` without checking the span type will crash.