From 56e7a995f63a88d5862d2d9845c8c40761e25a4e Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 16 Apr 2026 10:46:46 -0400 Subject: [PATCH 1/3] feat(opentelemetry): Add `tracingChannel` utility for TracingChannel context propagation Vendors and adapts the `otel-tracing-channel` package into `@sentry/opentelemetry`, providing a drop-in 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 internals. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/opentelemetry/src/index.ts | 3 + packages/opentelemetry/src/tracingChannel.ts | 92 +++++++ .../opentelemetry/test/tracingChannel.test.ts | 251 ++++++++++++++++++ 3 files changed, 346 insertions(+) create mode 100644 packages/opentelemetry/src/tracingChannel.ts create mode 100644 packages/opentelemetry/test/tracingChannel.test.ts diff --git a/packages/opentelemetry/src/index.ts b/packages/opentelemetry/src/index.ts index a49597f67fdf..785238a47f42 100644 --- a/packages/opentelemetry/src/index.ts +++ b/packages/opentelemetry/src/index.ts @@ -51,6 +51,9 @@ export { openTelemetrySetupCheck } from './utils/setupCheck'; export { getSentryResource } from './resource'; +export { tracingChannel } from './tracingChannel'; +export type { OtelTracingChannel, OtelTracingChannelTransform } from './tracingChannel'; + export { withStreamedSpan } from '@sentry/core'; // Legacy diff --git a/packages/opentelemetry/src/tracingChannel.ts b/packages/opentelemetry/src/tracingChannel.ts new file mode 100644 index 000000000000..984986b7cdcb --- /dev/null +++ b/packages/opentelemetry/src/tracingChannel.ts @@ -0,0 +1,92 @@ +/** + * Vendored and adapted from https://github.com/logaretm/otel-tracing-channel + * + * Creates a TracingChannel with proper OpenTelemetry context propagation + * using Node.js diagnostic_channel's `bindStore` mechanism. + */ +import type { TracingChannel, TracingChannelSubscribers } from 'node:diagnostics_channel'; +import { tracingChannel as nativeTracingChannel } from 'node:diagnostics_channel'; +import type { Span } from '@opentelemetry/api'; +import { context, trace } from '@opentelemetry/api'; +import { logger } from '@sentry/core'; +import type { SentryAsyncLocalStorageContextManager } from './asyncLocalStorageContextManager'; +import type { AsyncLocalStorageLookup } from './contextManager'; +import { DEBUG_BUILD } from './debug-build'; + +/** + * Transform function that creates a span from the channel data. + */ +export type OtelTracingChannelTransform = (data: TData) => Span; + +type WithSpan = TData & { _sentrySpan?: Span }; + +/** + * A TracingChannel whose `subscribe` / `unsubscribe` accept partial subscriber + * objects — you only need to provide handlers for the events you care about. + */ +export interface OtelTracingChannel< + TData extends object = object, + TDataWithSpan extends object = WithSpan, +> extends Omit, 'subscribe' | 'unsubscribe'> { + subscribe(subscribers: Partial>): void; + unsubscribe(subscribers: Partial>): void; +} + +interface ContextApi { + _getContextManager(): SentryAsyncLocalStorageContextManager; +} + +/** + * Creates a new tracing channel with proper OTel context propagation. + * + * When the channel's `tracePromise` / `traceSync` / `traceCallback` is called, + * the `transformStart` function runs inside `bindStore` so that: + * 1. A new span is created from the channel data. + * 2. The span is set on the OTel context stored in AsyncLocalStorage. + * 3. Downstream code (including Sentry's span processor) sees the correct parent. + * + * @param channelNameOrInstance - Either a channel name string or an existing TracingChannel instance. + * @param transformStart - Function that creates an OpenTelemetry span from the channel data. + * @returns The tracing channel with OTel context bound. + */ +export function tracingChannel( + channelNameOrInstance: string, + transformStart: OtelTracingChannelTransform, +): OtelTracingChannel> { + const channel = nativeTracingChannel, WithSpan>( + channelNameOrInstance, + ) as unknown as OtelTracingChannel>; + + let lookup: AsyncLocalStorageLookup | undefined; + try { + const contextManager = (context as unknown as ContextApi)._getContextManager(); + lookup = contextManager.getAsyncLocalStorageLookup(); + } catch { + // getAsyncLocalStorageLookup may not exist if using a non-Sentry context manager + } + + if (!lookup) { + DEBUG_BUILD && + logger.warn( + '[TracingChannel] Could not access OpenTelemetry AsyncLocalStorage, context propagation will not work.', + ); + return channel; + } + + const otelStorage = lookup.asyncLocalStorage; + + // Bind the start channel so that each trace invocation runs the transform + // and stores the resulting context (with span) in AsyncLocalStorage. + // @ts-expect-error bindStore types don't account for AsyncLocalStorage of a different generic type + channel.start.bindStore(otelStorage, (data: WithSpan) => { + const span = transformStart(data); + + // Store the span on data so downstream event handlers (asyncEnd, error, etc.) can access it. + data._sentrySpan = span; + + // Return the context with the span set — this is what gets stored in AsyncLocalStorage. + return trace.setSpan(context.active(), span); + }); + + return channel; +} diff --git a/packages/opentelemetry/test/tracingChannel.test.ts b/packages/opentelemetry/test/tracingChannel.test.ts new file mode 100644 index 000000000000..2b5f72327352 --- /dev/null +++ b/packages/opentelemetry/test/tracingChannel.test.ts @@ -0,0 +1,251 @@ +import { context, trace } from '@opentelemetry/api'; +import type { ReadableSpan } from '@opentelemetry/sdk-trace-base'; +import { type Span, spanToJSON } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { startSpanManual } from '../src/trace'; +import { tracingChannel } from '../src/tracingChannel'; +import { getActiveSpan } from '../src/utils/getActiveSpan'; +import { getParentSpanId } from '../src/utils/getParentSpanId'; +import { cleanupOtel, mockSdkInit } from './helpers/mockSdkInit'; + +describe('tracingChannel', () => { + beforeEach(() => { + mockSdkInit({ tracesSampleRate: 1 }); + }); + + afterEach(async () => { + await cleanupOtel(); + }); + + it('sets the created span as the active span inside traceSync', () => { + const channel = tracingChannel<{ op: string }>('test:sync:active', data => { + return startSpanManual({ name: 'channel-span', op: data.op }, span => span); + }); + + channel.subscribe({ + end: data => { + data._sentrySpan?.end(); + }, + }); + + channel.traceSync( + () => { + const active = getActiveSpan(); + expect(active).toBeDefined(); + expect(spanToJSON(active!).op).toBe('test.op'); + }, + { op: 'test.op' }, + ); + }); + + it('sets the created span as the active span inside tracePromise', async () => { + const channel = tracingChannel<{ op: string }>('test:promise:active', data => { + return startSpanManual({ name: 'channel-span', op: data.op }, span => span); + }); + + channel.subscribe({ + asyncEnd: data => { + data._sentrySpan?.end(); + }, + }); + + await channel.tracePromise( + async () => { + const active = getActiveSpan(); + expect(active).toBeDefined(); + expect(spanToJSON(active!).op).toBe('test.op'); + }, + { op: 'test.op' }, + ); + }); + + it('creates correct parent-child relationship with nested tracing channels', () => { + const outerChannel = tracingChannel<{ name: string }>('test:nested:outer', data => { + return startSpanManual({ name: data.name, op: 'outer' }, span => span); + }); + + const innerChannel = tracingChannel<{ name: string }>('test:nested:inner', data => { + return startSpanManual({ name: data.name, op: 'inner' }, span => span); + }); + + outerChannel.subscribe({ + end: data => { + data._sentrySpan?.end(); + }, + }); + + innerChannel.subscribe({ + end: data => { + data._sentrySpan?.end(); + }, + }); + + let outerSpanId: string | undefined; + let innerParentSpanId: string | undefined; + + outerChannel.traceSync( + () => { + const outerSpan = getActiveSpan(); + outerSpanId = outerSpan?.spanContext().spanId; + + innerChannel.traceSync( + () => { + const innerSpan = getActiveSpan(); + innerParentSpanId = getParentSpanId(innerSpan as unknown as ReadableSpan); + }, + { name: 'inner-span' }, + ); + }, + { name: 'outer-span' }, + ); + + expect(outerSpanId).toBeDefined(); + expect(innerParentSpanId).toBe(outerSpanId); + }); + + it('creates correct parent-child relationship with nested async tracing channels', async () => { + const outerChannel = tracingChannel<{ name: string }>('test:nested-async:outer', data => { + return startSpanManual({ name: data.name, op: 'outer' }, span => span); + }); + + const innerChannel = tracingChannel<{ name: string }>('test:nested-async:inner', data => { + return startSpanManual({ name: data.name, op: 'inner' }, span => span); + }); + + outerChannel.subscribe({ + asyncEnd: data => { + data._sentrySpan?.end(); + }, + }); + + innerChannel.subscribe({ + asyncEnd: data => { + data._sentrySpan?.end(); + }, + }); + + let outerSpanId: string | undefined; + let innerParentSpanId: string | undefined; + + await outerChannel.tracePromise( + async () => { + const outerSpan = getActiveSpan(); + outerSpanId = outerSpan?.spanContext().spanId; + + await innerChannel.tracePromise( + async () => { + const innerSpan = getActiveSpan(); + innerParentSpanId = getParentSpanId(innerSpan as unknown as ReadableSpan); + }, + { name: 'inner-span' }, + ); + }, + { name: 'outer-span' }, + ); + + expect(outerSpanId).toBeDefined(); + expect(innerParentSpanId).toBe(outerSpanId); + }); + + it('creates correct parent when a tracing channel is nested inside startSpanManual', () => { + const channel = tracingChannel<{ name: string }>('test:inside-startspan', data => { + return startSpanManual({ name: data.name, op: 'channel' }, span => span); + }); + + channel.subscribe({ + end: data => { + data._sentrySpan?.end(); + }, + }); + + let manualSpanId: string | undefined; + let channelParentSpanId: string | undefined; + + startSpanManual({ name: 'manual-parent' }, parentSpan => { + manualSpanId = parentSpan.spanContext().spanId; + + channel.traceSync( + () => { + const channelSpan = getActiveSpan(); + channelParentSpanId = getParentSpanId(channelSpan as unknown as ReadableSpan); + }, + { name: 'channel-child' }, + ); + + parentSpan.end(); + }); + + expect(manualSpanId).toBeDefined(); + expect(channelParentSpanId).toBe(manualSpanId); + }); + + it('makes the channel span available on data.span', () => { + let spanFromData: unknown; + + const channel = tracingChannel<{ name: string }>('test:data-span', data => { + return startSpanManual({ name: data.name }, span => span); + }); + + channel.subscribe({ + end: data => { + spanFromData = data._sentrySpan; + data._sentrySpan?.end(); + }, + }); + + channel.traceSync(() => {}, { name: 'test-span' }); + + expect(spanFromData).toBeDefined(); + expect(spanToJSON(spanFromData as unknown as Span).description).toBe('test-span'); + }); + + it('shares the same trace ID across nested channels', () => { + const outerChannel = tracingChannel<{ name: string }>('test:trace-id:outer', data => { + return startSpanManual({ name: data.name }, span => span); + }); + + const innerChannel = tracingChannel<{ name: string }>('test:trace-id:inner', data => { + return startSpanManual({ name: data.name }, span => span); + }); + + outerChannel.subscribe({ end: data => data._sentrySpan?.end() }); + innerChannel.subscribe({ end: data => data._sentrySpan?.end() }); + + let outerTraceId: string | undefined; + let innerTraceId: string | undefined; + + outerChannel.traceSync( + () => { + outerTraceId = getActiveSpan()?.spanContext().traceId; + + innerChannel.traceSync( + () => { + innerTraceId = getActiveSpan()?.spanContext().traceId; + }, + { name: 'inner' }, + ); + }, + { name: 'outer' }, + ); + + expect(outerTraceId).toBeDefined(); + expect(innerTraceId).toBe(outerTraceId); + }); + + it('does not leak context outside of traceSync', () => { + const channel = tracingChannel<{ name: string }>('test:no-leak', data => { + return startSpanManual({ name: data.name }, span => span); + }); + + channel.subscribe({ end: data => data._sentrySpan?.end() }); + + const activeBefore = trace.getSpan(context.active()); + + channel.traceSync(() => {}, { name: 'scoped-span' }); + + const activeAfter = trace.getSpan(context.active()); + + expect(activeBefore).toBeUndefined(); + expect(activeAfter).toBeUndefined(); + }); +}); From 1b87a8f7a4de2ad21238d390a715fc51656f9739 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 16 Apr 2026 12:18:56 -0400 Subject: [PATCH 2/3] ref: move tracing channel helper to subpath export because nextjs is a framework held together by ductape --- packages/opentelemetry/package.json | 10 ++++++++++ packages/opentelemetry/rollup.npm.config.mjs | 3 +++ packages/opentelemetry/src/index.ts | 3 --- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/opentelemetry/package.json b/packages/opentelemetry/package.json index 122dc7b92f83..829117432bd3 100644 --- a/packages/opentelemetry/package.json +++ b/packages/opentelemetry/package.json @@ -26,6 +26,16 @@ "types": "./build/types/index.d.ts", "default": "./build/cjs/index.js" } + }, + "./tracingChannel": { + "import": { + "types": "./build/types/tracingChannel.d.ts", + "default": "./build/esm/tracingChannel.js" + }, + "require": { + "types": "./build/types/tracingChannel.d.ts", + "default": "./build/cjs/tracingChannel.js" + } } }, "typesVersions": { diff --git a/packages/opentelemetry/rollup.npm.config.mjs b/packages/opentelemetry/rollup.npm.config.mjs index e015fea4935e..e6f5ecdd4871 100644 --- a/packages/opentelemetry/rollup.npm.config.mjs +++ b/packages/opentelemetry/rollup.npm.config.mjs @@ -2,6 +2,9 @@ import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollu export default makeNPMConfigVariants( makeBaseNPMConfig({ + // `tracingChannel` is a Node.js-only subpath so `node:diagnostics_channel` + // isn't pulled into the main bundle (breaks edge/browser builds). + entrypoints: ['src/index.ts', 'src/tracingChannel.ts'], packageSpecificConfig: { output: { // set exports to 'named' or 'auto' so that rollup doesn't warn diff --git a/packages/opentelemetry/src/index.ts b/packages/opentelemetry/src/index.ts index 785238a47f42..a49597f67fdf 100644 --- a/packages/opentelemetry/src/index.ts +++ b/packages/opentelemetry/src/index.ts @@ -51,9 +51,6 @@ export { openTelemetrySetupCheck } from './utils/setupCheck'; export { getSentryResource } from './resource'; -export { tracingChannel } from './tracingChannel'; -export type { OtelTracingChannel, OtelTracingChannelTransform } from './tracingChannel'; - export { withStreamedSpan } from '@sentry/core'; // Legacy From 5973ab55e81fb4b5d54e291faff7a3002aa875ab Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 17 Apr 2026 11:13:32 -0400 Subject: [PATCH 3/3] ref(opentelemetry): use kebab-case for tracing-channel subpath export Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/opentelemetry/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opentelemetry/package.json b/packages/opentelemetry/package.json index 829117432bd3..fa0484e51c6d 100644 --- a/packages/opentelemetry/package.json +++ b/packages/opentelemetry/package.json @@ -27,7 +27,7 @@ "default": "./build/cjs/index.js" } }, - "./tracingChannel": { + "./tracing-channel": { "import": { "types": "./build/types/tracingChannel.d.ts", "default": "./build/esm/tracingChannel.js"