From a5639e4bb2fc08254857da1acc0a8e3f43d8cd9b Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Fri, 24 Jul 2026 11:30:42 +0200 Subject: [PATCH 1/2] ref(opentelemetry): Remove `wrapContextManager` export Instead, the context manager accepts the async local storage instance now, so we can also use this in vercel-edge. this will eventually go away/move to server-utils likely so we can revisit this in a follow up. --- .../src/instrument.ts | 3 +- .../node-otel-sdk-node/src/instrument.ts | 3 +- .../src/instrument.ts | 3 +- packages/node/src/sdk/initOtel.ts | 5 +- packages/opentelemetry/README.md | 2 +- .../src/asyncLocalStorageContextManager.ts | 11 +- packages/opentelemetry/src/contextManager.ts | 72 ------ packages/opentelemetry/src/exports.ts | 3 - packages/opentelemetry/src/index.ts | 3 +- .../opentelemetry/test/helpers/initOtel.ts | 3 +- .../opentelemetry/test/tracerProvider.test.ts | 3 +- packages/vercel-edge/src/sdk.ts | 42 +++- .../abstract-async-hooks-context-manager.ts | 235 ------------------ .../async-local-storage-context-manager.ts | 84 ------- 14 files changed, 58 insertions(+), 414 deletions(-) delete mode 100644 packages/opentelemetry/src/contextManager.ts delete mode 100644 packages/vercel-edge/src/vendored/abstract-async-hooks-context-manager.ts delete mode 100644 packages/vercel-edge/src/vendored/async-local-storage-context-manager.ts diff --git a/dev-packages/e2e-tests/test-applications/node-otel-custom-sampler/src/instrument.ts b/dev-packages/e2e-tests/test-applications/node-otel-custom-sampler/src/instrument.ts index de09f0965baa..d709a7ea2ce7 100644 --- a/dev-packages/e2e-tests/test-applications/node-otel-custom-sampler/src/instrument.ts +++ b/dev-packages/e2e-tests/test-applications/node-otel-custom-sampler/src/instrument.ts @@ -2,6 +2,7 @@ import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'; import * as Sentry from '@sentry/node'; import { SentryPropagator, SentrySpanProcessor } from '@sentry/opentelemetry'; import { CustomSampler } from './custom-sampler'; +import { AsyncLocalStorage } from 'node:async_hooks'; Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions @@ -24,7 +25,7 @@ const provider = new NodeTracerProvider({ provider.register({ propagator: new SentryPropagator(), - contextManager: new Sentry.SentryContextManager(), + contextManager: new Sentry.SentryContextManager(new AsyncLocalStorage()), }); Sentry.validateOpenTelemetrySetup(); diff --git a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/src/instrument.ts b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/src/instrument.ts index 5cb2e5570db9..d861e75ee120 100644 --- a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/src/instrument.ts +++ b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/src/instrument.ts @@ -2,6 +2,7 @@ const opentelemetry = require('@opentelemetry/sdk-node'); const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http'); const Sentry = require('@sentry/node'); const { SentrySpanProcessor, SentryPropagator, SentrySampler } = require('@sentry/opentelemetry'); +import { AsyncLocalStorage } from 'node:async_hooks'; const sentryClient = Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions @@ -20,7 +21,7 @@ const sentryClient = Sentry.init({ const sdk = new opentelemetry.NodeSDK({ sampler: sentryClient ? new SentrySampler(sentryClient) : undefined, textMapPropagator: new SentryPropagator(), - contextManager: new Sentry.SentryContextManager(), + contextManager: new Sentry.SentryContextManager(new AsyncLocalStorage()), spanProcessors: [ new SentrySpanProcessor(), new opentelemetry.node.BatchSpanProcessor( diff --git a/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/src/instrument.ts b/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/src/instrument.ts index ea9b6ae57545..b16e9914f0ca 100644 --- a/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/src/instrument.ts +++ b/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/src/instrument.ts @@ -5,6 +5,7 @@ const Sentry = require('@sentry/node'); const { SentryPropagator } = require('@sentry/opentelemetry'); const { UndiciInstrumentation } = require('@opentelemetry/instrumentation-undici'); const { registerInstrumentations } = require('@opentelemetry/instrumentation'); +import { AsyncLocalStorage } from 'node:async_hooks'; Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions @@ -34,7 +35,7 @@ const provider = new NodeTracerProvider({ // Initialize the provider provider.register({ propagator: new SentryPropagator(), - contextManager: new Sentry.SentryContextManager(), + contextManager: new Sentry.SentryContextManager(new AsyncLocalStorage()), }); registerInstrumentations({ diff --git a/packages/node/src/sdk/initOtel.ts b/packages/node/src/sdk/initOtel.ts index 3b264c993f56..2bf1bb92c3c8 100644 --- a/packages/node/src/sdk/initOtel.ts +++ b/packages/node/src/sdk/initOtel.ts @@ -21,6 +21,7 @@ import { } from '@sentry/opentelemetry'; import { DEBUG_BUILD } from '../debug-build'; import { getOpenTelemetryInstrumentationToPreload } from '../integrations/tracing'; +import { AsyncLocalStorage } from 'node:async_hooks'; // About 277h - this must fit into new Array(len)! const MAX_MAX_SPAN_WAIT_DURATION = 1_000_000; @@ -175,7 +176,7 @@ export function setupOtel( registerGlobalTracerProvider(provider); propagation.setGlobalPropagator(new SentryPropagator()); - const ctxManager = new SentryContextManager(); + const ctxManager = new SentryContextManager(new AsyncLocalStorage()); context.setGlobalContextManager(ctxManager); return [provider, ctxManager.getAsyncLocalStorageLookup()]; @@ -201,7 +202,7 @@ function setupSentryTracerProvider( propagation.setGlobalPropagator(new SentryPropagator()); - const ctxManager = new SentryContextManager(); + const ctxManager = new SentryContextManager(new AsyncLocalStorage()); context.setGlobalContextManager(ctxManager); client.on('spanEnd', span => { diff --git a/packages/opentelemetry/README.md b/packages/opentelemetry/README.md index 3fc8413e6144..0970e1db1bc4 100644 --- a/packages/opentelemetry/README.md +++ b/packages/opentelemetry/README.md @@ -76,7 +76,7 @@ function setupSentry() { // Initialize the provider trace.setGlobalTracerProvider(provider); propagation.setGlobalPropagator(new SentryPropagator()); - context.setGlobalContextManager(new SentryAsyncLocalStorageContextManager()); + context.setGlobalContextManager(new SentryAsyncLocalStorageContextManager(new AsyncLocalStorage())); setOpenTelemetryContextAsyncContextStrategy(); } diff --git a/packages/opentelemetry/src/asyncLocalStorageContextManager.ts b/packages/opentelemetry/src/asyncLocalStorageContextManager.ts index a7b2abcad9c2..c1c02e739632 100644 --- a/packages/opentelemetry/src/asyncLocalStorageContextManager.ts +++ b/packages/opentelemetry/src/asyncLocalStorageContextManager.ts @@ -23,14 +23,17 @@ import type { Context, ContextManager } from '@opentelemetry/api'; import { ROOT_CONTEXT } from '@opentelemetry/api'; -import { AsyncLocalStorage } from 'node:async_hooks'; +import type { AsyncLocalStorage } from 'node:async_hooks'; import { EventEmitter } from 'node:events'; -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'; +export type AsyncLocalStorageLookup = { + asyncLocalStorage: AsyncLocalStorage; + contextSymbol: symbol; +}; type ListenerFn = (...args: unknown[]) => unknown; /** @@ -50,13 +53,13 @@ export class SentryAsyncLocalStorageContextManager implements ContextManager { private readonly _kOtListeners = Symbol('OtListeners'); private _wrapped = false; - public constructor() { + public constructor(asyncLocalStorage: AsyncLocalStorage) { 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(); + ?.asyncLocalStorage as AsyncLocalStorage) ?? asyncLocalStorage; } public active(): Context { diff --git a/packages/opentelemetry/src/contextManager.ts b/packages/opentelemetry/src/contextManager.ts deleted file mode 100644 index f1c3228c5dfa..000000000000 --- a/packages/opentelemetry/src/contextManager.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type { AsyncLocalStorage } from 'node:async_hooks'; -import type { Context, ContextManager } from '@opentelemetry/api'; -import { SENTRY_SCOPES_CONTEXT_KEY } from './constants'; -import { buildContextWithSentryScopes } from './utils/buildContextWithSentryScopes'; -import { setIsSetup } from './utils/setupCheck'; - -export type AsyncLocalStorageLookup = { - asyncLocalStorage: AsyncLocalStorage; - contextSymbol: symbol; -}; - -type ExtendedContextManagerInstance = new ( - ...args: unknown[] -) => ContextManagerInstance & { - getAsyncLocalStorageLookup(): AsyncLocalStorageLookup; -}; - -/** - * Wrap an OpenTelemetry ContextManager in a way that ensures the context is kept in sync with the Sentry Scope. - * - * Usage: - * import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; - * const SentryContextManager = wrapContextManagerClass(AsyncLocalStorageContextManager); - * const contextManager = new SentryContextManager(); - * - * @deprecated Use {@link SentryAsyncLocalStorageContextManager} instead. - */ -export function wrapContextManagerClass( - ContextManagerClass: new (...args: unknown[]) => ContextManagerInstance, -): ExtendedContextManagerInstance { - /** - * This is a custom ContextManager for OpenTelemetry, which extends the default AsyncLocalStorageContextManager. - * It ensures that we create new scopes per context, so that the OTEL Context & the Sentry Scope are always in sync. - * - * Note that we currently only support AsyncHooks with this, - * but since this should work for Node 14+ anyhow that should be good enough. - */ - - // @ts-expect-error TS does not like this, but we know this is fine - class SentryContextManager extends ContextManagerClass { - public constructor(...args: unknown[]) { - super(...args); - setIsSetup('SentryContextManager'); - } - /** - * Overwrite with() of the original AsyncLocalStorageContextManager - * to ensure we also create new scopes per context. - */ - public with ReturnType>( - context: Context, - fn: F, - thisArg?: ThisParameterType, - ...args: A - ): ReturnType { - const ctx2 = buildContextWithSentryScopes(context, this.active()); - return super.with(ctx2, fn, thisArg, ...args); - } - - /** - * Gets underlying AsyncLocalStorage and symbol to allow lookup of scope. - */ - public getAsyncLocalStorageLookup(): AsyncLocalStorageLookup { - return { - // @ts-expect-error This is on the base class, but not part of the interface - asyncLocalStorage: this._asyncLocalStorage, - contextSymbol: SENTRY_SCOPES_CONTEXT_KEY, - }; - } - } - - return SentryContextManager as unknown as ExtendedContextManagerInstance; -} diff --git a/packages/opentelemetry/src/exports.ts b/packages/opentelemetry/src/exports.ts index 7816f437351c..9bda18df5cca 100644 --- a/packages/opentelemetry/src/exports.ts +++ b/packages/opentelemetry/src/exports.ts @@ -34,9 +34,6 @@ export { suppressTracing } from './utils/suppressTracing'; export { setupEventContextTrace } from './setupEventContextTrace'; -// eslint-disable-next-line typescript/no-deprecated -export { wrapContextManagerClass } from './contextManager'; - export { SentryPropagator, shouldPropagateTraceForUrl } from './propagator'; export { SentrySpanProcessor } from './spanProcessor'; export { SentrySampler, wrapSamplingDecision } from './sampler'; diff --git a/packages/opentelemetry/src/index.ts b/packages/opentelemetry/src/index.ts index 0dc4703ef990..f037204d30c8 100644 --- a/packages/opentelemetry/src/index.ts +++ b/packages/opentelemetry/src/index.ts @@ -1,8 +1,7 @@ export * from './exports'; // Node-specific exports -export { SentryAsyncLocalStorageContextManager } from './asyncLocalStorageContextManager'; -export type { AsyncLocalStorageLookup } from './contextManager'; +export { SentryAsyncLocalStorageContextManager, type AsyncLocalStorageLookup } from './asyncLocalStorageContextManager'; // We export the node-specific variant here that uses async local storage export { setNodeOpenTelemetryContextAsyncContextStrategy as setOpenTelemetryContextAsyncContextStrategy } from './nodeAsyncContextStrategy'; diff --git a/packages/opentelemetry/test/helpers/initOtel.ts b/packages/opentelemetry/test/helpers/initOtel.ts index 959d5a454319..361e12bb1146 100644 --- a/packages/opentelemetry/test/helpers/initOtel.ts +++ b/packages/opentelemetry/test/helpers/initOtel.ts @@ -11,6 +11,7 @@ import { setupEventContextTrace } from '../../src/setupEventContextTrace'; import { SentrySpanProcessor } from '../../src/spanProcessor'; import { enhanceDscWithOpenTelemetryRootSpanName } from '../../src/utils/enhanceDscWithOpenTelemetryRootSpanName'; import type { TestClient } from './TestClient'; +import { AsyncLocalStorage } from 'node:async_hooks'; /** * Initialize OpenTelemetry for Node. @@ -62,7 +63,7 @@ export function setupOtel(client: Client): [BasicTracerProvider, SentrySpanProce trace.setGlobalTracerProvider(provider); propagation.setGlobalPropagator(new SentryPropagator()); - context.setGlobalContextManager(new SentryAsyncLocalStorageContextManager()); + context.setGlobalContextManager(new SentryAsyncLocalStorageContextManager(new AsyncLocalStorage())); return [provider, spanProcessor]; } diff --git a/packages/opentelemetry/test/tracerProvider.test.ts b/packages/opentelemetry/test/tracerProvider.test.ts index 27e074bbd85a..9ca9626e2dec 100644 --- a/packages/opentelemetry/test/tracerProvider.test.ts +++ b/packages/opentelemetry/test/tracerProvider.test.ts @@ -19,13 +19,14 @@ import { applyOtelSpanData } from '../src/applyOtelSpanData'; import { SentryTracerProvider } from '../src/tracerProvider'; import { cleanupOtel } from './helpers/mockSdkInit'; import { init as initTestClient } from './helpers/TestClient'; +import { AsyncLocalStorage } from 'node:async_hooks'; describe('SentryTracerProvider', () => { beforeEach(() => { (global as { __SENTRY__?: unknown }).__SENTRY__ = {}; setOpenTelemetryContextAsyncContextStrategy(); initTestClient({ tracesSampleRate: 1 }); - context.setGlobalContextManager(new SentryAsyncLocalStorageContextManager()); + context.setGlobalContextManager(new SentryAsyncLocalStorageContextManager(new AsyncLocalStorage())); trace.setGlobalTracerProvider(new SentryTracerProvider()); }); diff --git a/packages/vercel-edge/src/sdk.ts b/packages/vercel-edge/src/sdk.ts index b2b99587e3e2..ec60529f7911 100644 --- a/packages/vercel-edge/src/sdk.ts +++ b/packages/vercel-edge/src/sdk.ts @@ -1,3 +1,4 @@ +import type { Context } from '@opentelemetry/api'; import { context, diag, DiagLogLevel, propagation, trace } from '@opentelemetry/api'; import { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; import type { Client, Integration, Options } from '@sentry/core'; @@ -22,12 +23,12 @@ import { enhanceDscWithOpenTelemetryRootSpanName, getSentryResource, openTelemetrySetupCheck, + SentryAsyncLocalStorageContextManager, SentryPropagator, SentrySampler, SentrySpanProcessor, setOpenTelemetryContextAsyncContextStrategy, setupEventContextTrace, - wrapContextManagerClass, } from '@sentry/opentelemetry'; import { VercelEdgeClient } from './client'; import { DEBUG_BUILD } from './debug-build'; @@ -35,12 +36,17 @@ import { winterCGFetchIntegration } from './integrations/wintercg-fetch'; import { makeEdgeTransport } from './transports'; import type { VercelEdgeOptions } from './types'; import { getVercelEnv } from './utils/vercel'; -import { AsyncLocalStorageContextManager } from './vendored/async-local-storage-context-manager'; declare const process: { env: Record; }; +interface AsyncLocalStorage { + getStore(): T | undefined; + run(store: T, callback: (...args: unknown[]) => R, ...args: unknown[]): R; + disable(): void; +} + const nodeStackParser = createStackParser(nodeStackLineParser()); /** Get the default integrations for the browser SDK. */ @@ -166,16 +172,40 @@ export function setupOtel(client: VercelEdgeClient): void { ], }); - // eslint-disable-next-line typescript/no-deprecated - const SentryContextManager = wrapContextManagerClass(AsyncLocalStorageContextManager); - trace.setGlobalTracerProvider(provider); propagation.setGlobalPropagator(new SentryPropagator()); - context.setGlobalContextManager(new SentryContextManager()); + context.setGlobalContextManager(new SentryAsyncLocalStorageContextManager(getAsyncLocalStorage())); client.traceProvider = provider; } +function getAsyncLocalStorage(): AsyncLocalStorage { + const MaybeGlobalAsyncLocalStorageConstructor = ( + GLOBAL_OBJ as { AsyncLocalStorage?: new () => AsyncLocalStorage } + ).AsyncLocalStorage; + + if (!MaybeGlobalAsyncLocalStorageConstructor) { + DEBUG_BUILD && + debug.warn( + "Tried to register AsyncLocalStorage async context strategy in a runtime that doesn't support AsyncLocalStorage.", + ); + + return { + getStore() { + return undefined; + }, + run(_store: Context, callback: (...args: unknown[]) => R, ...args: unknown[]): R { + return callback.apply(this, args); + }, + disable() { + // noop + }, + }; + } + + return new MaybeGlobalAsyncLocalStorageConstructor(); +} + /** * Setup the OTEL logger to use our own debug logger. */ diff --git a/packages/vercel-edge/src/vendored/abstract-async-hooks-context-manager.ts b/packages/vercel-edge/src/vendored/abstract-async-hooks-context-manager.ts deleted file mode 100644 index bda70f85539e..000000000000 --- a/packages/vercel-edge/src/vendored/abstract-async-hooks-context-manager.ts +++ /dev/null @@ -1,235 +0,0 @@ -/* - * Copyright The OpenTelemetry Authors - * SPDX-License-Identifier: Apache-2.0 - * - * NOTICE from the Sentry authors: - * - Code vendored from: https://github.com/open-telemetry/opentelemetry-js/blob/6515ed8098333646a63a74a8c0150cc2daf520db/packages/opentelemetry-context-async-hooks/src/AbstractAsyncHooksContextManager.ts - * - Modifications: - * - Added lint rules - * - Modified bind() method not to rely on Node.js specific APIs - */ - -/* eslint-disable @typescript-eslint/explicit-member-accessibility */ -/* eslint-disable @typescript-eslint/member-ordering */ -/* eslint-disable jsdoc/require-jsdoc */ -/* eslint-disable @typescript-eslint/ban-types */ -/* eslint-disable @typescript-eslint/explicit-function-return-type */ -/* eslint-disable @typescript-eslint/no-unsafe-member-access */ -/* eslint-disable prefer-rest-params */ -/* eslint-disable @typescript-eslint/no-dynamic-delete */ -/* eslint-disable @typescript-eslint/unbound-method */ -/* eslint-disable @typescript-eslint/no-this-alias */ - -import type { Context, ContextManager } from '@opentelemetry/api'; - -type Func = (...args: unknown[]) => T; - -// Inline EventEmitter interface to avoid Node.js module dependency -// This prevents Node.js type leaks in edge runtime environments -interface EventEmitter { - addListener?(event: string, listener: Func): this; - on?(event: string, listener: Func): this; - once?(event: string, listener: Func): this; - prependListener?(event: string, listener: Func): this; - prependOnceListener?(event: string, listener: Func): this; - removeListener?(event: string, listener: Func): this; - off?(event: string, listener: Func): this; - removeAllListeners?(event?: string): this; -} - -/** - * Store a map for each event of all original listeners and their "patched" - * version. So when a listener is removed by the user, the corresponding - * patched function will be also removed. - */ -interface PatchMap { - [name: string]: WeakMap, Func>; -} - -const ADD_LISTENER_METHODS = [ - 'addListener' as const, - 'on' as const, - 'once' as const, - 'prependListener' as const, - 'prependOnceListener' as const, -]; - -export abstract class AbstractAsyncHooksContextManager implements ContextManager { - abstract active(): Context; - - abstract with ReturnType>( - context: Context, - fn: F, - thisArg?: ThisParameterType, - ...args: A - ): ReturnType; - - abstract enable(): this; - - abstract disable(): this; - - /** - * Binds a the certain context or the active one to the target function and then returns the target - * @param context A context (span) to be bind to target - * @param target a function or event emitter. When target or one of its callbacks is called, - * the provided context will be used as the active context for the duration of the call. - */ - bind(context: Context, target: T): T { - if (typeof target === 'object' && target !== null && 'on' in target) { - return this._bindEventEmitter(context, target as unknown as EventEmitter) as T; - } - - if (typeof target === 'function') { - return this._bindFunction(context, target); - } - return target; - } - - private _bindFunction(context: Context, target: T): T { - const manager = this; - const contextWrapper = function (this: never, ...args: unknown[]) { - return manager.with(context, () => target.apply(this, args)); - }; - Object.defineProperty(contextWrapper, 'length', { - enumerable: false, - configurable: true, - writable: false, - value: target.length, - }); - /** - * It isn't possible to tell Typescript that contextWrapper is the same as T - * so we forced to cast as any here. - */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return contextWrapper as any; - } - - /** - * By default, EventEmitter call their callback with their context, which we do - * not want, instead we will bind a specific context to all callbacks that - * go through it. - * @param context the context we want to bind - * @param ee EventEmitter an instance of EventEmitter to patch - */ - private _bindEventEmitter(context: Context, ee: T): T { - const map = this._getPatchMap(ee); - if (map !== undefined) return ee; - this._createPatchMap(ee); - - // patch methods that add a listener to propagate context - ADD_LISTENER_METHODS.forEach(methodName => { - if (ee[methodName] === undefined) return; - ee[methodName] = this._patchAddListener(ee, ee[methodName], context); - }); - // patch methods that remove a listener - if (typeof ee.removeListener === 'function') { - ee.removeListener = this._patchRemoveListener(ee, ee.removeListener); - } - if (typeof ee.off === 'function') { - ee.off = this._patchRemoveListener(ee, ee.off); - } - // patch method that remove all listeners - if (typeof ee.removeAllListeners === 'function') { - ee.removeAllListeners = this._patchRemoveAllListeners(ee, ee.removeAllListeners); - } - return ee; - } - - /** - * Patch methods that remove a given listener so that we match the "patched" - * version of that listener (the one that propagate context). - * @param ee EventEmitter instance - * @param original reference to the patched method - */ - private _patchRemoveListener(ee: EventEmitter, original: Function) { - const contextManager = this; - return function (this: never, event: string, listener: Func) { - const events = contextManager._getPatchMap(ee)?.[event]; - if (events === undefined) { - return original.call(this, event, listener); - } - const patchedListener = events.get(listener); - return original.call(this, event, patchedListener || listener); - }; - } - - /** - * Patch methods that remove all listeners so we remove our - * internal references for a given event. - * @param ee EventEmitter instance - * @param original reference to the patched method - */ - private _patchRemoveAllListeners(ee: EventEmitter, original: Function) { - const contextManager = this; - return function (this: never, event: string) { - const map = contextManager._getPatchMap(ee); - if (map !== undefined) { - if (arguments.length === 0) { - contextManager._createPatchMap(ee); - } else if (map[event] !== undefined) { - delete map[event]; - } - } - return original.apply(this, arguments); - }; - } - - /** - * Patch methods on an event emitter instance that can add listeners so we - * can force them to propagate a given context. - * @param ee EventEmitter instance - * @param original reference to the patched method - * @param [context] context to propagate when calling listeners - */ - private _patchAddListener(ee: EventEmitter, original: Function, context: Context) { - const contextManager = this; - return function (this: never, event: string, listener: Func) { - /** - * This check is required to prevent double-wrapping the listener. - * The implementation for ee.once wraps the listener and calls ee.on. - * Without this check, we would wrap that wrapped listener. - * This causes an issue because ee.removeListener depends on the onceWrapper - * to properly remove the listener. If we wrap their wrapper, we break - * that detection. - */ - if (contextManager._wrapped) { - return original.call(this, event, listener); - } - let map = contextManager._getPatchMap(ee); - if (map === undefined) { - map = contextManager._createPatchMap(ee); - } - let listeners = map[event]; - if (listeners === undefined) { - listeners = new WeakMap(); - map[event] = listeners; - } - const patchedListener = contextManager.bind(context, listener); - // store a weak reference of the user listener to ours - listeners.set(listener, patchedListener); - - /** - * See comment at the start of this function for the explanation of this property. - */ - contextManager._wrapped = true; - try { - return original.call(this, event, patchedListener); - } finally { - contextManager._wrapped = false; - } - }; - } - - private _createPatchMap(ee: EventEmitter): PatchMap { - const map = Object.create(null); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (ee as any)[this._kOtListeners] = map; - return map; - } - private _getPatchMap(ee: EventEmitter): PatchMap | undefined { - return (ee as never)[this._kOtListeners]; - } - - private readonly _kOtListeners = Symbol('OtListeners'); - private _wrapped = false; -} diff --git a/packages/vercel-edge/src/vendored/async-local-storage-context-manager.ts b/packages/vercel-edge/src/vendored/async-local-storage-context-manager.ts deleted file mode 100644 index cdf5fd1a015a..000000000000 --- a/packages/vercel-edge/src/vendored/async-local-storage-context-manager.ts +++ /dev/null @@ -1,84 +0,0 @@ -/* - * Copyright The OpenTelemetry Authors - * SPDX-License-Identifier: Apache-2.0 - * - * NOTICE from the Sentry authors: - * - Code vendored from: https://github.com/open-telemetry/opentelemetry-js/blob/6515ed8098333646a63a74a8c0150cc2daf520db/packages/opentelemetry-context-async-hooks/src/AbstractAsyncHooksContextManager.ts - * - Modifications: - * - Added lint rules - * - Modified import path to AbstractAsyncHooksContextManager - * - Added Sentry logging - * - Modified constructor to access AsyncLocalStorage class from global object instead of the Node.js API - */ - -/* eslint-disable @typescript-eslint/explicit-member-accessibility */ -/* eslint-disable jsdoc/require-jsdoc */ -/* eslint-disable @typescript-eslint/no-explicit-any */ - -import type { Context } from '@opentelemetry/api'; -import { ROOT_CONTEXT } from '@opentelemetry/api'; -import { debug, GLOBAL_OBJ } from '@sentry/core'; -import { DEBUG_BUILD } from '../debug-build'; -import { AbstractAsyncHooksContextManager } from './abstract-async-hooks-context-manager'; - -// Inline AsyncLocalStorage interface to avoid Node.js module dependency -// This prevents Node.js type leaks in edge runtime environments -interface AsyncLocalStorage { - getStore(): T | undefined; - run(store: T, callback: (...args: any[]) => R, ...args: any[]): R; - disable(): void; -} - -export class AsyncLocalStorageContextManager extends AbstractAsyncHooksContextManager { - private _asyncLocalStorage: AsyncLocalStorage; - - constructor() { - super(); - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - const MaybeGlobalAsyncLocalStorageConstructor = (GLOBAL_OBJ as any).AsyncLocalStorage; - - if (!MaybeGlobalAsyncLocalStorageConstructor) { - DEBUG_BUILD && - debug.warn( - "Tried to register AsyncLocalStorage async context strategy in a runtime that doesn't support AsyncLocalStorage.", - ); - - this._asyncLocalStorage = { - getStore() { - return undefined; - }, - run(_store: Context, callback: (...args: any[]) => R, ...args: any[]): R { - return callback.apply(this, args); - }, - disable() { - // noop - }, - }; - } else { - this._asyncLocalStorage = new MaybeGlobalAsyncLocalStorageConstructor(); - } - } - - active(): Context { - return this._asyncLocalStorage.getStore() ?? ROOT_CONTEXT; - } - - with ReturnType>( - context: Context, - fn: F, - thisArg?: ThisParameterType, - ...args: A - ): ReturnType { - const cb = thisArg == null ? fn : fn.bind(thisArg); - return this._asyncLocalStorage.run(context, cb as never, ...args); - } - - enable(): this { - return this; - } - - disable(): this { - this._asyncLocalStorage.disable(); - return this; - } -} From 3f7d8fab7615f66a318b9169e757b4a8bec07581 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Fri, 24 Jul 2026 11:43:55 +0200 Subject: [PATCH 2/2] actually other approach --- .../node-otel-custom-sampler/src/instrument.ts | 3 +-- .../node-otel-sdk-node/src/instrument.ts | 3 +-- .../node-otel-without-tracing/src/instrument.ts | 3 +-- packages/node/src/sdk/initOtel.ts | 5 ++--- packages/opentelemetry/README.md | 2 +- .../src/asyncLocalStorageContextManager.ts | 16 +++++++++++----- packages/opentelemetry/test/helpers/initOtel.ts | 3 +-- .../opentelemetry/test/tracerProvider.test.ts | 3 +-- 8 files changed, 19 insertions(+), 19 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/node-otel-custom-sampler/src/instrument.ts b/dev-packages/e2e-tests/test-applications/node-otel-custom-sampler/src/instrument.ts index d709a7ea2ce7..de09f0965baa 100644 --- a/dev-packages/e2e-tests/test-applications/node-otel-custom-sampler/src/instrument.ts +++ b/dev-packages/e2e-tests/test-applications/node-otel-custom-sampler/src/instrument.ts @@ -2,7 +2,6 @@ import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'; import * as Sentry from '@sentry/node'; import { SentryPropagator, SentrySpanProcessor } from '@sentry/opentelemetry'; import { CustomSampler } from './custom-sampler'; -import { AsyncLocalStorage } from 'node:async_hooks'; Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions @@ -25,7 +24,7 @@ const provider = new NodeTracerProvider({ provider.register({ propagator: new SentryPropagator(), - contextManager: new Sentry.SentryContextManager(new AsyncLocalStorage()), + contextManager: new Sentry.SentryContextManager(), }); Sentry.validateOpenTelemetrySetup(); diff --git a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/src/instrument.ts b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/src/instrument.ts index d861e75ee120..5cb2e5570db9 100644 --- a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/src/instrument.ts +++ b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/src/instrument.ts @@ -2,7 +2,6 @@ const opentelemetry = require('@opentelemetry/sdk-node'); const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http'); const Sentry = require('@sentry/node'); const { SentrySpanProcessor, SentryPropagator, SentrySampler } = require('@sentry/opentelemetry'); -import { AsyncLocalStorage } from 'node:async_hooks'; const sentryClient = Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions @@ -21,7 +20,7 @@ const sentryClient = Sentry.init({ const sdk = new opentelemetry.NodeSDK({ sampler: sentryClient ? new SentrySampler(sentryClient) : undefined, textMapPropagator: new SentryPropagator(), - contextManager: new Sentry.SentryContextManager(new AsyncLocalStorage()), + contextManager: new Sentry.SentryContextManager(), spanProcessors: [ new SentrySpanProcessor(), new opentelemetry.node.BatchSpanProcessor( diff --git a/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/src/instrument.ts b/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/src/instrument.ts index b16e9914f0ca..ea9b6ae57545 100644 --- a/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/src/instrument.ts +++ b/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/src/instrument.ts @@ -5,7 +5,6 @@ const Sentry = require('@sentry/node'); const { SentryPropagator } = require('@sentry/opentelemetry'); const { UndiciInstrumentation } = require('@opentelemetry/instrumentation-undici'); const { registerInstrumentations } = require('@opentelemetry/instrumentation'); -import { AsyncLocalStorage } from 'node:async_hooks'; Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions @@ -35,7 +34,7 @@ const provider = new NodeTracerProvider({ // Initialize the provider provider.register({ propagator: new SentryPropagator(), - contextManager: new Sentry.SentryContextManager(new AsyncLocalStorage()), + contextManager: new Sentry.SentryContextManager(), }); registerInstrumentations({ diff --git a/packages/node/src/sdk/initOtel.ts b/packages/node/src/sdk/initOtel.ts index 2bf1bb92c3c8..3b264c993f56 100644 --- a/packages/node/src/sdk/initOtel.ts +++ b/packages/node/src/sdk/initOtel.ts @@ -21,7 +21,6 @@ import { } from '@sentry/opentelemetry'; import { DEBUG_BUILD } from '../debug-build'; import { getOpenTelemetryInstrumentationToPreload } from '../integrations/tracing'; -import { AsyncLocalStorage } from 'node:async_hooks'; // About 277h - this must fit into new Array(len)! const MAX_MAX_SPAN_WAIT_DURATION = 1_000_000; @@ -176,7 +175,7 @@ export function setupOtel( registerGlobalTracerProvider(provider); propagation.setGlobalPropagator(new SentryPropagator()); - const ctxManager = new SentryContextManager(new AsyncLocalStorage()); + const ctxManager = new SentryContextManager(); context.setGlobalContextManager(ctxManager); return [provider, ctxManager.getAsyncLocalStorageLookup()]; @@ -202,7 +201,7 @@ function setupSentryTracerProvider( propagation.setGlobalPropagator(new SentryPropagator()); - const ctxManager = new SentryContextManager(new AsyncLocalStorage()); + const ctxManager = new SentryContextManager(); context.setGlobalContextManager(ctxManager); client.on('spanEnd', span => { diff --git a/packages/opentelemetry/README.md b/packages/opentelemetry/README.md index 0970e1db1bc4..3fc8413e6144 100644 --- a/packages/opentelemetry/README.md +++ b/packages/opentelemetry/README.md @@ -76,7 +76,7 @@ function setupSentry() { // Initialize the provider trace.setGlobalTracerProvider(provider); propagation.setGlobalPropagator(new SentryPropagator()); - context.setGlobalContextManager(new SentryAsyncLocalStorageContextManager(new AsyncLocalStorage())); + context.setGlobalContextManager(new SentryAsyncLocalStorageContextManager()); setOpenTelemetryContextAsyncContextStrategy(); } diff --git a/packages/opentelemetry/src/asyncLocalStorageContextManager.ts b/packages/opentelemetry/src/asyncLocalStorageContextManager.ts index c1c02e739632..c296de4153d4 100644 --- a/packages/opentelemetry/src/asyncLocalStorageContextManager.ts +++ b/packages/opentelemetry/src/asyncLocalStorageContextManager.ts @@ -23,8 +23,8 @@ import type { Context, ContextManager } from '@opentelemetry/api'; import { ROOT_CONTEXT } from '@opentelemetry/api'; -import type { AsyncLocalStorage } from 'node:async_hooks'; -import { EventEmitter } from 'node:events'; +import { AsyncLocalStorage } from 'node:async_hooks'; +import type { EventEmitter } from 'node:events'; import { SENTRY_SCOPES_CONTEXT_KEY } from './constants'; import { buildContextWithSentryScopes } from './utils/buildContextWithSentryScopes'; import { setIsSetup } from './utils/setupCheck'; @@ -53,13 +53,14 @@ export class SentryAsyncLocalStorageContextManager implements ContextManager { private readonly _kOtListeners = Symbol('OtListeners'); private _wrapped = false; - public constructor(asyncLocalStorage: AsyncLocalStorage) { + public constructor(asyncLocalStorage?: AsyncLocalStorage) { 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) ?? asyncLocalStorage; + ?.asyncLocalStorage as AsyncLocalStorage) ?? + (asyncLocalStorage || new AsyncLocalStorage()); } public active(): Context { @@ -87,7 +88,7 @@ export class SentryAsyncLocalStorageContextManager implements ContextManager { } public bind(context: Context, target: T): T { - if (target instanceof EventEmitter) { + if (isEventEmitter(target)) { return this._bindEventEmitter(context, target); } if (typeof target === 'function') { @@ -221,3 +222,8 @@ export class SentryAsyncLocalStorageContextManager implements ContextManager { return (ee as unknown as Record)[this._kOtListeners]; } } + +// We use this instead of instanceof EventEmitter to make sure this also works in non-Node.js environments (e.g. vercel-edge) +function isEventEmitter(target: unknown): target is EventEmitter { + return typeof target === 'object' && target !== null && 'on' in target; +} diff --git a/packages/opentelemetry/test/helpers/initOtel.ts b/packages/opentelemetry/test/helpers/initOtel.ts index 361e12bb1146..959d5a454319 100644 --- a/packages/opentelemetry/test/helpers/initOtel.ts +++ b/packages/opentelemetry/test/helpers/initOtel.ts @@ -11,7 +11,6 @@ import { setupEventContextTrace } from '../../src/setupEventContextTrace'; import { SentrySpanProcessor } from '../../src/spanProcessor'; import { enhanceDscWithOpenTelemetryRootSpanName } from '../../src/utils/enhanceDscWithOpenTelemetryRootSpanName'; import type { TestClient } from './TestClient'; -import { AsyncLocalStorage } from 'node:async_hooks'; /** * Initialize OpenTelemetry for Node. @@ -63,7 +62,7 @@ export function setupOtel(client: Client): [BasicTracerProvider, SentrySpanProce trace.setGlobalTracerProvider(provider); propagation.setGlobalPropagator(new SentryPropagator()); - context.setGlobalContextManager(new SentryAsyncLocalStorageContextManager(new AsyncLocalStorage())); + context.setGlobalContextManager(new SentryAsyncLocalStorageContextManager()); return [provider, spanProcessor]; } diff --git a/packages/opentelemetry/test/tracerProvider.test.ts b/packages/opentelemetry/test/tracerProvider.test.ts index 9ca9626e2dec..27e074bbd85a 100644 --- a/packages/opentelemetry/test/tracerProvider.test.ts +++ b/packages/opentelemetry/test/tracerProvider.test.ts @@ -19,14 +19,13 @@ import { applyOtelSpanData } from '../src/applyOtelSpanData'; import { SentryTracerProvider } from '../src/tracerProvider'; import { cleanupOtel } from './helpers/mockSdkInit'; import { init as initTestClient } from './helpers/TestClient'; -import { AsyncLocalStorage } from 'node:async_hooks'; describe('SentryTracerProvider', () => { beforeEach(() => { (global as { __SENTRY__?: unknown }).__SENTRY__ = {}; setOpenTelemetryContextAsyncContextStrategy(); initTestClient({ tracesSampleRate: 1 }); - context.setGlobalContextManager(new SentryAsyncLocalStorageContextManager(new AsyncLocalStorage())); + context.setGlobalContextManager(new SentryAsyncLocalStorageContextManager()); trace.setGlobalTracerProvider(new SentryTracerProvider()); });