Skip to content

feat(core)!: Make beforeSendSpan compatible with streamed spans by default - #22643

Open
Lms24 wants to merge 7 commits into
developfrom
feat/before-send-span-streamed-default
Open

feat(core)!: Make beforeSendSpan compatible with streamed spans by default#22643
Lms24 wants to merge 7 commits into
developfrom
feat/before-send-span-streamed-default

Conversation

@Lms24

@Lms24 Lms24 commented Jul 24, 2026

Copy link
Copy Markdown
Member

This PR changes beforeSendSpan to receive StreamedSpanJSON by default, matching the new trace lifecycle default.

Callbacks that intentionally process legacy transaction span JSON need to add the withStaticSpan wrapper that marks the callback as "static"-compatible and hands users the SpanJSON type they used in the callback beforehand.

The withStreamedSpanwrapper remains available as a deprecated compatibility helper and is scheduled for removal in version 12.

More changes:

  • invalid beforeSendSpan callbacks no longer lead to switching the traceLifecycle. Since it now has a default and users need to actively opt out of span streaming, I think it's fair to treat incompatible callbacks as "invalid" and hence skip over them.
  • The beforeSendSpan compatibility checks were moved from the integrations into the core client which ensures that they always run now, even if users selected the static life cycle and hence spanStreamingIntegration doesn't get added.
  • Because we removed sending INP spans as v1 (SpanJSON) spans in favour of always sending them as v2 spans, we now convert a StreamedSpanJson to SpanJson in captureSpan, hand it to the static callback and then convert it back. Not great but I think we need to let users still scrub INP spans.

Closes #22349

@github-actions

github-actions Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

size-limit report 📦

Path Size % Change Change
@sentry/browser 30 kB +0.63% +186 B 🔺
@sentry/browser - with treeshaking flags 28.17 kB +0.54% +149 B 🔺
@sentry/browser (incl. Tracing) 47.24 kB +0.31% +146 B 🔺
@sentry/browser (incl. Tracing + Span Streaming) 47.26 kB +0.35% +161 B 🔺
@sentry/browser (incl. Tracing, Profiling) 51.96 kB +0.29% +149 B 🔺
@sentry/browser (incl. Tracing, Replay) 86.57 kB +0.2% +171 B 🔺
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags 75.99 kB +0.21% +155 B 🔺
@sentry/browser (incl. Tracing, Replay with Canvas) 91.26 kB +0.16% +139 B 🔺
@sentry/browser (incl. Tracing, Replay, Feedback) 103.92 kB +0.15% +152 B 🔺
@sentry/browser (incl. Feedback) 47.33 kB +0.41% +191 B 🔺
@sentry/browser (incl. sendFeedback) 34.83 kB +0.51% +175 B 🔺
@sentry/browser (incl. FeedbackAsync) 39.95 kB +0.47% +186 B 🔺
@sentry/browser (incl. Metrics) 31.07 kB +0.56% +172 B 🔺
@sentry/browser (incl. Logs) 31.3 kB +0.57% +176 B 🔺
@sentry/browser (incl. Metrics & Logs) 31.97 kB +0.54% +171 B 🔺
@sentry/react 31.78 kB +0.58% +181 B 🔺
@sentry/react (incl. Tracing) 49.5 kB +0.35% +169 B 🔺
@sentry/vue 34.91 kB +0.5% +173 B 🔺
@sentry/vue (incl. Tracing) 49.21 kB +0.33% +158 B 🔺
@sentry/svelte 30.03 kB +0.6% +177 B 🔺
CDN Bundle 32.01 kB +0.46% +146 B 🔺
CDN Bundle (incl. Tracing) 47.55 kB +0.2% +94 B 🔺
CDN Bundle (incl. Logs, Metrics) 33.58 kB +0.49% +161 B 🔺
CDN Bundle (incl. Tracing, Logs, Metrics) 48.94 kB +0.22% +107 B 🔺
CDN Bundle (incl. Replay, Logs, Metrics) 72.91 kB +0.18% +129 B 🔺
CDN Bundle (incl. Tracing, Replay) 85.18 kB +0.11% +87 B 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) 86.51 kB +0.14% +117 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) 90.97 kB +0.13% +113 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) 92.28 kB +0.12% +105 B 🔺
CDN Bundle - uncompressed 95.43 kB +0.41% +388 B 🔺
CDN Bundle (incl. Tracing) - uncompressed 142.64 kB +0.28% +388 B 🔺
CDN Bundle (incl. Logs, Metrics) - uncompressed 100.14 kB +0.39% +388 B 🔺
CDN Bundle (incl. Tracing, Logs, Metrics) - uncompressed 146.62 kB +0.27% +388 B 🔺
CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed 224.9 kB +0.18% +388 B 🔺
CDN Bundle (incl. Tracing, Replay) - uncompressed 261.9 kB +0.15% +388 B 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed 265.86 kB +0.15% +388 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed 275.6 kB +0.15% +388 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) - uncompressed 279.56 kB +0.14% +388 B 🔺
@sentry/nextjs (client) 52.07 kB +0.3% +151 B 🔺
@sentry/sveltekit (client) 47.67 kB +0.35% +166 B 🔺
@sentry/core/server 79.73 kB +0.16% +123 B 🔺
@sentry/core/browser 51.76 kB +0.33% +167 B 🔺
@sentry/node 121.43 kB +0.12% +137 B 🔺
@sentry/node/import (ESM hook with diagnostics-channel injection) 166 B - -
@sentry/node - without tracing 84.9 kB +0.15% +123 B 🔺
@sentry/aws-serverless 93.16 kB +0.15% +136 B 🔺
@sentry/cloudflare (withSentry) - minified 198.18 kB +0.3% +589 B 🔺
@sentry/cloudflare (withSentry) 487.16 kB +0.29% +1.38 kB 🔺

View base workflow run

@Lms24
Lms24 force-pushed the feat/before-send-span-streamed-default branch from a32a10f to bec33b0 Compare July 27, 2026 08:41
@Lms24
Lms24 force-pushed the feat/before-send-span-streamed-default branch from bec33b0 to e303625 Compare July 27, 2026 14:58

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit e303625. Configure here.

Comment thread packages/core/src/client.ts
Base automatically changed from feat/span-streaming-default to develop July 28, 2026 07:36
@Lms24
Lms24 force-pushed the feat/before-send-span-streamed-default branch 2 times, most recently from a6f845e to 344055e Compare July 28, 2026 08:22
@Lms24 Lms24 changed the title feat(core)!: Stream beforeSendSpan payloads by default feat(core)!: Make beforeSendSpan compatible with streamed spans by default Jul 28, 2026
@Lms24 Lms24 self-assigned this Jul 28, 2026
@Lms24
Lms24 force-pushed the feat/before-send-span-streamed-default branch from ec83b6f to 9f59f8c Compare July 29, 2026 14:51
Lms24 and others added 7 commits July 29, 2026 17:37
Make streamed span JSON the default beforeSendSpan contract and provide
withStaticSpan for callbacks that still process transaction span JSON.
Deprecate withStreamedSpan for removal in version 12.

BREAKING CHANGE: beforeSendSpan receives StreamedSpanJSON by default.
Fixes #22349
Co-Authored-By: Cursor <cursoragent@cursor.com>

Co-authored-by: Cursor <cursoragent@cursor.com>
`withStreamedSpan` no longer marks its callback. Nothing reads `_streamed` since
`isStreamedBeforeSendSpanCallback` became "not wrapped with `withStaticSpan`", so the
wrapper returns the callback unchanged instead of mutating a function it was handed.

Resolve `traceLifecycle` once in the `Client` constructor, where any value other than
`'static'` normalizes to the `'stream'` default. Neither span streaming integration
writes back to the option anymore, which removes an ordering hazard: integrations that
read `hasSpanStreamingEnabled` during their own `setup` could observe the value before
or after the mutation depending on integration order. The registration gates now test
`!== 'static'` so they agree with the normalized value; previously an unknown value
failed the gate but still resolved to `'stream'`, leaving nothing to flush spans.

Move the `beforeSendSpan` format check into the constructor as well. A callback is only
invoked for the span format matching the trace lifecycle, so a mismatch means it is
never called. The client can warn where the integration could not: with
`traceLifecycle: 'static'` the streaming integration is never registered, so an
unwrapped callback previously went unreported.

A `withStaticSpan` callback under `traceLifecycle: 'stream'` no longer downgrades the
lifecycle to `'static'`. Opting out of streaming requires setting
`traceLifecycle: 'static'` explicitly.

Refs #22349
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Lms24
Lms24 force-pushed the feat/before-send-span-streamed-default branch from 9f59f8c to 9176c4b Compare July 29, 2026 15:37
@Lms24
Lms24 marked this pull request as ready for review July 29, 2026 15:42
@Lms24
Lms24 requested review from a team as code owners July 29, 2026 15:42
@Lms24
Lms24 requested review from nicohrubec and s1gr1d and removed request for a team July 29, 2026 15:42
@Lms24
Lms24 requested review from isaacs, msonnb and mydea and removed request for a team July 29, 2026 15:43

@isaacs isaacs left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not super familiar with this part of the machinery, so leaving it as a comment. If I'm misreading things or someone else disagrees, I'm happy to be overruled :)

Since span streaming is the direction we're moving, and the goal is to make it the default, moving these checks out of the integration and into the client feels like the right call.

// A `beforeSendSpan` callback is only invoked for the span format matching the trace lifecycle,
// so a mismatch means it is silently never called.
if (
DEBUG_BUILD &&

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems like it might potentially be a bigger deal than we'd want to gate behind a DEBUG_BUILD.

Since the default is switching without any transitional state, it seems very possible that a user who fails to read the migration guide (or just misses it) might not wrap their callback.

Also, because this is in the constructor, it wouldn't be able to do a debug.warn if the user manually constructs a client before enabling logging. If I'm reading that right, then it might be good to not gate this behind DEBUG_BUILD, and move the warning to init() or the first captureSpan() with a guard to only emit the warning once.

this._options = {
attachStacktrace: true,
...options,
traceLifecycle: options.traceLifecycle === 'static' ? 'static' : 'stream',

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

l: It's good to coerce this to a known value, but feels somewhat unsafe to coerce a typo without any real indicator. I guess most of our users are using typescript, and the type does defend against that? I'd just worry about something like { traceLifecycle: 'stattic' } quietly doing the opposite of what the user intends.

EDIT: actually, when we read it from the env, we have essentially the same logic, in the end. The values static and stream are passed through as-is, and anything else is undefined, which ends up being stream anyway. So, nevermind, this is fine. Actually, it's really good, because before this exact same logic was spread all over the place.

* `profile_id` and `exclusive_time` stay readable through `data`.
*/
export function streamedSpanJsonToSpanJson(span: StreamedSpanJSON): SpanJSON {
const data = { ...span.attributes } as SpanAttributes;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wait, isn't span.attributes a RawAttributes<Record<string, unknown>> here? And it's being cast to SpanAttributes, meaning that if you expect something to be a string in data, it's actually going to be a { value: string }, right? So, down below, we'd be setting op to a non-string, and if it gets stringified, wouldn't it become [object Object]? Or am I misreading how this is used?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, did some more digging on this. I think it's only an issue if you read the values out during the static beforeSpan callback.

For example, if you have this in your options:

new Client({
  // ... other options, dsn, etc
  beforeSendSpan: withStaticSpan((span: SpanJSON) => {
    // this *should* be fine if it's a SpanJSON, because the value
    // is string|number|..., not { value: <whatever> }
    console.log(String(span.data['render.duration']));
    return span;
  }),
})

And then something does this:

scope.setAttribute('render.duration', { value: 150, unit: 'millisecond' });
const span = startInactiveSpan({ name: 'my-span', parentSpan: rootSpan });

then what gets logged is [object Object], because it's not actually getting a SpanJSON, but a StreamedSpanJSON, so the attributes are a RawAttributes object.

I'm not sure if this is a thing we need to guard against, but it feels like the typecast there is little bit of a landmine.

Assuming you never munge it in the callback, I think it's probably fine though? It just casts as one then back as the other.

status: !span.status || span.status === 'ok' || span.status === 'cancelled' ? 'ok' : 'error',
is_segment: false,
attributes: { ...(span.data as RawAttributes<Record<string, unknown>>) },
is_segment: span.is_segment ?? false,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

low/comment: This function is also called by extractGenAiSpansFromEvent in packages/core/src/tracing/spans/extractGenAiSpans.ts, and the is_segment is changing from a hard-coded false to is_segment ?? false.

Also, the op is copied in the other direction now.

Both changes seem fine, at least currently, but it seems like maybe sharing one converter for both "reconstruct a span the user just edited" and "extract gen-AI spans for the wire" might be weird/surprising if those things need to change differently?

* @returns The modified span payload that will be sent.
*/
beforeSendSpan?: ((span: SpanJSON) => SpanJSON) & { _streamed?: true };
beforeSendSpan?: BeforeSendStreamedSpanCallback | BeforeSendStaticSpanCallbackMarker;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't this mean that beforeSendSpan: { _streamed: true } would be a type-allowed option? 🤔

expect(serialized.attributes['sentry.op']).toEqual({ type: 'string', value: 'http.client' });
});

it("doesn't a default beforeSendSpan callback without the withStaticSpan wrapper", () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
it("doesn't a default beforeSendSpan callback without the withStaticSpan wrapper", () => {
it("doesn't apply a default beforeSendSpan callback without the withStaticSpan wrapper", () => {

endTimestamp: 2,
sampled: true,
expect(mockSend).toHaveBeenCalled();
const sentSpan = mockSend.mock.calls[0]![0]![1][0][1].items[0];

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yikes. Is it possible to have a helper or something to flesh this out more readably? Or at least a comment explaining the structure here? I trust that it's the right test, but it's getting a bit line-noise.

links: [{ trace_id: 'aabb', span_id: 'ccdd', sampled: true, attributes: { foo: 'bar' } }],
});

expect(spanJsonToStreamedSpanJson(streamedSpanJsonToSpanJson(streamedSpan))).toEqual(streamedSpan);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test looks like it's asserting an identity (yToX(xToY(x)) === x) but in this case, there are actually some things that will change. Eg, status is forced to either error or ok, the attributes['sentry.op'] is potentially updated, etc. It'd be good to have a test here that shows those intentional changes, as well, so the intention is locked.

Comment thread MIGRATION.md
| `data` | `attributes` |
| `op` | `attributes['sentry.op']` |
| `timestamp` | `end_timestamp` |
| `status` (`string`) | `status` (`'ok'` or `'error'`) |

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It'd be good to have a note here that any other status values like cancelled or unauthenticated are now coerced to ok, and that the added information now resides in the sentry.status.message attribute. Anyone who was previously doing something with that field's data needs to look elsewhere for it.

startTimestamp: 1,
endTimestamp: 2,
sampled: false,
describe('standalone spans', () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a nice test reorganization, explains what's actually going on more clearly. 👍

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Make streamed span types the default in public APIs

2 participants