Skip to content

[do not merge] feat: Span streaming & new span API #16278

[do not merge] feat: Span streaming & new span API

[do not merge] feat: Span streaming & new span API #16278

Triggered via pull request February 25, 2026 10:11
Status Success
Total duration 1m 23s
Artifacts

codeql-analysis.yml

on: pull_request
Matrix: Analyze
Fit to window
Zoom out
Zoom in

Annotations

6 errors and 30 warnings
API incompatibility: sentry_sdk.traces.start_span rejects op and origin parameters causing TypeError: sentry_sdk/ai/utils.py#L539
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.
[9Z8-K2H] API incompatibility: sentry_sdk.traces.start_span rejects op and origin parameters causing TypeError (additional location): sentry_sdk/integrations/graphene.py#L151
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.
UnboundLocalError when span setup fails in _wrap_tracer: sentry_sdk/integrations/celery/__init__.py#L324
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()`.
UnboundLocalError when transaction methods fail in streaming mode: sentry_sdk/integrations/celery/__init__.py#L324
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#L151
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.
[CDZ-H9M] StreamedSpan is never started, causing finish() to silently fail (additional location): sentry_sdk/integrations/rust_tracing.py#L216
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.
Error cleanup skipped for StreamedSpan in streaming mode: sentry_sdk/integrations/anthropic.py#L610
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.
[JR4-GN3] Error cleanup skipped for StreamedSpan in streaming mode (additional location): sentry_sdk/integrations/celery/__init__.py#L104
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.
Missing @wraps(f) decorator in _wrap_task_call breaks celery-once compatibility: sentry_sdk/integrations/celery/__init__.py#L397
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#L135
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.
[V38-VQF] Spans leak when Redis command raises exception (additional location): sentry_sdk/integrations/redis/_sync_common.py#L135
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.
[V38-VQF] Spans leak when Redis command raises exception (additional location): sentry_sdk/_span_batcher.py#L68
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.
Deprecation warning emitted on every HTTP request in span streaming mode: sentry_sdk/integrations/stdlib.py#L183
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.
[QEH-NWC] Deprecation warning emitted on every HTTP request in span streaming mode (additional location): sentry_sdk/integrations/stdlib.py#L320
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.
Parsing span missing parent_span in streaming mode causing incorrect span hierarchy: sentry_sdk/integrations/strawberry.py#L261
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.
[C2V-D6A] Parsing span missing parent_span in streaming mode causing incorrect span hierarchy (additional location): sentry_sdk/integrations/strawberry.py#L314
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.
set_transaction_name raises AttributeError when span is NoOpStreamedSpan: sentry_sdk/scope.py#L828
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#L1303
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.
[H6A-EK6] sample_rate can be None when converted to string, causing "None" to be propagated in baggage (additional location): sentry_sdk/tracing_utils.py#L816
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.
Missing return after 'sampled is None' warning allows span to be marked as finished without being captured: sentry_sdk/traces.py#L418
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#L763
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#L1498
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#L500
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.
Duplicate span serialization causes unnecessary CPU overhead: sentry_sdk/_span_batcher.py#L77
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.
[PTJ-V4V] Duplicate span serialization causes unnecessary CPU overhead (additional location): sentry_sdk/integrations/redis/_async_common.py#L135
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.
[PTJ-V4V] Duplicate span serialization causes unnecessary CPU overhead (additional location): sentry_sdk/integrations/redis/_sync_common.py#L135
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.
Streaming path does not set custom_sampling_context for traces_sampler: sentry_sdk/integrations/asgi.py#L225
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.
[E87-QM9] Streaming path does not set custom_sampling_context for traces_sampler (additional location): sentry_sdk/integrations/strawberry.py#L261
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.
StreamedSpan always sets ERROR status regardless of input argument: sentry_sdk/integrations/celery/__init__.py#L104
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#L152
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#L314
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.
[UL4-7LJ] Async resolve() missing set_op() and set_origin() for StreamedSpan (additional location): sentry_sdk/traces.py#L707
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.
[UL4-7LJ] Async resolve() missing set_op() and set_origin() for StreamedSpan (additional location): sentry_sdk/traces.py#L691
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.
Missing return after 'Discarding transaction' warning leads to inconsistent behavior: sentry_sdk/traces.py#L418
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#L816
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#L500
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"]`.