feat(tracing): Enable Sentry span streaming mode - #8245
Conversation
Turn on `trace_lifecycle="stream"` so spans are sent to Sentry in batches as they finish, instead of buffering a whole trace in memory and shipping it as a single transaction event. This removes the 1000-span-per-transaction cap, keeps memory flat in long-running consumers, and preserves spans that already finished when a process is killed mid-trace (e.g. an OOM-kill). Stream mode disables the legacy tracing API, and it fails silently: `start_span()`, `start_transaction()` and `update_current_span()` become no-ops, and `get_current_span()` / `scope.span` / `scope.transaction` return None. Every call site therefore has to move to `sentry_sdk.traces` in the same change or tracing goes dark with no error. `op` becomes the `sentry.op` attribute, `description` becomes the span name, and `set_data`/`set_tag` on a span become `set_attribute`. Scope tags and scope attributes reach disjoint destinations: tags land on error events only, attributes on spans only. Query-path tags are converted to attributes so they stay queryable on trace data; the consumer and error-reporting paths keep `set_tag`, since they have no active span and would otherwise lose the data entirely. Three things beyond the mechanical translation: - Several spans passed raw SQL as `description`, which becomes the span name -- the grouping key -- under stream mode. Give them stable names and move the SQL to a `db.query.text` attribute. - `metrics.incr`/`metrics.timing` no longer exist in sentry-sdk 2.66.1, so SentryMetricsBackend was a latent AttributeError. Map them to `count` and `distribution`. - Fix three pre-existing Mapping-vs-dict type errors in ClickhouseConnectPool. They were previously unreported because the mypy pre-commit hook only checks changed files, and this change touches that file. Drop the on-demand profiler: it drives `Transaction._profile`, which has no stream-mode equivalent. Note that `SENTRY_TRACE_SAMPLE_RATE` is 0 in every checked-in settings file, so this emits nothing until a deployment sets it. The CLI-init transaction previously forced `sampled=True`; stream mode has no equivalent, so it now obeys the sample rate like every other span. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 5cc1bea. Configure here.
| if not params: | ||
| return None | ||
| return dict(params) if isinstance(params, Mapping) else params |
There was a problem hiding this comment.
I'm not sure I understand the logic behind the else params. It seems to go against what the docstring suggests is the intended behaviour of this function.
| if query_id is not None: | ||
| span.set_attribute("query_id", query_id) |
There was a problem hiding this comment.
| if query_id is not None: | |
| span.set_attribute("query_id", query_id) | |
| span.set_attribute("query_id", query_id or "unknown-query-id") |
or even go back to span.set_attribute("query_id", str(query_id)) so that when looking it's more obvious that query_id is missing.
| name=f"INSERT INTO {table}", | ||
| attributes={ | ||
| "sentry.op": "db.clickhouse", | ||
| sentry_sdk.consts.SPANDATA.DB_SYSTEM: "clickhouse", |
There was a problem hiding this comment.
Do we also want a sentry_sdk.consts.SPANDATA.DB_QUERY_TEXT: query,?
No particular reason for me to want it, just want to make sure the exclusion was deliberate.
| for validator_func in VALIDATORS: | ||
| description = getattr(validator_func, "__name__", "custom") | ||
| with sentry_sdk.start_span(op="validator", description=description): | ||
| with traces.start_span(name=description, attributes={"sentry.op": "validator"}): |
There was a problem hiding this comment.
can we pull out the sentry.op into a constant? I feel like I'm seeing it in way too many places.
| if TYPE_CHECKING: | ||
| from sentry_sdk._types import Attributes | ||
|
|
There was a problem hiding this comment.
Is this a bug in the sentry_sdk? I feel like reaching into a private file to get the types for an input into start_span isn't the desired flow we want.
(obvs non-blocking)
There was a problem hiding this comment.
I don't think we expose this in a public file yet, but definitely makes sense to.
I've opened an issue: getsentry/sentry-python#6970
| # sentry_sdk must be >=2.62 for `sentry_sdk.traces` (span streaming API). | ||
| # Keep the floor in sync with pyproject.toml. | ||
| additional_dependencies: [ 'fastjsonschema', 'pyyaml', 'sentry_sdk>=2.66.1' ] |
There was a problem hiding this comment.
| # sentry_sdk must be >=2.62 for `sentry_sdk.traces` (span streaming API). | |
| # Keep the floor in sync with pyproject.toml. | |
| additional_dependencies: [ 'fastjsonschema', 'pyyaml', 'sentry_sdk>=2.66.1' ] | |
| # sentry_sdk must be >=2.62 for `sentry_sdk.traces` (span streaming API). | |
| additional_dependencies: [ 'fastjsonschema', 'pyyaml', 'sentry_sdk>=2.66.1' ] |
this is almost definitely going to go out of sync with pyproject. no point in leaving that in the comment.
alexander-alderman-webb
left a comment
There was a problem hiding this comment.
Looks good to me overall.
The two bot comments look valid.
The main possibly disruptive change is calling Scope.set_attribute() where there was previously Scope.set_tag(), which includes edits of sentry_sdk.set_tag() -> sentry_sdk.set_attribute().
Keep in mind that attributes on the scope are applied to logs, metrics, and (streamed) spans, whereas tags are applied to exceptions. Completely replacing set_tag() with set_attribute() results in the data not showing up on exceptions while the scope is active.
| def _attributes(tags: Tags | None) -> dict[str, Any] | None: | ||
| """The SDK takes an invariant ``dict``; ``Tags`` is a ``Mapping``.""" | ||
| return dict(tags) if tags is not None else None |
There was a problem hiding this comment.
FYI you can clean this up (change type definitions in SentryMetricsBackend).
Just surfacing this, not a big deal or blocking.
Dual-write scope tags and attributes on API error-enrichment paths so values still land on Sentry issues during the tags→attributes transition. Centralize the stream-mode op key as SENTRY_OP, harden querylog trace_id lookup, clarify clickhouse-connect param narrowing, and always set insert query_id on spans.

Why
Snuba runs the Sentry Python SDK in transaction mode: spans accumulate in memory and ship as one transaction event when the root span ends. Stream mode (
trace_lifecycle="stream") sends them in batches as they finish, which gets us:Why it's one big change
Stream mode disables the legacy tracing API, and it fails silently.
start_span(),start_transaction()andupdate_current_span()become no-ops;get_current_span(),scope.spanandscope.transactionreturnNone. No exception, no warning at runtime. So every call site has to move tosentry_sdk.tracesin the same commit or tracing just goes dark.The translation:
op→sentry.opattribute,description→ spanname,set_data/set_tagon a span →set_attribute.StreamedSpangenuinely has noop,description,set_data,set_tag, orstart_child, so mypy catches most of the sweep.Tags vs. attributes
Worth a close look in review. Verified empirically against 2.66.1: scope tags and scope attributes reach disjoint destinations — tags land on error events only, attributes on spans only. Since stream mode emits no transaction event, query-path tags had to become attributes to stay queryable on trace data.
But the consumer and error-reporting paths (
cli/consumer.py,lw_deletions_consumer.py,consumers/consumer.py,clickhouse/http.py) have no active span at all — no WSGI middleware, no root segment. Converting those would have sent the data nowhere, so they keepset_tag, with a comment explaining why.Beyond the mechanical translation
description, which becomes the span name — the grouping key — under stream mode. Unbounded cardinality. Now stable names with the SQL indb.query.text, matching what the SDK itself does.AttributeError.metrics.incr/metrics.timingdon't exist in sentry-sdk 2.66.1, soSentryMetricsBackendwould raise on any deploy withDOGSTATSD_SOCKET_PATHset. Mapped tocount/distribution.ClickhouseConnectPool. Previously unreported because the mypy hook only checks changed files; touching that file surfaced them, so they're fixed here.Transaction._profile, which has no stream-mode equivalent. Removed the module, itsondemand_profiler_hostnamesoption, and the docs section.Verification
mypy .— clean across all 1079 files (was 3 errors on master).-p no:warningsinpyproject.tomlmakes-W error::...inert, so this needed anaddoptsoverride — confirmed with a deliberate canary that the guard actually fires before trusting the green.http.serversegment with the migrated attributes; child spans nest correctly.trace_idverified at sample rate 0 (the default) —NoOpStreamedSpaninherits the propagation context's id, so it stays a real 32-hex value. The RPC path now reads it from the propagation context directly, which would otherwise have silently started writing"".Deploy note
SENTRY_TRACE_SAMPLE_RATEis 0 in every checked-in settings file, so this emits nothing until a deployment sets it (configured in ops, not here). One behavior change to be aware of: the CLI-init transaction usedsampled=Trueto bypass sampling, and stream mode has no equivalent — it now obeys the sample rate like every other span. Thesnuba_init_timemetric is unaffected.🤖 Generated with Claude Code