[do not merge] feat: Span streaming & new span API #5317
12 issues
code-review: Found 12 issues (2 high, 8 medium, 2 low)
High
UnboundLocalError when transaction methods fail in streaming mode - `sentry_sdk/integrations/celery/__init__.py:324-337`
In _wrap_tracer, span_ctx is declared (line 324) but only assigned inside the capture_internal_exceptions() block (line 337). If an exception occurs in transaction.set_origin(), transaction.set_source(), or transaction.set_op() after transaction is assigned, the check if transaction is None (line 362) will pass, but span_ctx will be unbound. This causes an UnboundLocalError at line 365 when with span_ctx: is executed. The user-visible consequence is a runtime crash in Celery task execution when these methods fail.
StreamedSpan is never started, causing finish() to silently fail - `sentry_sdk/integrations/graphene.py:151-166`
In streaming mode, sentry_sdk.traces.start_span() returns a span that must be used as a context manager (with with statement) or explicitly started via .start() before calling .finish(). The current code creates the span but never enters it, so _context_manager_state is uninitialized. When finish() calls __exit__(), the attempt to access _context_manager_state raises an AttributeError that is silently caught by capture_internal_exceptions(). The span is never sent to Sentry in streaming mode.
Also found at:
sentry_sdk/integrations/rust_tracing.py:216-241
Medium
Duplicate span serialization causes unnecessary CPU overhead - `sentry_sdk/_span_batcher.py:77-81`
Every span is serialized twice: once in _estimate_size() (line 80) via _to_transport_format(), and again during _flush() (line 134). For spans with large attribute dictionaries, this doubles the serialization cost. The size estimation could use a simpler heuristic (e.g., counting attribute keys/values) instead of full serialization.
Also found at:
sentry_sdk/integrations/redis/_async_common.py:135-137sentry_sdk/integrations/redis/_sync_common.py:135
Streaming path does not set custom_sampling_context for traces_sampler - `sentry_sdk/integrations/asgi.py:225-227`
In the legacy path (line 272), custom_sampling_context={"asgi_scope": scope} is passed to start_transaction, making it available to traces_sampler callbacks. However, the streaming path (lines 225-227) does not call sentry_scope.set_custom_sampling_context({"asgi_scope": scope}) before start_span(). Users with custom traces_sampler functions that rely on asgi_scope for sampling decisions will not have access to it when using streaming mode.
Also found at:
sentry_sdk/integrations/strawberry.py:261-263
StreamedSpan always sets ERROR status regardless of input argument - `sentry_sdk/integrations/celery/__init__.py:104-105`
The _set_status function ignores the status parameter for StreamedSpan instances, always setting SpanStatus.ERROR. This means both _set_status("aborted") (called for Celery control flow exceptions like Retry, Ignore, Reject) and _set_status("internal_error") result in the same ERROR status. For control flow exceptions, this may incorrectly characterize intentional retries/ignores as errors.
redis.is_cluster attribute missing when name is empty - `sentry_sdk/integrations/redis/utils.py:152-160`
The refactored _set_client_data function now only sets redis.is_cluster when name is truthy, whereas the original code always set it unconditionally. This is a behavioral change that will cause the redis.is_cluster tag/attribute to be missing from spans when name is empty or None, potentially affecting monitoring and debugging capabilities.
Async resolve() missing set_op() and set_origin() for StreamedSpan - `sentry_sdk/integrations/strawberry.py:314-320`
In the async SentryAsyncExtension.resolve() method, when creating a StreamedSpan, the code does not call span.set_op(OP.GRAPHQL_RESOLVE) or span.set_origin(StrawberryIntegration.origin). However, the sync SentrySyncExtension.resolve() method does include these calls (lines 356-357). This inconsistency means async GraphQL resolvers using span streaming will produce spans without the operation type and origin metadata, resulting in incomplete tracing data in Sentry.
Also found at:
sentry_sdk/traces.py:707-711sentry_sdk/traces.py:691
Missing return after 'Discarding transaction' warning leads to inconsistent behavior - `sentry_sdk/traces.py:418-419`
On line 418-419, when self.sampled is None, a warning "Discarding transaction without sampling decision" is logged, but execution continues. The span may still be captured on line 434 if self.segment.sampled is truthy (which shouldn't happen when sampled is None, but creates ambiguous control flow). The log message claims the transaction is being discarded, but there's no return to enforce this, unlike the self.sampled is False check which properly returns on line 416.
Missing name truthiness check allows empty transaction names in baggage - `sentry_sdk/tracing_utils.py:816-820`
The populate_from_segment method sets sentry_items["transaction"] = segment._name without first checking if segment._name is truthy. The legacy populate_from_transaction method checks transaction.name and transaction.source not in LOW_QUALITY_TRANSACTION_SOURCES before setting the transaction. This inconsistency could lead to empty string transaction names being propagated in baggage headers.
Tautological assertion compares trace_id to itself - `tests/tracing/test_span_streaming.py:500`
Line 500 contains assert segment1["trace_id"] == segment1["trace_id"] which compares the value to itself and will always pass. Based on the test name test_sibling_segments and the corresponding test_sibling_segments_new_trace test which asserts segment1["trace_id"] != segment2["trace_id"], the intention here is to verify that sibling segments share the same trace_id when new_trace() is not called. The assertion should be segment1["trace_id"] == segment2["trace_id"].
Low
Unused import: json module is imported but never used - `sentry_sdk/_span_batcher.py:1`
The json module is imported at line 1 but is never used anywhere in the file. This appears to be leftover from removed code. While not affecting runtime behavior, unused imports should be cleaned up.
Type annotation uses Python 3.9+ syntax in Python 3.6+ codebase - `sentry_sdk/tracing_utils.py:478`
The type annotation Optional[dict[str, Any]] uses lowercase dict which is Python 3.9+ syntax (PEP 585). The SDK supports Python 3.6+ and consistently uses Dict from typing elsewhere in this file (e.g., lines 96, 384, 453, 536). While string-quoted annotations aren't evaluated at runtime, this inconsistency could cause issues with type checkers targeting older Python versions.
Duration: 30m 47s · Tokens: 13.0M in / 147.3k out · Cost: $17.04 (+extraction: $0.03, +merge: $0.01)
Annotations
Check failure on line 337 in sentry_sdk/integrations/celery/__init__.py
github-actions / warden: code-review
UnboundLocalError when transaction methods fail in streaming mode
In `_wrap_tracer`, `span_ctx` is declared (line 324) but only assigned inside the `capture_internal_exceptions()` block (line 337). If an exception occurs in `transaction.set_origin()`, `transaction.set_source()`, or `transaction.set_op()` after `transaction` is assigned, the check `if transaction is None` (line 362) will pass, but `span_ctx` will be unbound. This causes an `UnboundLocalError` at line 365 when `with span_ctx:` is executed. The user-visible consequence is a runtime crash in Celery task execution when these methods fail.
Check failure on line 166 in sentry_sdk/integrations/graphene.py
github-actions / warden: code-review
StreamedSpan is never started, causing finish() to silently fail
In streaming mode, `sentry_sdk.traces.start_span()` returns a span that must be used as a context manager (with `with` statement) or explicitly started via `.start()` before calling `.finish()`. The current code creates the span but never enters it, so `_context_manager_state` is uninitialized. When `finish()` calls `__exit__()`, the attempt to access `_context_manager_state` raises an `AttributeError` that is silently caught by `capture_internal_exceptions()`. The span is never sent to Sentry in streaming mode.
Check failure on line 241 in sentry_sdk/integrations/rust_tracing.py
github-actions / warden: code-review
[CDZ-H9M] StreamedSpan is never started, causing finish() to silently fail (additional location)
In streaming mode, `sentry_sdk.traces.start_span()` returns a span that must be used as a context manager (with `with` statement) or explicitly started via `.start()` before calling `.finish()`. The current code creates the span but never enters it, so `_context_manager_state` is uninitialized. When `finish()` calls `__exit__()`, the attempt to access `_context_manager_state` raises an `AttributeError` that is silently caught by `capture_internal_exceptions()`. The span is never sent to Sentry in streaming mode.
Check warning on line 81 in sentry_sdk/_span_batcher.py
github-actions / warden: code-review
Duplicate span serialization causes unnecessary CPU overhead
Every span is serialized twice: once in `_estimate_size()` (line 80) via `_to_transport_format()`, and again during `_flush()` (line 134). For spans with large attribute dictionaries, this doubles the serialization cost. The size estimation could use a simpler heuristic (e.g., counting attribute keys/values) instead of full serialization.
Check warning on line 137 in sentry_sdk/integrations/redis/_async_common.py
github-actions / warden: code-review
[PTJ-V4V] Duplicate span serialization causes unnecessary CPU overhead (additional location)
Every span is serialized twice: once in `_estimate_size()` (line 80) via `_to_transport_format()`, and again during `_flush()` (line 134). For spans with large attribute dictionaries, this doubles the serialization cost. The size estimation could use a simpler heuristic (e.g., counting attribute keys/values) instead of full serialization.
Check warning on line 135 in sentry_sdk/integrations/redis/_sync_common.py
github-actions / warden: code-review
[PTJ-V4V] Duplicate span serialization causes unnecessary CPU overhead (additional location)
Every span is serialized twice: once in `_estimate_size()` (line 80) via `_to_transport_format()`, and again during `_flush()` (line 134). For spans with large attribute dictionaries, this doubles the serialization cost. The size estimation could use a simpler heuristic (e.g., counting attribute keys/values) instead of full serialization.
Check warning on line 227 in sentry_sdk/integrations/asgi.py
github-actions / warden: code-review
Streaming path does not set custom_sampling_context for traces_sampler
In the legacy path (line 272), `custom_sampling_context={"asgi_scope": scope}` is passed to `start_transaction`, making it available to `traces_sampler` callbacks. However, the streaming path (lines 225-227) does not call `sentry_scope.set_custom_sampling_context({"asgi_scope": scope})` before `start_span()`. Users with custom `traces_sampler` functions that rely on `asgi_scope` for sampling decisions will not have access to it when using streaming mode.
Check warning on line 263 in sentry_sdk/integrations/strawberry.py
github-actions / warden: code-review
[E87-QM9] Streaming path does not set custom_sampling_context for traces_sampler (additional location)
In the legacy path (line 272), `custom_sampling_context={"asgi_scope": scope}` is passed to `start_transaction`, making it available to `traces_sampler` callbacks. However, the streaming path (lines 225-227) does not call `sentry_scope.set_custom_sampling_context({"asgi_scope": scope})` before `start_span()`. Users with custom `traces_sampler` functions that rely on `asgi_scope` for sampling decisions will not have access to it when using streaming mode.
Check warning on line 105 in sentry_sdk/integrations/celery/__init__.py
github-actions / warden: code-review
StreamedSpan always sets ERROR status regardless of input argument
The `_set_status` function ignores the `status` parameter for `StreamedSpan` instances, always setting `SpanStatus.ERROR`. This means both `_set_status("aborted")` (called for Celery control flow exceptions like Retry, Ignore, Reject) and `_set_status("internal_error")` result in the same ERROR status. For control flow exceptions, this may incorrectly characterize intentional retries/ignores as errors.
Check warning on line 160 in sentry_sdk/integrations/redis/utils.py
github-actions / warden: code-review
redis.is_cluster attribute missing when name is empty
The refactored `_set_client_data` function now only sets `redis.is_cluster` when `name` is truthy, whereas the original code always set it unconditionally. This is a behavioral change that will cause the `redis.is_cluster` tag/attribute to be missing from spans when `name` is empty or None, potentially affecting monitoring and debugging capabilities.
Check warning on line 320 in sentry_sdk/integrations/strawberry.py
github-actions / warden: code-review
Async resolve() missing set_op() and set_origin() for StreamedSpan
In the async `SentryAsyncExtension.resolve()` method, when creating a `StreamedSpan`, the code does not call `span.set_op(OP.GRAPHQL_RESOLVE)` or `span.set_origin(StrawberryIntegration.origin)`. However, the sync `SentrySyncExtension.resolve()` method does include these calls (lines 356-357). This inconsistency means async GraphQL resolvers using span streaming will produce spans without the operation type and origin metadata, resulting in incomplete tracing data in Sentry.
Check warning on line 711 in sentry_sdk/traces.py
github-actions / warden: code-review
[UL4-7LJ] Async resolve() missing set_op() and set_origin() for StreamedSpan (additional location)
In the async `SentryAsyncExtension.resolve()` method, when creating a `StreamedSpan`, the code does not call `span.set_op(OP.GRAPHQL_RESOLVE)` or `span.set_origin(StrawberryIntegration.origin)`. However, the sync `SentrySyncExtension.resolve()` method does include these calls (lines 356-357). This inconsistency means async GraphQL resolvers using span streaming will produce spans without the operation type and origin metadata, resulting in incomplete tracing data in Sentry.
Check warning on line 691 in sentry_sdk/traces.py
github-actions / warden: code-review
[UL4-7LJ] Async resolve() missing set_op() and set_origin() for StreamedSpan (additional location)
In the async `SentryAsyncExtension.resolve()` method, when creating a `StreamedSpan`, the code does not call `span.set_op(OP.GRAPHQL_RESOLVE)` or `span.set_origin(StrawberryIntegration.origin)`. However, the sync `SentrySyncExtension.resolve()` method does include these calls (lines 356-357). This inconsistency means async GraphQL resolvers using span streaming will produce spans without the operation type and origin metadata, resulting in incomplete tracing data in Sentry.
Check warning on line 419 in sentry_sdk/traces.py
github-actions / warden: code-review
Missing return after 'Discarding transaction' warning leads to inconsistent behavior
On line 418-419, when `self.sampled is None`, a warning "Discarding transaction without sampling decision" is logged, but execution continues. The span may still be captured on line 434 if `self.segment.sampled` is truthy (which shouldn't happen when sampled is None, but creates ambiguous control flow). The log message claims the transaction is being discarded, but there's no `return` to enforce this, unlike the `self.sampled is False` check which properly returns on line 416.
Check warning on line 820 in sentry_sdk/tracing_utils.py
github-actions / warden: code-review
Missing name truthiness check allows empty transaction names in baggage
The `populate_from_segment` method sets `sentry_items["transaction"] = segment._name` without first checking if `segment._name` is truthy. The legacy `populate_from_transaction` method checks `transaction.name and transaction.source not in LOW_QUALITY_TRANSACTION_SOURCES` before setting the transaction. This inconsistency could lead to empty string transaction names being propagated in baggage headers.
Check warning on line 500 in tests/tracing/test_span_streaming.py
github-actions / warden: code-review
Tautological assertion compares trace_id to itself
Line 500 contains `assert segment1["trace_id"] == segment1["trace_id"]` which compares the value to itself and will always pass. Based on the test name `test_sibling_segments` and the corresponding `test_sibling_segments_new_trace` test which asserts `segment1["trace_id"] != segment2["trace_id"]`, the intention here is to verify that sibling segments share the same trace_id when `new_trace()` is not called. The assertion should be `segment1["trace_id"] == segment2["trace_id"]`.