diff --git a/packages/opentelemetry/package.json b/packages/opentelemetry/package.json index 122dc7b92f83..fa0484e51c6d 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" } + }, + "./tracing-channel": { + "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/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(); + }); +});