[do not merge] feat: Span streaming & new span API #5317
16 issues
find-bugs: Found 16 issues (2 high, 11 medium, 3 low)
High
API incompatibility: sentry_sdk.traces.start_span rejects op and origin parameters causing TypeError - `sentry_sdk/ai/utils.py:539-542`
The get_start_span_function() returns sentry_sdk.traces.start_span when streaming mode is enabled or the current span is a StreamedSpan. However, sentry_sdk.traces.start_span(name, attributes, parent_span) has a fixed signature that doesn't accept op, origin, or other keyword arguments. Integrations (e.g., Anthropic at line 408-412) call the returned function with op=..., name=..., origin=..., which will raise TypeError: start_span() got unexpected keyword argument 'op' when streaming mode is enabled, breaking all AI integrations.
Also found at:
sentry_sdk/integrations/graphene.py:151-166
UnboundLocalError when span setup fails in _wrap_tracer - `sentry_sdk/integrations/celery/__init__.py:324-337`
In the _wrap_tracer function, if an exception occurs after transaction is assigned (line 332) but before span_ctx is assigned (line 337), the code proceeds past the if transaction is None check (line 362) and attempts to use span_ctx in with span_ctx: (line 365). Since span_ctx was declared but never assigned, this causes an UnboundLocalError. This can happen if set_origin, set_source, or set_op throw an exception, which gets silently caught by capture_internal_exceptions().
Medium
Error cleanup skipped for StreamedSpan in streaming mode - `sentry_sdk/integrations/anthropic.py:610-612`
The change restricts error cleanup to only legacy Span instances with isinstance(span, Span), but when span streaming mode is enabled, get_start_span_function() returns StreamedSpan instances instead. When an exception occurs, _capture_exception() calls set_span_errored() which sets StreamedSpan.status to SpanStatus.ERROR (value "error"). Even if the isinstance check were extended to include StreamedSpan, the status comparison span.status == SPANSTATUS.INTERNAL_ERROR (value "internal_error") would fail because StreamedSpan uses a different status value. This causes errored spans to not be properly closed via __exit__ in streaming mode.
Also found at:
sentry_sdk/integrations/celery/__init__.py:104-107
Missing @wraps(f) decorator in _wrap_task_call breaks celery-once compatibility - `sentry_sdk/integrations/celery/__init__.py:397`
The @ensure_integration_enabled decorator was removed from _wrap_task_call but @wraps(f) was not added as a replacement. The code comment on lines 392-396 explicitly warns: "if we ever remove the @ensure_integration_enabled decorator, we need to add @functools.wraps(f) here" because celery-once looks at the method's name. Without this, the wrapper function's __name__ will be _inner instead of the original function's name, breaking celery-once compatibility.
Spans leak when Redis command raises exception - `sentry_sdk/integrations/redis/_async_common.py:135`
In _sentry_execute_command, spans are entered via __enter__() but exited only in the happy path. If old_execute_command raises an exception, db_span.__exit__() and cache_span.__exit__() are never called. This causes spans to remain on the scope stack, leading to orphaned spans and potential memory leaks. The StreamedSpan.__exit__ method also sets the span status to ERROR on exceptions, which won't happen here.
Also found at:
sentry_sdk/integrations/redis/_sync_common.py:135-146sentry_sdk/_span_batcher.py:68-70
Deprecation warning emitted on every HTTP request in span streaming mode - `sentry_sdk/integrations/stdlib.py:183`
The getresponse function calls span.finish() at line 183 regardless of the span type. For StreamedSpan instances (when span streaming is enabled), finish() is deprecated and emits a warning via warnings.warn() before calling end(). This means every HTTP request made through stdlib's HTTPConnection will generate a deprecation warning when using the new span streaming mode. Users will see noisy warnings in their logs/stderr for normal SDK operation.
Also found at:
sentry_sdk/integrations/stdlib.py:320
Parsing span missing parent_span in streaming mode causing incorrect span hierarchy - `sentry_sdk/integrations/strawberry.py:261-265`
In the on_parse method, when span streaming is enabled, the span is created without the parent_span=self.graphql_span argument (line 261), unlike the on_validate method which correctly passes it (line 240). Without the explicit parent, the parsing span will be parented to whatever is currently on the scope, which may result in incorrect span hierarchy where the parsing span is not properly nested under the graphql_span.
Also found at:
sentry_sdk/integrations/strawberry.py:314-320
set_transaction_name raises AttributeError when span is NoOpStreamedSpan - `sentry_sdk/scope.py:828-831`
When self._span is a NoOpStreamedSpan, the check isinstance(self._span, StreamedSpan) returns True (since NoOpStreamedSpan inherits from StreamedSpan). However, NoOpStreamedSpan.__init__ sets self.segment = None. This causes self._span.segment.set_name(name) to raise AttributeError: 'NoneType' object has no attribute 'set_name'. Any call to scope.set_transaction_name() while a NoOpStreamedSpan is active will crash.
sample_rate can be None when converted to string, causing "None" to be propagated in baggage - `sentry_sdk/scope.py:1303`
In _update_sample_rate_from_segment, str(span.sample_rate) is called unconditionally when baggage is not None. However, span.sample_rate can be None in several scenarios (tracing disabled, sampled already set, invalid sample rate). This results in "None" being stored in the baggage's sentry_items["sample_rate"], which would propagate incorrect sampling data to downstream services. The equivalent code in start_transaction (line 1116) correctly checks if transaction.sample_rate is not None before updating baggage.
Also found at:
sentry_sdk/tracing_utils.py:816-820
Missing return after 'sampled is None' warning allows span to be marked as finished without being captured - `sentry_sdk/traces.py:418-419`
At line 418-419, when self.sampled is None, the code logs 'Discarding transaction without sampling decision' but does not return. Unlike the legacy implementation in tracing.py:1035-1038 which returns after this warning, this code continues execution, sets _finished = True at line 437, and also fails to record the lost event metric. This means spans with no sampling decision are silently lost without proper telemetry tracking, and the log message is misleading.
NoOpStreamedSpan returns invalid hardcoded trace IDs that can propagate to downstream services - `sentry_sdk/traces.py:763-769`
The span_id and trace_id properties in NoOpStreamedSpan return hardcoded "000000" values. When a NoOpStreamedSpan is active as scope.span, calling sentry_sdk.get_traceparent() returns "000000-000000-0" instead of falling back to the propagation context. This can cause invalid trace headers to be propagated to downstream services, breaking distributed tracing continuity.
Empty dict in ignore_spans config silently ignores ALL spans - `sentry_sdk/tracing_utils.py:1498-1516`
The is_ignored_span function initializes name_matches and attributes_match to True, then only overwrites them if the rule dict contains 'name' or 'attributes' keys respectively. An empty dict rule {} in ignore_spans config would match every span because both variables remain True. This could cause silent data loss if a user accidentally includes an empty dict in their config.
Test assertion compares value to itself, always passes - `tests/tracing/test_span_streaming.py:500`
Line 500 asserts segment1["trace_id"] == segment1["trace_id"] which is always true (comparing a value to itself). The test test_sibling_segments intends to verify that sibling segments share the same trace_id, so the assertion should be segment1["trace_id"] == segment2["trace_id"]. This bug means the test doesn't actually verify the intended behavior, and a regression could go undetected.
Low
Unused `json` import - `sentry_sdk/_span_batcher.py:1`
The json module is imported at line 1 but is never used in the file. The only uses of the word 'json' in this file are string literals (content type headers) and the keyword argument to PayloadRef(). This is a code quality issue that could confuse readers about the module's dependencies.
redis.is_cluster attribute no longer set when name is empty - `sentry_sdk/integrations/redis/utils.py:152-160`
In the original implementation, span.set_tag("redis.is_cluster", is_cluster) was called unconditionally at the start of _set_client_data. The refactored code moves this inside the if name: block, so when name is falsy (empty string), redis.is_cluster is never set on the span. This is a behavioral regression that could affect observability for Redis spans where the command name is not provided.
Dead code in NoOpStreamedSpan.__enter__ - fallback to get_current_scope() never executes - `sentry_sdk/traces.py:691`
In NoOpStreamedSpan.__enter__, line 691 uses self._scope or sentry_sdk.get_current_scope(). However, line 688-689 already returns early if self._scope is None. Therefore, when execution reaches line 691, self._scope is guaranteed to be non-None (truthy), so the or sentry_sdk.get_current_scope() clause will never execute. This is dead code that indicates the logic may not match the developer's intent.
Duration: 20m 3s · Tokens: 21.5M in / 179.2k out · Cost: $29.62 (+extraction: $0.04, +merge: $0.01)
Annotations
Check failure on line 542 in sentry_sdk/ai/utils.py
github-actions / warden: find-bugs
API incompatibility: sentry_sdk.traces.start_span rejects op and origin parameters causing TypeError
The `get_start_span_function()` returns `sentry_sdk.traces.start_span` when streaming mode is enabled or the current span is a StreamedSpan. However, `sentry_sdk.traces.start_span(name, attributes, parent_span)` has a fixed signature that doesn't accept `op`, `origin`, or other keyword arguments. Integrations (e.g., Anthropic at line 408-412) call the returned function with `op=...`, `name=...`, `origin=...`, which will raise `TypeError: start_span() got unexpected keyword argument 'op'` when streaming mode is enabled, breaking all AI integrations.
Check failure on line 166 in sentry_sdk/integrations/graphene.py
github-actions / warden: find-bugs
[9Z8-K2H] API incompatibility: sentry_sdk.traces.start_span rejects op and origin parameters causing TypeError (additional location)
The `get_start_span_function()` returns `sentry_sdk.traces.start_span` when streaming mode is enabled or the current span is a StreamedSpan. However, `sentry_sdk.traces.start_span(name, attributes, parent_span)` has a fixed signature that doesn't accept `op`, `origin`, or other keyword arguments. Integrations (e.g., Anthropic at line 408-412) call the returned function with `op=...`, `name=...`, `origin=...`, which will raise `TypeError: start_span() got unexpected keyword argument 'op'` when streaming mode is enabled, breaking all AI integrations.
Check failure on line 337 in sentry_sdk/integrations/celery/__init__.py
github-actions / warden: find-bugs
UnboundLocalError when span setup fails in _wrap_tracer
In the `_wrap_tracer` function, if an exception occurs after `transaction` is assigned (line 332) but before `span_ctx` is assigned (line 337), the code proceeds past the `if transaction is None` check (line 362) and attempts to use `span_ctx` in `with span_ctx:` (line 365). Since `span_ctx` was declared but never assigned, this causes an `UnboundLocalError`. This can happen if `set_origin`, `set_source`, or `set_op` throw an exception, which gets silently caught by `capture_internal_exceptions()`.
Check warning on line 612 in sentry_sdk/integrations/anthropic.py
github-actions / warden: find-bugs
Error cleanup skipped for StreamedSpan in streaming mode
The change restricts error cleanup to only legacy `Span` instances with `isinstance(span, Span)`, but when span streaming mode is enabled, `get_start_span_function()` returns `StreamedSpan` instances instead. When an exception occurs, `_capture_exception()` calls `set_span_errored()` which sets `StreamedSpan.status` to `SpanStatus.ERROR` (value "error"). Even if the isinstance check were extended to include `StreamedSpan`, the status comparison `span.status == SPANSTATUS.INTERNAL_ERROR` (value "internal_error") would fail because `StreamedSpan` uses a different status value. This causes errored spans to not be properly closed via `__exit__` in streaming mode.
Check warning on line 107 in sentry_sdk/integrations/celery/__init__.py
github-actions / warden: find-bugs
[JR4-GN3] Error cleanup skipped for StreamedSpan in streaming mode (additional location)
The change restricts error cleanup to only legacy `Span` instances with `isinstance(span, Span)`, but when span streaming mode is enabled, `get_start_span_function()` returns `StreamedSpan` instances instead. When an exception occurs, `_capture_exception()` calls `set_span_errored()` which sets `StreamedSpan.status` to `SpanStatus.ERROR` (value "error"). Even if the isinstance check were extended to include `StreamedSpan`, the status comparison `span.status == SPANSTATUS.INTERNAL_ERROR` (value "internal_error") would fail because `StreamedSpan` uses a different status value. This causes errored spans to not be properly closed via `__exit__` in streaming mode.
Check warning on line 397 in sentry_sdk/integrations/celery/__init__.py
github-actions / warden: find-bugs
Missing @wraps(f) decorator in _wrap_task_call breaks celery-once compatibility
The `@ensure_integration_enabled` decorator was removed from `_wrap_task_call` but `@wraps(f)` was not added as a replacement. The code comment on lines 392-396 explicitly warns: "if we ever remove the @ensure_integration_enabled decorator, we need to add @functools.wraps(f) here" because celery-once looks at the method's name. Without this, the wrapper function's `__name__` will be `_inner` instead of the original function's name, breaking celery-once compatibility.
Check warning on line 135 in sentry_sdk/integrations/redis/_async_common.py
github-actions / warden: find-bugs
Spans leak when Redis command raises exception
In `_sentry_execute_command`, spans are entered via `__enter__()` but exited only in the happy path. If `old_execute_command` raises an exception, `db_span.__exit__()` and `cache_span.__exit__()` are never called. This causes spans to remain on the scope stack, leading to orphaned spans and potential memory leaks. The `StreamedSpan.__exit__` method also sets the span status to ERROR on exceptions, which won't happen here.
Check warning on line 146 in sentry_sdk/integrations/redis/_sync_common.py
github-actions / warden: find-bugs
[V38-VQF] Spans leak when Redis command raises exception (additional location)
In `_sentry_execute_command`, spans are entered via `__enter__()` but exited only in the happy path. If `old_execute_command` raises an exception, `db_span.__exit__()` and `cache_span.__exit__()` are never called. This causes spans to remain on the scope stack, leading to orphaned spans and potential memory leaks. The `StreamedSpan.__exit__` method also sets the span status to ERROR on exceptions, which won't happen here.
Check warning on line 70 in sentry_sdk/_span_batcher.py
github-actions / warden: find-bugs
[V38-VQF] Spans leak when Redis command raises exception (additional location)
In `_sentry_execute_command`, spans are entered via `__enter__()` but exited only in the happy path. If `old_execute_command` raises an exception, `db_span.__exit__()` and `cache_span.__exit__()` are never called. This causes spans to remain on the scope stack, leading to orphaned spans and potential memory leaks. The `StreamedSpan.__exit__` method also sets the span status to ERROR on exceptions, which won't happen here.
Check warning on line 183 in sentry_sdk/integrations/stdlib.py
github-actions / warden: find-bugs
Deprecation warning emitted on every HTTP request in span streaming mode
The `getresponse` function calls `span.finish()` at line 183 regardless of the span type. For `StreamedSpan` instances (when span streaming is enabled), `finish()` is deprecated and emits a warning via `warnings.warn()` before calling `end()`. This means every HTTP request made through stdlib's HTTPConnection will generate a deprecation warning when using the new span streaming mode. Users will see noisy warnings in their logs/stderr for normal SDK operation.
Check warning on line 320 in sentry_sdk/integrations/stdlib.py
github-actions / warden: find-bugs
[QEH-NWC] Deprecation warning emitted on every HTTP request in span streaming mode (additional location)
The `getresponse` function calls `span.finish()` at line 183 regardless of the span type. For `StreamedSpan` instances (when span streaming is enabled), `finish()` is deprecated and emits a warning via `warnings.warn()` before calling `end()`. This means every HTTP request made through stdlib's HTTPConnection will generate a deprecation warning when using the new span streaming mode. Users will see noisy warnings in their logs/stderr for normal SDK operation.
Check warning on line 265 in sentry_sdk/integrations/strawberry.py
github-actions / warden: find-bugs
Parsing span missing parent_span in streaming mode causing incorrect span hierarchy
In the `on_parse` method, when span streaming is enabled, the span is created without the `parent_span=self.graphql_span` argument (line 261), unlike the `on_validate` method which correctly passes it (line 240). Without the explicit parent, the parsing span will be parented to whatever is currently on the scope, which may result in incorrect span hierarchy where the parsing span is not properly nested under the graphql_span.
Check warning on line 320 in sentry_sdk/integrations/strawberry.py
github-actions / warden: find-bugs
[C2V-D6A] Parsing span missing parent_span in streaming mode causing incorrect span hierarchy (additional location)
In the `on_parse` method, when span streaming is enabled, the span is created without the `parent_span=self.graphql_span` argument (line 261), unlike the `on_validate` method which correctly passes it (line 240). Without the explicit parent, the parsing span will be parented to whatever is currently on the scope, which may result in incorrect span hierarchy where the parsing span is not properly nested under the graphql_span.
Check warning on line 831 in sentry_sdk/scope.py
github-actions / warden: find-bugs
set_transaction_name raises AttributeError when span is NoOpStreamedSpan
When `self._span` is a `NoOpStreamedSpan`, the check `isinstance(self._span, StreamedSpan)` returns `True` (since `NoOpStreamedSpan` inherits from `StreamedSpan`). However, `NoOpStreamedSpan.__init__` sets `self.segment = None`. This causes `self._span.segment.set_name(name)` to raise `AttributeError: 'NoneType' object has no attribute 'set_name'`. Any call to `scope.set_transaction_name()` while a `NoOpStreamedSpan` is active will crash.
Check warning on line 1303 in sentry_sdk/scope.py
github-actions / warden: find-bugs
sample_rate can be None when converted to string, causing "None" to be propagated in baggage
In `_update_sample_rate_from_segment`, `str(span.sample_rate)` is called unconditionally when `baggage` is not None. However, `span.sample_rate` can be `None` in several scenarios (tracing disabled, sampled already set, invalid sample rate). This results in `"None"` being stored in the baggage's `sentry_items["sample_rate"]`, which would propagate incorrect sampling data to downstream services. The equivalent code in `start_transaction` (line 1116) correctly checks `if transaction.sample_rate is not None` before updating baggage.
Check warning on line 820 in sentry_sdk/tracing_utils.py
github-actions / warden: find-bugs
[H6A-EK6] sample_rate can be None when converted to string, causing "None" to be propagated in baggage (additional location)
In `_update_sample_rate_from_segment`, `str(span.sample_rate)` is called unconditionally when `baggage` is not None. However, `span.sample_rate` can be `None` in several scenarios (tracing disabled, sampled already set, invalid sample rate). This results in `"None"` being stored in the baggage's `sentry_items["sample_rate"]`, which would propagate incorrect sampling data to downstream services. The equivalent code in `start_transaction` (line 1116) correctly checks `if transaction.sample_rate is not None` before updating baggage.
Check warning on line 419 in sentry_sdk/traces.py
github-actions / warden: find-bugs
Missing return after 'sampled is None' warning allows span to be marked as finished without being captured
At line 418-419, when `self.sampled is None`, the code logs 'Discarding transaction without sampling decision' but does not return. Unlike the legacy implementation in `tracing.py:1035-1038` which returns after this warning, this code continues execution, sets `_finished = True` at line 437, and also fails to record the lost event metric. This means spans with no sampling decision are silently lost without proper telemetry tracking, and the log message is misleading.
Check warning on line 769 in sentry_sdk/traces.py
github-actions / warden: find-bugs
NoOpStreamedSpan returns invalid hardcoded trace IDs that can propagate to downstream services
The `span_id` and `trace_id` properties in `NoOpStreamedSpan` return hardcoded "000000" values. When a `NoOpStreamedSpan` is active as `scope.span`, calling `sentry_sdk.get_traceparent()` returns "000000-000000-0" instead of falling back to the propagation context. This can cause invalid trace headers to be propagated to downstream services, breaking distributed tracing continuity.
Check warning on line 1516 in sentry_sdk/tracing_utils.py
github-actions / warden: find-bugs
Empty dict in ignore_spans config silently ignores ALL spans
The `is_ignored_span` function initializes `name_matches` and `attributes_match` to `True`, then only overwrites them if the rule dict contains 'name' or 'attributes' keys respectively. An empty dict rule `{}` in `ignore_spans` config would match every span because both variables remain `True`. This could cause silent data loss if a user accidentally includes an empty dict in their config.
Check warning on line 500 in tests/tracing/test_span_streaming.py
github-actions / warden: find-bugs
Test assertion compares value to itself, always passes
Line 500 asserts `segment1["trace_id"] == segment1["trace_id"]` which is always true (comparing a value to itself). The test `test_sibling_segments` intends to verify that sibling segments share the same trace_id, so the assertion should be `segment1["trace_id"] == segment2["trace_id"]`. This bug means the test doesn't actually verify the intended behavior, and a regression could go undetected.