Skip to content

Commit fa9fea2

Browse files
logaretmclaude
andauthored
feat(opentelemetry): Add tracingChannel utility for context propagation (#20358)
- Vendors and adapts [`otel-tracing-channel`](https://github.com/logaretm/otel-tracing-channel) into `@sentry/opentelemetry` - Provides a drop-in `tracingChannel()` wrapper around Node.js `TracingChannel` that automatically binds OTel context propagation via `bindStore` - Uses our public `getAsyncLocalStorageLookup()` API to access the ALS instead of relying on OTel private `_asyncLocalStorage` internals - `subscribe`/`unsubscribe` accept partial subscriber objects so callers only provide handlers they need The util is exposed in a subpath export because nextjs does not externalize `node:diagnostic_channels` and will crash. --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 9ee1f77 commit fa9fea2

File tree

4 files changed

+356
-0
lines changed

4 files changed

+356
-0
lines changed

packages/opentelemetry/package.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,16 @@
2626
"types": "./build/types/index.d.ts",
2727
"default": "./build/cjs/index.js"
2828
}
29+
},
30+
"./tracing-channel": {
31+
"import": {
32+
"types": "./build/types/tracingChannel.d.ts",
33+
"default": "./build/esm/tracingChannel.js"
34+
},
35+
"require": {
36+
"types": "./build/types/tracingChannel.d.ts",
37+
"default": "./build/cjs/tracingChannel.js"
38+
}
2939
}
3040
},
3141
"typesVersions": {

packages/opentelemetry/rollup.npm.config.mjs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollu
22

33
export default makeNPMConfigVariants(
44
makeBaseNPMConfig({
5+
// `tracingChannel` is a Node.js-only subpath so `node:diagnostics_channel`
6+
// isn't pulled into the main bundle (breaks edge/browser builds).
7+
entrypoints: ['src/index.ts', 'src/tracingChannel.ts'],
58
packageSpecificConfig: {
69
output: {
710
// set exports to 'named' or 'auto' so that rollup doesn't warn
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/**
2+
* Vendored and adapted from https://github.com/logaretm/otel-tracing-channel
3+
*
4+
* Creates a TracingChannel with proper OpenTelemetry context propagation
5+
* using Node.js diagnostic_channel's `bindStore` mechanism.
6+
*/
7+
import type { TracingChannel, TracingChannelSubscribers } from 'node:diagnostics_channel';
8+
import { tracingChannel as nativeTracingChannel } from 'node:diagnostics_channel';
9+
import type { Span } from '@opentelemetry/api';
10+
import { context, trace } from '@opentelemetry/api';
11+
import { logger } from '@sentry/core';
12+
import type { SentryAsyncLocalStorageContextManager } from './asyncLocalStorageContextManager';
13+
import type { AsyncLocalStorageLookup } from './contextManager';
14+
import { DEBUG_BUILD } from './debug-build';
15+
16+
/**
17+
* Transform function that creates a span from the channel data.
18+
*/
19+
export type OtelTracingChannelTransform<TData = object> = (data: TData) => Span;
20+
21+
type WithSpan<TData = object> = TData & { _sentrySpan?: Span };
22+
23+
/**
24+
* A TracingChannel whose `subscribe` / `unsubscribe` accept partial subscriber
25+
* objects — you only need to provide handlers for the events you care about.
26+
*/
27+
export interface OtelTracingChannel<
28+
TData extends object = object,
29+
TDataWithSpan extends object = WithSpan<TData>,
30+
> extends Omit<TracingChannel<TData, TDataWithSpan>, 'subscribe' | 'unsubscribe'> {
31+
subscribe(subscribers: Partial<TracingChannelSubscribers<TDataWithSpan>>): void;
32+
unsubscribe(subscribers: Partial<TracingChannelSubscribers<TDataWithSpan>>): void;
33+
}
34+
35+
interface ContextApi {
36+
_getContextManager(): SentryAsyncLocalStorageContextManager;
37+
}
38+
39+
/**
40+
* Creates a new tracing channel with proper OTel context propagation.
41+
*
42+
* When the channel's `tracePromise` / `traceSync` / `traceCallback` is called,
43+
* the `transformStart` function runs inside `bindStore` so that:
44+
* 1. A new span is created from the channel data.
45+
* 2. The span is set on the OTel context stored in AsyncLocalStorage.
46+
* 3. Downstream code (including Sentry's span processor) sees the correct parent.
47+
*
48+
* @param channelNameOrInstance - Either a channel name string or an existing TracingChannel instance.
49+
* @param transformStart - Function that creates an OpenTelemetry span from the channel data.
50+
* @returns The tracing channel with OTel context bound.
51+
*/
52+
export function tracingChannel<TData extends object = object>(
53+
channelNameOrInstance: string,
54+
transformStart: OtelTracingChannelTransform<TData>,
55+
): OtelTracingChannel<TData, WithSpan<TData>> {
56+
const channel = nativeTracingChannel<WithSpan<TData>, WithSpan<TData>>(
57+
channelNameOrInstance,
58+
) as unknown as OtelTracingChannel<TData, WithSpan<TData>>;
59+
60+
let lookup: AsyncLocalStorageLookup | undefined;
61+
try {
62+
const contextManager = (context as unknown as ContextApi)._getContextManager();
63+
lookup = contextManager.getAsyncLocalStorageLookup();
64+
} catch {
65+
// getAsyncLocalStorageLookup may not exist if using a non-Sentry context manager
66+
}
67+
68+
if (!lookup) {
69+
DEBUG_BUILD &&
70+
logger.warn(
71+
'[TracingChannel] Could not access OpenTelemetry AsyncLocalStorage, context propagation will not work.',
72+
);
73+
return channel;
74+
}
75+
76+
const otelStorage = lookup.asyncLocalStorage;
77+
78+
// Bind the start channel so that each trace invocation runs the transform
79+
// and stores the resulting context (with span) in AsyncLocalStorage.
80+
// @ts-expect-error bindStore types don't account for AsyncLocalStorage of a different generic type
81+
channel.start.bindStore(otelStorage, (data: WithSpan<TData>) => {
82+
const span = transformStart(data);
83+
84+
// Store the span on data so downstream event handlers (asyncEnd, error, etc.) can access it.
85+
data._sentrySpan = span;
86+
87+
// Return the context with the span set — this is what gets stored in AsyncLocalStorage.
88+
return trace.setSpan(context.active(), span);
89+
});
90+
91+
return channel;
92+
}
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
import { context, trace } from '@opentelemetry/api';
2+
import type { ReadableSpan } from '@opentelemetry/sdk-trace-base';
3+
import { type Span, spanToJSON } from '@sentry/core';
4+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
5+
import { startSpanManual } from '../src/trace';
6+
import { tracingChannel } from '../src/tracingChannel';
7+
import { getActiveSpan } from '../src/utils/getActiveSpan';
8+
import { getParentSpanId } from '../src/utils/getParentSpanId';
9+
import { cleanupOtel, mockSdkInit } from './helpers/mockSdkInit';
10+
11+
describe('tracingChannel', () => {
12+
beforeEach(() => {
13+
mockSdkInit({ tracesSampleRate: 1 });
14+
});
15+
16+
afterEach(async () => {
17+
await cleanupOtel();
18+
});
19+
20+
it('sets the created span as the active span inside traceSync', () => {
21+
const channel = tracingChannel<{ op: string }>('test:sync:active', data => {
22+
return startSpanManual({ name: 'channel-span', op: data.op }, span => span);
23+
});
24+
25+
channel.subscribe({
26+
end: data => {
27+
data._sentrySpan?.end();
28+
},
29+
});
30+
31+
channel.traceSync(
32+
() => {
33+
const active = getActiveSpan();
34+
expect(active).toBeDefined();
35+
expect(spanToJSON(active!).op).toBe('test.op');
36+
},
37+
{ op: 'test.op' },
38+
);
39+
});
40+
41+
it('sets the created span as the active span inside tracePromise', async () => {
42+
const channel = tracingChannel<{ op: string }>('test:promise:active', data => {
43+
return startSpanManual({ name: 'channel-span', op: data.op }, span => span);
44+
});
45+
46+
channel.subscribe({
47+
asyncEnd: data => {
48+
data._sentrySpan?.end();
49+
},
50+
});
51+
52+
await channel.tracePromise(
53+
async () => {
54+
const active = getActiveSpan();
55+
expect(active).toBeDefined();
56+
expect(spanToJSON(active!).op).toBe('test.op');
57+
},
58+
{ op: 'test.op' },
59+
);
60+
});
61+
62+
it('creates correct parent-child relationship with nested tracing channels', () => {
63+
const outerChannel = tracingChannel<{ name: string }>('test:nested:outer', data => {
64+
return startSpanManual({ name: data.name, op: 'outer' }, span => span);
65+
});
66+
67+
const innerChannel = tracingChannel<{ name: string }>('test:nested:inner', data => {
68+
return startSpanManual({ name: data.name, op: 'inner' }, span => span);
69+
});
70+
71+
outerChannel.subscribe({
72+
end: data => {
73+
data._sentrySpan?.end();
74+
},
75+
});
76+
77+
innerChannel.subscribe({
78+
end: data => {
79+
data._sentrySpan?.end();
80+
},
81+
});
82+
83+
let outerSpanId: string | undefined;
84+
let innerParentSpanId: string | undefined;
85+
86+
outerChannel.traceSync(
87+
() => {
88+
const outerSpan = getActiveSpan();
89+
outerSpanId = outerSpan?.spanContext().spanId;
90+
91+
innerChannel.traceSync(
92+
() => {
93+
const innerSpan = getActiveSpan();
94+
innerParentSpanId = getParentSpanId(innerSpan as unknown as ReadableSpan);
95+
},
96+
{ name: 'inner-span' },
97+
);
98+
},
99+
{ name: 'outer-span' },
100+
);
101+
102+
expect(outerSpanId).toBeDefined();
103+
expect(innerParentSpanId).toBe(outerSpanId);
104+
});
105+
106+
it('creates correct parent-child relationship with nested async tracing channels', async () => {
107+
const outerChannel = tracingChannel<{ name: string }>('test:nested-async:outer', data => {
108+
return startSpanManual({ name: data.name, op: 'outer' }, span => span);
109+
});
110+
111+
const innerChannel = tracingChannel<{ name: string }>('test:nested-async:inner', data => {
112+
return startSpanManual({ name: data.name, op: 'inner' }, span => span);
113+
});
114+
115+
outerChannel.subscribe({
116+
asyncEnd: data => {
117+
data._sentrySpan?.end();
118+
},
119+
});
120+
121+
innerChannel.subscribe({
122+
asyncEnd: data => {
123+
data._sentrySpan?.end();
124+
},
125+
});
126+
127+
let outerSpanId: string | undefined;
128+
let innerParentSpanId: string | undefined;
129+
130+
await outerChannel.tracePromise(
131+
async () => {
132+
const outerSpan = getActiveSpan();
133+
outerSpanId = outerSpan?.spanContext().spanId;
134+
135+
await innerChannel.tracePromise(
136+
async () => {
137+
const innerSpan = getActiveSpan();
138+
innerParentSpanId = getParentSpanId(innerSpan as unknown as ReadableSpan);
139+
},
140+
{ name: 'inner-span' },
141+
);
142+
},
143+
{ name: 'outer-span' },
144+
);
145+
146+
expect(outerSpanId).toBeDefined();
147+
expect(innerParentSpanId).toBe(outerSpanId);
148+
});
149+
150+
it('creates correct parent when a tracing channel is nested inside startSpanManual', () => {
151+
const channel = tracingChannel<{ name: string }>('test:inside-startspan', data => {
152+
return startSpanManual({ name: data.name, op: 'channel' }, span => span);
153+
});
154+
155+
channel.subscribe({
156+
end: data => {
157+
data._sentrySpan?.end();
158+
},
159+
});
160+
161+
let manualSpanId: string | undefined;
162+
let channelParentSpanId: string | undefined;
163+
164+
startSpanManual({ name: 'manual-parent' }, parentSpan => {
165+
manualSpanId = parentSpan.spanContext().spanId;
166+
167+
channel.traceSync(
168+
() => {
169+
const channelSpan = getActiveSpan();
170+
channelParentSpanId = getParentSpanId(channelSpan as unknown as ReadableSpan);
171+
},
172+
{ name: 'channel-child' },
173+
);
174+
175+
parentSpan.end();
176+
});
177+
178+
expect(manualSpanId).toBeDefined();
179+
expect(channelParentSpanId).toBe(manualSpanId);
180+
});
181+
182+
it('makes the channel span available on data.span', () => {
183+
let spanFromData: unknown;
184+
185+
const channel = tracingChannel<{ name: string }>('test:data-span', data => {
186+
return startSpanManual({ name: data.name }, span => span);
187+
});
188+
189+
channel.subscribe({
190+
end: data => {
191+
spanFromData = data._sentrySpan;
192+
data._sentrySpan?.end();
193+
},
194+
});
195+
196+
channel.traceSync(() => {}, { name: 'test-span' });
197+
198+
expect(spanFromData).toBeDefined();
199+
expect(spanToJSON(spanFromData as unknown as Span).description).toBe('test-span');
200+
});
201+
202+
it('shares the same trace ID across nested channels', () => {
203+
const outerChannel = tracingChannel<{ name: string }>('test:trace-id:outer', data => {
204+
return startSpanManual({ name: data.name }, span => span);
205+
});
206+
207+
const innerChannel = tracingChannel<{ name: string }>('test:trace-id:inner', data => {
208+
return startSpanManual({ name: data.name }, span => span);
209+
});
210+
211+
outerChannel.subscribe({ end: data => data._sentrySpan?.end() });
212+
innerChannel.subscribe({ end: data => data._sentrySpan?.end() });
213+
214+
let outerTraceId: string | undefined;
215+
let innerTraceId: string | undefined;
216+
217+
outerChannel.traceSync(
218+
() => {
219+
outerTraceId = getActiveSpan()?.spanContext().traceId;
220+
221+
innerChannel.traceSync(
222+
() => {
223+
innerTraceId = getActiveSpan()?.spanContext().traceId;
224+
},
225+
{ name: 'inner' },
226+
);
227+
},
228+
{ name: 'outer' },
229+
);
230+
231+
expect(outerTraceId).toBeDefined();
232+
expect(innerTraceId).toBe(outerTraceId);
233+
});
234+
235+
it('does not leak context outside of traceSync', () => {
236+
const channel = tracingChannel<{ name: string }>('test:no-leak', data => {
237+
return startSpanManual({ name: data.name }, span => span);
238+
});
239+
240+
channel.subscribe({ end: data => data._sentrySpan?.end() });
241+
242+
const activeBefore = trace.getSpan(context.active());
243+
244+
channel.traceSync(() => {}, { name: 'scoped-span' });
245+
246+
const activeAfter = trace.getSpan(context.active());
247+
248+
expect(activeBefore).toBeUndefined();
249+
expect(activeAfter).toBeUndefined();
250+
});
251+
});

0 commit comments

Comments
 (0)