Skip to content

fix(core): Allow non-recording spans to carry explicit sampling decisions#21406

Open
andreiborza wants to merge 13 commits into
developfrom
ab/nonrecording-span-sampling
Open

fix(core): Allow non-recording spans to carry explicit sampling decisions#21406
andreiborza wants to merge 13 commits into
developfrom
ab/nonrecording-span-sampling

Conversation

@andreiborza

@andreiborza andreiborza commented Jun 9, 2026

Copy link
Copy Markdown
Member

What

Non-recording spans can now carry a sampled flag and parentSpanId on their span context, so spanToTraceHeader/spanToTraceparentHeader and spanToJSON reflect the real decision.

In Tracing without Performance mode, root non-recording spans keep the sampling decision deferred (no sentry-trace flag, no sentry-sampled/sample_rate in the DSC) instead of asserting a negative decision that would suppress downstream sampling.

Spans created for an unsampled trace (or ignored spans) carry an explicit sampled: false.

Placeholder/idle spans also inherit traceId/parentSpanId from the propagation context and capture their scopes.

Why

These changes are important for when we switch over to our own TracerProvider and Tracer because we will create native Sentry spans (including SentryNonRecordingSpan) and they need to be on par with the previously used OTel spans.

Comment thread packages/core/src/utils/spanUtils.ts
@github-actions

github-actions Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

size-limit report 📦

Path Size % Change Change
@sentry/browser 27.39 kB +0.01% +1 B 🔺
@sentry/browser - with treeshaking flags 25.82 kB +0.01% +2 B 🔺
@sentry/browser (incl. Tracing) 46 kB +0.7% +317 B 🔺
@sentry/browser (incl. Tracing + Span Streaming) 48.25 kB +0.69% +326 B 🔺
@sentry/browser (incl. Tracing, Profiling) 50.77 kB +0.59% +293 B 🔺
@sentry/browser (incl. Tracing, Replay) 85.21 kB +0.37% +312 B 🔺
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags 74.82 kB +0.42% +312 B 🔺
@sentry/browser (incl. Tracing, Replay with Canvas) 89.92 kB +0.37% +328 B 🔺
@sentry/browser (incl. Tracing, Replay, Feedback) 102.58 kB +0.3% +299 B 🔺
@sentry/browser (incl. Feedback) 44.55 kB +0.01% +1 B 🔺
@sentry/browser (incl. sendFeedback) 32.19 kB -0.01% -1 B 🔽
@sentry/browser (incl. FeedbackAsync) 37.3 kB +0.01% +2 B 🔺
@sentry/browser (incl. Metrics) 28.46 kB +0.02% +3 B 🔺
@sentry/browser (incl. Logs) 28.7 kB +0.02% +3 B 🔺
@sentry/browser (incl. Metrics & Logs) 29.39 kB +0.01% +2 B 🔺
@sentry/react 29.18 kB +0.02% +3 B 🔺
@sentry/react (incl. Tracing) 48.31 kB +0.71% +340 B 🔺
@sentry/vue 32.65 kB +0.78% +251 B 🔺
@sentry/vue (incl. Tracing) 47.88 kB +0.65% +309 B 🔺
@sentry/svelte 27.41 kB -0.01% -1 B 🔽
CDN Bundle 29.81 kB +0.11% +32 B 🔺
CDN Bundle (incl. Tracing) 48.51 kB +0.73% +349 B 🔺
CDN Bundle (incl. Logs, Metrics) 31.35 kB +0.09% +26 B 🔺
CDN Bundle (incl. Tracing, Logs, Metrics) 49.82 kB +0.69% +339 B 🔺
CDN Bundle (incl. Replay, Logs, Metrics) 70.64 kB +0.05% +31 B 🔺
CDN Bundle (incl. Tracing, Replay) 85.86 kB +0.42% +352 B 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) 87.14 kB +0.44% +381 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) 91.71 kB +0.4% +364 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) 92.97 kB +0.4% +368 B 🔺
CDN Bundle - uncompressed 88.54 kB +0.1% +80 B 🔺
CDN Bundle (incl. Tracing) - uncompressed 146.92 kB +0.86% +1.24 kB 🔺
CDN Bundle (incl. Logs, Metrics) - uncompressed 93.25 kB +0.09% +80 B 🔺
CDN Bundle (incl. Tracing, Logs, Metrics) - uncompressed 150.89 kB +0.84% +1.24 kB 🔺
CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed 218.07 kB +0.04% +80 B 🔺
CDN Bundle (incl. Tracing, Replay) - uncompressed 265.79 kB +0.48% +1.25 kB 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed 269.75 kB +0.47% +1.25 kB 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed 279.49 kB +0.45% +1.25 kB 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) - uncompressed 283.44 kB +0.45% +1.25 kB 🔺
@sentry/nextjs (client) 50.72 kB +0.58% +288 B 🔺
@sentry/sveltekit (client) 46.42 kB +0.69% +317 B 🔺
@sentry/core/server 76.25 kB +0.27% +204 B 🔺
@sentry/core/browser 63.38 kB +0.31% +190 B 🔺
@sentry/node-core 62.03 kB +0.51% +310 B 🔺
@sentry/node 130.78 kB +0.22% +285 B 🔺
@sentry/node - without tracing 74.4 kB +0.4% +296 B 🔺
@sentry/aws-serverless 86.56 kB +0.32% +272 B 🔺
@sentry/cloudflare (withSentry) - minified 174.9 kB +0.7% +1.21 kB 🔺
@sentry/cloudflare (withSentry) 437.07 kB +0.75% +3.22 kB 🔺

View base workflow run

Comment thread packages/core/src/tracing/trace.ts Outdated
Comment thread packages/core/src/tracing/trace.ts
Comment thread packages/core/src/tracing/trace.ts Outdated
@andreiborza andreiborza force-pushed the ab/nonrecording-span-sampling branch from 9a455d0 to 69d87c1 Compare June 9, 2026 17:27
* @internal
*/
public recordException(_exception: unknown, _time?: number | undefined): void {
public recordException(_exception: unknown, _time?: SpanTimeInput | undefined): void {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This is a widening change to the more correct SpanTimeInput type, it contains number.

@andreiborza andreiborza marked this pull request as ready for review June 10, 2026 08:22
@andreiborza andreiborza requested a review from a team as a code owner June 10, 2026 08:22
@andreiborza andreiborza requested review from JPeer264, Lms24, chargome, logaretm, mydea and nicohrubec and removed request for a team and JPeer264 June 10, 2026 08:22

@logaretm logaretm 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.

Minor Qs, I noticed we added sampled flag to non recording spans but never set in the constructor opts.

Maybe we set it somewhere else?

Comment thread packages/core/src/tracing/idleSpan.ts
Comment thread packages/core/src/tracing/trace.ts

@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.

Found some small suggestions that might improve or tidy it up, defend against future mistakes, etc. But generally, this looks great :)

Comment thread packages/core/src/tracing/trace.ts Outdated
dropUndefinedKeys({
...getDynamicSamplingContextFromSpan(span),
transaction: source === 'url' ? undefined : spanArguments.name,
})) satisfies Partial<DynamicSamplingContext>;

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 block is almost identical with the bit in packages/core/src/tracing/idleSpan.ts, looks like they only differ in the default name. Can that be abstracted out to a helper function, like a fancier version of freezeDscOnSpan for TwP root spans?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

👍 extracted in 45a8a2f


return new SentryNonRecordingSpan({
dropReason: 'ignored',
sampled: 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.

The root span leaves sampled: undefined, and this one (intentionally) sets it false. It might be a good idea to add a comment so that we don't come along later and ""fix"" the inconsistency. (Also, below, on line 563.)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Updated in 07dfbf4

@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 c394cf7. Configure here.

Comment thread packages/core/src/tracing/trace.ts Outdated
Comment thread packages/core/src/utils/spanUtils.ts
…ions

Non-recording spans can now carry a `sampled` flag and `parentSpanId` on their
span context, so `spanToTraceHeader`/`spanToTraceparentHeader` and `spanToJSON`
reflect the real decision.

In Tracing without Performance mode, root non-recording spans keep the sampling
decision deferred (no `sentry-trace` flag, no `sentry-sampled`/`sample_rate` in
the DSC) instead of asserting a negative decision that would suppress downstream
sampling.

Spans created for an unsampled trace (or ignored spans) carry an explicit
`sampled: false`.
@andreiborza andreiborza force-pushed the ab/nonrecording-span-sampling branch from b539317 to 07dfbf4 Compare June 12, 2026 07:39

@Lms24 Lms24 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.

Just some minor qs and a request for streamed spans. Good change overall (even in isolation without the traceprovider switch :) )

event: Event,
{ includeSampleRand = false, sdk = 'cloudflare' }: { includeSampleRand?: boolean; sdk?: 'cloudflare' | 'hono' } = {},
{
includeSamplingFields = 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.

super-l: Not a fan of this tbh as it still limits what we're expecting in the specific values. Why not directly assert on the envelope headers?

This is fine for the PR though, so no need to change it :)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Agreed, it would be better to assert actual values.

I had a go at this but it snowballs quite a bit so I'll extract that out into a separate PR later.

* Sentry-specific sampling decision for this span context.
* `undefined` means no local sampling decision was made yet.
*/
sampled?: boolean | undefined;

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: not opposed to this but just to double check: We're fine with diverging from OTel here? My understanding is we can do it because our tracer implementation will just create SentrySpans, so this should be fine.

return {
span_id,
trace_id,
parent_span_id: (span as { parentSpanId?: string }).parentSpanId,

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.

m: I think we can also update this on spanToStreamedSpanJSON, right?

startSpan({ name: 'ignored-child' }, span => {
expect(span).toBeInstanceOf(SentryNonRecordingSpan);
// The ignored span still links to its parent so `spanToJSON` can surface it.
expect(spanToJSON(span).parent_span_id).toBe(rootSpan.spanContext().spanId);

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.

m: spanToJSON does not change based on traceLifecycle: stream. i think we need to assert against spanToStreamedSpanJSON here.

// In TwP mode, a new trace's sampling decision stays deferred (like `startSpan`) while a
// continued trace carries the upstream decision, so baggage and the `sentry-trace` header
// agree. Idle spans are always trace roots, so we freeze the DSC here.
freezeDscOnTwpRootSpan(span, {

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 am not a fan of Twp as it is not super clear. Can we just call this e.g. freezeDscOnRootSpanWithoutSampling or something along these lines?

spanId: this._spanId,
traceId: this._traceId,
traceFlags: TRACE_FLAG_NONE,
traceFlags: this._sampled ? TRACE_FLAG_SAMPLED : TRACE_FLAG_NONE,

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 weird and Imho not ideal, that a non recording span can be sampled = true? We should enforce that this is either false or undefined. If it is true we should not create a non recording span I suppose...

parentSpanId?: string;
sampled?: boolean;
dsc?: Partial<DynamicSamplingContext>;
} = parentSpan

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.

Can we not use an existing utility like spantodsc or similar here?

...getDynamicSamplingContextFromSpan(span),
} satisfies Partial<DynamicSamplingContext>;
freezeDscOnSpan(span, dsc);
freezeDscOnTwpRootSpan(span, {

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 not a twp span, is it? Just a forced transaction?

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.

5 participants