From 228bc7232a5555cc4b49bf1f8838b80e76910b34 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 24 Mar 2026 15:32:22 -0400 Subject: [PATCH 1/4] fix(node): Ensure `startNewTrace` propagates traceId in OTel environments `startNewTrace` only set the traceId on the Sentry scope's propagation context but did not inject it into the OTel context. This caused each `startInactiveSpan` call within the callback to get a fresh random traceId from OTel's tracer instead of sharing the one from `startNewTrace`. Add an OTel-aware `startNewTrace` implementation that injects the new traceId as a remote span context, following the same pattern as `continueTrace`. Closes #19952 Closes #18401 Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/core/src/asyncContext/types.ts | 4 ++ packages/core/src/tracing/trace.ts | 5 ++ .../opentelemetry/src/asyncContextStrategy.ts | 3 +- packages/opentelemetry/src/trace.ts | 32 +++++++++ packages/opentelemetry/test/trace.test.ts | 67 ++++++++++++++++++- 5 files changed, 109 insertions(+), 2 deletions(-) diff --git a/packages/core/src/asyncContext/types.ts b/packages/core/src/asyncContext/types.ts index 97af0af1b88a..be1ea92a7736 100644 --- a/packages/core/src/asyncContext/types.ts +++ b/packages/core/src/asyncContext/types.ts @@ -3,6 +3,7 @@ import type { getTraceData } from '../utils/traceData'; import type { continueTrace, startInactiveSpan, + startNewTrace, startSpan, startSpanManual, suppressTracing, @@ -76,4 +77,7 @@ export interface AsyncContextStrategy { * and `` HTML tags. */ continueTrace?: typeof continueTrace; + + /** Start a new trace, ensuring all spans in the callback share the same traceId. */ + startNewTrace?: typeof startNewTrace; } diff --git a/packages/core/src/tracing/trace.ts b/packages/core/src/tracing/trace.ts index 28a5bccd4147..d3043808260f 100644 --- a/packages/core/src/tracing/trace.ts +++ b/packages/core/src/tracing/trace.ts @@ -291,6 +291,11 @@ export function suppressTracing(callback: () => T): T { * or page will automatically create a new trace. */ export function startNewTrace(callback: () => T): T { + const acs = getAcs(); + if (acs.startNewTrace) { + return acs.startNewTrace(callback); + } + return withScope(scope => { scope.setPropagationContext({ traceId: generateTraceId(), diff --git a/packages/opentelemetry/src/asyncContextStrategy.ts b/packages/opentelemetry/src/asyncContextStrategy.ts index 9f7b38d0b43d..7cb8dc0f54eb 100644 --- a/packages/opentelemetry/src/asyncContextStrategy.ts +++ b/packages/opentelemetry/src/asyncContextStrategy.ts @@ -6,7 +6,7 @@ import { SENTRY_FORK_SET_ISOLATION_SCOPE_CONTEXT_KEY, SENTRY_FORK_SET_SCOPE_CONTEXT_KEY, } from './constants'; -import { continueTrace, startInactiveSpan, startSpan, startSpanManual, withActiveSpan } from './trace'; +import { continueTrace, startInactiveSpan, startNewTrace, startSpan, startSpanManual, withActiveSpan } from './trace'; import type { CurrentScopes } from './types'; import { getContextFromScope, getScopesFromContext } from './utils/contextData'; import { getActiveSpan } from './utils/getActiveSpan'; @@ -104,6 +104,7 @@ export function setOpenTelemetryContextAsyncContextStrategy(): void { suppressTracing, getTraceData, continueTrace, + startNewTrace, // The types here don't fully align, because our own `Span` type is narrower // than the OTEL one - but this is OK for here, as we now we'll only have OTEL spans passed around withActiveSpan: withActiveSpan as typeof defaultWithActiveSpan, diff --git a/packages/opentelemetry/src/trace.ts b/packages/opentelemetry/src/trace.ts index b651ea16ccab..d0412c76cc66 100644 --- a/packages/opentelemetry/src/trace.ts +++ b/packages/opentelemetry/src/trace.ts @@ -10,6 +10,9 @@ import type { TraceContext, } from '@sentry/core'; import { + _INTERNAL_safeMathRandom, + generateSpanId, + generateTraceId, getClient, getCurrentScope, getDynamicSamplingContextFromScope, @@ -291,6 +294,35 @@ export function continueTrace(options: Parameters[0 return continueTraceAsRemoteSpan(context.active(), options, callback); } +/** + * Start a new trace with a unique traceId, ensuring all spans created within the callback + * share the same traceId. + * + * This is a custom version of `startNewTrace` for OTEL-powered environments. + * It injects the new traceId as a remote span context into the OTEL context, so that + * `startInactiveSpan` and `startSpan` pick it up correctly. + */ +export function startNewTrace(callback: () => T): T { + const traceId = generateTraceId(); + const spanId = generateSpanId(); + + getCurrentScope().setPropagationContext({ + traceId, + sampleRand: _INTERNAL_safeMathRandom(), + }); + + const spanContext: SpanContext = { + traceId, + spanId, + isRemote: true, + traceFlags: TraceFlags.NONE, + }; + + const ctxWithTrace = trace.setSpanContext(context.active(), spanContext); + + return context.with(ctxWithTrace, callback); +} + /** * Get the trace context for a given scope. * We have a custom implementation here because we need an OTEL-specific way to get the span from a scope. diff --git a/packages/opentelemetry/test/trace.test.ts b/packages/opentelemetry/test/trace.test.ts index aa8829341963..a091877e03bf 100644 --- a/packages/opentelemetry/test/trace.test.ts +++ b/packages/opentelemetry/test/trace.test.ts @@ -21,7 +21,7 @@ import { } from '@sentry/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { getParentSpanId } from '../../../packages/opentelemetry/src/utils/getParentSpanId'; -import { continueTrace, startInactiveSpan, startSpan, startSpanManual } from '../src/trace'; +import { continueTrace, startInactiveSpan, startNewTrace, startSpan, startSpanManual } from '../src/trace'; import type { AbstractSpan } from '../src/types'; import { getActiveSpan } from '../src/utils/getActiveSpan'; import { getSamplingDecision } from '../src/utils/getSamplingDecision'; @@ -2093,6 +2093,71 @@ describe('span.end() timestamp conversion', () => { }); }); +describe('startNewTrace', () => { + beforeEach(() => { + mockSdkInit({ tracesSampleRate: 1 }); + }); + + afterEach(async () => { + await cleanupOtel(); + }); + + it('sequential startInactiveSpan calls share the same traceId', () => { + startNewTrace(() => { + const propagationContext = getCurrentScope().getPropagationContext(); + + const span1 = startInactiveSpan({ name: 'span-1' }); + const span2 = startInactiveSpan({ name: 'span-2' }); + const span3 = startInactiveSpan({ name: 'span-3' }); + + const traceId1 = span1.spanContext().traceId; + const traceId2 = span2.spanContext().traceId; + const traceId3 = span3.spanContext().traceId; + + expect(traceId1).toBe(propagationContext.traceId); + expect(traceId2).toBe(propagationContext.traceId); + expect(traceId3).toBe(propagationContext.traceId); + + span1.end(); + span2.end(); + span3.end(); + }); + }); + + it('startSpan inside startNewTrace uses the correct traceId', () => { + startNewTrace(() => { + const propagationContext = getCurrentScope().getPropagationContext(); + + startSpan({ name: 'parent-span' }, parentSpan => { + const parentTraceId = parentSpan.spanContext().traceId; + expect(parentTraceId).toBe(propagationContext.traceId); + + const child = startInactiveSpan({ name: 'child-span' }); + expect(child.spanContext().traceId).toBe(propagationContext.traceId); + child.end(); + }); + }); + }); + + it('generates a different traceId than the outer trace', () => { + startSpan({ name: 'outer-span' }, outerSpan => { + const outerTraceId = outerSpan.spanContext().traceId; + + startNewTrace(() => { + const innerSpan = startInactiveSpan({ name: 'inner-span' }); + const innerTraceId = innerSpan.spanContext().traceId; + + expect(innerTraceId).not.toBe(outerTraceId); + + const propagationContext = getCurrentScope().getPropagationContext(); + expect(innerTraceId).toBe(propagationContext.traceId); + + innerSpan.end(); + }); + }); + }); +}); + function getSpanName(span: AbstractSpan): string | undefined { return spanHasName(span) ? span.name : undefined; } From 96e2cda62156a4f7a8b8351dceee835d34b49b88 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 24 Mar 2026 15:59:30 -0400 Subject: [PATCH 2/4] fix(node): Move setPropagationContext inside context.with to prevent scope leakage Address review feedback: calling setPropagationContext before context.with mutated the outer scope, leaking the new traceId after the callback returned. Move it inside the context.with callback so it only affects the cloned scope. Also add a regression test asserting the outer scope is not modified. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/opentelemetry/src/trace.ts | 13 +++++++------ packages/opentelemetry/test/trace.test.ts | 13 +++++++++++++ 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/packages/opentelemetry/src/trace.ts b/packages/opentelemetry/src/trace.ts index d0412c76cc66..7c9d09a169b9 100644 --- a/packages/opentelemetry/src/trace.ts +++ b/packages/opentelemetry/src/trace.ts @@ -306,11 +306,6 @@ export function startNewTrace(callback: () => T): T { const traceId = generateTraceId(); const spanId = generateSpanId(); - getCurrentScope().setPropagationContext({ - traceId, - sampleRand: _INTERNAL_safeMathRandom(), - }); - const spanContext: SpanContext = { traceId, spanId, @@ -320,7 +315,13 @@ export function startNewTrace(callback: () => T): T { const ctxWithTrace = trace.setSpanContext(context.active(), spanContext); - return context.with(ctxWithTrace, callback); + return context.with(ctxWithTrace, () => { + getCurrentScope().setPropagationContext({ + traceId, + sampleRand: _INTERNAL_safeMathRandom(), + }); + return callback(); + }); } /** diff --git a/packages/opentelemetry/test/trace.test.ts b/packages/opentelemetry/test/trace.test.ts index a091877e03bf..39aa534650ce 100644 --- a/packages/opentelemetry/test/trace.test.ts +++ b/packages/opentelemetry/test/trace.test.ts @@ -2156,6 +2156,19 @@ describe('startNewTrace', () => { }); }); }); + + it('does not leak the new traceId to the outer scope', () => { + const outerPropagationContext = getCurrentScope().getPropagationContext(); + const outerTraceId = outerPropagationContext.traceId; + + startNewTrace(() => { + const innerTraceId = getCurrentScope().getPropagationContext().traceId; + expect(innerTraceId).not.toBe(outerTraceId); + }); + + const afterTraceId = getCurrentScope().getPropagationContext().traceId; + expect(afterTraceId).toBe(outerTraceId); + }); }); function getSpanName(span: AbstractSpan): string | undefined { From 46e1ab924f646068ba46d8378d720208dd47e32a Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 24 Mar 2026 16:06:13 -0400 Subject: [PATCH 3/4] test(node): Add test verifying startNewTrace respects tracesSampleRate Ensures TraceFlags.NONE on the remote span context does not cause the sampler to inherit a false sampling decision from the parent. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/opentelemetry/test/trace.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/opentelemetry/test/trace.test.ts b/packages/opentelemetry/test/trace.test.ts index 39aa534650ce..639a9aeaf280 100644 --- a/packages/opentelemetry/test/trace.test.ts +++ b/packages/opentelemetry/test/trace.test.ts @@ -2157,6 +2157,17 @@ describe('startNewTrace', () => { }); }); + it('allows spans to be sampled based on tracesSampleRate', () => { + startNewTrace(() => { + const span = startInactiveSpan({ name: 'sampled-span' }); + // tracesSampleRate is 1 in mockSdkInit, so spans should be sampled + // This verifies that TraceFlags.NONE on the remote span context does not + // cause the sampler to inherit a "not sampled" decision from the parent + expect(spanIsSampled(span)).toBe(true); + span.end(); + }); + }); + it('does not leak the new traceId to the outer scope', () => { const outerPropagationContext = getCurrentScope().getPropagationContext(); const outerTraceId = outerPropagationContext.traceId; From ebd5c4c4f397c4671d732f73e2cb780e7a07db61 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Wed, 25 Mar 2026 21:57:50 -0400 Subject: [PATCH 4/4] fix(test): Make scope leak test deterministic Use an explicit traceId in the inner scope instead of relying on generateTraceId producing different values, which can be deterministic in CI with mocked random. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/opentelemetry/test/trace.test.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/opentelemetry/test/trace.test.ts b/packages/opentelemetry/test/trace.test.ts index 639a9aeaf280..a6a7f35ab76a 100644 --- a/packages/opentelemetry/test/trace.test.ts +++ b/packages/opentelemetry/test/trace.test.ts @@ -2169,16 +2169,20 @@ describe('startNewTrace', () => { }); it('does not leak the new traceId to the outer scope', () => { - const outerPropagationContext = getCurrentScope().getPropagationContext(); - const outerTraceId = outerPropagationContext.traceId; + const outerScope = getCurrentScope(); + const outerTraceId = outerScope.getPropagationContext().traceId; startNewTrace(() => { - const innerTraceId = getCurrentScope().getPropagationContext().traceId; - expect(innerTraceId).not.toBe(outerTraceId); + // Manually set a known traceId on the inner scope to verify it doesn't leak + getCurrentScope().setPropagationContext({ + traceId: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + sampleRand: 0.5, + }); }); - const afterTraceId = getCurrentScope().getPropagationContext().traceId; + const afterTraceId = outerScope.getPropagationContext().traceId; expect(afterTraceId).toBe(outerTraceId); + expect(afterTraceId).not.toBe('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'); }); });