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

Closed
andreiborza wants to merge 15 commits into
developfrom
ab/nonrecording-span-sampling
Closed

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

Conversation

@andreiborza

@andreiborzaandreiborza 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 threadpackages/core/src/utils/spanUtils.ts Outdated
@github-actions

github-actionsBot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

size-limit report 📦

PathSize% ChangeChange
@sentry/browser27.41 kB+0.02%+4 B 🔺
@sentry/browser - with treeshaking flags25.84 kB+0.02%+4 B 🔺
@sentry/browser (incl. Tracing)46.23 kB+1.16%+530 B 🔺
@sentry/browser (incl. Tracing + Span Streaming)48.48 kB+1.14%+543 B 🔺
@sentry/browser (incl. Tracing, Profiling)51.01 kB+1.01%+507 B 🔺
@sentry/browser (incl. Tracing, Replay)85.42 kB+0.59%+501 B 🔺
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags75.03 kB+0.68%+503 B 🔺
@sentry/browser (incl. Tracing, Replay with Canvas)90.12 kB+0.57%+508 B 🔺
@sentry/browser (incl. Tracing, Replay, Feedback)102.8 kB+0.5%+503 B 🔺
@sentry/browser (incl. Feedback)44.57 kB+0.02%+5 B 🔺
@sentry/browser (incl. sendFeedback)32.21 kB+0.02%+4 B 🔺
@sentry/browser (incl. FeedbackAsync)37.32 kB+0.03%+8 B 🔺
@sentry/browser (incl. Metrics)28.48 kB+0.04%+10 B 🔺
@sentry/browser (incl. Logs)28.71 kB+0.03%+8 B 🔺
@sentry/browser (incl. Metrics & Logs)29.42 kB+0.06%+15 B 🔺
@sentry/react29.21 kB+0.04%+9 B 🔺
@sentry/react (incl. Tracing)48.53 kB+1.12%+533 B 🔺
@sentry/vue32.9 kB+1.48%+477 B 🔺
@sentry/vue (incl. Tracing)48.13 kB+1.14%+542 B 🔺
@sentry/svelte27.43 kB+0.02%+4 B 🔺
CDN Bundle29.89 kB+0.34%+100 B 🔺
CDN Bundle (incl. Tracing)48.74 kB+1.14%+547 B 🔺
CDN Bundle (incl. Logs, Metrics)31.43 kB+0.33%+102 B 🔺
CDN Bundle (incl. Tracing, Logs, Metrics)50.03 kB+1.09%+536 B 🔺
CDN Bundle (incl. Replay, Logs, Metrics)70.73 kB+0.15%+103 B 🔺
CDN Bundle (incl. Tracing, Replay)86.03 kB+0.6%+507 B 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics)87.35 kB+0.67%+575 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback)91.9 kB+0.59%+530 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics)93.17 kB+0.6%+554 B 🔺
CDN Bundle - uncompressed88.85 kB+0.3%+261 B 🔺
CDN Bundle (incl. Tracing) - uncompressed147.64 kB+1.27%+1.84 kB 🔺
CDN Bundle (incl. Logs, Metrics) - uncompressed93.55 kB+0.28%+261 B 🔺
CDN Bundle (incl. Tracing, Logs, Metrics) - uncompressed151.61 kB+1.23%+1.84 kB 🔺
CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed218.38 kB+0.12%+261 B 🔺
CDN Bundle (incl. Tracing, Replay) - uncompressed266.52 kB+0.7%+1.85 kB 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed270.48 kB+0.69%+1.85 kB 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed280.22 kB+0.67%+1.85 kB 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) - uncompressed284.17 kB+0.66%+1.85 kB 🔺
@sentry/nextjs (client)50.93 kB+0.95%+479 B 🔺
@sentry/sveltekit (client)46.64 kB+1.13%+521 B 🔺
@sentry/core/server76.53 kB+0.6%+456 B 🔺
@sentry/core/browser63.68 kB+0.74%+462 B 🔺
@sentry/node-core61.98 kB+0.42%+256 B 🔺
@sentry/node130.76 kB+0.19%+240 B 🔺
@sentry/node - without tracing74.36 kB+0.35%+253 B 🔺
@sentry/aws-serverless86.52 kB+0.27%+229 B 🔺
@sentry/cloudflare (withSentry) - minified175.72 kB+1.17%+2.03 kB 🔺
@sentry/cloudflare (withSentry)438.89 kB+1.17%+5.04 kB 🔺

View base workflow run

Comment threadpackages/core/src/tracing/trace.ts Outdated
Comment threadpackages/core/src/tracing/trace.ts
Comment threadpackages/core/src/tracing/trace.ts Outdated
@andreiborza
andreiborzaforce-pushed the ab/nonrecording-span-sampling branch from 9a455d0 to 69d87c1CompareJune 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
MemberAuthor

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 ownerJune 10, 2026 08:22
@andreiborza
andreiborza requested review from JPeer264, Lms24, chargome, logaretm, mydea and nicohrubec and removed request for a team and JPeer264June 10, 2026 08:22

@logaretmlogaretm 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 threadpackages/core/src/tracing/idleSpan.ts
Comment threadpackages/core/src/tracing/trace.ts

@isaacsisaacs 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 threadpackages/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
MemberAuthor

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
MemberAuthor

Choose a reason for hiding this comment

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

Updated in 07dfbf4

Comment threadpackages/core/src/tracing/trace.ts Outdated
Comment threadpackages/core/src/utils/spanUtils.ts Outdated
…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
andreiborzaforce-pushed the ab/nonrecording-span-sampling branch from b539317 to 07dfbf4CompareJune 12, 2026 07:39

@Lms24Lms24 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
MemberAuthor

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.

Comment threadpackages/core/src/types/span.ts Outdated
* 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.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Right, I walked back on this and go for the same _sampled + tracestate flag approach to be in line with SentrySpan.

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?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Added in d5887e7

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.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Updated in d5887e7.

// 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?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Right, renamed to freezeDscOnRootSpanWithoutSampling in ba51d24

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

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

I reworked this to be in line with SentrySpan, using _sampled + a flag in trace state and using the same helpers across both span types in ba51d24

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?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Updated in ba51d24, if I understand your comment correctly 😅

Comment threadpackages/core/src/tracing/trace.ts Outdated
...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?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Updated in ba51d24

@andreiborza
andreiborzaforce-pushed the ab/nonrecording-span-sampling branch from ca1ccfb to ba51d24CompareJune 14, 2026 15:07
@andreiborza

Copy link
Copy Markdown
MemberAuthor

bugbot run

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

Comment threadpackages/core/src/tracing/trace.ts
@andreiborza

Copy link
Copy Markdown
MemberAuthor

Closing in favor of #21549 which keeps SentryNonRecordingSpan a thin container and uses the scope to handle the sampling decision in cases where it's needed.

@JPeer264
JPeer264 deleted the ab/nonrecording-span-sampling branch June 16, 2026 05:26
andreiborza added a commit that referenced this pull request Jun 17, 2026
…21549)
In Tracing-without-Performance (spans disabled), a root placeholder
previously froze a negative sampling decision in the DSC, which
suppressed downstream sampling instead of leaving the decision to a
performance-enabled service further along the trace.
The scope is the source of truth for a TwP placeholder's trace state:
- `getTraceData` reads the sampling decision from the scope (deferred
for a new trace, the upstream decision for a
continued trace), so the outgoing `sentry-trace` header omits the flag
instead of asserting `-0`. The span id comes from the scope's
`propagationSpanId` (a fresh id is generated when the scope has none).
- `getDynamicSamplingContextFromSpan` resolves a placeholder's DSC from
its captured scope (continued traces keep the incoming DSC; new traces
derive it from the client).
The scope is only consulted for genuine TwP placeholders. A
non-recording span in tracing mode, the child of an unsampled span, or
an ignored span carries an explicit negative decision and keeps
propagating `-0` via `spanToTraceHeader`.
A new (head-of-trace) TwP trace does not stamp a local `transaction` in
its DSC; continued traces still propagate the upstream decision and DSC.
No DSC is written to the scope at span start, preserving the browser's
"scope stays DSC-free between navigations" behavior.
This is an alternative to
#21406
Sign up for freeto 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.

6 participants

@andreiborza@isaacs@mydea@logaretm@Lms24@chargome
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

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

Closed
andreiborza wants to merge 15 commits into
developfrom
ab/nonrecording-span-sampling
Closed

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

Conversation

@andreiborza

@andreiborzaandreiborza 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 threadpackages/core/src/utils/spanUtils.ts Outdated
@github-actions

github-actionsBot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

size-limit report 📦

PathSize% ChangeChange
@sentry/browser27.41 kB+0.02%+4 B 🔺
@sentry/browser - with treeshaking flags25.84 kB+0.02%+4 B 🔺
@sentry/browser (incl. Tracing)46.23 kB+1.16%+530 B 🔺
@sentry/browser (incl. Tracing + Span Streaming)48.48 kB+1.14%+543 B 🔺
@sentry/browser (incl. Tracing, Profiling)51.01 kB+1.01%+507 B 🔺
@sentry/browser (incl. Tracing, Replay)85.42 kB+0.59%+501 B 🔺
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags75.03 kB+0.68%+503 B 🔺
@sentry/browser (incl. Tracing, Replay with Canvas)90.12 kB+0.57%+508 B 🔺
@sentry/browser (incl. Tracing, Replay, Feedback)102.8 kB+0.5%+503 B 🔺
@sentry/browser (incl. Feedback)44.57 kB+0.02%+5 B 🔺
@sentry/browser (incl. sendFeedback)32.21 kB+0.02%+4 B 🔺
@sentry/browser (incl. FeedbackAsync)37.32 kB+0.03%+8 B 🔺
@sentry/browser (incl. Metrics)28.48 kB+0.04%+10 B 🔺
@sentry/browser (incl. Logs)28.71 kB+0.03%+8 B 🔺
@sentry/browser (incl. Metrics & Logs)29.42 kB+0.06%+15 B 🔺
@sentry/react29.21 kB+0.04%+9 B 🔺
@sentry/react (incl. Tracing)48.53 kB+1.12%+533 B 🔺
@sentry/vue32.9 kB+1.48%+477 B 🔺
@sentry/vue (incl. Tracing)48.13 kB+1.14%+542 B 🔺
@sentry/svelte27.43 kB+0.02%+4 B 🔺
CDN Bundle29.89 kB+0.34%+100 B 🔺
CDN Bundle (incl. Tracing)48.74 kB+1.14%+547 B 🔺
CDN Bundle (incl. Logs, Metrics)31.43 kB+0.33%+102 B 🔺
CDN Bundle (incl. Tracing, Logs, Metrics)50.03 kB+1.09%+536 B 🔺
CDN Bundle (incl. Replay, Logs, Metrics)70.73 kB+0.15%+103 B 🔺
CDN Bundle (incl. Tracing, Replay)86.03 kB+0.6%+507 B 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics)87.35 kB+0.67%+575 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback)91.9 kB+0.59%+530 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics)93.17 kB+0.6%+554 B 🔺
CDN Bundle - uncompressed88.85 kB+0.3%+261 B 🔺
CDN Bundle (incl. Tracing) - uncompressed147.64 kB+1.27%+1.84 kB 🔺
CDN Bundle (incl. Logs, Metrics) - uncompressed93.55 kB+0.28%+261 B 🔺
CDN Bundle (incl. Tracing, Logs, Metrics) - uncompressed151.61 kB+1.23%+1.84 kB 🔺
CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed218.38 kB+0.12%+261 B 🔺
CDN Bundle (incl. Tracing, Replay) - uncompressed266.52 kB+0.7%+1.85 kB 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed270.48 kB+0.69%+1.85 kB 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed280.22 kB+0.67%+1.85 kB 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) - uncompressed284.17 kB+0.66%+1.85 kB 🔺
@sentry/nextjs (client)50.93 kB+0.95%+479 B 🔺
@sentry/sveltekit (client)46.64 kB+1.13%+521 B 🔺
@sentry/core/server76.53 kB+0.6%+456 B 🔺
@sentry/core/browser63.68 kB+0.74%+462 B 🔺
@sentry/node-core61.98 kB+0.42%+256 B 🔺
@sentry/node130.76 kB+0.19%+240 B 🔺
@sentry/node - without tracing74.36 kB+0.35%+253 B 🔺
@sentry/aws-serverless86.52 kB+0.27%+229 B 🔺
@sentry/cloudflare (withSentry) - minified175.72 kB+1.17%+2.03 kB 🔺
@sentry/cloudflare (withSentry)438.89 kB+1.17%+5.04 kB 🔺

View base workflow run

Comment threadpackages/core/src/tracing/trace.ts Outdated
Comment threadpackages/core/src/tracing/trace.ts
Comment threadpackages/core/src/tracing/trace.ts Outdated
@andreiborza
andreiborzaforce-pushed the ab/nonrecording-span-sampling branch from 9a455d0 to 69d87c1CompareJune 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
MemberAuthor

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 ownerJune 10, 2026 08:22
@andreiborza
andreiborza requested review from JPeer264, Lms24, chargome, logaretm, mydea and nicohrubec and removed request for a team and JPeer264June 10, 2026 08:22

@logaretmlogaretm 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 threadpackages/core/src/tracing/idleSpan.ts
Comment threadpackages/core/src/tracing/trace.ts

@isaacsisaacs 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 threadpackages/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
MemberAuthor

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
MemberAuthor

Choose a reason for hiding this comment

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

Updated in 07dfbf4

Comment threadpackages/core/src/tracing/trace.ts Outdated
Comment threadpackages/core/src/utils/spanUtils.ts Outdated
…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
andreiborzaforce-pushed the ab/nonrecording-span-sampling branch from b539317 to 07dfbf4CompareJune 12, 2026 07:39

@Lms24Lms24 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
MemberAuthor

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.

Comment threadpackages/core/src/types/span.ts Outdated
* 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.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Right, I walked back on this and go for the same _sampled + tracestate flag approach to be in line with SentrySpan.

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?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Added in d5887e7

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.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Updated in d5887e7.

// 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?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Right, renamed to freezeDscOnRootSpanWithoutSampling in ba51d24

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

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

I reworked this to be in line with SentrySpan, using _sampled + a flag in trace state and using the same helpers across both span types in ba51d24

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?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Updated in ba51d24, if I understand your comment correctly 😅

Comment threadpackages/core/src/tracing/trace.ts Outdated
...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?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Updated in ba51d24

@andreiborza
andreiborzaforce-pushed the ab/nonrecording-span-sampling branch from ca1ccfb to ba51d24CompareJune 14, 2026 15:07
@andreiborza

Copy link
Copy Markdown
MemberAuthor

bugbot run

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

Comment threadpackages/core/src/tracing/trace.ts
@andreiborza

Copy link
Copy Markdown
MemberAuthor

Closing in favor of #21549 which keeps SentryNonRecordingSpan a thin container and uses the scope to handle the sampling decision in cases where it's needed.

@JPeer264
JPeer264 deleted the ab/nonrecording-span-sampling branch June 16, 2026 05:26
andreiborza added a commit that referenced this pull request Jun 17, 2026
…21549)
In Tracing-without-Performance (spans disabled), a root placeholder
previously froze a negative sampling decision in the DSC, which
suppressed downstream sampling instead of leaving the decision to a
performance-enabled service further along the trace.
The scope is the source of truth for a TwP placeholder's trace state:
- `getTraceData` reads the sampling decision from the scope (deferred
for a new trace, the upstream decision for a
continued trace), so the outgoing `sentry-trace` header omits the flag
instead of asserting `-0`. The span id comes from the scope's
`propagationSpanId` (a fresh id is generated when the scope has none).
- `getDynamicSamplingContextFromSpan` resolves a placeholder's DSC from
its captured scope (continued traces keep the incoming DSC; new traces
derive it from the client).
The scope is only consulted for genuine TwP placeholders. A
non-recording span in tracing mode, the child of an unsampled span, or
an ignored span carries an explicit negative decision and keeps
propagating `-0` via `spanToTraceHeader`.
A new (head-of-trace) TwP trace does not stamp a local `transaction` in
its DSC; continued traces still propagate the upstream decision and DSC.
No DSC is written to the scope at span start, preserving the browser's
"scope stays DSC-free between navigations" behavior.
This is an alternative to
#21406
Sign up for freeto 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.

6 participants

@andreiborza@isaacs@mydea@logaretm@Lms24@chargome
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

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

Closed
andreiborza wants to merge 15 commits into
developfrom
ab/nonrecording-span-sampling
Closed

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

Conversation

@andreiborza

@andreiborzaandreiborza 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 threadpackages/core/src/utils/spanUtils.ts Outdated
@github-actions

github-actionsBot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

size-limit report 📦

PathSize% ChangeChange
@sentry/browser27.41 kB+0.02%+4 B 🔺
@sentry/browser - with treeshaking flags25.84 kB+0.02%+4 B 🔺
@sentry/browser (incl. Tracing)46.23 kB+1.16%+530 B 🔺
@sentry/browser (incl. Tracing + Span Streaming)48.48 kB+1.14%+543 B 🔺
@sentry/browser (incl. Tracing, Profiling)51.01 kB+1.01%+507 B 🔺
@sentry/browser (incl. Tracing, Replay)85.42 kB+0.59%+501 B 🔺
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags75.03 kB+0.68%+503 B 🔺
@sentry/browser (incl. Tracing, Replay with Canvas)90.12 kB+0.57%+508 B 🔺
@sentry/browser (incl. Tracing, Replay, Feedback)102.8 kB+0.5%+503 B 🔺
@sentry/browser (incl. Feedback)44.57 kB+0.02%+5 B 🔺
@sentry/browser (incl. sendFeedback)32.21 kB+0.02%+4 B 🔺
@sentry/browser (incl. FeedbackAsync)37.32 kB+0.03%+8 B 🔺
@sentry/browser (incl. Metrics)28.48 kB+0.04%+10 B 🔺
@sentry/browser (incl. Logs)28.71 kB+0.03%+8 B 🔺
@sentry/browser (incl. Metrics & Logs)29.42 kB+0.06%+15 B 🔺
@sentry/react29.21 kB+0.04%+9 B 🔺
@sentry/react (incl. Tracing)48.53 kB+1.12%+533 B 🔺
@sentry/vue32.9 kB+1.48%+477 B 🔺
@sentry/vue (incl. Tracing)48.13 kB+1.14%+542 B 🔺
@sentry/svelte27.43 kB+0.02%+4 B 🔺
CDN Bundle29.89 kB+0.34%+100 B 🔺
CDN Bundle (incl. Tracing)48.74 kB+1.14%+547 B 🔺
CDN Bundle (incl. Logs, Metrics)31.43 kB+0.33%+102 B 🔺
CDN Bundle (incl. Tracing, Logs, Metrics)50.03 kB+1.09%+536 B 🔺
CDN Bundle (incl. Replay, Logs, Metrics)70.73 kB+0.15%+103 B 🔺
CDN Bundle (incl. Tracing, Replay)86.03 kB+0.6%+507 B 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics)87.35 kB+0.67%+575 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback)91.9 kB+0.59%+530 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics)93.17 kB+0.6%+554 B 🔺
CDN Bundle - uncompressed88.85 kB+0.3%+261 B 🔺
CDN Bundle (incl. Tracing) - uncompressed147.64 kB+1.27%+1.84 kB 🔺
CDN Bundle (incl. Logs, Metrics) - uncompressed93.55 kB+0.28%+261 B 🔺
CDN Bundle (incl. Tracing, Logs, Metrics) - uncompressed151.61 kB+1.23%+1.84 kB 🔺
CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed218.38 kB+0.12%+261 B 🔺
CDN Bundle (incl. Tracing, Replay) - uncompressed266.52 kB+0.7%+1.85 kB 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed270.48 kB+0.69%+1.85 kB 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed280.22 kB+0.67%+1.85 kB 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) - uncompressed284.17 kB+0.66%+1.85 kB 🔺
@sentry/nextjs (client)50.93 kB+0.95%+479 B 🔺
@sentry/sveltekit (client)46.64 kB+1.13%+521 B 🔺
@sentry/core/server76.53 kB+0.6%+456 B 🔺
@sentry/core/browser63.68 kB+0.74%+462 B 🔺
@sentry/node-core61.98 kB+0.42%+256 B 🔺
@sentry/node130.76 kB+0.19%+240 B 🔺
@sentry/node - without tracing74.36 kB+0.35%+253 B 🔺
@sentry/aws-serverless86.52 kB+0.27%+229 B 🔺
@sentry/cloudflare (withSentry) - minified175.72 kB+1.17%+2.03 kB 🔺
@sentry/cloudflare (withSentry)438.89 kB+1.17%+5.04 kB 🔺

View base workflow run

Comment threadpackages/core/src/tracing/trace.ts Outdated
Comment threadpackages/core/src/tracing/trace.ts
Comment threadpackages/core/src/tracing/trace.ts Outdated
@andreiborza
andreiborzaforce-pushed the ab/nonrecording-span-sampling branch from 9a455d0 to 69d87c1CompareJune 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
MemberAuthor

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 ownerJune 10, 2026 08:22
@andreiborza
andreiborza requested review from JPeer264, Lms24, chargome, logaretm, mydea and nicohrubec and removed request for a team and JPeer264June 10, 2026 08:22

@logaretmlogaretm 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 threadpackages/core/src/tracing/idleSpan.ts
Comment threadpackages/core/src/tracing/trace.ts

@isaacsisaacs 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 threadpackages/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
MemberAuthor

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
MemberAuthor

Choose a reason for hiding this comment

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

Updated in 07dfbf4

Comment threadpackages/core/src/tracing/trace.ts Outdated
Comment threadpackages/core/src/utils/spanUtils.ts Outdated
…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
andreiborzaforce-pushed the ab/nonrecording-span-sampling branch from b539317 to 07dfbf4CompareJune 12, 2026 07:39

@Lms24Lms24 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
MemberAuthor

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.

Comment threadpackages/core/src/types/span.ts Outdated
* 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.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Right, I walked back on this and go for the same _sampled + tracestate flag approach to be in line with SentrySpan.

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?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Added in d5887e7

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.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Updated in d5887e7.

// 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?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Right, renamed to freezeDscOnRootSpanWithoutSampling in ba51d24

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

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

I reworked this to be in line with SentrySpan, using _sampled + a flag in trace state and using the same helpers across both span types in ba51d24

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?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Updated in ba51d24, if I understand your comment correctly 😅

Comment threadpackages/core/src/tracing/trace.ts Outdated
...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?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Updated in ba51d24

@andreiborza
andreiborzaforce-pushed the ab/nonrecording-span-sampling branch from ca1ccfb to ba51d24CompareJune 14, 2026 15:07
@andreiborza

Copy link
Copy Markdown
MemberAuthor

bugbot run

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

Comment threadpackages/core/src/tracing/trace.ts
@andreiborza

Copy link
Copy Markdown
MemberAuthor

Closing in favor of #21549 which keeps SentryNonRecordingSpan a thin container and uses the scope to handle the sampling decision in cases where it's needed.

@JPeer264
JPeer264 deleted the ab/nonrecording-span-sampling branch June 16, 2026 05:26
andreiborza added a commit that referenced this pull request Jun 17, 2026
…21549)
In Tracing-without-Performance (spans disabled), a root placeholder
previously froze a negative sampling decision in the DSC, which
suppressed downstream sampling instead of leaving the decision to a
performance-enabled service further along the trace.
The scope is the source of truth for a TwP placeholder's trace state:
- `getTraceData` reads the sampling decision from the scope (deferred
for a new trace, the upstream decision for a
continued trace), so the outgoing `sentry-trace` header omits the flag
instead of asserting `-0`. The span id comes from the scope's
`propagationSpanId` (a fresh id is generated when the scope has none).
- `getDynamicSamplingContextFromSpan` resolves a placeholder's DSC from
its captured scope (continued traces keep the incoming DSC; new traces
derive it from the client).
The scope is only consulted for genuine TwP placeholders. A
non-recording span in tracing mode, the child of an unsampled span, or
an ignored span carries an explicit negative decision and keeps
propagating `-0` via `spanToTraceHeader`.
A new (head-of-trace) TwP trace does not stamp a local `transaction` in
its DSC; continued traces still propagate the upstream decision and DSC.
No DSC is written to the scope at span start, preserving the browser's
"scope stays DSC-free between navigations" behavior.
This is an alternative to
#21406
Sign up for freeto 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.

6 participants

@andreiborza@isaacs@mydea@logaretm@Lms24@chargome
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

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

Closed
andreiborza wants to merge 15 commits into
developfrom
ab/nonrecording-span-sampling
Closed

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

Conversation

@andreiborza

@andreiborzaandreiborza 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 threadpackages/core/src/utils/spanUtils.ts Outdated
@github-actions

github-actionsBot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

size-limit report 📦

PathSize% ChangeChange
@sentry/browser27.41 kB+0.02%+4 B 🔺
@sentry/browser - with treeshaking flags25.84 kB+0.02%+4 B 🔺
@sentry/browser (incl. Tracing)46.23 kB+1.16%+530 B 🔺
@sentry/browser (incl. Tracing + Span Streaming)48.48 kB+1.14%+543 B 🔺
@sentry/browser (incl. Tracing, Profiling)51.01 kB+1.01%+507 B 🔺
@sentry/browser (incl. Tracing, Replay)85.42 kB+0.59%+501 B 🔺
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags75.03 kB+0.68%+503 B 🔺
@sentry/browser (incl. Tracing, Replay with Canvas)90.12 kB+0.57%+508 B 🔺
@sentry/browser (incl. Tracing, Replay, Feedback)102.8 kB+0.5%+503 B 🔺
@sentry/browser (incl. Feedback)44.57 kB+0.02%+5 B 🔺
@sentry/browser (incl. sendFeedback)32.21 kB+0.02%+4 B 🔺
@sentry/browser (incl. FeedbackAsync)37.32 kB+0.03%+8 B 🔺
@sentry/browser (incl. Metrics)28.48 kB+0.04%+10 B 🔺
@sentry/browser (incl. Logs)28.71 kB+0.03%+8 B 🔺
@sentry/browser (incl. Metrics & Logs)29.42 kB+0.06%+15 B 🔺
@sentry/react29.21 kB+0.04%+9 B 🔺
@sentry/react (incl. Tracing)48.53 kB+1.12%+533 B 🔺
@sentry/vue32.9 kB+1.48%+477 B 🔺
@sentry/vue (incl. Tracing)48.13 kB+1.14%+542 B 🔺
@sentry/svelte27.43 kB+0.02%+4 B 🔺
CDN Bundle29.89 kB+0.34%+100 B 🔺
CDN Bundle (incl. Tracing)48.74 kB+1.14%+547 B 🔺
CDN Bundle (incl. Logs, Metrics)31.43 kB+0.33%+102 B 🔺
CDN Bundle (incl. Tracing, Logs, Metrics)50.03 kB+1.09%+536 B 🔺
CDN Bundle (incl. Replay, Logs, Metrics)70.73 kB+0.15%+103 B 🔺
CDN Bundle (incl. Tracing, Replay)86.03 kB+0.6%+507 B 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics)87.35 kB+0.67%+575 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback)91.9 kB+0.59%+530 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics)93.17 kB+0.6%+554 B 🔺
CDN Bundle - uncompressed88.85 kB+0.3%+261 B 🔺
CDN Bundle (incl. Tracing) - uncompressed147.64 kB+1.27%+1.84 kB 🔺
CDN Bundle (incl. Logs, Metrics) - uncompressed93.55 kB+0.28%+261 B 🔺
CDN Bundle (incl. Tracing, Logs, Metrics) - uncompressed151.61 kB+1.23%+1.84 kB 🔺
CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed218.38 kB+0.12%+261 B 🔺
CDN Bundle (incl. Tracing, Replay) - uncompressed266.52 kB+0.7%+1.85 kB 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed270.48 kB+0.69%+1.85 kB 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed280.22 kB+0.67%+1.85 kB 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) - uncompressed284.17 kB+0.66%+1.85 kB 🔺
@sentry/nextjs (client)50.93 kB+0.95%+479 B 🔺
@sentry/sveltekit (client)46.64 kB+1.13%+521 B 🔺
@sentry/core/server76.53 kB+0.6%+456 B 🔺
@sentry/core/browser63.68 kB+0.74%+462 B 🔺
@sentry/node-core61.98 kB+0.42%+256 B 🔺
@sentry/node130.76 kB+0.19%+240 B 🔺
@sentry/node - without tracing74.36 kB+0.35%+253 B 🔺
@sentry/aws-serverless86.52 kB+0.27%+229 B 🔺
@sentry/cloudflare (withSentry) - minified175.72 kB+1.17%+2.03 kB 🔺
@sentry/cloudflare (withSentry)438.89 kB+1.17%+5.04 kB 🔺

View base workflow run

Comment threadpackages/core/src/tracing/trace.ts Outdated
Comment threadpackages/core/src/tracing/trace.ts
Comment threadpackages/core/src/tracing/trace.ts Outdated
@andreiborza
andreiborzaforce-pushed the ab/nonrecording-span-sampling branch from 9a455d0 to 69d87c1CompareJune 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
MemberAuthor

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 ownerJune 10, 2026 08:22
@andreiborza
andreiborza requested review from JPeer264, Lms24, chargome, logaretm, mydea and nicohrubec and removed request for a team and JPeer264June 10, 2026 08:22

@logaretmlogaretm 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 threadpackages/core/src/tracing/idleSpan.ts
Comment threadpackages/core/src/tracing/trace.ts

@isaacsisaacs 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 threadpackages/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
MemberAuthor

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
MemberAuthor

Choose a reason for hiding this comment

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

Updated in 07dfbf4

Comment threadpackages/core/src/tracing/trace.ts Outdated
Comment threadpackages/core/src/utils/spanUtils.ts Outdated
…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
andreiborzaforce-pushed the ab/nonrecording-span-sampling branch from b539317 to 07dfbf4CompareJune 12, 2026 07:39

@Lms24Lms24 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
MemberAuthor

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.

Comment threadpackages/core/src/types/span.ts Outdated
* 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.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Right, I walked back on this and go for the same _sampled + tracestate flag approach to be in line with SentrySpan.

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?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Added in d5887e7

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.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Updated in d5887e7.

// 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?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Right, renamed to freezeDscOnRootSpanWithoutSampling in ba51d24

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

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

I reworked this to be in line with SentrySpan, using _sampled + a flag in trace state and using the same helpers across both span types in ba51d24

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?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Updated in ba51d24, if I understand your comment correctly 😅

Comment threadpackages/core/src/tracing/trace.ts Outdated
...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?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Updated in ba51d24

@andreiborza
andreiborzaforce-pushed the ab/nonrecording-span-sampling branch from ca1ccfb to ba51d24CompareJune 14, 2026 15:07
@andreiborza

Copy link
Copy Markdown
MemberAuthor

bugbot run

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

Comment threadpackages/core/src/tracing/trace.ts
@andreiborza

Copy link
Copy Markdown
MemberAuthor

Closing in favor of #21549 which keeps SentryNonRecordingSpan a thin container and uses the scope to handle the sampling decision in cases where it's needed.

@JPeer264
JPeer264 deleted the ab/nonrecording-span-sampling branch June 16, 2026 05:26
andreiborza added a commit that referenced this pull request Jun 17, 2026
…21549)
In Tracing-without-Performance (spans disabled), a root placeholder
previously froze a negative sampling decision in the DSC, which
suppressed downstream sampling instead of leaving the decision to a
performance-enabled service further along the trace.
The scope is the source of truth for a TwP placeholder's trace state:
- `getTraceData` reads the sampling decision from the scope (deferred
for a new trace, the upstream decision for a
continued trace), so the outgoing `sentry-trace` header omits the flag
instead of asserting `-0`. The span id comes from the scope's
`propagationSpanId` (a fresh id is generated when the scope has none).
- `getDynamicSamplingContextFromSpan` resolves a placeholder's DSC from
its captured scope (continued traces keep the incoming DSC; new traces
derive it from the client).
The scope is only consulted for genuine TwP placeholders. A
non-recording span in tracing mode, the child of an unsampled span, or
an ignored span carries an explicit negative decision and keeps
propagating `-0` via `spanToTraceHeader`.
A new (head-of-trace) TwP trace does not stamp a local `transaction` in
its DSC; continued traces still propagate the upstream decision and DSC.
No DSC is written to the scope at span start, preserving the browser's
"scope stays DSC-free between navigations" behavior.
This is an alternative to
#21406
Sign up for freeto 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.

6 participants

@andreiborza@isaacs@mydea@logaretm@Lms24@chargome
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

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

Closed
andreiborza wants to merge 15 commits into
developfrom
ab/nonrecording-span-sampling
Closed

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

Conversation

@andreiborza

@andreiborzaandreiborza 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 threadpackages/core/src/utils/spanUtils.ts Outdated
@github-actions

github-actionsBot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

size-limit report 📦

PathSize% ChangeChange
@sentry/browser27.41 kB+0.02%+4 B 🔺
@sentry/browser - with treeshaking flags25.84 kB+0.02%+4 B 🔺
@sentry/browser (incl. Tracing)46.23 kB+1.16%+530 B 🔺
@sentry/browser (incl. Tracing + Span Streaming)48.48 kB+1.14%+543 B 🔺
@sentry/browser (incl. Tracing, Profiling)51.01 kB+1.01%+507 B 🔺
@sentry/browser (incl. Tracing, Replay)85.42 kB+0.59%+501 B 🔺
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags75.03 kB+0.68%+503 B 🔺
@sentry/browser (incl. Tracing, Replay with Canvas)90.12 kB+0.57%+508 B 🔺
@sentry/browser (incl. Tracing, Replay, Feedback)102.8 kB+0.5%+503 B 🔺
@sentry/browser (incl. Feedback)44.57 kB+0.02%+5 B 🔺
@sentry/browser (incl. sendFeedback)32.21 kB+0.02%+4 B 🔺
@sentry/browser (incl. FeedbackAsync)37.32 kB+0.03%+8 B 🔺
@sentry/browser (incl. Metrics)28.48 kB+0.04%+10 B 🔺
@sentry/browser (incl. Logs)28.71 kB+0.03%+8 B 🔺
@sentry/browser (incl. Metrics & Logs)29.42 kB+0.06%+15 B 🔺
@sentry/react29.21 kB+0.04%+9 B 🔺
@sentry/react (incl. Tracing)48.53 kB+1.12%+533 B 🔺
@sentry/vue32.9 kB+1.48%+477 B 🔺
@sentry/vue (incl. Tracing)48.13 kB+1.14%+542 B 🔺
@sentry/svelte27.43 kB+0.02%+4 B 🔺
CDN Bundle29.89 kB+0.34%+100 B 🔺
CDN Bundle (incl. Tracing)48.74 kB+1.14%+547 B 🔺
CDN Bundle (incl. Logs, Metrics)31.43 kB+0.33%+102 B 🔺
CDN Bundle (incl. Tracing, Logs, Metrics)50.03 kB+1.09%+536 B 🔺
CDN Bundle (incl. Replay, Logs, Metrics)70.73 kB+0.15%+103 B 🔺
CDN Bundle (incl. Tracing, Replay)86.03 kB+0.6%+507 B 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics)87.35 kB+0.67%+575 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback)91.9 kB+0.59%+530 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics)93.17 kB+0.6%+554 B 🔺
CDN Bundle - uncompressed88.85 kB+0.3%+261 B 🔺
CDN Bundle (incl. Tracing) - uncompressed147.64 kB+1.27%+1.84 kB 🔺
CDN Bundle (incl. Logs, Metrics) - uncompressed93.55 kB+0.28%+261 B 🔺
CDN Bundle (incl. Tracing, Logs, Metrics) - uncompressed151.61 kB+1.23%+1.84 kB 🔺
CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed218.38 kB+0.12%+261 B 🔺
CDN Bundle (incl. Tracing, Replay) - uncompressed266.52 kB+0.7%+1.85 kB 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed270.48 kB+0.69%+1.85 kB 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed280.22 kB+0.67%+1.85 kB 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) - uncompressed284.17 kB+0.66%+1.85 kB 🔺
@sentry/nextjs (client)50.93 kB+0.95%+479 B 🔺
@sentry/sveltekit (client)46.64 kB+1.13%+521 B 🔺
@sentry/core/server76.53 kB+0.6%+456 B 🔺
@sentry/core/browser63.68 kB+0.74%+462 B 🔺
@sentry/node-core61.98 kB+0.42%+256 B 🔺
@sentry/node130.76 kB+0.19%+240 B 🔺
@sentry/node - without tracing74.36 kB+0.35%+253 B 🔺
@sentry/aws-serverless86.52 kB+0.27%+229 B 🔺
@sentry/cloudflare (withSentry) - minified175.72 kB+1.17%+2.03 kB 🔺
@sentry/cloudflare (withSentry)438.89 kB+1.17%+5.04 kB 🔺

View base workflow run

Comment threadpackages/core/src/tracing/trace.ts Outdated
Comment threadpackages/core/src/tracing/trace.ts
Comment threadpackages/core/src/tracing/trace.ts Outdated
@andreiborza
andreiborzaforce-pushed the ab/nonrecording-span-sampling branch from 9a455d0 to 69d87c1CompareJune 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
MemberAuthor

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 ownerJune 10, 2026 08:22
@andreiborza
andreiborza requested review from JPeer264, Lms24, chargome, logaretm, mydea and nicohrubec and removed request for a team and JPeer264June 10, 2026 08:22

@logaretmlogaretm 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 threadpackages/core/src/tracing/idleSpan.ts
Comment threadpackages/core/src/tracing/trace.ts

@isaacsisaacs 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 threadpackages/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
MemberAuthor

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
MemberAuthor

Choose a reason for hiding this comment

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

Updated in 07dfbf4

Comment threadpackages/core/src/tracing/trace.ts Outdated
Comment threadpackages/core/src/utils/spanUtils.ts Outdated
…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
andreiborzaforce-pushed the ab/nonrecording-span-sampling branch from b539317 to 07dfbf4CompareJune 12, 2026 07:39

@Lms24Lms24 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
MemberAuthor

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.

Comment threadpackages/core/src/types/span.ts Outdated
* 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.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Right, I walked back on this and go for the same _sampled + tracestate flag approach to be in line with SentrySpan.

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?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Added in d5887e7

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.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Updated in d5887e7.

// 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?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Right, renamed to freezeDscOnRootSpanWithoutSampling in ba51d24

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

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

I reworked this to be in line with SentrySpan, using _sampled + a flag in trace state and using the same helpers across both span types in ba51d24

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?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Updated in ba51d24, if I understand your comment correctly 😅

Comment threadpackages/core/src/tracing/trace.ts Outdated
...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?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Updated in ba51d24

@andreiborza
andreiborzaforce-pushed the ab/nonrecording-span-sampling branch from ca1ccfb to ba51d24CompareJune 14, 2026 15:07
@andreiborza

Copy link
Copy Markdown
MemberAuthor

bugbot run

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

Comment threadpackages/core/src/tracing/trace.ts
@andreiborza

Copy link
Copy Markdown
MemberAuthor

Closing in favor of #21549 which keeps SentryNonRecordingSpan a thin container and uses the scope to handle the sampling decision in cases where it's needed.

@JPeer264
JPeer264 deleted the ab/nonrecording-span-sampling branch June 16, 2026 05:26
andreiborza added a commit that referenced this pull request Jun 17, 2026
…21549)
In Tracing-without-Performance (spans disabled), a root placeholder
previously froze a negative sampling decision in the DSC, which
suppressed downstream sampling instead of leaving the decision to a
performance-enabled service further along the trace.
The scope is the source of truth for a TwP placeholder's trace state:
- `getTraceData` reads the sampling decision from the scope (deferred
for a new trace, the upstream decision for a
continued trace), so the outgoing `sentry-trace` header omits the flag
instead of asserting `-0`. The span id comes from the scope's
`propagationSpanId` (a fresh id is generated when the scope has none).
- `getDynamicSamplingContextFromSpan` resolves a placeholder's DSC from
its captured scope (continued traces keep the incoming DSC; new traces
derive it from the client).
The scope is only consulted for genuine TwP placeholders. A
non-recording span in tracing mode, the child of an unsampled span, or
an ignored span carries an explicit negative decision and keeps
propagating `-0` via `spanToTraceHeader`.
A new (head-of-trace) TwP trace does not stamp a local `transaction` in
its DSC; continued traces still propagate the upstream decision and DSC.
No DSC is written to the scope at span start, preserving the browser's
"scope stays DSC-free between navigations" behavior.
This is an alternative to
#21406
Sign up for freeto 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.

6 participants

@andreiborza@isaacs@mydea@logaretm@Lms24@chargome
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

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

Closed
andreiborza wants to merge 15 commits into
developfrom
ab/nonrecording-span-sampling
Closed

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

Conversation

@andreiborza

@andreiborzaandreiborza 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 threadpackages/core/src/utils/spanUtils.ts Outdated
@github-actions

github-actionsBot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

size-limit report 📦

PathSize% ChangeChange
@sentry/browser27.41 kB+0.02%+4 B 🔺
@sentry/browser - with treeshaking flags25.84 kB+0.02%+4 B 🔺
@sentry/browser (incl. Tracing)46.23 kB+1.16%+530 B 🔺
@sentry/browser (incl. Tracing + Span Streaming)48.48 kB+1.14%+543 B 🔺
@sentry/browser (incl. Tracing, Profiling)51.01 kB+1.01%+507 B 🔺
@sentry/browser (incl. Tracing, Replay)85.42 kB+0.59%+501 B 🔺
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags75.03 kB+0.68%+503 B 🔺
@sentry/browser (incl. Tracing, Replay with Canvas)90.12 kB+0.57%+508 B 🔺
@sentry/browser (incl. Tracing, Replay, Feedback)102.8 kB+0.5%+503 B 🔺
@sentry/browser (incl. Feedback)44.57 kB+0.02%+5 B 🔺
@sentry/browser (incl. sendFeedback)32.21 kB+0.02%+4 B 🔺
@sentry/browser (incl. FeedbackAsync)37.32 kB+0.03%+8 B 🔺
@sentry/browser (incl. Metrics)28.48 kB+0.04%+10 B 🔺
@sentry/browser (incl. Logs)28.71 kB+0.03%+8 B 🔺
@sentry/browser (incl. Metrics & Logs)29.42 kB+0.06%+15 B 🔺
@sentry/react29.21 kB+0.04%+9 B 🔺
@sentry/react (incl. Tracing)48.53 kB+1.12%+533 B 🔺
@sentry/vue32.9 kB+1.48%+477 B 🔺
@sentry/vue (incl. Tracing)48.13 kB+1.14%+542 B 🔺
@sentry/svelte27.43 kB+0.02%+4 B 🔺
CDN Bundle29.89 kB+0.34%+100 B 🔺
CDN Bundle (incl. Tracing)48.74 kB+1.14%+547 B 🔺
CDN Bundle (incl. Logs, Metrics)31.43 kB+0.33%+102 B 🔺
CDN Bundle (incl. Tracing, Logs, Metrics)50.03 kB+1.09%+536 B 🔺
CDN Bundle (incl. Replay, Logs, Metrics)70.73 kB+0.15%+103 B 🔺
CDN Bundle (incl. Tracing, Replay)86.03 kB+0.6%+507 B 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics)87.35 kB+0.67%+575 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback)91.9 kB+0.59%+530 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics)93.17 kB+0.6%+554 B 🔺
CDN Bundle - uncompressed88.85 kB+0.3%+261 B 🔺
CDN Bundle (incl. Tracing) - uncompressed147.64 kB+1.27%+1.84 kB 🔺
CDN Bundle (incl. Logs, Metrics) - uncompressed93.55 kB+0.28%+261 B 🔺
CDN Bundle (incl. Tracing, Logs, Metrics) - uncompressed151.61 kB+1.23%+1.84 kB 🔺
CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed218.38 kB+0.12%+261 B 🔺
CDN Bundle (incl. Tracing, Replay) - uncompressed266.52 kB+0.7%+1.85 kB 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed270.48 kB+0.69%+1.85 kB 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed280.22 kB+0.67%+1.85 kB 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) - uncompressed284.17 kB+0.66%+1.85 kB 🔺
@sentry/nextjs (client)50.93 kB+0.95%+479 B 🔺
@sentry/sveltekit (client)46.64 kB+1.13%+521 B 🔺
@sentry/core/server76.53 kB+0.6%+456 B 🔺
@sentry/core/browser63.68 kB+0.74%+462 B 🔺
@sentry/node-core61.98 kB+0.42%+256 B 🔺
@sentry/node130.76 kB+0.19%+240 B 🔺
@sentry/node - without tracing74.36 kB+0.35%+253 B 🔺
@sentry/aws-serverless86.52 kB+0.27%+229 B 🔺
@sentry/cloudflare (withSentry) - minified175.72 kB+1.17%+2.03 kB 🔺
@sentry/cloudflare (withSentry)438.89 kB+1.17%+5.04 kB 🔺

View base workflow run

Comment threadpackages/core/src/tracing/trace.ts Outdated
Comment threadpackages/core/src/tracing/trace.ts
Comment threadpackages/core/src/tracing/trace.ts Outdated
@andreiborza
andreiborzaforce-pushed the ab/nonrecording-span-sampling branch from 9a455d0 to 69d87c1CompareJune 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
MemberAuthor

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 ownerJune 10, 2026 08:22
@andreiborza
andreiborza requested review from JPeer264, Lms24, chargome, logaretm, mydea and nicohrubec and removed request for a team and JPeer264June 10, 2026 08:22

@logaretmlogaretm 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 threadpackages/core/src/tracing/idleSpan.ts
Comment threadpackages/core/src/tracing/trace.ts

@isaacsisaacs 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 threadpackages/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
MemberAuthor

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
MemberAuthor

Choose a reason for hiding this comment

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

Updated in 07dfbf4

Comment threadpackages/core/src/tracing/trace.ts Outdated
Comment threadpackages/core/src/utils/spanUtils.ts Outdated
…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
andreiborzaforce-pushed the ab/nonrecording-span-sampling branch from b539317 to 07dfbf4CompareJune 12, 2026 07:39

@Lms24Lms24 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
MemberAuthor

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.

Comment threadpackages/core/src/types/span.ts Outdated
* 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.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Right, I walked back on this and go for the same _sampled + tracestate flag approach to be in line with SentrySpan.

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?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Added in d5887e7

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.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Updated in d5887e7.

// 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?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Right, renamed to freezeDscOnRootSpanWithoutSampling in ba51d24

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

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

I reworked this to be in line with SentrySpan, using _sampled + a flag in trace state and using the same helpers across both span types in ba51d24

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?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Updated in ba51d24, if I understand your comment correctly 😅

Comment threadpackages/core/src/tracing/trace.ts Outdated
...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?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Updated in ba51d24

@andreiborza
andreiborzaforce-pushed the ab/nonrecording-span-sampling branch from ca1ccfb to ba51d24CompareJune 14, 2026 15:07
@andreiborza

Copy link
Copy Markdown
MemberAuthor

bugbot run

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

Comment threadpackages/core/src/tracing/trace.ts
@andreiborza

Copy link
Copy Markdown
MemberAuthor

Closing in favor of #21549 which keeps SentryNonRecordingSpan a thin container and uses the scope to handle the sampling decision in cases where it's needed.

@JPeer264
JPeer264 deleted the ab/nonrecording-span-sampling branch June 16, 2026 05:26
andreiborza added a commit that referenced this pull request Jun 17, 2026
…21549)
In Tracing-without-Performance (spans disabled), a root placeholder
previously froze a negative sampling decision in the DSC, which
suppressed downstream sampling instead of leaving the decision to a
performance-enabled service further along the trace.
The scope is the source of truth for a TwP placeholder's trace state:
- `getTraceData` reads the sampling decision from the scope (deferred
for a new trace, the upstream decision for a
continued trace), so the outgoing `sentry-trace` header omits the flag
instead of asserting `-0`. The span id comes from the scope's
`propagationSpanId` (a fresh id is generated when the scope has none).
- `getDynamicSamplingContextFromSpan` resolves a placeholder's DSC from
its captured scope (continued traces keep the incoming DSC; new traces
derive it from the client).
The scope is only consulted for genuine TwP placeholders. A
non-recording span in tracing mode, the child of an unsampled span, or
an ignored span carries an explicit negative decision and keeps
propagating `-0` via `spanToTraceHeader`.
A new (head-of-trace) TwP trace does not stamp a local `transaction` in
its DSC; continued traces still propagate the upstream decision and DSC.
No DSC is written to the scope at span start, preserving the browser's
"scope stays DSC-free between navigations" behavior.
This is an alternative to
#21406
Sign up for freeto 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.

6 participants

@andreiborza@isaacs@mydea@logaretm@Lms24@chargome
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

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

Closed
andreiborza wants to merge 15 commits into
developfrom
ab/nonrecording-span-sampling
Closed

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

Conversation

@andreiborza

@andreiborzaandreiborza 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 threadpackages/core/src/utils/spanUtils.ts Outdated
@github-actions

github-actionsBot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

size-limit report 📦

PathSize% ChangeChange
@sentry/browser27.41 kB+0.02%+4 B 🔺
@sentry/browser - with treeshaking flags25.84 kB+0.02%+4 B 🔺
@sentry/browser (incl. Tracing)46.23 kB+1.16%+530 B 🔺
@sentry/browser (incl. Tracing + Span Streaming)48.48 kB+1.14%+543 B 🔺
@sentry/browser (incl. Tracing, Profiling)51.01 kB+1.01%+507 B 🔺
@sentry/browser (incl. Tracing, Replay)85.42 kB+0.59%+501 B 🔺
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags75.03 kB+0.68%+503 B 🔺
@sentry/browser (incl. Tracing, Replay with Canvas)90.12 kB+0.57%+508 B 🔺
@sentry/browser (incl. Tracing, Replay, Feedback)102.8 kB+0.5%+503 B 🔺
@sentry/browser (incl. Feedback)44.57 kB+0.02%+5 B 🔺
@sentry/browser (incl. sendFeedback)32.21 kB+0.02%+4 B 🔺
@sentry/browser (incl. FeedbackAsync)37.32 kB+0.03%+8 B 🔺
@sentry/browser (incl. Metrics)28.48 kB+0.04%+10 B 🔺
@sentry/browser (incl. Logs)28.71 kB+0.03%+8 B 🔺
@sentry/browser (incl. Metrics & Logs)29.42 kB+0.06%+15 B 🔺
@sentry/react29.21 kB+0.04%+9 B 🔺
@sentry/react (incl. Tracing)48.53 kB+1.12%+533 B 🔺
@sentry/vue32.9 kB+1.48%+477 B 🔺
@sentry/vue (incl. Tracing)48.13 kB+1.14%+542 B 🔺
@sentry/svelte27.43 kB+0.02%+4 B 🔺
CDN Bundle29.89 kB+0.34%+100 B 🔺
CDN Bundle (incl. Tracing)48.74 kB+1.14%+547 B 🔺
CDN Bundle (incl. Logs, Metrics)31.43 kB+0.33%+102 B 🔺
CDN Bundle (incl. Tracing, Logs, Metrics)50.03 kB+1.09%+536 B 🔺
CDN Bundle (incl. Replay, Logs, Metrics)70.73 kB+0.15%+103 B 🔺
CDN Bundle (incl. Tracing, Replay)86.03 kB+0.6%+507 B 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics)87.35 kB+0.67%+575 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback)91.9 kB+0.59%+530 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics)93.17 kB+0.6%+554 B 🔺
CDN Bundle - uncompressed88.85 kB+0.3%+261 B 🔺
CDN Bundle (incl. Tracing) - uncompressed147.64 kB+1.27%+1.84 kB 🔺
CDN Bundle (incl. Logs, Metrics) - uncompressed93.55 kB+0.28%+261 B 🔺
CDN Bundle (incl. Tracing, Logs, Metrics) - uncompressed151.61 kB+1.23%+1.84 kB 🔺
CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed218.38 kB+0.12%+261 B 🔺
CDN Bundle (incl. Tracing, Replay) - uncompressed266.52 kB+0.7%+1.85 kB 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed270.48 kB+0.69%+1.85 kB 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed280.22 kB+0.67%+1.85 kB 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) - uncompressed284.17 kB+0.66%+1.85 kB 🔺
@sentry/nextjs (client)50.93 kB+0.95%+479 B 🔺
@sentry/sveltekit (client)46.64 kB+1.13%+521 B 🔺
@sentry/core/server76.53 kB+0.6%+456 B 🔺
@sentry/core/browser63.68 kB+0.74%+462 B 🔺
@sentry/node-core61.98 kB+0.42%+256 B 🔺
@sentry/node130.76 kB+0.19%+240 B 🔺
@sentry/node - without tracing74.36 kB+0.35%+253 B 🔺
@sentry/aws-serverless86.52 kB+0.27%+229 B 🔺
@sentry/cloudflare (withSentry) - minified175.72 kB+1.17%+2.03 kB 🔺
@sentry/cloudflare (withSentry)438.89 kB+1.17%+5.04 kB 🔺

View base workflow run

Comment threadpackages/core/src/tracing/trace.ts Outdated
Comment threadpackages/core/src/tracing/trace.ts
Comment threadpackages/core/src/tracing/trace.ts Outdated
@andreiborza
andreiborzaforce-pushed the ab/nonrecording-span-sampling branch from 9a455d0 to 69d87c1CompareJune 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
MemberAuthor

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 ownerJune 10, 2026 08:22
@andreiborza
andreiborza requested review from JPeer264, Lms24, chargome, logaretm, mydea and nicohrubec and removed request for a team and JPeer264June 10, 2026 08:22

@logaretmlogaretm 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 threadpackages/core/src/tracing/idleSpan.ts
Comment threadpackages/core/src/tracing/trace.ts

@isaacsisaacs 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 threadpackages/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
MemberAuthor

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
MemberAuthor

Choose a reason for hiding this comment

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

Updated in 07dfbf4

Comment threadpackages/core/src/tracing/trace.ts Outdated
Comment threadpackages/core/src/utils/spanUtils.ts Outdated
…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
andreiborzaforce-pushed the ab/nonrecording-span-sampling branch from b539317 to 07dfbf4CompareJune 12, 2026 07:39

@Lms24Lms24 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
MemberAuthor

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.

Comment threadpackages/core/src/types/span.ts Outdated
* 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.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Right, I walked back on this and go for the same _sampled + tracestate flag approach to be in line with SentrySpan.

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?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Added in d5887e7

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.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Updated in d5887e7.

// 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?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Right, renamed to freezeDscOnRootSpanWithoutSampling in ba51d24

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

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

I reworked this to be in line with SentrySpan, using _sampled + a flag in trace state and using the same helpers across both span types in ba51d24

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?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Updated in ba51d24, if I understand your comment correctly 😅

Comment threadpackages/core/src/tracing/trace.ts Outdated
...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?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Updated in ba51d24

@andreiborza
andreiborzaforce-pushed the ab/nonrecording-span-sampling branch from ca1ccfb to ba51d24CompareJune 14, 2026 15:07
@andreiborza

Copy link
Copy Markdown
MemberAuthor

bugbot run

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

Comment threadpackages/core/src/tracing/trace.ts
@andreiborza

Copy link
Copy Markdown
MemberAuthor

Closing in favor of #21549 which keeps SentryNonRecordingSpan a thin container and uses the scope to handle the sampling decision in cases where it's needed.

@JPeer264
JPeer264 deleted the ab/nonrecording-span-sampling branch June 16, 2026 05:26
andreiborza added a commit that referenced this pull request Jun 17, 2026
…21549)
In Tracing-without-Performance (spans disabled), a root placeholder
previously froze a negative sampling decision in the DSC, which
suppressed downstream sampling instead of leaving the decision to a
performance-enabled service further along the trace.
The scope is the source of truth for a TwP placeholder's trace state:
- `getTraceData` reads the sampling decision from the scope (deferred
for a new trace, the upstream decision for a
continued trace), so the outgoing `sentry-trace` header omits the flag
instead of asserting `-0`. The span id comes from the scope's
`propagationSpanId` (a fresh id is generated when the scope has none).
- `getDynamicSamplingContextFromSpan` resolves a placeholder's DSC from
its captured scope (continued traces keep the incoming DSC; new traces
derive it from the client).
The scope is only consulted for genuine TwP placeholders. A
non-recording span in tracing mode, the child of an unsampled span, or
an ignored span carries an explicit negative decision and keeps
propagating `-0` via `spanToTraceHeader`.
A new (head-of-trace) TwP trace does not stamp a local `transaction` in
its DSC; continued traces still propagate the upstream decision and DSC.
No DSC is written to the scope at span start, preserving the browser's
"scope stays DSC-free between navigations" behavior.
This is an alternative to
#21406
Sign up for freeto 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.

6 participants

@andreiborza@isaacs@mydea@logaretm@Lms24@chargome
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

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

Closed
andreiborza wants to merge 15 commits into
developfrom
ab/nonrecording-span-sampling
Closed

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

Conversation

@andreiborza

@andreiborzaandreiborza 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 threadpackages/core/src/utils/spanUtils.ts Outdated
@github-actions

github-actionsBot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

size-limit report 📦

PathSize% ChangeChange
@sentry/browser27.41 kB+0.02%+4 B 🔺
@sentry/browser - with treeshaking flags25.84 kB+0.02%+4 B 🔺
@sentry/browser (incl. Tracing)46.23 kB+1.16%+530 B 🔺
@sentry/browser (incl. Tracing + Span Streaming)48.48 kB+1.14%+543 B 🔺
@sentry/browser (incl. Tracing, Profiling)51.01 kB+1.01%+507 B 🔺
@sentry/browser (incl. Tracing, Replay)85.42 kB+0.59%+501 B 🔺
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags75.03 kB+0.68%+503 B 🔺
@sentry/browser (incl. Tracing, Replay with Canvas)90.12 kB+0.57%+508 B 🔺
@sentry/browser (incl. Tracing, Replay, Feedback)102.8 kB+0.5%+503 B 🔺
@sentry/browser (incl. Feedback)44.57 kB+0.02%+5 B 🔺
@sentry/browser (incl. sendFeedback)32.21 kB+0.02%+4 B 🔺
@sentry/browser (incl. FeedbackAsync)37.32 kB+0.03%+8 B 🔺
@sentry/browser (incl. Metrics)28.48 kB+0.04%+10 B 🔺
@sentry/browser (incl. Logs)28.71 kB+0.03%+8 B 🔺
@sentry/browser (incl. Metrics & Logs)29.42 kB+0.06%+15 B 🔺
@sentry/react29.21 kB+0.04%+9 B 🔺
@sentry/react (incl. Tracing)48.53 kB+1.12%+533 B 🔺
@sentry/vue32.9 kB+1.48%+477 B 🔺
@sentry/vue (incl. Tracing)48.13 kB+1.14%+542 B 🔺
@sentry/svelte27.43 kB+0.02%+4 B 🔺
CDN Bundle29.89 kB+0.34%+100 B 🔺
CDN Bundle (incl. Tracing)48.74 kB+1.14%+547 B 🔺
CDN Bundle (incl. Logs, Metrics)31.43 kB+0.33%+102 B 🔺
CDN Bundle (incl. Tracing, Logs, Metrics)50.03 kB+1.09%+536 B 🔺
CDN Bundle (incl. Replay, Logs, Metrics)70.73 kB+0.15%+103 B 🔺
CDN Bundle (incl. Tracing, Replay)86.03 kB+0.6%+507 B 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics)87.35 kB+0.67%+575 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback)91.9 kB+0.59%+530 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics)93.17 kB+0.6%+554 B 🔺
CDN Bundle - uncompressed88.85 kB+0.3%+261 B 🔺
CDN Bundle (incl. Tracing) - uncompressed147.64 kB+1.27%+1.84 kB 🔺
CDN Bundle (incl. Logs, Metrics) - uncompressed93.55 kB+0.28%+261 B 🔺
CDN Bundle (incl. Tracing, Logs, Metrics) - uncompressed151.61 kB+1.23%+1.84 kB 🔺
CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed218.38 kB+0.12%+261 B 🔺
CDN Bundle (incl. Tracing, Replay) - uncompressed266.52 kB+0.7%+1.85 kB 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed270.48 kB+0.69%+1.85 kB 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed280.22 kB+0.67%+1.85 kB 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) - uncompressed284.17 kB+0.66%+1.85 kB 🔺
@sentry/nextjs (client)50.93 kB+0.95%+479 B 🔺
@sentry/sveltekit (client)46.64 kB+1.13%+521 B 🔺
@sentry/core/server76.53 kB+0.6%+456 B 🔺
@sentry/core/browser63.68 kB+0.74%+462 B 🔺
@sentry/node-core61.98 kB+0.42%+256 B 🔺
@sentry/node130.76 kB+0.19%+240 B 🔺
@sentry/node - without tracing74.36 kB+0.35%+253 B 🔺
@sentry/aws-serverless86.52 kB+0.27%+229 B 🔺
@sentry/cloudflare (withSentry) - minified175.72 kB+1.17%+2.03 kB 🔺
@sentry/cloudflare (withSentry)438.89 kB+1.17%+5.04 kB 🔺

View base workflow run

Comment threadpackages/core/src/tracing/trace.ts Outdated
Comment threadpackages/core/src/tracing/trace.ts
Comment threadpackages/core/src/tracing/trace.ts Outdated
@andreiborza
andreiborzaforce-pushed the ab/nonrecording-span-sampling branch from 9a455d0 to 69d87c1CompareJune 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
MemberAuthor

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 ownerJune 10, 2026 08:22
@andreiborza
andreiborza requested review from JPeer264, Lms24, chargome, logaretm, mydea and nicohrubec and removed request for a team and JPeer264June 10, 2026 08:22

@logaretmlogaretm 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 threadpackages/core/src/tracing/idleSpan.ts
Comment threadpackages/core/src/tracing/trace.ts

@isaacsisaacs 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 threadpackages/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
MemberAuthor

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
MemberAuthor

Choose a reason for hiding this comment

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

Updated in 07dfbf4

Comment threadpackages/core/src/tracing/trace.ts Outdated
Comment threadpackages/core/src/utils/spanUtils.ts Outdated
…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
andreiborzaforce-pushed the ab/nonrecording-span-sampling branch from b539317 to 07dfbf4CompareJune 12, 2026 07:39

@Lms24Lms24 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
MemberAuthor

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.

Comment threadpackages/core/src/types/span.ts Outdated
* 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.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Right, I walked back on this and go for the same _sampled + tracestate flag approach to be in line with SentrySpan.

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?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Added in d5887e7

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.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Updated in d5887e7.

// 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?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Right, renamed to freezeDscOnRootSpanWithoutSampling in ba51d24

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

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

I reworked this to be in line with SentrySpan, using _sampled + a flag in trace state and using the same helpers across both span types in ba51d24

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?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Updated in ba51d24, if I understand your comment correctly 😅

Comment threadpackages/core/src/tracing/trace.ts Outdated
...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?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Updated in ba51d24

@andreiborza
andreiborzaforce-pushed the ab/nonrecording-span-sampling branch from ca1ccfb to ba51d24CompareJune 14, 2026 15:07
@andreiborza

Copy link
Copy Markdown
MemberAuthor

bugbot run

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

Comment threadpackages/core/src/tracing/trace.ts
@andreiborza

Copy link
Copy Markdown
MemberAuthor

Closing in favor of #21549 which keeps SentryNonRecordingSpan a thin container and uses the scope to handle the sampling decision in cases where it's needed.

@JPeer264
JPeer264 deleted the ab/nonrecording-span-sampling branch June 16, 2026 05:26
andreiborza added a commit that referenced this pull request Jun 17, 2026
…21549)
In Tracing-without-Performance (spans disabled), a root placeholder
previously froze a negative sampling decision in the DSC, which
suppressed downstream sampling instead of leaving the decision to a
performance-enabled service further along the trace.
The scope is the source of truth for a TwP placeholder's trace state:
- `getTraceData` reads the sampling decision from the scope (deferred
for a new trace, the upstream decision for a
continued trace), so the outgoing `sentry-trace` header omits the flag
instead of asserting `-0`. The span id comes from the scope's
`propagationSpanId` (a fresh id is generated when the scope has none).
- `getDynamicSamplingContextFromSpan` resolves a placeholder's DSC from
its captured scope (continued traces keep the incoming DSC; new traces
derive it from the client).
The scope is only consulted for genuine TwP placeholders. A
non-recording span in tracing mode, the child of an unsampled span, or
an ignored span carries an explicit negative decision and keeps
propagating `-0` via `spanToTraceHeader`.
A new (head-of-trace) TwP trace does not stamp a local `transaction` in
its DSC; continued traces still propagate the upstream decision and DSC.
No DSC is written to the scope at span start, preserving the browser's
"scope stays DSC-free between navigations" behavior.
This is an alternative to
#21406
Sign up for freeto 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.

6 participants

@andreiborza@isaacs@mydea@logaretm@Lms24@chargome