diff --git a/packages/core/src/asyncContext/index.ts b/packages/core/src/asyncContext/index.ts index ea5f1db67ac0..d13874bd854b 100644 --- a/packages/core/src/asyncContext/index.ts +++ b/packages/core/src/asyncContext/index.ts @@ -1,9 +1,7 @@ import type { Carrier } from './../carrier'; import { getMainCarrier, getSentryCarrier } from './../carrier'; -import type { Scope } from './../scope'; -import { _setSpanForScope } from './../utils/spanOnScope'; import { getStackAsyncContextStrategy } from './stackStrategy'; -import type { AsyncContextStrategy, TracingChannelBinding } from './types'; +import type { AsyncContextStrategy } from './types'; /** * @private Private API with no semver guarantees! @@ -31,35 +29,3 @@ export function getAsyncContextStrategy(carrier: Carrier): AsyncContextStrategy // Otherwise, use the default one (stack) return getStackAsyncContextStrategy(); } - -/** - * Get the runtime binding needed to connect tracing channels to async context. - */ -export function getTracingChannelBinding(): TracingChannelBinding | undefined { - return getAsyncContextStrategy(getMainCarrier()).getTracingChannelBinding?.(); -} - -/** - * Build the default {@link TracingChannelBinding} shared by AsyncLocalStorage-based strategies. - * - * The ALS instance is supplied by the caller (kept as `unknown`). - * The binding clones the current scope, plants the span on it, and reuses the existing isolation scope. - * - * The OpenTelemetry strategy does not use this: its store value is an OTel context, not a - * `{ scope, isolationScope }` pair. - */ -export function _INTERNAL_createTracingChannelBinding( - asyncLocalStorage: unknown, - getScopes: () => { scope: Scope; isolationScope: Scope }, -): TracingChannelBinding { - return { - asyncLocalStorage, - getStoreWithActiveSpan: span => { - const { scope, isolationScope } = getScopes(); - const activeScope = scope.clone(); - _setSpanForScope(activeScope, span); - - return { scope: activeScope, isolationScope }; - }, - }; -} diff --git a/packages/core/src/asyncContext/tracing-channel-binding.ts b/packages/core/src/asyncContext/tracing-channel-binding.ts new file mode 100644 index 000000000000..892f9d90640e --- /dev/null +++ b/packages/core/src/asyncContext/tracing-channel-binding.ts @@ -0,0 +1,59 @@ +import { getMainCarrier } from '../carrier'; +import type { Scope } from '../scope'; +import { _setSpanForScope } from '../utils/spanOnScope'; +import { safeUnref } from '../utils/timer'; +import { getAsyncContextStrategy } from './index'; +import type { TracingChannelBinding } from './types'; + +/** + * Execute a callback whenever the tracing channel binding is available. + * If it is not available after retry, the callback is not executed. + */ +export function waitForTracingChannelBinding(callback: () => void, retries = 1): void { + const binding = getAsyncContextStrategy(getMainCarrier()).getTracingChannelBinding?.(); + + if (binding) { + callback(); + return; + } + + if (!retries) { + return; + } + + // It is possible that the binding is not available yet when this is initially called + // This happens when users use a custom OTEL setup + // In this case, we wait for a tick and try again afterwards + // If it still fails, we bail and do nothing + // `safeUnref` so this retry timer never keeps the process alive on its own (Node server runtimes). + safeUnref( + setTimeout(() => { + waitForTracingChannelBinding(callback, retries - 1); + }, 1), + ); +} + +/** + * Build the default {@link TracingChannelBinding} shared by AsyncLocalStorage-based strategies. + * + * The ALS instance is supplied by the caller (kept as `unknown`). + * The binding clones the current scope, plants the span on it, and reuses the existing isolation scope. + * + * The OpenTelemetry strategy does not use this: its store value is an OTel context, not a + * `{ scope, isolationScope }` pair. + */ +export function _INTERNAL_createTracingChannelBinding( + asyncLocalStorage: unknown, + getScopes: () => { scope: Scope; isolationScope: Scope }, +): TracingChannelBinding { + return { + asyncLocalStorage, + getStoreWithActiveSpan: span => { + const { scope, isolationScope } = getScopes(); + const activeScope = scope.clone(); + _setSpanForScope(activeScope, span); + + return { scope: activeScope, isolationScope }; + }, + }; +} diff --git a/packages/core/src/shared-exports.ts b/packages/core/src/shared-exports.ts index bba7726261a1..d22d01c2ede7 100644 --- a/packages/core/src/shared-exports.ts +++ b/packages/core/src/shared-exports.ts @@ -49,11 +49,11 @@ export { hasExternalPropagationContext, } from './currentScopes'; export { getDefaultCurrentScope, getDefaultIsolationScope } from './defaultScopes'; +export { setAsyncContextStrategy, getAsyncContextStrategy } from './asyncContext'; export { - setAsyncContextStrategy, - getTracingChannelBinding as _INTERNAL_getTracingChannelBinding, + waitForTracingChannelBinding, _INTERNAL_createTracingChannelBinding, -} from './asyncContext'; +} from './asyncContext/tracing-channel-binding'; export { getGlobalSingleton, getMainCarrier } from './carrier'; export { makeSession, closeSession, updateSession } from './session'; export { Scope } from './scope'; diff --git a/packages/core/test/lib/asyncContext/tracing-channel-binding.test.ts b/packages/core/test/lib/asyncContext/tracing-channel-binding.test.ts new file mode 100644 index 000000000000..a6113d9df2ad --- /dev/null +++ b/packages/core/test/lib/asyncContext/tracing-channel-binding.test.ts @@ -0,0 +1,123 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { getAsyncContextStrategy, setAsyncContextStrategy } from '../../../src/asyncContext'; +import { waitForTracingChannelBinding } from '../../../src/asyncContext/tracing-channel-binding'; +import type { TracingChannelBinding } from '../../../src/asyncContext/types'; +import { getMainCarrier } from '../../../src/carrier'; + +const FAKE_BINDING: TracingChannelBinding = { + asyncLocalStorage: {}, + getStoreWithActiveSpan: () => ({}), +}; + +/** Install an async context strategy whose `getTracingChannelBinding` is driven by `provider`. */ +function setBindingProvider(provider: (() => TracingChannelBinding | undefined) | undefined): void { + setAsyncContextStrategy({ + ...getAsyncContextStrategy(getMainCarrier()), + getTracingChannelBinding: provider, + }); +} + +describe('waitForTracingChannelBinding', () => { + beforeEach(() => { + vi.useFakeTimers(); + setAsyncContextStrategy(undefined); + }); + + afterEach(() => { + setAsyncContextStrategy(undefined); + vi.useRealTimers(); + }); + + it('runs the callback synchronously when the binding is already available', () => { + const getBinding = vi.fn(() => FAKE_BINDING); + setBindingProvider(getBinding); + + const callback = vi.fn(); + waitForTracingChannelBinding(callback); + + expect(callback).toHaveBeenCalledTimes(1); + // Resolved on the first attempt, so no retry should be scheduled. + expect(getBinding).toHaveBeenCalledTimes(1); + vi.runAllTimers(); + expect(getBinding).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('retries on the next tick and runs the callback once the binding becomes available', () => { + const getBinding = vi.fn<[], TracingChannelBinding | undefined>(() => FAKE_BINDING); + getBinding.mockReturnValueOnce(undefined); + setBindingProvider(getBinding); + + const callback = vi.fn(); + waitForTracingChannelBinding(callback); + + // Not available on the first (synchronous) attempt. + expect(callback).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('does not run the callback if the binding never becomes available (default single retry)', () => { + const getBinding = vi.fn(() => undefined); + setBindingProvider(getBinding); + + const callback = vi.fn(); + waitForTracingChannelBinding(callback); + + expect(callback).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1); + expect(callback).not.toHaveBeenCalled(); + + // The single retry is exhausted — no further attempts are scheduled. + expect(getBinding).toHaveBeenCalledTimes(2); + vi.runAllTimers(); + expect(getBinding).toHaveBeenCalledTimes(2); + expect(callback).not.toHaveBeenCalled(); + }); + + it('does not retry when retries is 0', () => { + const getBinding = vi.fn(() => undefined); + setBindingProvider(getBinding); + + const callback = vi.fn(); + waitForTracingChannelBinding(callback, 0); + + expect(callback).not.toHaveBeenCalled(); + expect(getBinding).toHaveBeenCalledTimes(1); + + // No retry is scheduled when no retries remain. + vi.runAllTimers(); + expect(getBinding).toHaveBeenCalledTimes(1); + expect(callback).not.toHaveBeenCalled(); + }); + + it('honors a custom retry count', () => { + const getBinding = vi.fn<[], TracingChannelBinding | undefined>(() => FAKE_BINDING); + getBinding.mockReturnValueOnce(undefined).mockReturnValueOnce(undefined).mockReturnValue(FAKE_BINDING); + setBindingProvider(getBinding); + + const callback = vi.fn(); + waitForTracingChannelBinding(callback, 2); + + expect(callback).not.toHaveBeenCalled(); // attempt 1 (sync): undefined + + vi.advanceTimersByTime(1); + expect(callback).not.toHaveBeenCalled(); // attempt 2: undefined + + vi.advanceTimersByTime(1); + expect(callback).toHaveBeenCalledTimes(1); // attempt 3: available + }); + + it('does nothing when the strategy exposes no `getTracingChannelBinding`', () => { + // The default (stack) strategy has no tracing-channel binding support. + setAsyncContextStrategy(undefined); + + const callback = vi.fn(); + waitForTracingChannelBinding(callback, 0); + + expect(callback).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/node-core/src/sdk/index.ts b/packages/node-core/src/sdk/index.ts index 31493a273d4a..dd245d85ad08 100644 --- a/packages/node-core/src/sdk/index.ts +++ b/packages/node-core/src/sdk/index.ts @@ -114,7 +114,7 @@ function _init( initializeEsmLoader(); } - setOpenTelemetryContextAsyncContextStrategy(); + setOpenTelemetryContextAsyncContextStrategy(options); const scope = getCurrentScope(); scope.update(options.initialScope); diff --git a/packages/node/src/integrations/tracing/redis/index.ts b/packages/node/src/integrations/tracing/redis/index.ts index cf72cf396257..f1d1d625ba82 100644 --- a/packages/node/src/integrations/tracing/redis/index.ts +++ b/packages/node/src/integrations/tracing/redis/index.ts @@ -7,6 +7,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_OP, spanToJSON, truncate, + waitForTracingChannelBinding, } from '@sentry/core'; import * as dc from 'node:diagnostics_channel'; import { subscribeRedisDiagnosticChannels, type RedisTracingChannelFactory } from '@sentry/server-utils'; @@ -128,9 +129,9 @@ export const instrumentRedis = Object.assign( // so defer to the next tick. // Check this here to ensure this does not fail at runtime for Node <= 18.18.0 if (dc.tracingChannel) { - void Promise.resolve().then(() => - subscribeRedisDiagnosticChannels(dc.tracingChannel as RedisTracingChannelFactory, cacheResponseHook), - ); + waitForTracingChannelBinding(() => { + subscribeRedisDiagnosticChannels(dc.tracingChannel as RedisTracingChannelFactory, cacheResponseHook); + }); } // todo: implement them gradually diff --git a/packages/opentelemetry/src/asyncContextStrategy.ts b/packages/opentelemetry/src/asyncContextStrategy.ts index b9ad711c299c..f2af9d0afd4f 100644 --- a/packages/opentelemetry/src/asyncContextStrategy.ts +++ b/packages/opentelemetry/src/asyncContextStrategy.ts @@ -1,5 +1,5 @@ import * as api from '@opentelemetry/api'; -import type { Scope, Span, withActiveSpan as defaultWithActiveSpan } from '@sentry/core'; +import type { Scope, TracingChannelBinding, withActiveSpan as defaultWithActiveSpan } from '@sentry/core'; import { getDefaultCurrentScope, getDefaultIsolationScope, setAsyncContextStrategy } from '@sentry/core'; import { SENTRY_FORK_ISOLATION_SCOPE_CONTEXT_KEY, @@ -13,19 +13,13 @@ import { getActiveSpan } from './utils/getActiveSpan'; import { getTraceData } from './utils/getTraceData'; import { suppressTracing } from './utils/suppressTracing'; -interface ContextApi { - _getContextManager(): { - getAsyncLocalStorageLookup(): { - asyncLocalStorage: unknown; - }; - }; -} - /** * Sets the async context strategy to use follow the OTEL context under the hood. * We handle forking a hub inside of our custom OTEL Context Manager (./otelContextManager.ts) */ -export function setOpenTelemetryContextAsyncContextStrategy(): void { +export function setOpenTelemetryContextAsyncContextStrategy(options?: { + getTracingChannelBinding?: () => TracingChannelBinding | undefined; +}): void { function getScopes(): CurrentScopes { const ctx = api.context.active(); const scopes = getScopesFromContext(ctx); @@ -116,18 +110,6 @@ export function setOpenTelemetryContextAsyncContextStrategy(): void { // 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, - getTracingChannelBinding: () => { - try { - const contextManager = (api.context as unknown as ContextApi)._getContextManager(); - const lookup = contextManager.getAsyncLocalStorageLookup(); - - return { - asyncLocalStorage: lookup.asyncLocalStorage, - getStoreWithActiveSpan: (span: Span) => api.trace.setSpan(api.context.active(), span as api.Span), - }; - } catch { - return undefined; - } - }, + getTracingChannelBinding: options?.getTracingChannelBinding, }); } diff --git a/packages/opentelemetry/src/asyncLocalStorageContextManager.ts b/packages/opentelemetry/src/asyncLocalStorageContextManager.ts index e1a7db98e527..a7b2abcad9c2 100644 --- a/packages/opentelemetry/src/asyncLocalStorageContextManager.ts +++ b/packages/opentelemetry/src/asyncLocalStorageContextManager.ts @@ -25,10 +25,11 @@ import type { Context, ContextManager } from '@opentelemetry/api'; import { ROOT_CONTEXT } from '@opentelemetry/api'; import { AsyncLocalStorage } from 'node:async_hooks'; import { EventEmitter } from 'node:events'; -import { SENTRY_SCOPES_CONTEXT_KEY } from './constants'; import type { AsyncLocalStorageLookup } from './contextManager'; +import { SENTRY_SCOPES_CONTEXT_KEY } from './constants'; import { buildContextWithSentryScopes } from './utils/buildContextWithSentryScopes'; import { setIsSetup } from './utils/setupCheck'; +import { getAsyncContextStrategy, getMainCarrier } from '@sentry/core'; type ListenerFn = (...args: unknown[]) => unknown; @@ -44,13 +45,18 @@ const ADD_LISTENER_METHODS = ['addListener', 'on', 'once', 'prependListener', 'p * Semantics match `@opentelemetry/context-async-hooks` (function `bind` + `EventEmitter` patching). */ export class SentryAsyncLocalStorageContextManager implements ContextManager { - protected readonly _asyncLocalStorage = new AsyncLocalStorage(); + protected readonly _asyncLocalStorage: AsyncLocalStorage; private readonly _kOtListeners = Symbol('OtListeners'); private _wrapped = false; public constructor() { setIsSetup('SentryContextManager'); + // Pick the instance from the async context strategy + // this should normally always be there, but if it is not for whatever reason, we fall back to a new instance + this._asyncLocalStorage = + (getAsyncContextStrategy(getMainCarrier()).getTracingChannelBinding?.() + ?.asyncLocalStorage as AsyncLocalStorage) ?? new AsyncLocalStorage(); } public active(): Context { diff --git a/packages/opentelemetry/src/exports.ts b/packages/opentelemetry/src/exports.ts index 5eac9d3a7a4a..cbfb005b117e 100644 --- a/packages/opentelemetry/src/exports.ts +++ b/packages/opentelemetry/src/exports.ts @@ -39,7 +39,6 @@ export { suppressTracing } from './utils/suppressTracing'; export { setupEventContextTrace } from './setupEventContextTrace'; -export { setOpenTelemetryContextAsyncContextStrategy } from './asyncContextStrategy'; // eslint-disable-next-line typescript/no-deprecated export { wrapContextManagerClass } from './contextManager'; diff --git a/packages/opentelemetry/src/index.browser.ts b/packages/opentelemetry/src/index.browser.ts index 4667379fc749..c3877aa759ff 100644 --- a/packages/opentelemetry/src/index.browser.ts +++ b/packages/opentelemetry/src/index.browser.ts @@ -12,6 +12,9 @@ export class SentryAsyncLocalStorageContextManager { } } +// This is the generic, non-node specific async context strategy +export { setOpenTelemetryContextAsyncContextStrategy } from './asyncContextStrategy'; + export type AsyncLocalStorageLookup = { asyncLocalStorage: unknown; contextSymbol: symbol; diff --git a/packages/opentelemetry/src/index.ts b/packages/opentelemetry/src/index.ts index 66766f554327..0dc4703ef990 100644 --- a/packages/opentelemetry/src/index.ts +++ b/packages/opentelemetry/src/index.ts @@ -3,3 +3,6 @@ export * from './exports'; // Node-specific exports export { SentryAsyncLocalStorageContextManager } from './asyncLocalStorageContextManager'; export type { AsyncLocalStorageLookup } from './contextManager'; + +// We export the node-specific variant here that uses async local storage +export { setNodeOpenTelemetryContextAsyncContextStrategy as setOpenTelemetryContextAsyncContextStrategy } from './nodeAsyncContextStrategy'; diff --git a/packages/opentelemetry/src/nodeAsyncContextStrategy.ts b/packages/opentelemetry/src/nodeAsyncContextStrategy.ts new file mode 100644 index 000000000000..2b55f21bad67 --- /dev/null +++ b/packages/opentelemetry/src/nodeAsyncContextStrategy.ts @@ -0,0 +1,60 @@ +import * as api from '@opentelemetry/api'; +import { setOpenTelemetryContextAsyncContextStrategy } from './asyncContextStrategy'; +import { AsyncLocalStorage } from 'node:async_hooks'; +import type { TracingChannelBinding } from '@sentry/core'; + +interface ContextApi { + _getContextManager(): + | undefined + | { + getAsyncLocalStorageLookup(): { + asyncLocalStorage: unknown; + }; + }; +} + +export function setNodeOpenTelemetryContextAsyncContextStrategy(options?: { skipOpenTelemetrySetup?: boolean }): void { + setOpenTelemetryContextAsyncContextStrategy({ + getTracingChannelBinding: !options?.skipOpenTelemetrySetup + ? getDefaultAsyncLocalStorageFactory() + : getCustomAsyncLocalStorageFactory(), + }); +} + +/** + * In the default case, we build the local storage instance ourselves here. + * The default asyncLocalStorageContextManager will then use this internally. + */ +function getDefaultAsyncLocalStorageFactory(): () => TracingChannelBinding { + const defaultAsyncLocalStorage = new AsyncLocalStorage(); + + return () => { + return { + asyncLocalStorage: defaultAsyncLocalStorage, + getStoreWithActiveSpan: span => api.trace.setSpan(api.context.active(), span), + } satisfies TracingChannelBinding; + }; +} + +/** + * If we have a custom context manager, we need to access it via the context manager + * this may not be available yet, if this is called before the Otel ContextManager was setup + * in this case, we need to return undefined and retry later, hoping that the setup works by then + */ +function getCustomAsyncLocalStorageFactory(): () => TracingChannelBinding | undefined { + return () => { + try { + const contextManager = (api.context as unknown as ContextApi)._getContextManager(); + const asyncLocalStorage = contextManager?.getAsyncLocalStorageLookup().asyncLocalStorage; + + return asyncLocalStorage + ? ({ + asyncLocalStorage, + getStoreWithActiveSpan: span => api.trace.setSpan(api.context.active(), span as api.Span), + } satisfies TracingChannelBinding) + : undefined; + } catch { + return undefined; + } + }; +} diff --git a/packages/server-utils/src/tracing-channel.ts b/packages/server-utils/src/tracing-channel.ts index a0d449eb1489..b00a977cd74f 100644 --- a/packages/server-utils/src/tracing-channel.ts +++ b/packages/server-utils/src/tracing-channel.ts @@ -1,7 +1,7 @@ import type { TracingChannel, TracingChannelSubscribers } from 'node:diagnostics_channel'; import type { AsyncLocalStorage } from 'node:async_hooks'; import type { ExclusiveEventHintOrCaptureContext, Span } from '@sentry/core'; -import { _INTERNAL_getTracingChannelBinding, debug, captureException, SPAN_STATUS_ERROR } from '@sentry/core'; +import { debug, captureException, SPAN_STATUS_ERROR, getAsyncContextStrategy, getMainCarrier } from '@sentry/core'; import { DEBUG_BUILD } from './debug-build'; export type TracingChannelPayloadWithSpan = TData & { @@ -133,7 +133,7 @@ function bindSpanToChannelStore( getSpan: (data: TracingChannelPayloadWithSpan) => Span | undefined, ): TracingChannelBindingHandle { // Grabs the tracing channel binding defined by the AsyncContext strategy implementation - const binding = _INTERNAL_getTracingChannelBinding(); + const binding = getAsyncContextStrategy(getMainCarrier()).getTracingChannelBinding?.(); // If no binding, then either the implementer doesn't support tracing channels or there is no active strategy // Failure mode here means we would still access the channel and potentially subscribe to it, but parenting will be off. diff --git a/packages/server-utils/src/vercel-ai/index.ts b/packages/server-utils/src/vercel-ai/index.ts index 18e9dccf3ef5..251ba91519c5 100644 --- a/packages/server-utils/src/vercel-ai/index.ts +++ b/packages/server-utils/src/vercel-ai/index.ts @@ -1,4 +1,4 @@ -import { defineIntegration, type IntegrationFn } from '@sentry/core'; +import { defineIntegration, waitForTracingChannelBinding, type IntegrationFn } from '@sentry/core'; import { subscribeVercelAiTracingChannel } from './vercel-ai-dc-subscriber'; import * as dc from 'node:diagnostics_channel'; @@ -35,9 +35,9 @@ const _vercelAiIntegration = ((options: VercelAiOptions = {}) => { // Subscribe to the `ai` SDK's native telemetry tracing channel (ai >= 7). // This is a no-op on versions that don't publish to the channel, so it is always safe to call. - // The factory needs the Sentry OTel context manager, which `initOpenTelemetry()` registers after `setupOnce`, so defer a tick. - // Options are passed in here rather than read back off the integration per event. - void Promise.resolve().then(() => subscribeVercelAiTracingChannel(dc.tracingChannel, options)); + waitForTracingChannelBinding(() => { + subscribeVercelAiTracingChannel(dc.tracingChannel, options); + }); }, }; }) satisfies IntegrationFn; diff --git a/packages/vercel-edge/src/sdk.ts b/packages/vercel-edge/src/sdk.ts index be5ef0ea9403..566b067bb243 100644 --- a/packages/vercel-edge/src/sdk.ts +++ b/packages/vercel-edge/src/sdk.ts @@ -63,7 +63,9 @@ export function getDefaultIntegrations(_options: Options): Integration[] { /** Inits the Sentry NextJS SDK on the Edge Runtime. */ export function init(options: VercelEdgeOptions = {}): Client | undefined { - setOpenTelemetryContextAsyncContextStrategy(); + // We force skipOpenTelemetrySetup: true here, because this triggers the custom lookup for the AsyncLocalStorage instance + // Since we use a custom Context Manager here (because AsyncLocalStorage is looked up differently than in Node), we need to do this + setOpenTelemetryContextAsyncContextStrategy({ skipOpenTelemetrySetup: true }); const scope = getCurrentScope(); scope.update(options.initialScope);