From ba712f0be31bade1c77e4af73ef10c897345b667 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Mon, 15 Jun 2026 14:45:56 +0200 Subject: [PATCH 1/8] fix(core): Defer TwP sampling by reading trace state from the scope 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) while keeping the placeholder's stable span id, so the outgoing `sentry-trace` header omits the flag instead of asserting `-0`. - `getDynamicSamplingContextFromSpan` resolves a placeholder's DSC from its captured scope (continued traces keep the incoming DSC; new traces derive it from the client). 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. --- .../cloudflare-integration-tests/expect.ts | 19 +- .../suites/basic/test.ts | 40 ++-- .../suites/double-instrumentation/test.ts | 40 ++-- .../suites/hono-integration/test.ts | 2 +- .../suites/hono-sdk/test.ts | 2 +- .../suites/integrations/http-server/test.ts | 86 +++++---- .../suites/tracing/dsc-url-source/index.ts | 20 ++ .../suites/tracing/dsc-url-source/test.ts | 55 ++++++ .../tracing/dsc-url-source/wrangler.jsonc | 6 + .../suites/tracing/headers/test.ts | 60 +++--- .../src/tracing/dynamicSamplingContext.ts | 11 ++ packages/core/src/tracing/idleSpan.ts | 25 +-- .../src/tracing/sentryNonRecordingSpan.ts | 2 +- packages/core/src/tracing/trace.ts | 32 ++-- packages/core/src/utils/traceData.ts | 29 ++- .../core/test/lib/tracing/idleSpan.test.ts | 126 ++++++++++++- packages/core/test/lib/tracing/trace.test.ts | 80 +++++++- .../core/test/lib/utils/traceData.test.ts | 174 ++++++++++++++++++ 18 files changed, 655 insertions(+), 154 deletions(-) create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/dsc-url-source/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/dsc-url-source/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/dsc-url-source/wrangler.jsonc diff --git a/dev-packages/cloudflare-integration-tests/expect.ts b/dev-packages/cloudflare-integration-tests/expect.ts index c3e2bd007436..9931fd30c6a8 100644 --- a/dev-packages/cloudflare-integration-tests/expect.ts +++ b/dev-packages/cloudflare-integration-tests/expect.ts @@ -61,7 +61,17 @@ export function expectedEvent(event: Event, { sdk }: { sdk: 'cloudflare' | 'hono export function eventEnvelope( event: Event, - { includeSampleRand = false, sdk = 'cloudflare' }: { includeSampleRand?: boolean; sdk?: 'cloudflare' | 'hono' } = {}, + { + includeSamplingFields = false, + includeSampleRand = false, + includeTransaction = true, + sdk = 'cloudflare', + }: { + includeSamplingFields?: boolean; + includeSampleRand?: boolean; + includeTransaction?: boolean; + sdk?: 'cloudflare' | 'hono'; + } = {}, ): Envelope { return [ { @@ -72,10 +82,11 @@ export function eventEnvelope( environment: event.environment || 'production', public_key: 'public', trace_id: UUID_MATCHER, - sample_rate: expect.any(String), + ...(includeSamplingFields && { sample_rate: expect.any(String), sampled: expect.any(String) }), ...(includeSampleRand && { sample_rand: expect.stringMatching(/^[01](\.\d+)?$/) }), - sampled: expect.any(String), - transaction: expect.any(String), + // A new (head-of-trace) TwP trace does not stamp a local transaction in its DSC; the DSC is + // resolved from the scope. Continued traces still carry the upstream transaction. + ...(includeTransaction && { transaction: expect.any(String) }), }, }, [[{ type: 'event' }, expectedEvent(event, { sdk })]], diff --git a/dev-packages/cloudflare-integration-tests/suites/basic/test.ts b/dev-packages/cloudflare-integration-tests/suites/basic/test.ts index 347c0d3530d8..1f6480f4cab4 100644 --- a/dev-packages/cloudflare-integration-tests/suites/basic/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/basic/test.ts @@ -5,26 +5,30 @@ import { createRunner } from '../../runner'; it('Basic error in fetch handler', async ({ signal }) => { const runner = createRunner(__dirname) .expect( - eventEnvelope({ - level: 'error', - exception: { - values: [ - { - type: 'Error', - value: 'This is a test error from the Cloudflare integration tests', - stacktrace: { - frames: expect.any(Array), + eventEnvelope( + { + level: 'error', + exception: { + values: [ + { + type: 'Error', + value: 'This is a test error from the Cloudflare integration tests', + stacktrace: { + frames: expect.any(Array), + }, + mechanism: { type: 'auto.http.cloudflare', handled: false }, }, - mechanism: { type: 'auto.http.cloudflare', handled: false }, - }, - ], + ], + }, + request: { + headers: expect.any(Object), + method: 'GET', + url: expect.any(String), + }, + // A new (head-of-trace) TwP trace does not stamp a local transaction in its DSC. }, - request: { - headers: expect.any(Object), - method: 'GET', - url: expect.any(String), - }, - }), + { includeTransaction: false }, + ), ) .start(signal); await runner.makeRequest('get', '/', { expectError: true }); diff --git a/dev-packages/cloudflare-integration-tests/suites/double-instrumentation/test.ts b/dev-packages/cloudflare-integration-tests/suites/double-instrumentation/test.ts index 453cdb8a09f4..ab0b2155349b 100644 --- a/dev-packages/cloudflare-integration-tests/suites/double-instrumentation/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/double-instrumentation/test.ts @@ -5,26 +5,30 @@ import { createRunner } from '../../runner'; it('Only sends one error event when withSentry is called twice', async ({ signal }) => { const runner = createRunner(__dirname) .expect( - eventEnvelope({ - level: 'error', - exception: { - values: [ - { - type: 'Error', - value: 'Test error from double-instrumented worker', - stacktrace: { - frames: expect.any(Array), + eventEnvelope( + { + level: 'error', + exception: { + values: [ + { + type: 'Error', + value: 'Test error from double-instrumented worker', + stacktrace: { + frames: expect.any(Array), + }, + mechanism: { type: 'auto.http.cloudflare', handled: false }, }, - mechanism: { type: 'auto.http.cloudflare', handled: false }, - }, - ], + ], + }, + request: { + headers: expect.any(Object), + method: 'GET', + url: expect.any(String), + }, }, - request: { - headers: expect.any(Object), - method: 'GET', - url: expect.any(String), - }, - }), + // `/error` resolves to a raw URL span (source `url`), so the TwP DSC omits the span name. + { includeTransaction: false }, + ), ) // The http.server span produces a transaction envelope that is sent in parallel with the // error event. Either can arrive first at the mock server, so ignore it here to keep the diff --git a/dev-packages/cloudflare-integration-tests/suites/hono-integration/test.ts b/dev-packages/cloudflare-integration-tests/suites/hono-integration/test.ts index 0cf4f1dec328..e69cb0951c39 100644 --- a/dev-packages/cloudflare-integration-tests/suites/hono-integration/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/hono-integration/test.ts @@ -28,7 +28,7 @@ it('Hono app captures errors', async ({ signal }) => { url: expect.any(String), }, }, - { includeSampleRand: true }, + { includeSamplingFields: true, includeSampleRand: true }, ), ) // Second envelope: transaction event diff --git a/dev-packages/cloudflare-integration-tests/suites/hono-sdk/test.ts b/dev-packages/cloudflare-integration-tests/suites/hono-sdk/test.ts index c1f17ddb6d19..bbaa75aae4e8 100644 --- a/dev-packages/cloudflare-integration-tests/suites/hono-sdk/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/hono-sdk/test.ts @@ -39,7 +39,7 @@ it('Hono app captures parametrized errors (Hono SDK)', async ({ signal }) => { }, ], }, - { includeSampleRand: true, sdk: 'hono' }, + { includeSamplingFields: true, includeSampleRand: true, sdk: 'hono' }, ), ) diff --git a/dev-packages/cloudflare-integration-tests/suites/integrations/http-server/test.ts b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server/test.ts index 6773a4cf297e..f36849462a42 100644 --- a/dev-packages/cloudflare-integration-tests/suites/integrations/http-server/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server/test.ts @@ -5,16 +5,20 @@ import { createRunner } from '../../../runner'; it('Captures JSON request body', async ({ signal }) => { const runner = createRunner(__dirname) .expect( - eventEnvelope({ - level: 'info', - message: 'POST JSON request', - request: { - headers: expect.any(Object), - method: 'POST', - url: expect.stringContaining('/post-json'), - data: '{"username":"test","action":"login"}', + eventEnvelope( + { + level: 'info', + message: 'POST JSON request', + request: { + headers: expect.any(Object), + method: 'POST', + url: expect.stringContaining('/post-json'), + data: '{"username":"test","action":"login"}', + }, }, - }), + // Raw URL span (source `url`), so the TwP DSC omits the span name. + { includeTransaction: false }, + ), ) .start(signal); @@ -29,16 +33,20 @@ it('Captures JSON request body', async ({ signal }) => { it('Captures form-urlencoded request body', async ({ signal }) => { const runner = createRunner(__dirname) .expect( - eventEnvelope({ - level: 'info', - message: 'POST form request', - request: { - headers: expect.any(Object), - method: 'POST', - url: expect.stringContaining('/post-form'), - data: 'username=test&password=secret', + eventEnvelope( + { + level: 'info', + message: 'POST form request', + request: { + headers: expect.any(Object), + method: 'POST', + url: expect.stringContaining('/post-form'), + data: 'username=test&password=secret', + }, }, - }), + // Raw URL span (source `url`), so the TwP DSC omits the span name. + { includeTransaction: false }, + ), ) .start(signal); @@ -53,16 +61,20 @@ it('Captures form-urlencoded request body', async ({ signal }) => { it('Captures plain text request body', async ({ signal }) => { const runner = createRunner(__dirname) .expect( - eventEnvelope({ - level: 'info', - message: 'POST text request', - request: { - headers: expect.any(Object), - method: 'POST', - url: expect.stringContaining('/post-text'), - data: 'This is plain text content', + eventEnvelope( + { + level: 'info', + message: 'POST text request', + request: { + headers: expect.any(Object), + method: 'POST', + url: expect.stringContaining('/post-text'), + data: 'This is plain text content', + }, }, - }), + // Raw URL span (source `url`), so the TwP DSC omits the span name. + { includeTransaction: false }, + ), ) .start(signal); @@ -77,15 +89,19 @@ it('Captures plain text request body', async ({ signal }) => { it('Does not capture body for POST without content', async ({ signal }) => { const runner = createRunner(__dirname) .expect( - eventEnvelope({ - level: 'info', - message: 'POST no body request', - request: { - headers: expect.any(Object), - method: 'POST', - url: expect.stringContaining('/post-no-body'), + eventEnvelope( + { + level: 'info', + message: 'POST no body request', + request: { + headers: expect.any(Object), + method: 'POST', + url: expect.stringContaining('/post-no-body'), + }, }, - }), + // Raw URL span (source `url`), so the TwP DSC omits the span name. + { includeTransaction: false }, + ), ) .start(signal); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/dsc-url-source/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/dsc-url-source/index.ts new file mode 100644 index 000000000000..2f914b401772 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/dsc-url-source/index.ts @@ -0,0 +1,20 @@ +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; +} + +// Tracing is enabled (not TwP), but the route is a raw, non-parametrized URL so the +// http.server span source is `url`. The span name must therefore be omitted from the +// DSC (raw URLs may contain PII), even though a real transaction is recorded. +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + }), + { + async fetch(_request, _env, _ctx) { + throw new Error('Test error from URL-source worker'); + }, + }, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/dsc-url-source/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/dsc-url-source/test.ts new file mode 100644 index 000000000000..14ea900e179a --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/dsc-url-source/test.ts @@ -0,0 +1,55 @@ +import { expect, it } from 'vitest'; +import { eventEnvelope } from '../../../expect'; +import { createRunner } from '../../../runner'; + +it('omits the span name from the DSC for url-source spans when tracing is enabled', async ({ signal }) => { + const runner = createRunner(__dirname) + // Error event: because tracing is enabled, the DSC carries the sampling fields. But the span + // source is `url`, so the span name is omitted from the DSC (raw URLs may contain PII). + .expect( + eventEnvelope( + { + level: 'error', + exception: { + values: [ + { + type: 'Error', + value: 'Test error from URL-source worker', + stacktrace: { + frames: expect.any(Array), + }, + mechanism: { type: 'auto.http.cloudflare', handled: false }, + }, + ], + }, + request: { + headers: expect.any(Object), + method: 'GET', + url: expect.any(String), + }, + }, + { includeSamplingFields: true, includeSampleRand: true, includeTransaction: false }, + ), + ) + // Transaction event: proves we are NOT in TwP — the span is recorded with a `url` source and + // carries the name on the event itself, even though it is intentionally absent from the DSC. + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1]; + expect(transactionEvent).toEqual( + expect.objectContaining({ + type: 'transaction', + transaction: 'GET /error', + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + data: expect.objectContaining({ 'sentry.source': 'url' }), + }), + }), + }), + ); + }) + .unordered() + .start(signal); + await runner.makeRequest('get', '/error', { expectError: true }); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/dsc-url-source/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/dsc-url-source/wrangler.jsonc new file mode 100644 index 000000000000..d6be01281f0c --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/dsc-url-source/wrangler.jsonc @@ -0,0 +1,6 @@ +{ + "name": "worker-name", + "compatibility_date": "2025-06-17", + "main": "index.ts", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/headers/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/headers/test.ts index d92fde438eb8..a20ec16a354f 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/headers/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/headers/test.ts @@ -9,8 +9,8 @@ it('Tracing headers', async ({ signal }) => { const [SERVER_URL, closeTestServer] = await createTestServer() .get('/', headers => { expect(headers['baggage']).toEqual(expect.any(String)); - expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f\d]{32})-([a-f\d]{16})-0$/)); - expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000-0'); + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f\d]{32})-([a-f\d]{16})$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); expect(headers['traceparent']).toEqual(expect.stringMatching(/^00-([a-f\d]{32})-([a-f\d]{16})-00$/)); }) .start(); @@ -18,38 +18,42 @@ it('Tracing headers', async ({ signal }) => { const runner = createRunner(__dirname) .withServerUrl(SERVER_URL) .expect( - eventEnvelope({ - level: 'error', - exception: { - values: [ + eventEnvelope( + { + level: 'error', + exception: { + values: [ + { + type: 'Error', + value: 'Test error to capture trace headers', + stacktrace: { + frames: expect.any(Array), + }, + mechanism: { type: 'auto.http.cloudflare', handled: false }, + }, + ], + }, + breadcrumbs: [ { - type: 'Error', - value: 'Test error to capture trace headers', - stacktrace: { - frames: expect.any(Array), + category: 'fetch', + data: { + method: 'GET', + status_code: 200, + url: expect.stringContaining('http://localhost:'), }, - mechanism: { type: 'auto.http.cloudflare', handled: false }, + timestamp: expect.any(Number), + type: 'http', }, ], - }, - breadcrumbs: [ - { - category: 'fetch', - data: { - method: 'GET', - status_code: 200, - url: expect.stringContaining('http://localhost:'), - }, - timestamp: expect.any(Number), - type: 'http', + request: { + headers: expect.any(Object), + method: 'GET', + url: expect.any(String), }, - ], - request: { - headers: expect.any(Object), - method: 'GET', - url: expect.any(String), + // A new (head-of-trace) TwP trace does not stamp a local transaction in its DSC. }, - }), + { includeTransaction: false }, + ), ) .start(signal); diff --git a/packages/core/src/tracing/dynamicSamplingContext.ts b/packages/core/src/tracing/dynamicSamplingContext.ts index 134d4c2d5da3..1509092d432d 100644 --- a/packages/core/src/tracing/dynamicSamplingContext.ts +++ b/packages/core/src/tracing/dynamicSamplingContext.ts @@ -14,6 +14,7 @@ import { extractOrgIdFromClient } from '../utils/dsn'; import { hasSpansEnabled } from '../utils/hasSpansEnabled'; import { addNonEnumerableProperty } from '../utils/object'; import { getRootSpan, spanIsSampled, spanToJSON } from '../utils/spanUtils'; +import { SentryNonRecordingSpan } from './sentryNonRecordingSpan'; import { getCapturedScopesOnSpan } from './utils'; /** @@ -105,6 +106,16 @@ export function getDynamicSamplingContextFromSpan(span: Span): Readonly; - freezeDscOnSpan(span, dsc); + // Capture scopes so consumers (e.g. SentryTraceProvider) can read them and so the DSC can be + // resolved from the scope by `getDynamicSamplingContextFromSpan`. + setCapturedScopesOnSpan(span, scope, getIsolationScope()); return span; } - const scope = getCurrentScope(); const previousActiveSpan = getActiveSpan(); const span = _startIdleSpan(startSpanOptions); diff --git a/packages/core/src/tracing/sentryNonRecordingSpan.ts b/packages/core/src/tracing/sentryNonRecordingSpan.ts index 4c4c1064eedb..fbab52bd9a5b 100644 --- a/packages/core/src/tracing/sentryNonRecordingSpan.ts +++ b/packages/core/src/tracing/sentryNonRecordingSpan.ts @@ -98,7 +98,7 @@ export class SentryNonRecordingSpan implements Span { * @hidden * @internal */ - public recordException(_exception: unknown, _time?: number | undefined): void { + public recordException(_exception: unknown, _time?: SpanTimeInput | undefined): void { // noop } } diff --git a/packages/core/src/tracing/trace.ts b/packages/core/src/tracing/trace.ts index 3134c58309b1..4e2f116262f7 100644 --- a/packages/core/src/tracing/trace.ts +++ b/packages/core/src/tracing/trace.ts @@ -11,7 +11,6 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, } from '../semanticAttributes'; -import type { DynamicSamplingContext } from '../types/envelope'; import type { ClientOptions } from '../types/options'; import type { SentrySpanArguments, Span, SpanTimeInput } from '../types/span'; import type { StartSpanOptions } from '../types/startSpanOptions'; @@ -344,21 +343,26 @@ function createChildOrRootSpan({ forceTransaction?: boolean; scope: Scope; }): Span { + const isolationScope = getIsolationScope(); + if (!hasSpansEnabled()) { - const span = new SentryNonRecordingSpan(); - - // If this is a root span, we ensure to freeze a DSC - // So we can have at least partial data here - if (forceTransaction || !parentSpan) { - const dsc = { - sampled: 'false', - sample_rate: '0', - transaction: spanArguments.name, - ...getDynamicSamplingContextFromSpan(span), - } satisfies Partial; - freezeDscOnSpan(span, dsc); + const scopePropagationContext = { ...isolationScope.getPropagationContext(), ...scope.getPropagationContext() }; + const traceId = parentSpan ? parentSpan.spanContext().traceId : scopePropagationContext.traceId; + + // The placeholder is a thin marker; it carries no sampling decision or DSC. Both are read from + // the scope: the sampling decision in `getTraceData`, the DSC in `getDynamicSamplingContextFromSpan`. + const span = new SentryNonRecordingSpan({ traceId }); + + // Nested placeholders link to their parent so `getRootSpan` resolves to the root placeholder, + // whose captured scope is the source of truth. Root/forced placeholders are their own root. + if (parentSpan && !forceTransaction) { + addChildSpanToSpan(parentSpan, span); } + // Capture scopes so consumers (e.g. SentryTraceProvider) can read them and so the DSC can be + // resolved from the scope by `getDynamicSamplingContextFromSpan`. Consistent with `startIdleSpan`. + setCapturedScopesOnSpan(span, scope, isolationScope); + return span; } @@ -376,8 +380,6 @@ function createChildOrRootSpan({ }); } - const isolationScope = getIsolationScope(); - let span: Span; if (parentSpan && !forceTransaction) { span = _startChildSpan(parentSpan, scope, spanArguments); diff --git a/packages/core/src/utils/traceData.ts b/packages/core/src/utils/traceData.ts index fe39ab539994..96b24bf0cdbe 100644 --- a/packages/core/src/utils/traceData.ts +++ b/packages/core/src/utils/traceData.ts @@ -5,6 +5,7 @@ import { getClient, getCurrentScope, hasExternalPropagationContext } from '../cu import { isEnabled } from '../exports'; import type { Scope } from '../scope'; import { getDynamicSamplingContextFromScope, getDynamicSamplingContextFromSpan } from '../tracing'; +import { SentryNonRecordingSpan } from '../tracing/sentryNonRecordingSpan'; import type { Span } from '../types/span'; import type { SerializedTraceData } from '../types/tracing'; import { dynamicSamplingContextToSentryBaggageHeader } from './baggage'; @@ -47,13 +48,19 @@ export function getTraceData( const scope = options.scope || getCurrentScope(); const span = options.span || getActiveSpan(); - // When no active span and external propagation context is registered (e.g. OTLP integration), - // return empty to let the OTel propagator handle outgoing request propagation. + // A non-recording span is a Tracing-without-Performance placeholder that carries no sampling + // decision of its own — the scope is the source of truth. We keep the placeholder's (stable) + // span id but read the sampling decision from the scope. + const isNonRecordingSpan = span instanceof SentryNonRecordingSpan; + + // When there's no recording span and an external propagation context is registered (e.g. OTLP + // integration), return empty to let the external propagator handle outgoing request propagation. if (!span && hasExternalPropagationContext()) { return {}; } - const sentryTrace = span ? spanToTraceHeader(span) : scopeToTraceHeader(scope); + const sentryTrace = + span && !isNonRecordingSpan ? spanToTraceHeader(span) : scopeToTraceHeader(scope, span?.spanContext().spanId); const dsc = span ? getDynamicSamplingContextFromSpan(span) : getDynamicSamplingContextFromScope(client, scope); const baggage = dynamicSamplingContextToSentryBaggageHeader(dsc); @@ -69,7 +76,10 @@ export function getTraceData( }; if (options.propagateTraceparent) { - traceData.traceparent = span ? spanToTraceparentHeader(span) : scopeToTraceparentHeader(scope); + traceData.traceparent = + span && !isNonRecordingSpan + ? spanToTraceparentHeader(span) + : scopeToTraceparentHeader(scope, span?.spanContext().spanId); } return traceData; @@ -77,13 +87,16 @@ export function getTraceData( /** * Get a sentry-trace header value for the given scope. + * + * `spanId` overrides the scope's propagation span id — used to keep a non-recording placeholder's + * (stable) span id while still taking the sampling decision from the scope. */ -function scopeToTraceHeader(scope: Scope): string { +function scopeToTraceHeader(scope: Scope, spanId?: string): string { const { traceId, sampled, propagationSpanId } = scope.getPropagationContext(); - return generateSentryTraceHeader(traceId, propagationSpanId, sampled); + return generateSentryTraceHeader(traceId, spanId ?? propagationSpanId, sampled); } -function scopeToTraceparentHeader(scope: Scope): string { +function scopeToTraceparentHeader(scope: Scope, spanId?: string): string { const { traceId, sampled, propagationSpanId } = scope.getPropagationContext(); - return generateTraceparentHeader(traceId, propagationSpanId, sampled); + return generateTraceparentHeader(traceId, spanId ?? propagationSpanId, sampled); } diff --git a/packages/core/test/lib/tracing/idleSpan.test.ts b/packages/core/test/lib/tracing/idleSpan.test.ts index 29aaa63c2bb0..ca9e08c5565d 100644 --- a/packages/core/test/lib/tracing/idleSpan.test.ts +++ b/packages/core/test/lib/tracing/idleSpan.test.ts @@ -1,15 +1,19 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { getActiveSpan, + getCapturedScopesOnSpan, getClient, getCurrentScope, getDynamicSamplingContextFromSpan, getGlobalScope, getIsolationScope, + getTraceData, SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, SentryNonRecordingSpan, SentrySpan, setCurrentClient, + spanToBaggageHeader, spanToJSON, startInactiveSpan, startSpan, @@ -59,22 +63,134 @@ describe('startIdleSpan', () => { setCurrentClient(client); client.init(); + getCurrentScope().setPropagationContext({ + traceId: '12345678901234567890123456789012', + sampleRand: 0.42, + }); + const idleSpan = startIdleSpan({ name: 'foo' }); expect(idleSpan).toBeDefined(); expect(idleSpan).toBeInstanceOf(SentryNonRecordingSpan); - // DSC is still correctly set on the span + + // Continues the trace from the scope, with the sampling decision deferred (no `sampled`/`sample_rate`). + expect(idleSpan.spanContext().traceId).toBe('12345678901234567890123456789012'); expect(getDynamicSamplingContextFromSpan(idleSpan)).toEqual({ environment: 'production', public_key: '123', - sample_rate: '0', - sampled: 'false', - trace_id: expect.stringMatching(/[a-f0-9]{32}/), + trace_id: '12345678901234567890123456789012', }); - // not set as active span, though + // The deferred decision surfaces via `getTraceData` (read from the scope): the `sentry-trace` + // header omits the flag and the baggage asserts no sampling decision. + const data = getTraceData({ span: idleSpan }); + expect(data['sentry-trace']).toBe(`12345678901234567890123456789012-${idleSpan.spanContext().spanId}`); + expect(data.baggage).not.toContain('sentry-sampled'); + expect(data.baggage).not.toContain('sentry-sample_rate'); + + // Scopes are captured on the placeholder so consumers (e.g. SentryTraceProvider) can read them. + expect(getCapturedScopesOnSpan(idleSpan).scope).toBe(getCurrentScope()); + expect(getCapturedScopesOnSpan(idleSpan).isolationScope).toBe(getIsolationScope()); + expect(getActiveSpan()).toBe(undefined); }); + it('preserves a continued trace DSC transaction when tracing is disabled', () => { + const options = getDefaultTestClientOptions({ dsn }); + const client = new TestClient(options); + setCurrentClient(client); + client.init(); + + getCurrentScope().setPropagationContext({ + traceId: '12345678901234567890123456789012', + sampleRand: 0.42, + dsc: { + environment: 'production', + public_key: '123', + trace_id: '12345678901234567890123456789012', + transaction: 'upstream-root', + sampled: 'true', + sample_rate: '0.5', + }, + }); + + const idleSpan = startIdleSpan({ name: 'foo' }); + + // The continued trace's frozen DSC wins over the local idle span name. + expect(getDynamicSamplingContextFromSpan(idleSpan)).toEqual({ + environment: 'production', + public_key: '123', + trace_id: '12345678901234567890123456789012', + transaction: 'upstream-root', + sampled: 'true', + sample_rate: '0.5', + }); + }); + + it('keeps a continued trace sampling decision when tracing is disabled', () => { + const options = getDefaultTestClientOptions({ dsn }); + const client = new TestClient(options); + setCurrentClient(client); + client.init(); + + getCurrentScope().setPropagationContext({ + traceId: '12345678901234567890123456789012', + parentSpanId: '1234567890123456', + sampleRand: 0.42, + sampled: true, + dsc: { sampled: 'true' }, + }); + + const idleSpan = startIdleSpan({ name: 'foo' }); + + // The placeholder carries no decision of its own; the upstream sampling decision and DSC are + // read from the scope. `getTraceData` reflects the positive decision in both headers. + expect(getDynamicSamplingContextFromSpan(idleSpan).sampled).toBe('true'); + const data = getTraceData({ span: idleSpan }); + expect(data['sentry-trace']).toBe(`12345678901234567890123456789012-${idleSpan.spanContext().spanId}-1`); + expect(data.baggage).toContain('sentry-sampled=true'); + }); + + it('freezes a continued trace empty DSC as-is when tracing is disabled', () => { + const options = getDefaultTestClientOptions({ dsn }); + const client = new TestClient(options); + setCurrentClient(client); + client.init(); + + // A continued `sentry-trace` without baggage yields an empty frozen DSC marker. + getCurrentScope().setPropagationContext({ + traceId: '12345678901234567890123456789012', + sampleRand: 0.42, + dsc: {}, + }); + + const idleSpan = startIdleSpan({ name: 'foo' }); + + // We are not head of trace: don't fabricate client fields or inject the local transaction. + expect(getDynamicSamplingContextFromSpan(idleSpan)).toEqual({}); + }); + + it('does not add a url-source span name to the DSC when tracing is disabled', () => { + const options = getDefaultTestClientOptions({ dsn }); + const client = new TestClient(options); + setCurrentClient(client); + client.init(); + + // Mirrors a browser pageload/navigation span, whose name is the URL path. + const idleSpan = startIdleSpan({ + name: '/users/123e4567-e89b-12d3-a456-426614174000', + attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url' }, + }); + + expect(idleSpan).toBeInstanceOf(SentryNonRecordingSpan); + // URLs might contain PII, so the span name must not end up in the DSC. + expect(getDynamicSamplingContextFromSpan(idleSpan)).toEqual({ + environment: 'production', + public_key: '123', + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); + expect(spanToBaggageHeader(idleSpan)).not.toContain('sentry-transaction'); + }); + it('does not finish idle span if there are still active activities', () => { const idleSpan = startIdleSpan({ name: 'foo' }); expect(idleSpan).toBeDefined(); diff --git a/packages/core/test/lib/tracing/trace.test.ts b/packages/core/test/lib/tracing/trace.test.ts index 0d0e652e64aa..138dc3b2d0ee 100644 --- a/packages/core/test/lib/tracing/trace.test.ts +++ b/packages/core/test/lib/tracing/trace.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { + getCapturedScopesOnSpan, getCurrentScope, getGlobalScope, getIsolationScope, @@ -8,6 +9,7 @@ import { Scope, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, setAsyncContextStrategy, setCurrentClient, spanToJSON, @@ -230,18 +232,73 @@ describe('startSpan', () => { setCurrentClient(client); client.init(); + getCurrentScope().setPropagationContext({ + traceId: '12345678901234567890123456789012', + sampleRand: 0.42, + }); + + let scopeInCallback: Scope | undefined; const span = startSpan({ name: 'GET users/[id]' }, span => { + scopeInCallback = getCurrentScope(); return span; }); expect(span).toBeDefined(); expect(span).toBeInstanceOf(SentryNonRecordingSpan); + expect(span.spanContext().traceId).toBe('12345678901234567890123456789012'); + expect(getDynamicSamplingContextFromSpan(span)).toEqual({ + environment: 'production', + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); + + // Scopes are captured on the placeholder so consumers (e.g. SentryTraceProvider) can read them. + // `startSpan` forks the scope, so the captured scope is the one active inside the callback. + expect(getCapturedScopesOnSpan(span).scope).toBe(scopeInCallback); + expect(getCapturedScopesOnSpan(span).isolationScope).toBe(getIsolationScope()); + }); + + it('freezes a continued trace empty DSC as-is when tracing is disabled', () => { + const options = getDefaultTestClientOptions({}); + client = new TestClient(options); + setCurrentClient(client); + client.init(); + + // A continued `sentry-trace` without baggage yields an empty frozen DSC marker. + getCurrentScope().setPropagationContext({ + traceId: '12345678901234567890123456789012', + sampleRand: 0.42, + dsc: {}, + }); + + const span = startSpan({ name: 'GET users/[id]' }, span => { + return span; + }); + + expect(span).toBeInstanceOf(SentryNonRecordingSpan); + expect(span.spanContext().traceId).toBe('12345678901234567890123456789012'); + // We are not head of trace: don't fabricate client fields or inject the local transaction. + expect(getDynamicSamplingContextFromSpan(span)).toEqual({}); + }); + + it('does not add a url-source span name to the DSC when tracing is disabled', () => { + const options = getDefaultTestClientOptions({}); + client = new TestClient(options); + setCurrentClient(client); + client.init(); + + const span = startSpan( + { + name: '/users/123e4567-e89b-12d3-a456-426614174000', + attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url' }, + }, + span => span, + ); + + expect(span).toBeInstanceOf(SentryNonRecordingSpan); + // URLs might contain PII, so the span name must not end up in the DSC. expect(getDynamicSamplingContextFromSpan(span)).toEqual({ environment: 'production', - sample_rate: '0', - sampled: 'false', trace_id: expect.stringMatching(/[a-f0-9]{32}/), - transaction: 'GET users/[id]', }); }); @@ -874,7 +931,9 @@ describe('startSpanManual', () => { setCurrentClient(client); client.init(); + let scopeInCallback: Scope | undefined; const span = startSpanManual({ name: 'GET users/[id]' }, span => { + scopeInCallback = getCurrentScope(); return span; }); @@ -882,11 +941,13 @@ describe('startSpanManual', () => { expect(span).toBeInstanceOf(SentryNonRecordingSpan); expect(getDynamicSamplingContextFromSpan(span)).toEqual({ environment: 'production', - sample_rate: '0', - sampled: 'false', trace_id: expect.stringMatching(/[a-f0-9]{32}/), - transaction: 'GET users/[id]', }); + + // Scopes are captured on the placeholder so consumers (e.g. SentryTraceProvider) can read them. + // `startSpanManual` forks the scope, so the captured scope is the one active inside the callback. + expect(getCapturedScopesOnSpan(span).scope).toBe(scopeInCallback); + expect(getCapturedScopesOnSpan(span).isolationScope).toBe(getIsolationScope()); }); it('creates & finishes span', async () => { @@ -1394,11 +1455,12 @@ describe('startInactiveSpan', () => { expect(span).toBeInstanceOf(SentryNonRecordingSpan); expect(getDynamicSamplingContextFromSpan(span)).toEqual({ environment: 'production', - sample_rate: '0', - sampled: 'false', trace_id: expect.stringMatching(/[a-f0-9]{32}/), - transaction: 'GET users/[id]', }); + + // Scopes are captured on the placeholder so consumers (e.g. SentryTraceProvider) can read them. + expect(getCapturedScopesOnSpan(span).scope).toBe(getCurrentScope()); + expect(getCapturedScopesOnSpan(span).isolationScope).toBe(getIsolationScope()); }); it('creates & finishes span', async () => { diff --git a/packages/core/test/lib/utils/traceData.test.ts b/packages/core/test/lib/utils/traceData.test.ts index b2101502cb43..09ffdb1c7ea8 100644 --- a/packages/core/test/lib/utils/traceData.test.ts +++ b/packages/core/test/lib/utils/traceData.test.ts @@ -11,6 +11,7 @@ import { SentrySpan, setAsyncContextStrategy, setCurrentClient, + startSpan, withActiveSpan, } from '../../../src/'; import { getAsyncContextStrategy } from '../../../src/asyncContext'; @@ -142,6 +143,179 @@ describe('getTraceData', () => { }); }); + it('does not add a sampled flag for an active TwP placeholder span', () => { + setupClient({ tracesSampleRate: undefined }); + + getCurrentScope().setPropagationContext({ + traceId: '12345678901234567890123456789012', + sampleRand: 0.42, + }); + + startSpan({ name: 'twp-root' }, () => { + const data = getTraceData(); + + expect(data).toEqual({ + 'sentry-trace': expect.stringMatching(/^12345678901234567890123456789012-[a-f0-9]{16}$/), + baggage: 'sentry-environment=production,sentry-public_key=123,sentry-trace_id=12345678901234567890123456789012', + }); + expect(data['sentry-trace']?.split('-')).toHaveLength(2); + }); + }); + + it('keeps an explicit negative sampling decision for an active unsampled span', () => { + setupClient({ tracesSampleRate: 0 }); + + startSpan({ name: 'unsampled-root' }, () => { + const data = getTraceData(); + + expect(data['sentry-trace']).toMatch(/^[a-f0-9]{32}-[a-f0-9]{16}-0$/); + expect(data.baggage).toContain('sentry-sampled=false'); + }); + }); + + it('keeps a continued positive sampling decision for an active TwP placeholder span', () => { + setupClient({ tracesSampleRate: undefined }); + + getCurrentScope().setPropagationContext({ + traceId: '12345678901234567890123456789012', + parentSpanId: '1234567890123456', + sampleRand: 0.42, + sampled: true, + dsc: { + environment: 'production', + public_key: '123', + trace_id: '12345678901234567890123456789012', + sampled: 'true', + }, + }); + + startSpan({ name: 'twp-root' }, () => { + const data = getTraceData(); + + // The upstream decision must survive in the sentry-trace header so it agrees with the frozen baggage. + expect(data['sentry-trace']).toMatch(/^12345678901234567890123456789012-[a-f0-9]{16}-1$/); + expect(data.baggage).toContain('sentry-sampled=true'); + }); + }); + + it('keeps a continued negative sampling decision for an active TwP placeholder span', () => { + setupClient({ tracesSampleRate: undefined }); + + getCurrentScope().setPropagationContext({ + traceId: '12345678901234567890123456789012', + parentSpanId: '1234567890123456', + sampleRand: 0.42, + sampled: false, + dsc: {}, + }); + + startSpan({ name: 'twp-root' }, () => { + const data = getTraceData(); + + expect(data['sentry-trace']).toMatch(/^12345678901234567890123456789012-[a-f0-9]{16}-0$/); + }); + }); + + it('keeps a continued sampling decision on nested TwP placeholder spans', () => { + setupClient({ tracesSampleRate: undefined }); + + getCurrentScope().setPropagationContext({ + traceId: '12345678901234567890123456789012', + parentSpanId: '1234567890123456', + sampleRand: 0.42, + sampled: true, + dsc: {}, + }); + + startSpan({ name: 'twp-root' }, () => { + startSpan({ name: 'twp-child' }, () => { + const data = getTraceData(); + + expect(data['sentry-trace']).toMatch(/^12345678901234567890123456789012-[a-f0-9]{16}-1$/); + }); + }); + }); + + it('keeps a continued trace frozen DSC on nested TwP placeholder spans', () => { + setupClient({ tracesSampleRate: undefined }); + + getCurrentScope().setPropagationContext({ + traceId: '12345678901234567890123456789012', + parentSpanId: '1234567890123456', + sampleRand: 0.42, + sampled: true, + dsc: { + environment: 'staging', + public_key: 'key', + trace_id: '12345678901234567890123456789012', + transaction: 'upstream-root', + sampled: 'true', + }, + }); + + startSpan({ name: 'twp-root' }, () => { + startSpan({ name: 'twp-child' }, () => { + startSpan({ name: 'twp-grandchild' }, () => { + const data = getTraceData(); + + // Header and baggage must agree at any depth: both reflect the continued trace. + expect(data['sentry-trace']).toMatch(/^12345678901234567890123456789012-[a-f0-9]{16}-1$/); + expect(data.baggage).toContain('sentry-transaction=upstream-root'); + expect(data.baggage).toContain('sentry-sampled=true'); + expect(data.baggage).toContain('sentry-environment=staging'); + }); + }); + }); + }); + + it('does not fabricate baggage for a continued empty frozen DSC on nested TwP placeholder spans', () => { + setupClient({ tracesSampleRate: undefined }); + + getCurrentScope().setPropagationContext({ + traceId: '12345678901234567890123456789012', + parentSpanId: '1234567890123456', + sampleRand: 0.42, + sampled: true, + dsc: {}, + }); + + startSpan({ name: 'twp-root' }, () => { + startSpan({ name: 'twp-child' }, () => { + const data = getTraceData(); + + // We are not head of trace: a continued `sentry-trace`-only trace must not + // gain locally fabricated client fields at depth either. + expect(data.baggage ?? '').not.toContain('sentry-environment'); + expect(data.baggage ?? '').not.toContain('sentry-public_key'); + }); + }); + }); + + it('preserves a continued trace DSC transaction when starting a TwP span', () => { + setupClient({ tracesSampleRate: undefined }); + + getCurrentScope().setPropagationContext({ + traceId: '12345678901234567890123456789012', + sampleRand: 0.42, + dsc: { + environment: 'production', + public_key: '123', + trace_id: '12345678901234567890123456789012', + transaction: 'upstream-root', + sampled: 'true', + sample_rate: '0.5', + }, + }); + + startSpan({ name: 'db.query' }, () => { + const data = getTraceData(); + + // The local span name must not overwrite the frozen DSC of the continued trace. + expect(data.baggage).toContain('sentry-transaction=upstream-root'); + expect(data.baggage).not.toContain('db.query'); + }); + }); + it('allows to pass a span directly', () => { setupClient(); From d1d9d2d7f8950cee2709a24c5c62381449aa773d Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Mon, 15 Jun 2026 15:51:43 +0200 Subject: [PATCH 2/8] Remove unneccessary propagationcontext spread --- packages/core/src/tracing/idleSpan.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/core/src/tracing/idleSpan.ts b/packages/core/src/tracing/idleSpan.ts index 8cc3392376ca..f29b5a71b299 100644 --- a/packages/core/src/tracing/idleSpan.ts +++ b/packages/core/src/tracing/idleSpan.ts @@ -122,14 +122,9 @@ export function startIdleSpan(startSpanOptions: StartSpanOptions, options: Parti const scope = getCurrentScope(); if (!client || !hasSpansEnabled()) { - const propagationContext = { - ...getIsolationScope().getPropagationContext(), - ...scope.getPropagationContext(), - }; - // The placeholder is a thin marker; it carries no sampling decision or DSC. Both are read from // the scope: the sampling decision in `getTraceData`, the DSC in `getDynamicSamplingContextFromSpan`. - const span = new SentryNonRecordingSpan({ traceId: propagationContext.traceId }); + const span = new SentryNonRecordingSpan({ traceId: scope.getPropagationContext().traceId }); // Capture scopes so consumers (e.g. SentryTraceProvider) can read them and so the DSC can be // resolved from the scope by `getDynamicSamplingContextFromSpan`. From 6a7c654a376c9cc6841105bf9a07bcc4b15caae5 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Mon, 15 Jun 2026 19:30:08 +0200 Subject: [PATCH 3/8] Use propagationSpanId --- packages/core/src/utils/traceData.ts | 21 +++++++------------ .../core/test/lib/tracing/idleSpan.test.ts | 8 ++++--- 2 files changed, 12 insertions(+), 17 deletions(-) diff --git a/packages/core/src/utils/traceData.ts b/packages/core/src/utils/traceData.ts index 96b24bf0cdbe..d4bbd5472124 100644 --- a/packages/core/src/utils/traceData.ts +++ b/packages/core/src/utils/traceData.ts @@ -49,8 +49,7 @@ export function getTraceData( const span = options.span || getActiveSpan(); // A non-recording span is a Tracing-without-Performance placeholder that carries no sampling - // decision of its own — the scope is the source of truth. We keep the placeholder's (stable) - // span id but read the sampling decision from the scope. + // decision of its own. The scope is the source of truth, so we read the headers from the scope. const isNonRecordingSpan = span instanceof SentryNonRecordingSpan; // When there's no recording span and an external propagation context is registered (e.g. OTLP @@ -59,8 +58,7 @@ export function getTraceData( return {}; } - const sentryTrace = - span && !isNonRecordingSpan ? spanToTraceHeader(span) : scopeToTraceHeader(scope, span?.spanContext().spanId); + const sentryTrace = span && !isNonRecordingSpan ? spanToTraceHeader(span) : scopeToTraceHeader(scope); const dsc = span ? getDynamicSamplingContextFromSpan(span) : getDynamicSamplingContextFromScope(client, scope); const baggage = dynamicSamplingContextToSentryBaggageHeader(dsc); @@ -77,9 +75,7 @@ export function getTraceData( if (options.propagateTraceparent) { traceData.traceparent = - span && !isNonRecordingSpan - ? spanToTraceparentHeader(span) - : scopeToTraceparentHeader(scope, span?.spanContext().spanId); + span && !isNonRecordingSpan ? spanToTraceparentHeader(span) : scopeToTraceparentHeader(scope); } return traceData; @@ -87,16 +83,13 @@ export function getTraceData( /** * Get a sentry-trace header value for the given scope. - * - * `spanId` overrides the scope's propagation span id — used to keep a non-recording placeholder's - * (stable) span id while still taking the sampling decision from the scope. */ -function scopeToTraceHeader(scope: Scope, spanId?: string): string { +function scopeToTraceHeader(scope: Scope): string { const { traceId, sampled, propagationSpanId } = scope.getPropagationContext(); - return generateSentryTraceHeader(traceId, spanId ?? propagationSpanId, sampled); + return generateSentryTraceHeader(traceId, propagationSpanId, sampled); } -function scopeToTraceparentHeader(scope: Scope, spanId?: string): string { +function scopeToTraceparentHeader(scope: Scope): string { const { traceId, sampled, propagationSpanId } = scope.getPropagationContext(); - return generateTraceparentHeader(traceId, spanId ?? propagationSpanId, sampled); + return generateTraceparentHeader(traceId, propagationSpanId, sampled); } diff --git a/packages/core/test/lib/tracing/idleSpan.test.ts b/packages/core/test/lib/tracing/idleSpan.test.ts index ca9e08c5565d..9ef6c834d251 100644 --- a/packages/core/test/lib/tracing/idleSpan.test.ts +++ b/packages/core/test/lib/tracing/idleSpan.test.ts @@ -65,6 +65,7 @@ describe('startIdleSpan', () => { getCurrentScope().setPropagationContext({ traceId: '12345678901234567890123456789012', + propagationSpanId: '1234567890abcdef', sampleRand: 0.42, }); @@ -81,9 +82,9 @@ describe('startIdleSpan', () => { }); // The deferred decision surfaces via `getTraceData` (read from the scope): the `sentry-trace` - // header omits the flag and the baggage asserts no sampling decision. + // header uses the scope's propagation span id, omits the flag, and the baggage asserts no decision. const data = getTraceData({ span: idleSpan }); - expect(data['sentry-trace']).toBe(`12345678901234567890123456789012-${idleSpan.spanContext().spanId}`); + expect(data['sentry-trace']).toBe('12345678901234567890123456789012-1234567890abcdef'); expect(data.baggage).not.toContain('sentry-sampled'); expect(data.baggage).not.toContain('sentry-sample_rate'); @@ -135,6 +136,7 @@ describe('startIdleSpan', () => { getCurrentScope().setPropagationContext({ traceId: '12345678901234567890123456789012', parentSpanId: '1234567890123456', + propagationSpanId: '1234567890abcdef', sampleRand: 0.42, sampled: true, dsc: { sampled: 'true' }, @@ -146,7 +148,7 @@ describe('startIdleSpan', () => { // read from the scope. `getTraceData` reflects the positive decision in both headers. expect(getDynamicSamplingContextFromSpan(idleSpan).sampled).toBe('true'); const data = getTraceData({ span: idleSpan }); - expect(data['sentry-trace']).toBe(`12345678901234567890123456789012-${idleSpan.spanContext().spanId}-1`); + expect(data['sentry-trace']).toBe('12345678901234567890123456789012-1234567890abcdef-1'); expect(data.baggage).toContain('sentry-sampled=true'); }); From 51b7626006852b1ea61cd60f4559f96cd9689509 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Mon, 15 Jun 2026 19:59:53 +0200 Subject: [PATCH 4/8] Add spanIsNonRecordingSpan utility --- packages/core/src/tracing/dynamicSamplingContext.ts | 4 ++-- packages/core/src/tracing/idleSpan.ts | 4 ++-- packages/core/src/tracing/sentryNonRecordingSpan.ts | 13 +++++++++++++ packages/core/src/tracing/trace.ts | 8 ++++---- packages/core/src/utils/traceData.ts | 4 ++-- 5 files changed, 23 insertions(+), 10 deletions(-) diff --git a/packages/core/src/tracing/dynamicSamplingContext.ts b/packages/core/src/tracing/dynamicSamplingContext.ts index 1509092d432d..887b6ef99fbd 100644 --- a/packages/core/src/tracing/dynamicSamplingContext.ts +++ b/packages/core/src/tracing/dynamicSamplingContext.ts @@ -14,7 +14,7 @@ import { extractOrgIdFromClient } from '../utils/dsn'; import { hasSpansEnabled } from '../utils/hasSpansEnabled'; import { addNonEnumerableProperty } from '../utils/object'; import { getRootSpan, spanIsSampled, spanToJSON } from '../utils/spanUtils'; -import { SentryNonRecordingSpan } from './sentryNonRecordingSpan'; +import { spanIsNonRecordingSpan } from './sentryNonRecordingSpan'; import { getCapturedScopesOnSpan } from './utils'; /** @@ -109,7 +109,7 @@ export function getDynamicSamplingContextFromSpan(span: Span): Readonly Date: Mon, 15 Jun 2026 20:54:18 +0200 Subject: [PATCH 5/8] Keep explicit unsampled decision for non-recording spans in tracing mode --- .../rollup-utils/plugins/bundlePlugins.mjs | 2 ++ packages/core/src/utils/traceData.ts | 15 +++++++++------ packages/core/test/lib/utils/traceData.test.ts | 16 ++++++++++++++++ 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/dev-packages/rollup-utils/plugins/bundlePlugins.mjs b/dev-packages/rollup-utils/plugins/bundlePlugins.mjs index 56712e33a458..ef9fb4acc8a5 100644 --- a/dev-packages/rollup-utils/plugins/bundlePlugins.mjs +++ b/dev-packages/rollup-utils/plugins/bundlePlugins.mjs @@ -140,6 +140,8 @@ export function makeTerserPlugin() { '_sentryId', // Keeps the frozen DSC on a Sentry Span '_frozenDsc', + // Brand used by `spanIsNonRecordingSpan` to detect non-recording spans across bundles + '_sentryNonRecordingSpan', // These are used to keep span & scope relationships '_sentryRootSpan', '_sentryChildSpans', diff --git a/packages/core/src/utils/traceData.ts b/packages/core/src/utils/traceData.ts index 017d452fe9ab..62c98a8fc7f8 100644 --- a/packages/core/src/utils/traceData.ts +++ b/packages/core/src/utils/traceData.ts @@ -10,6 +10,7 @@ import type { Span } from '../types/span'; import type { SerializedTraceData } from '../types/tracing'; import { dynamicSamplingContextToSentryBaggageHeader } from './baggage'; import { debug } from './debug-logger'; +import { hasSpansEnabled } from './hasSpansEnabled'; import { getActiveSpan, spanToTraceHeader, spanToTraceparentHeader } from './spanUtils'; import { generateSentryTraceHeader, generateTraceparentHeader, TRACEPARENT_REGEXP } from './tracing'; @@ -48,9 +49,12 @@ export function getTraceData( const scope = options.scope || getCurrentScope(); const span = options.span || getActiveSpan(); - // A non-recording span is a Tracing-without-Performance placeholder that carries no sampling - // decision of its own. The scope is the source of truth, so we read the headers from the scope. - const isNonRecordingSpan = spanIsNonRecordingSpan(span); + // In Tracing-without-Performance (TwP) mode, spans are non-recording placeholders that carry no + // sampling decision of their own (it's deferred). The scope is the source of truth, so we read + // the trace headers from the scope. A non-recording span in *tracing* mode (e.g. an unsampled + // child or an ignored span) is different: it represents an explicit negative decision, which + // `spanToTraceHeader` correctly encodes as `-0`, so we keep reading those from the span. + const isTwpPlaceholder = spanIsNonRecordingSpan(span) && !hasSpansEnabled(client.getOptions()); // When there's no recording span and an external propagation context is registered (e.g. OTLP // integration), return empty to let the external propagator handle outgoing request propagation. @@ -58,7 +62,7 @@ export function getTraceData( return {}; } - const sentryTrace = span && !isNonRecordingSpan ? spanToTraceHeader(span) : scopeToTraceHeader(scope); + const sentryTrace = span && !isTwpPlaceholder ? spanToTraceHeader(span) : scopeToTraceHeader(scope); const dsc = span ? getDynamicSamplingContextFromSpan(span) : getDynamicSamplingContextFromScope(client, scope); const baggage = dynamicSamplingContextToSentryBaggageHeader(dsc); @@ -74,8 +78,7 @@ export function getTraceData( }; if (options.propagateTraceparent) { - traceData.traceparent = - span && !isNonRecordingSpan ? spanToTraceparentHeader(span) : scopeToTraceparentHeader(scope); + traceData.traceparent = span && !isTwpPlaceholder ? spanToTraceparentHeader(span) : scopeToTraceparentHeader(scope); } return traceData; diff --git a/packages/core/test/lib/utils/traceData.test.ts b/packages/core/test/lib/utils/traceData.test.ts index 09ffdb1c7ea8..44faad7e8cfe 100644 --- a/packages/core/test/lib/utils/traceData.test.ts +++ b/packages/core/test/lib/utils/traceData.test.ts @@ -173,6 +173,22 @@ describe('getTraceData', () => { }); }); + it('keeps the negative sampling decision for a non-recording child of an unsampled tracing-mode span', () => { + // With tracing enabled but sampled out, the child of an unsampled span is a non-recording span. + // Unlike a TwP placeholder, it carries an explicit negative decision that must propagate as `-0`, + // not be deferred by reading the (undecided) scope. + setupClient({ tracesSampleRate: 0 }); + + startSpan({ name: 'unsampled-root' }, () => { + startSpan({ name: 'unsampled-child' }, () => { + const data = getTraceData(); + + expect(data['sentry-trace']).toMatch(/^[a-f0-9]{32}-[a-f0-9]{16}-0$/); + expect(data.baggage).toContain('sentry-sampled=false'); + }); + }); + }); + it('keeps a continued positive sampling decision for an active TwP placeholder span', () => { setupClient({ tracesSampleRate: undefined }); From 14cf773720274c950995614b6d7864445521eb09 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Tue, 16 Jun 2026 15:24:07 +0200 Subject: [PATCH 6/8] Replace branding with Symbol.for --- dev-packages/rollup-utils/plugins/bundlePlugins.mjs | 2 -- packages/core/src/tracing/sentryNonRecordingSpan.ts | 7 ++++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/dev-packages/rollup-utils/plugins/bundlePlugins.mjs b/dev-packages/rollup-utils/plugins/bundlePlugins.mjs index ef9fb4acc8a5..56712e33a458 100644 --- a/dev-packages/rollup-utils/plugins/bundlePlugins.mjs +++ b/dev-packages/rollup-utils/plugins/bundlePlugins.mjs @@ -140,8 +140,6 @@ export function makeTerserPlugin() { '_sentryId', // Keeps the frozen DSC on a Sentry Span '_frozenDsc', - // Brand used by `spanIsNonRecordingSpan` to detect non-recording spans across bundles - '_sentryNonRecordingSpan', // These are used to keep span & scope relationships '_sentryRootSpan', '_sentryChildSpans', diff --git a/packages/core/src/tracing/sentryNonRecordingSpan.ts b/packages/core/src/tracing/sentryNonRecordingSpan.ts index 45e0ef930e21..bc65e90e185c 100644 --- a/packages/core/src/tracing/sentryNonRecordingSpan.ts +++ b/packages/core/src/tracing/sentryNonRecordingSpan.ts @@ -16,9 +16,10 @@ interface SentryNonRecordingSpanArguments extends SentrySpanArguments { dropReason?: EventDropReason; } -// Non-enumerable brand used to detect non-recording spans via {@link spanIsNonRecordingSpan} -// without `instanceof`, which is brittle when `@sentry/core` is duplicated across packages. -const NON_RECORDING_SPAN_FIELD = '_sentryNonRecordingSpan'; +// Brand used to detect non-recording spans via {@link spanIsNonRecordingSpan} without `instanceof`, +// which is brittle when `@sentry/core` is duplicated across packages. We use `Symbol.for` so the key +// is shared across copies of the module, and so user payloads (e.g. JSON) cannot spoof the marker. +const NON_RECORDING_SPAN_FIELD = Symbol.for('sentry.nonRecordingSpan'); /** * A Sentry Span that is non-recording, meaning it will not be sent to Sentry. From 8b318697a2228fbc0845da415a74dc963c2d41c7 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Tue, 16 Jun 2026 16:02:12 +0200 Subject: [PATCH 7/8] Ensure onlyIfParent spans have correct traceId in TwP --- packages/core/src/tracing/trace.ts | 6 +++--- .../core/test/lib/utils/traceData.test.ts | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/packages/core/src/tracing/trace.ts b/packages/core/src/tracing/trace.ts index 7212484ca974..4310ed3bf850 100644 --- a/packages/core/src/tracing/trace.ts +++ b/packages/core/src/tracing/trace.ts @@ -71,7 +71,7 @@ export function startSpan(options: StartSpanOptions, callback: (span: Span) = const missingRequiredParent = options.onlyIfParent && !parentSpan; const activeSpan = missingRequiredParent - ? new SentryNonRecordingSpan() + ? new SentryNonRecordingSpan({ traceId: scope.getPropagationContext().traceId }) : createChildOrRootSpan({ parentSpan, spanArguments, @@ -138,7 +138,7 @@ export function startSpanManual(options: StartSpanOptions, callback: (span: S const missingRequiredParent = options.onlyIfParent && !parentSpan; const activeSpan = missingRequiredParent - ? new SentryNonRecordingSpan() + ? new SentryNonRecordingSpan({ traceId: scope.getPropagationContext().traceId }) : createChildOrRootSpan({ parentSpan, spanArguments, @@ -209,7 +209,7 @@ export function startInactiveSpan(options: StartSpanOptions): Span { if (missingRequiredParent) { client?.recordDroppedEvent('no_parent_span', 'span'); - return new SentryNonRecordingSpan(); + return new SentryNonRecordingSpan({ traceId: scope.getPropagationContext().traceId }); } return createChildOrRootSpan({ diff --git a/packages/core/test/lib/utils/traceData.test.ts b/packages/core/test/lib/utils/traceData.test.ts index 44faad7e8cfe..c8fba2b424c7 100644 --- a/packages/core/test/lib/utils/traceData.test.ts +++ b/packages/core/test/lib/utils/traceData.test.ts @@ -162,6 +162,25 @@ describe('getTraceData', () => { }); }); + it('keeps the scope trace id consistent for an onlyIfParent placeholder without a parent', () => { + // `onlyIfParent` without a parent yields a non-recording placeholder. It must continue the + // scope's trace, so `sentry-trace` and `baggage` agree on the trace id instead of the + // placeholder inventing a random one (which would break distributed tracing). + setupClient({ tracesSampleRate: undefined }); + + getCurrentScope().setPropagationContext({ + traceId: '12345678901234567890123456789012', + sampleRand: 0.42, + }); + + startSpan({ name: 'child', onlyIfParent: true }, () => { + const data = getTraceData(); + + expect(data['sentry-trace']).toMatch(/^12345678901234567890123456789012-[a-f0-9]{16}$/); + expect(data.baggage).toContain('sentry-trace_id=12345678901234567890123456789012'); + }); + }); + it('keeps an explicit negative sampling decision for an active unsampled span', () => { setupClient({ tracesSampleRate: 0 }); From b65741ef80b8f8ff870b4facb3566f707eedc4cc Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Tue, 16 Jun 2026 16:31:50 +0200 Subject: [PATCH 8/8] Preserve continued trace DSC for onlyIfParent placeholders --- .../src/tracing/dynamicSamplingContext.ts | 2 +- packages/core/src/tracing/trace.ts | 28 +++++++++------- .../core/test/lib/utils/traceData.test.ts | 32 +++++++++++++++++++ 3 files changed, 49 insertions(+), 13 deletions(-) diff --git a/packages/core/src/tracing/dynamicSamplingContext.ts b/packages/core/src/tracing/dynamicSamplingContext.ts index 887b6ef99fbd..7aa7f159aa38 100644 --- a/packages/core/src/tracing/dynamicSamplingContext.ts +++ b/packages/core/src/tracing/dynamicSamplingContext.ts @@ -107,7 +107,7 @@ export function getDynamicSamplingContextFromSpan(span: Span): Readonly(options: StartSpanOptions, callback: (span: Span) = const missingRequiredParent = options.onlyIfParent && !parentSpan; const activeSpan = missingRequiredParent - ? new SentryNonRecordingSpan({ traceId: scope.getPropagationContext().traceId }) + ? startMissingRequiredParentSpan(scope, client) : createChildOrRootSpan({ parentSpan, spanArguments, @@ -79,10 +79,6 @@ export function startSpan(options: StartSpanOptions, callback: (span: Span) = scope, }); - if (missingRequiredParent) { - client?.recordDroppedEvent('no_parent_span', 'span'); - } - // Ignored root spans still need to be set on scope so that `getActiveSpan()` returns them // and descendants are also non-recording. Ignored child spans don't need this because // the parent span is already on scope. @@ -138,7 +134,7 @@ export function startSpanManual(options: StartSpanOptions, callback: (span: S const missingRequiredParent = options.onlyIfParent && !parentSpan; const activeSpan = missingRequiredParent - ? new SentryNonRecordingSpan({ traceId: scope.getPropagationContext().traceId }) + ? startMissingRequiredParentSpan(scope, getClient()) : createChildOrRootSpan({ parentSpan, spanArguments, @@ -146,10 +142,6 @@ export function startSpanManual(options: StartSpanOptions, callback: (span: S scope, }); - if (missingRequiredParent) { - getClient()?.recordDroppedEvent('no_parent_span', 'span'); - } - // We don't set ignored child spans onto the scope because there likely is an active, // unignored span on the scope already. if (!_isIgnoredSpan(activeSpan) || !parentSpan) { @@ -208,8 +200,7 @@ export function startInactiveSpan(options: StartSpanOptions): Span { const missingRequiredParent = options.onlyIfParent && !parentSpan; if (missingRequiredParent) { - client?.recordDroppedEvent('no_parent_span', 'span'); - return new SentryNonRecordingSpan({ traceId: scope.getPropagationContext().traceId }); + return startMissingRequiredParentSpan(scope, client); } return createChildOrRootSpan({ @@ -332,6 +323,19 @@ export function startNewTrace(callback: () => T): T { }); } +/** + * The placeholder returned from `startSpan*` when `onlyIfParent` is set but there is no parent span. + * It carries the current trace id and captured scopes so the trace data it propagates (and any nested + * span that resolves it as its root via `getRootSpan`) reads its DSC from the scope, preserving a + * continued trace's DSC instead of fabricating a fresh client one. Also records the dropped-span outcome. + */ +function startMissingRequiredParentSpan(scope: Scope, client: Client | undefined): SentryNonRecordingSpan { + client?.recordDroppedEvent('no_parent_span', 'span'); + const span = new SentryNonRecordingSpan({ traceId: scope.getPropagationContext().traceId }); + setCapturedScopesOnSpan(span, scope, getIsolationScope()); + return span; +} + function createChildOrRootSpan({ parentSpan, spanArguments, diff --git a/packages/core/test/lib/utils/traceData.test.ts b/packages/core/test/lib/utils/traceData.test.ts index c8fba2b424c7..de6682a10596 100644 --- a/packages/core/test/lib/utils/traceData.test.ts +++ b/packages/core/test/lib/utils/traceData.test.ts @@ -181,6 +181,38 @@ describe('getTraceData', () => { }); }); + it('preserves the continued-trace DSC under (and nested within) an onlyIfParent placeholder', () => { + // The placeholder captures the scope, so it (and any nested span that resolves it as its root + // via `getRootSpan`) reads the continued trace's DSC from the scope instead of fabricating a + // fresh client one. + setupClient({ tracesSampleRate: undefined }); + + getCurrentScope().setPropagationContext({ + traceId: '12345678901234567890123456789012', + sampleRand: 0.42, + sampled: true, + dsc: { + environment: 'production', + public_key: '123', + trace_id: '12345678901234567890123456789012', + sampled: 'true', + sample_rate: '0.5', + transaction: 'continued-root-txn', + }, + }); + + startSpan({ name: 'parent', onlyIfParent: true }, () => { + expect(getTraceData().baggage).toContain('sentry-transaction=continued-root-txn'); + + startSpan({ name: 'nested' }, () => { + const baggage = getTraceData().baggage; + expect(baggage).toContain('sentry-transaction=continued-root-txn'); + expect(baggage).toContain('sentry-sample_rate=0.5'); + expect(baggage).toContain('sentry-sampled=true'); + }); + }); + }); + it('keeps an explicit negative sampling decision for an active unsampled span', () => { setupClient({ tracesSampleRate: 0 });