From fbfdb3d9a1c2a38fe18e6656a300fffb3a3fea83 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Tue, 4 Aug 2026 11:15:18 +0200 Subject: [PATCH 1/2] fix(core): Propagate sample_rand when continuing a trace without incoming baggage When a trace is continued from a `sentry-trace` header with no (or only 3rd-party) baggage, the frozen Dynamic Sampling Context is empty and previously dropped the `sample_rand`. Downstream services then received baggage without `sentry-sample_rand`, so sampling decisions could diverge across the trace. Fold the scope's `sample_rand` into the otherwise-empty DSC at freeze time; a populated incoming DSC is left untouched. Co-Authored-By: Claude Opus 4.8 --- .../test/tracing/browserTracingIntegration.test.ts | 4 +++- packages/core/src/tracing/trace.ts | 10 ++++++++-- packages/sveltekit/test/server-common/handle.test.ts | 4 +++- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/browser/test/tracing/browserTracingIntegration.test.ts b/packages/browser/test/tracing/browserTracingIntegration.test.ts index ee8585ef53bd..769d8963586f 100644 --- a/packages/browser/test/tracing/browserTracingIntegration.test.ts +++ b/packages/browser/test/tracing/browserTracingIntegration.test.ts @@ -1132,7 +1132,9 @@ describe('browserTracingIntegration', () => { expect(spanIsSampled(idleSpan)).toBe(false); expect(dynamicSamplingContext).toBeDefined(); - expect(dynamicSamplingContext).toStrictEqual({}); + // Continuing a trace without an incoming Sentry DSC does not populate a new one, but the + // `sample_rand` is still propagated so downstream sampling decisions stay consistent. + expect(dynamicSamplingContext).toStrictEqual({ sample_rand: expect.stringMatching(/^0(\.\d+)?$/) }); // Propagation context keeps the meta tag trace data for later events on the same route to add them to the trace expect(propagationContext.traceId).toEqual('12312012123120121231201212312012'); diff --git a/packages/core/src/tracing/trace.ts b/packages/core/src/tracing/trace.ts index cfcdea9d4ecb..1e629d85d946 100644 --- a/packages/core/src/tracing/trace.ts +++ b/packages/core/src/tracing/trace.ts @@ -433,7 +433,7 @@ function createChildOrRootSpan({ freezeDscOnSpan(span, dsc); } else { - const { traceId, dsc, parentSpanId, sampled: parentSampled } = scope.getPropagationContext(); + const { traceId, dsc, parentSpanId, sampled: parentSampled, sampleRand } = scope.getPropagationContext(); span = _startRootSpan( { @@ -447,7 +447,13 @@ function createChildOrRootSpan({ ); if (dsc) { - freezeDscOnSpan(span, dsc); + // A trace continued without incoming baggage carries an empty DSC (we are not the head of + // trace). Fold in the scope's `sample_rand` so it still propagates downstream and sampling + // decisions stay consistent across the trace. A populated frozen DSC (e.g. an OTel remote + // parent whose DSC came in via baggage/trace state) is left untouched. + const dscWithSampleRand = + Object.keys(dsc).length === 0 && sampleRand !== undefined ? { sample_rand: sampleRand.toString() } : dsc; + freezeDscOnSpan(span, dscWithSampleRand); } } diff --git a/packages/sveltekit/test/server-common/handle.test.ts b/packages/sveltekit/test/server-common/handle.test.ts index bdfc39703da7..dab43d3b938d 100644 --- a/packages/sveltekit/test/server-common/handle.test.ts +++ b/packages/sveltekit/test/server-common/handle.test.ts @@ -265,7 +265,9 @@ describe('sentryHandle', () => { expect(_span!.spanContext().traceId).toEqual('1234567890abcdef1234567890abcdef'); expect(spanToJSON(_span!).parent_span_id).toEqual('1234567890abcdef'); expect(spanIsSampled(_span!)).toEqual(true); - expect(envelopeHeaders!.trace).toEqual({}); + // Continuing a trace without incoming baggage does not populate a new DSC, but the `sample_rand` + // is still propagated so downstream sampling decisions stay consistent across the trace. + expect(envelopeHeaders!.trace).toEqual({ sample_rand: expect.stringMatching(/^0(\.\d+)?$/) }); }); it('creates a transaction with dynamic sampling context from baggage header', async () => { From 128d9c5b8232a268faf56cf95346dcea5d3ce2cd Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Tue, 4 Aug 2026 11:28:25 +0200 Subject: [PATCH 2/2] Assert propagated sample_rand equals the scope value in the regression tests Compare the frozen DSC's sample_rand against the scope's propagation-context value instead of only format-checking it, so an implementation that minted a fresh random would fail. --- .../test/tracing/browserTracingIntegration.test.ts | 4 ++-- packages/sveltekit/test/server-common/handle.test.ts | 9 ++++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/browser/test/tracing/browserTracingIntegration.test.ts b/packages/browser/test/tracing/browserTracingIntegration.test.ts index 769d8963586f..a95b57d78724 100644 --- a/packages/browser/test/tracing/browserTracingIntegration.test.ts +++ b/packages/browser/test/tracing/browserTracingIntegration.test.ts @@ -1133,8 +1133,8 @@ describe('browserTracingIntegration', () => { expect(dynamicSamplingContext).toBeDefined(); // Continuing a trace without an incoming Sentry DSC does not populate a new one, but the - // `sample_rand` is still propagated so downstream sampling decisions stay consistent. - expect(dynamicSamplingContext).toStrictEqual({ sample_rand: expect.stringMatching(/^0(\.\d+)?$/) }); + // scope's `sample_rand` is still propagated so downstream sampling decisions stay consistent. + expect(dynamicSamplingContext).toStrictEqual({ sample_rand: propagationContext.sampleRand.toString() }); // Propagation context keeps the meta tag trace data for later events on the same route to add them to the trace expect(propagationContext.traceId).toEqual('12312012123120121231201212312012'); diff --git a/packages/sveltekit/test/server-common/handle.test.ts b/packages/sveltekit/test/server-common/handle.test.ts index dab43d3b938d..b52da044fbe7 100644 --- a/packages/sveltekit/test/server-common/handle.test.ts +++ b/packages/sveltekit/test/server-common/handle.test.ts @@ -1,6 +1,7 @@ import type { EventEnvelopeHeaders, Span } from '@sentry/core'; import { HTTP_ROUTE } from '@sentry/conventions/attributes'; import { + getCapturedScopesOnSpan, getRootSpan, getSpanDescendants, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, @@ -265,9 +266,11 @@ describe('sentryHandle', () => { expect(_span!.spanContext().traceId).toEqual('1234567890abcdef1234567890abcdef'); expect(spanToJSON(_span!).parent_span_id).toEqual('1234567890abcdef'); expect(spanIsSampled(_span!)).toEqual(true); - // Continuing a trace without incoming baggage does not populate a new DSC, but the `sample_rand` - // is still propagated so downstream sampling decisions stay consistent across the trace. - expect(envelopeHeaders!.trace).toEqual({ sample_rand: expect.stringMatching(/^0(\.\d+)?$/) }); + // Continuing a trace without incoming baggage does not populate a new DSC, but the scope's + // `sample_rand` is still propagated so downstream sampling decisions stay consistent. Assert it + // matches the scope value rather than just any number, so a freshly-minted rand would fail. + const scopeSampleRand = getCapturedScopesOnSpan(_span!).scope!.getPropagationContext().sampleRand; + expect(envelopeHeaders!.trace).toEqual({ sample_rand: scopeSampleRand.toString() }); }); it('creates a transaction with dynamic sampling context from baggage header', async () => {