Skip to content

Commit c0d52df

Browse files
logaretmclaude
andauthored
fix(node): Ensure startNewTrace propagates traceId in OTel environments (#19963)
## Summary - Add OTel-aware `startNewTrace` implementation that injects the new traceId as a remote span context into the OTel context - Add `startNewTrace` to the `AsyncContextStrategy` interface so OTel can override the default behavior - Register the new implementation in the OTel async context strategy ### Root Cause `startNewTrace` set a new `traceId` on the Sentry scope's propagation context but only called `withActiveSpan(null, callback)`, which in OTel translates to `trace.deleteSpan(context.active())`. This removed the active span but did **not** inject the new traceId into the OTel context. Each subsequent `startInactiveSpan` call created a root span with a fresh random traceId from OTel's tracer. The fix follows the same pattern as `continueTrace` — injecting the traceId as a remote span context via `trace.setSpanContext()` so all spans in the callback inherit it. Closes #19952 --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e3bdbed commit c0d52df

File tree

5 files changed

+138
-2
lines changed

5 files changed

+138
-2
lines changed

packages/core/src/asyncContext/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { getTraceData } from '../utils/traceData';
33
import type {
44
continueTrace,
55
startInactiveSpan,
6+
startNewTrace,
67
startSpan,
78
startSpanManual,
89
suppressTracing,
@@ -76,4 +77,7 @@ export interface AsyncContextStrategy {
7677
* and `<meta name="baggage">` HTML tags.
7778
*/
7879
continueTrace?: typeof continueTrace;
80+
81+
/** Start a new trace, ensuring all spans in the callback share the same traceId. */
82+
startNewTrace?: typeof startNewTrace;
7983
}

packages/core/src/tracing/trace.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,11 @@ export function suppressTracing<T>(callback: () => T): T {
291291
* or page will automatically create a new trace.
292292
*/
293293
export function startNewTrace<T>(callback: () => T): T {
294+
const acs = getAcs();
295+
if (acs.startNewTrace) {
296+
return acs.startNewTrace(callback);
297+
}
298+
294299
return withScope(scope => {
295300
scope.setPropagationContext({
296301
traceId: generateTraceId(),

packages/opentelemetry/src/asyncContextStrategy.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
SENTRY_FORK_SET_ISOLATION_SCOPE_CONTEXT_KEY,
77
SENTRY_FORK_SET_SCOPE_CONTEXT_KEY,
88
} from './constants';
9-
import { continueTrace, startInactiveSpan, startSpan, startSpanManual, withActiveSpan } from './trace';
9+
import { continueTrace, startInactiveSpan, startNewTrace, startSpan, startSpanManual, withActiveSpan } from './trace';
1010
import type { CurrentScopes } from './types';
1111
import { getContextFromScope, getScopesFromContext } from './utils/contextData';
1212
import { getActiveSpan } from './utils/getActiveSpan';
@@ -104,6 +104,7 @@ export function setOpenTelemetryContextAsyncContextStrategy(): void {
104104
suppressTracing,
105105
getTraceData,
106106
continueTrace,
107+
startNewTrace,
107108
// The types here don't fully align, because our own `Span` type is narrower
108109
// than the OTEL one - but this is OK for here, as we now we'll only have OTEL spans passed around
109110
withActiveSpan: withActiveSpan as typeof defaultWithActiveSpan,

packages/opentelemetry/src/trace.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ import type {
1010
TraceContext,
1111
} from '@sentry/core';
1212
import {
13+
_INTERNAL_safeMathRandom,
14+
generateSpanId,
15+
generateTraceId,
1316
getClient,
1417
getCurrentScope,
1518
getDynamicSamplingContextFromScope,
@@ -291,6 +294,36 @@ export function continueTrace<T>(options: Parameters<typeof baseContinueTrace>[0
291294
return continueTraceAsRemoteSpan(context.active(), options, callback);
292295
}
293296

297+
/**
298+
* Start a new trace with a unique traceId, ensuring all spans created within the callback
299+
* share the same traceId.
300+
*
301+
* This is a custom version of `startNewTrace` for OTEL-powered environments.
302+
* It injects the new traceId as a remote span context into the OTEL context, so that
303+
* `startInactiveSpan` and `startSpan` pick it up correctly.
304+
*/
305+
export function startNewTrace<T>(callback: () => T): T {
306+
const traceId = generateTraceId();
307+
const spanId = generateSpanId();
308+
309+
const spanContext: SpanContext = {
310+
traceId,
311+
spanId,
312+
isRemote: true,
313+
traceFlags: TraceFlags.NONE,
314+
};
315+
316+
const ctxWithTrace = trace.setSpanContext(context.active(), spanContext);
317+
318+
return context.with(ctxWithTrace, () => {
319+
getCurrentScope().setPropagationContext({
320+
traceId,
321+
sampleRand: _INTERNAL_safeMathRandom(),
322+
});
323+
return callback();
324+
});
325+
}
326+
294327
/**
295328
* Get the trace context for a given scope.
296329
* We have a custom implementation here because we need an OTEL-specific way to get the span from a scope.

packages/opentelemetry/test/trace.test.ts

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import {
2121
} from '@sentry/core';
2222
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2323
import { getParentSpanId } from '../../../packages/opentelemetry/src/utils/getParentSpanId';
24-
import { continueTrace, startInactiveSpan, startSpan, startSpanManual } from '../src/trace';
24+
import { continueTrace, startInactiveSpan, startNewTrace, startSpan, startSpanManual } from '../src/trace';
2525
import type { AbstractSpan } from '../src/types';
2626
import { getActiveSpan } from '../src/utils/getActiveSpan';
2727
import { getSamplingDecision } from '../src/utils/getSamplingDecision';
@@ -2093,6 +2093,99 @@ describe('span.end() timestamp conversion', () => {
20932093
});
20942094
});
20952095

2096+
describe('startNewTrace', () => {
2097+
beforeEach(() => {
2098+
mockSdkInit({ tracesSampleRate: 1 });
2099+
});
2100+
2101+
afterEach(async () => {
2102+
await cleanupOtel();
2103+
});
2104+
2105+
it('sequential startInactiveSpan calls share the same traceId', () => {
2106+
startNewTrace(() => {
2107+
const propagationContext = getCurrentScope().getPropagationContext();
2108+
2109+
const span1 = startInactiveSpan({ name: 'span-1' });
2110+
const span2 = startInactiveSpan({ name: 'span-2' });
2111+
const span3 = startInactiveSpan({ name: 'span-3' });
2112+
2113+
const traceId1 = span1.spanContext().traceId;
2114+
const traceId2 = span2.spanContext().traceId;
2115+
const traceId3 = span3.spanContext().traceId;
2116+
2117+
expect(traceId1).toBe(propagationContext.traceId);
2118+
expect(traceId2).toBe(propagationContext.traceId);
2119+
expect(traceId3).toBe(propagationContext.traceId);
2120+
2121+
span1.end();
2122+
span2.end();
2123+
span3.end();
2124+
});
2125+
});
2126+
2127+
it('startSpan inside startNewTrace uses the correct traceId', () => {
2128+
startNewTrace(() => {
2129+
const propagationContext = getCurrentScope().getPropagationContext();
2130+
2131+
startSpan({ name: 'parent-span' }, parentSpan => {
2132+
const parentTraceId = parentSpan.spanContext().traceId;
2133+
expect(parentTraceId).toBe(propagationContext.traceId);
2134+
2135+
const child = startInactiveSpan({ name: 'child-span' });
2136+
expect(child.spanContext().traceId).toBe(propagationContext.traceId);
2137+
child.end();
2138+
});
2139+
});
2140+
});
2141+
2142+
it('generates a different traceId than the outer trace', () => {
2143+
startSpan({ name: 'outer-span' }, outerSpan => {
2144+
const outerTraceId = outerSpan.spanContext().traceId;
2145+
2146+
startNewTrace(() => {
2147+
const innerSpan = startInactiveSpan({ name: 'inner-span' });
2148+
const innerTraceId = innerSpan.spanContext().traceId;
2149+
2150+
expect(innerTraceId).not.toBe(outerTraceId);
2151+
2152+
const propagationContext = getCurrentScope().getPropagationContext();
2153+
expect(innerTraceId).toBe(propagationContext.traceId);
2154+
2155+
innerSpan.end();
2156+
});
2157+
});
2158+
});
2159+
2160+
it('allows spans to be sampled based on tracesSampleRate', () => {
2161+
startNewTrace(() => {
2162+
const span = startInactiveSpan({ name: 'sampled-span' });
2163+
// tracesSampleRate is 1 in mockSdkInit, so spans should be sampled
2164+
// This verifies that TraceFlags.NONE on the remote span context does not
2165+
// cause the sampler to inherit a "not sampled" decision from the parent
2166+
expect(spanIsSampled(span)).toBe(true);
2167+
span.end();
2168+
});
2169+
});
2170+
2171+
it('does not leak the new traceId to the outer scope', () => {
2172+
const outerScope = getCurrentScope();
2173+
const outerTraceId = outerScope.getPropagationContext().traceId;
2174+
2175+
startNewTrace(() => {
2176+
// Manually set a known traceId on the inner scope to verify it doesn't leak
2177+
getCurrentScope().setPropagationContext({
2178+
traceId: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
2179+
sampleRand: 0.5,
2180+
});
2181+
});
2182+
2183+
const afterTraceId = outerScope.getPropagationContext().traceId;
2184+
expect(afterTraceId).toBe(outerTraceId);
2185+
expect(afterTraceId).not.toBe('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa');
2186+
});
2187+
});
2188+
20962189
function getSpanName(span: AbstractSpan): string | undefined {
20972190
return spanHasName(span) ? span.name : undefined;
20982191
}

0 commit comments

Comments
 (0)