diff --git a/packages/opentelemetry/src/asyncLocalStorageContextManager.ts b/packages/opentelemetry/src/asyncLocalStorageContextManager.ts index a7b2abcad9c2..c296de4153d4 100644 --- a/packages/opentelemetry/src/asyncLocalStorageContextManager.ts +++ b/packages/opentelemetry/src/asyncLocalStorageContextManager.ts @@ -24,13 +24,16 @@ 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 type { AsyncLocalStorageLookup } from './contextManager'; +import type { EventEmitter } from 'node:events'; 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,14 @@ 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 || new AsyncLocalStorage()); } public active(): Context { @@ -84,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') { @@ -218,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/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/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; - } -}