diff --git a/packages/browser/test/tracing/browserTracingIntegration.test.ts b/packages/browser/test/tracing/browserTracingIntegration.test.ts index ee8585ef53bd..a95b57d78724 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 + // 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/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..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,7 +266,11 @@ 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 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 () => {