diff --git a/dev-packages/browser-integration-tests/suites/opentelemetry/node-exports/init.js b/dev-packages/browser-integration-tests/suites/opentelemetry/node-exports/init.js deleted file mode 100644 index d9c534b801cd..000000000000 --- a/dev-packages/browser-integration-tests/suites/opentelemetry/node-exports/init.js +++ /dev/null @@ -1,8 +0,0 @@ -import * as Sentry from '@sentry/browser'; - -window.Sentry = Sentry; - -Sentry.init({ - traceLifecycle: 'static', - dsn: 'https://public@dsn.ingest.sentry.io/1337', -}); diff --git a/dev-packages/browser-integration-tests/suites/opentelemetry/node-exports/subject.js b/dev-packages/browser-integration-tests/suites/opentelemetry/node-exports/subject.js deleted file mode 100644 index 8d51286de101..000000000000 --- a/dev-packages/browser-integration-tests/suites/opentelemetry/node-exports/subject.js +++ /dev/null @@ -1,13 +0,0 @@ -import * as SentryOpenTelemetry from '@sentry/opentelemetry'; -import * as Sentry from '@sentry/browser'; - -// Verify that generally all imports can be resolved -// oxlint-disable-next-line no-console -for (const key in SentryOpenTelemetry) { - console.log(key, SentryOpenTelemetry[key]); -} - -// Verify that it console.errors if calling node-only thing -new SentryOpenTelemetry.SentryAsyncLocalStorageContextManager(); - -Sentry.captureException(new Error('test')); diff --git a/dev-packages/browser-integration-tests/suites/opentelemetry/node-exports/test.ts b/dev-packages/browser-integration-tests/suites/opentelemetry/node-exports/test.ts deleted file mode 100644 index 79a45bd8a1ed..000000000000 --- a/dev-packages/browser-integration-tests/suites/opentelemetry/node-exports/test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { expect } from '@playwright/test'; -import { sentryTest } from '../../../utils/fixtures'; -import { envelopeRequestParser, waitForErrorRequestOnUrl } from '../../../utils/helpers'; - -sentryTest('Should allow importing from @sentry/opentelemetry package', async ({ getLocalTestUrl, page }) => { - const bundle = process.env.PW_BUNDLE; - - if (bundle && bundle.includes('bundle')) { - sentryTest.skip(); - return; - } - - const consoleMessages: string[] = []; - page.on('console', msg => { - consoleMessages.push(msg.text()); - }); - - const url = await getLocalTestUrl({ testDir: __dirname }); - const req = await waitForErrorRequestOnUrl(page, url); - const eventData = envelopeRequestParser(req); - - expect(eventData.exception?.values).toHaveLength(1); - expect(eventData.exception?.values?.[0].value).toBe('test'); - - expect(consoleMessages).toContainEqual('SentryAsyncLocalStorageContextManager is not supported in the browser'); -}); diff --git a/dev-packages/e2e-tests/test-applications/deno-streamed/package.json b/dev-packages/e2e-tests/test-applications/deno-streamed/package.json index acc417cd3cd0..5f95becb0f0a 100644 --- a/dev-packages/e2e-tests/test-applications/deno-streamed/package.json +++ b/dev-packages/e2e-tests/test-applications/deno-streamed/package.json @@ -11,7 +11,7 @@ }, "dependencies": { "@sentry/deno": "file:../../packed/sentry-deno-packed.tgz", - "@opentelemetry/api": "^1.9.0", + "@opentelemetry/api": "^1.9.1", "ai": "^3.0.0", "zod": "^3.22.4" }, diff --git a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts index 4f03042f4b9a..079e2cfab7fc 100644 --- a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts +++ b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts @@ -15,10 +15,9 @@ const NODE_EXPORTS_IGNORE = [ // Probably generated by transpilation, no need to require it '__esModule', // Only required from the Node package - 'setNodeAsyncContextStrategy', + 'setOpenTelemetryContextAsyncContextStrategy', 'getDefaultIntegrationsWithoutPerformance', 'initWithoutDefaultIntegrations', - 'SentryContextManager', 'preloadOpenTelemetry', // Internal helper only needed within integrations (e.g. bunRuntimeMetricsIntegration) '_INTERNAL_normalizeCollectionInterval', diff --git a/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/package.json b/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/package.json index 04a6a80a500c..406783aea067 100644 --- a/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/package.json +++ b/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/package.json @@ -11,7 +11,7 @@ "test:assert": "pnpm test" }, "dependencies": { - "@opentelemetry/api": "1.9.0", + "@opentelemetry/api": "1.9.1", "@opentelemetry/sdk-trace-node": "2.6.1", "@opentelemetry/exporter-trace-otlp-http": "0.214.0", "@opentelemetry/instrumentation-undici": "0.24.0", 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 8a096bf201af..1be063e499b3 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 @@ -35,7 +35,9 @@ const provider = new NodeTracerProvider({ // Initialize the provider provider.register({ propagator: new SentryPropagator(), - contextManager: new Sentry.SentryContextManager(), + // We make sure to use the context manager that was previously set up in init + // TODO: This is not ideal but it is just an intermediate state until all of this otel linking stuff is gone + contextManager: null, }); registerInstrumentations({ diff --git a/packages/cloudflare/test/async.test.ts b/packages/cloudflare/test/async.test.ts index d4c27e380023..64a8d06ba29f 100644 --- a/packages/cloudflare/test/async.test.ts +++ b/packages/cloudflare/test/async.test.ts @@ -1,13 +1,4 @@ -import { - getCurrentScope, - getGlobalScope, - getIsolationScope, - GLOBAL_OBJ, - Scope, - withIsolationScope, - withScope, -} from '@sentry/core'; -import { AsyncLocalStorage } from 'async_hooks'; +import { getCurrentScope, getGlobalScope, getIsolationScope, Scope, withIsolationScope, withScope } from '@sentry/core'; import { beforeEach, describe, expect, it } from 'vitest'; import { setAsyncLocalStorageAsyncContextStrategy } from '../src/async'; @@ -17,7 +8,6 @@ describe('withScope()', () => { getCurrentScope().clear(); getGlobalScope().clear(); - (GLOBAL_OBJ as any).AsyncLocalStorage = AsyncLocalStorage; setAsyncLocalStorageAsyncContextStrategy(); }); @@ -95,7 +85,6 @@ describe('withIsolationScope()', () => { getIsolationScope().clear(); getCurrentScope().clear(); getGlobalScope().clear(); - (GLOBAL_OBJ as any).AsyncLocalStorage = AsyncLocalStorage; setAsyncLocalStorageAsyncContextStrategy(); }); diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 850a00a549a5..bf8dd5d7533e 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -54,10 +54,7 @@ export { getAutoPerformanceIntegrations } from './integrations/tracing'; export type { NodeOptions, OpenTelemetryServerRuntimeOptions } from './types'; -export { - // This needs exporting so the NodeClient can be used without calling init - setOpenTelemetryContextAsyncContextStrategy as setNodeAsyncContextStrategy, -} from '@sentry/opentelemetry'; +export { setOpenTelemetryContextAsyncContextStrategy } from '@sentry/opentelemetry'; export { addBreadcrumb, @@ -206,7 +203,6 @@ export { createGetModuleFromFilename } from './utils/module'; export { httpServerIntegration } from './integrations/http/httpServerIntegration'; export { httpServerSpansIntegration } from './integrations/http/httpServerSpansIntegration'; export { processSessionIntegration } from './integrations/processSession'; -export { SentryContextManager } from './otel/contextManager'; export { generateInstrumentOnce } from './otel/instrument'; export { NodeClient } from './sdk/client'; // eslint-disable-next-line typescript/no-deprecated diff --git a/packages/node/src/otel/contextManager.ts b/packages/node/src/otel/contextManager.ts deleted file mode 100644 index 8a41e322cfad..000000000000 --- a/packages/node/src/otel/contextManager.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { SentryAsyncLocalStorageContextManager } from '@sentry/opentelemetry'; - -/** - * This is a custom ContextManager for OpenTelemetry & Sentry. - * It ensures that we create a new hub per context, so that the OTEL Context & the Sentry Scopes are always in sync. - */ -export const SentryContextManager = SentryAsyncLocalStorageContextManager; diff --git a/packages/node/src/sdk/index.ts b/packages/node/src/sdk/index.ts index 4c39ede26ac3..25577a7df213 100644 --- a/packages/node/src/sdk/index.ts +++ b/packages/node/src/sdk/index.ts @@ -175,7 +175,7 @@ function _init( const clientOptions = getClientOptions({ ...options, defaultIntegrations }, getDefaultIntegrationsImpl); - setOpenTelemetryContextAsyncContextStrategy(clientOptions); + const asyncLocalStorageLookup = setOpenTelemetryContextAsyncContextStrategy(); const scope = getCurrentScope(); scope.update(clientOptions.initialScope); @@ -193,6 +193,7 @@ function _init( getCurrentScope().setClient(client); client.init(); + client.asyncLocalStorageLookup = asyncLocalStorageLookup; /*! rollup-include-cjs-only */ debug.log(`SDK initialized from CommonJS`); diff --git a/packages/node/src/sdk/initOtel.ts b/packages/node/src/sdk/initOtel.ts index ae4ade78350e..3107449582c3 100644 --- a/packages/node/src/sdk/initOtel.ts +++ b/packages/node/src/sdk/initOtel.ts @@ -1,12 +1,10 @@ import type { TracerProvider } from '@opentelemetry/api'; -import { context, propagation, trace } from '@opentelemetry/api'; +import { propagation, trace } from '@opentelemetry/api'; import { debug as coreDebug, hasSpanStreamingEnabled } from '@sentry/core'; -import { SentryContextManager } from '../otel/contextManager'; import { setupOpenTelemetryLogger } from '../otel/logger'; import type { NodeClient } from './client'; import { applyOtelSpanData, - type AsyncLocalStorageLookup, backfillStreamedSpanDataFromOtel, getSentryResource, SentryPropagator, @@ -78,9 +76,8 @@ export function initOpenTelemetry(client: NodeClient): void { setupOpenTelemetryLogger(); } - const [provider, asyncLocalStorageLookup] = setupOtel(client); + const provider = setupOtel(client); client.traceProvider = provider; - client.asyncLocalStorageLookup = asyncLocalStorageLookup; } interface NodePreloadOptions { @@ -125,7 +122,7 @@ function getPreloadMethods(integrationNames?: string[]): ((() => void) & { id: s } /** Just exported for tests. */ -export function setupOtel(client: NodeClient): [SentryTracerProvider | undefined, AsyncLocalStorageLookup | undefined] { +export function setupOtel(client: NodeClient): SentryTracerProvider | undefined { const provider = new SentryTracerProvider({ resource: getSentryResource('node') }); if (!registerGlobalTracerProvider(provider)) { @@ -133,14 +130,11 @@ export function setupOtel(client: NodeClient): [SentryTracerProvider | undefined coreDebug.warn( 'Could not register SentryTracerProvider because another OpenTelemetry tracer provider is already registered.', ); - return [undefined, undefined]; + return undefined; } propagation.setGlobalPropagator(new SentryPropagator()); - const ctxManager = new SentryContextManager(); - context.setGlobalContextManager(ctxManager); - client.on('spanEnd', span => { applyOtelSpanData(span, { finalizeStatus: true }); }); @@ -163,5 +157,5 @@ export function setupOtel(client: NodeClient): [SentryTracerProvider | undefined }; }); - return [provider, ctxManager.getAsyncLocalStorageLookup()]; + return provider; } diff --git a/packages/opentelemetry/package.json b/packages/opentelemetry/package.json index 1bfcd60cf792..bc651863474e 100644 --- a/packages/opentelemetry/package.json +++ b/packages/opentelemetry/package.json @@ -19,22 +19,8 @@ "./package.json": "./package.json", ".": { "types": "./build/types/index.d.ts", - "node": { - "import": "./build/esm/index.js", - "require": "./build/cjs/index.js" - }, - "worker": { - "import": "./build/esm/index.js", - "require": "./build/cjs/index.js" - }, - "browser": { - "import": "./build/esm/index.browser.js", - "require": "./build/cjs/index.browser.js" - }, - "default": { - "import": "./build/esm/index.js", - "require": "./build/cjs/index.js" - } + "import": "./build/esm/index.js", + "require": "./build/cjs/index.js" } }, "publishConfig": { diff --git a/packages/opentelemetry/rollup.npm.config.mjs b/packages/opentelemetry/rollup.npm.config.mjs index e694fb8d1001..fefff3a69ebb 100644 --- a/packages/opentelemetry/rollup.npm.config.mjs +++ b/packages/opentelemetry/rollup.npm.config.mjs @@ -2,7 +2,7 @@ import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollu export default makeNPMConfigVariants( makeBaseNPMConfig({ - entrypoints: ['src/index.ts', 'src/index.browser.ts'], + entrypoints: ['src/index.ts'], packageSpecificConfig: { output: { // set exports to 'named' or 'auto' so that rollup doesn't warn diff --git a/packages/opentelemetry/src/asyncContextStrategy.ts b/packages/opentelemetry/src/asyncContextStrategy.ts index 11dbda075d69..41cddace030e 100644 --- a/packages/opentelemetry/src/asyncContextStrategy.ts +++ b/packages/opentelemetry/src/asyncContextStrategy.ts @@ -1,24 +1,34 @@ import * as api from '@opentelemetry/api'; import type { Scope, TracingChannelBinding } from '@sentry/core'; -import { getDefaultCurrentScope, getDefaultIsolationScope, setAsyncContextStrategy } from '@sentry/core'; +import { + getDefaultCurrentScope, + getDefaultIsolationScope, + getRootSpan, + setAsyncContextStrategy, + spanIsIgnored, +} from '@sentry/core'; import { SENTRY_FORK_ISOLATION_SCOPE_CONTEXT_KEY, SENTRY_FORK_SET_ISOLATION_SCOPE_CONTEXT_KEY, SENTRY_FORK_SET_SCOPE_CONTEXT_KEY, + SENTRY_TRACE_STATE_CHILD_IGNORED, } from './constants'; import { continueTrace, startInactiveSpan, startNewTrace, startSpan, startSpanManual, withActiveSpan } from './trace'; import type { CurrentScopes } from './types'; import { getContextFromScope, getScopesFromContext } from './utils/contextData'; import { getActiveSpan } from './utils/getActiveSpan'; import { getTraceData } from './utils/getTraceData'; +import { AsyncLocalStorage } from 'node:async_hooks'; +import type { AsyncLocalStorageLookup } from './asyncLocalStorageContextManager'; +import { SentryAsyncLocalStorageContextManager } from './asyncLocalStorageContextManager'; /** * 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(options?: { - getTracingChannelBinding?: () => TracingChannelBinding | undefined; -}): void { +export function setOpenTelemetryContextAsyncContextStrategy(): AsyncLocalStorageLookup { + const asyncLocalStorage = new AsyncLocalStorage(); + function getScopes(): CurrentScopes { const ctx = api.context.active(); const scopes = getScopesFromContext(ctx); @@ -91,6 +101,13 @@ export function setOpenTelemetryContextAsyncContextStrategy(options?: { return getScopes().isolationScope; } + function getTracingChannelBinding(): TracingChannelBinding { + return { + asyncLocalStorage, + getStoreWithActiveSpan, + } satisfies TracingChannelBinding; + } + setAsyncContextStrategy({ withScope, withSetScope, @@ -108,6 +125,23 @@ export function setOpenTelemetryContextAsyncContextStrategy(options?: { // 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, - getTracingChannelBinding: options?.getTracingChannelBinding, + getTracingChannelBinding, }); + + const ctxManager = new SentryAsyncLocalStorageContextManager(asyncLocalStorage); + api.context.setGlobalContextManager(ctxManager); + + return ctxManager.getAsyncLocalStorageLookup(); +} + +function getStoreWithActiveSpan(span: Parameters[0]): api.Context { + const activeContext = api.context.active(); + + // Tracing channels bind directly to the context manager's AsyncLocalStorage and bypass + // SentryContextManager.with(), so ignored children must restore their parent here as well. + const isIgnoredChild = + (spanIsIgnored(span) && getRootSpan(span) !== span) || + span.spanContext().traceState?.get(SENTRY_TRACE_STATE_CHILD_IGNORED) === '1'; + + return isIgnoredChild ? activeContext : api.trace.setSpan(activeContext, span); } diff --git a/packages/opentelemetry/src/asyncLocalStorageContextManager.ts b/packages/opentelemetry/src/asyncLocalStorageContextManager.ts index 822ef51c8187..e2b1d05899d8 100644 --- a/packages/opentelemetry/src/asyncLocalStorageContextManager.ts +++ b/packages/opentelemetry/src/asyncLocalStorageContextManager.ts @@ -23,13 +23,15 @@ 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 { AsyncLocalStorage } from 'node:async_hooks'; +import type { EventEmitter } from 'node:events'; import { SENTRY_SCOPES_CONTEXT_KEY } from './constants'; import { buildContextWithSentryScopes } from './utils/buildContextWithSentryScopes'; -import { getAsyncContextStrategy, getMainCarrier } from '@sentry/core'; +export type AsyncLocalStorageLookup = { + asyncLocalStorage: AsyncLocalStorage; + contextSymbol: symbol; +}; type ListenerFn = (...args: unknown[]) => unknown; /** @@ -41,7 +43,6 @@ const ADD_LISTENER_METHODS = ['addListener', 'on', 'once', 'prependListener', 'p /** * OpenTelemetry-compatible context manager using Node.js `AsyncLocalStorage`. - * Semantics match `@opentelemetry/context-async-hooks` (function `bind` + `EventEmitter` patching). */ export class SentryAsyncLocalStorageContextManager implements ContextManager { protected readonly _asyncLocalStorage: AsyncLocalStorage; @@ -49,12 +50,8 @@ export class SentryAsyncLocalStorageContextManager implements ContextManager { private readonly _kOtListeners = Symbol('OtListeners'); private _wrapped = false; - public constructor() { - // 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 constructor(asyncLocalStorage: AsyncLocalStorage) { + this._asyncLocalStorage = asyncLocalStorage; } public active(): Context { @@ -86,7 +83,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') { @@ -124,30 +121,23 @@ export class SentryAsyncLocalStorageContextManager implements ContextManager { if (this._getPatchMap(ee) !== undefined) { return ee; } - this._createPatchMap(ee); + if (this._createPatchMap(ee) === undefined) { + return ee; + } for (const methodName of ADD_LISTENER_METHODS) { - if (ee[methodName] === undefined) continue; - ee[methodName] = this._patchAddListener( - ee, - ee[methodName] as unknown as (...args: unknown[]) => unknown, - context, - ); - } - if (typeof ee.removeListener === 'function') { - // oxlint-disable-next-line @typescript-eslint/unbound-method -- patched like upstream OTel context manager - ee.removeListener = this._patchRemoveListener(ee, ee.removeListener as (...args: unknown[]) => unknown); + const original = getMethod(ee, methodName); + if (!original) continue; + trySetMethod(ee, methodName, this._patchAddListener(ee, original, context)); } - if (typeof ee.off === 'function') { - // oxlint-disable-next-line @typescript-eslint/unbound-method - ee.off = this._patchRemoveListener(ee, ee.off as (...args: unknown[]) => unknown); + for (const methodName of ['removeListener', 'off'] as const) { + const original = getMethod(ee, methodName); + if (!original) continue; + trySetMethod(ee, methodName, this._patchRemoveListener(ee, original)); } - if (typeof ee.removeAllListeners === 'function') { - ee.removeAllListeners = this._patchRemoveAllListeners( - ee, - // oxlint-disable-next-line @typescript-eslint/unbound-method - ee.removeAllListeners as (...args: unknown[]) => unknown, - ); + const removeAllListeners = getMethod(ee, 'removeAllListeners'); + if (removeAllListeners) { + trySetMethod(ee, 'removeAllListeners', this._patchRemoveAllListeners(ee, removeAllListeners)); } return ee; } @@ -172,6 +162,10 @@ export class SentryAsyncLocalStorageContextManager implements ContextManager { const map = contextManager._getPatchMap(ee); if (map !== undefined) { if (arguments.length === 0) { + // Best-effort reset: if the emitter was frozen after binding, this cannot replace the map + // (and the map itself could not be cleared in place either). A stale map is harmless though — + // `_patchAddListener` overwrites entries when a listener is re-added, and `_patchRemoveListener` + // passing an already-unregistered wrapper to `original` is a no-op. contextManager._createPatchMap(ee); } else if (event !== undefined && map[event] !== undefined) { // oxlint-disable-next-line @typescript-eslint/no-dynamic-delete -- event-keyed listener map @@ -189,9 +183,9 @@ export class SentryAsyncLocalStorageContextManager implements ContextManager { if (contextManager._wrapped) { return original.call(this, event, listener); } - let map = contextManager._getPatchMap(ee); + const map = contextManager._getPatchMap(ee) ?? contextManager._createPatchMap(ee); if (map === undefined) { - map = contextManager._createPatchMap(ee); + return original.call(this, event, listener); } let listeners = map[event]; if (listeners === undefined) { @@ -210,13 +204,61 @@ export class SentryAsyncLocalStorageContextManager implements ContextManager { }; } - private _createPatchMap(ee: EventEmitter): PatchMap { + /** + * Attach a fresh patch map to the emitter. Returns `undefined` if the emitter does not accept the + * property (e.g. it is frozen or sealed), in which case the emitter must not be patched at all — + * without a patch map the remove-listener patches could not resolve their wrapped listeners. + */ + private _createPatchMap(ee: EventEmitter): PatchMap | undefined { const map = Object.create(null) as PatchMap; - (ee as unknown as Record)[this._kOtListeners] = map; - return map; + try { + (ee as unknown as Record)[this._kOtListeners] = map; + } catch { + return undefined; + } + return this._getPatchMap(ee) === map ? map : undefined; } private _getPatchMap(ee: EventEmitter): PatchMap | undefined { return (ee as unknown as Record)[this._kOtListeners]; } } + +/** + * Duck-typed `EventEmitter` check. + * + * We use this instead of `instanceof EventEmitter` so it also works in non-Node.js environments (e.g. vercel-edge) + * and across realms. Both `on` and `emit` must be callable — checking only for the presence of an `on` property + * would classify plain objects like `{ on: true }` as emitters and later try to call a non-function. + */ +function isEventEmitter(target: unknown): target is EventEmitter { + if (typeof target !== 'object' || !target) { + return false; + } + const candidate = target as Partial>; + return typeof candidate.on === 'function' && typeof candidate.emit === 'function'; +} + +/** + * Read a method off an emitter, ignoring inherited or own properties that are not callable. + */ +function getMethod(ee: T, methodName: keyof EventEmitter): ListenerFn | undefined { + const value = (ee as unknown as Record)[methodName as string]; + return typeof value === 'function' ? (value as ListenerFn) : undefined; +} + +/** + * Assign a patched method back onto the emitter. Emitters may be frozen or expose getter-only methods, + * in which case we leave the original method in place instead of throwing. + */ +function trySetMethod( + ee: T, + methodName: keyof EventEmitter, + patched: (...args: never[]) => unknown, +): void { + try { + (ee as unknown as Record)[methodName as string] = patched; + } catch { + // Nothing to do, the emitter keeps its unpatched method + } +} diff --git a/packages/opentelemetry/src/contextManager.ts b/packages/opentelemetry/src/contextManager.ts deleted file mode 100644 index 6027782e4588..000000000000 --- a/packages/opentelemetry/src/contextManager.ts +++ /dev/null @@ -1,67 +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'; - -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 { - /** - * 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 deleted file mode 100644 index af4435c8b493..000000000000 --- a/packages/opentelemetry/src/exports.ts +++ /dev/null @@ -1,17 +0,0 @@ -export { getScopesFromContext } from './utils/contextData'; - -export { enhanceDscWithOpenTelemetryRootSpanName } from './utils/enhanceDscWithOpenTelemetryRootSpanName'; - -export { getTraceContextForScope } from './trace'; - -export { setupEventContextTrace } from './setupEventContextTrace'; - -// eslint-disable-next-line typescript/no-deprecated -export { wrapContextManagerClass } from './contextManager'; - -export { SentryPropagator } from './propagator'; -export { applyOtelSpanData } from './applyOtelSpanData'; -export { backfillStreamedSpanDataFromOtel } from './utils/backfillStreamedSpanData'; -export { SentryTracerProvider } from './tracerProvider'; - -export { getSentryResource } from './resource'; diff --git a/packages/opentelemetry/src/index.browser.ts b/packages/opentelemetry/src/index.browser.ts deleted file mode 100644 index c3877aa759ff..000000000000 --- a/packages/opentelemetry/src/index.browser.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { consoleSandbox } from '@sentry/core'; - -export * from './exports'; - -// Stubs for node-specific exports -export class SentryAsyncLocalStorageContextManager { - public constructor() { - consoleSandbox(() => { - // oxlint-disable-next-line no-console - console.error('SentryAsyncLocalStorageContextManager is not supported in the browser'); - }); - } -} - -// 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 0dc4703ef990..2a06eeee940a 100644 --- a/packages/opentelemetry/src/index.ts +++ b/packages/opentelemetry/src/index.ts @@ -1,8 +1,18 @@ -export * from './exports'; +export { getScopesFromContext } from './utils/contextData'; -// Node-specific exports -export { SentryAsyncLocalStorageContextManager } from './asyncLocalStorageContextManager'; -export type { AsyncLocalStorageLookup } from './contextManager'; +export { enhanceDscWithOpenTelemetryRootSpanName } from './utils/enhanceDscWithOpenTelemetryRootSpanName'; -// We export the node-specific variant here that uses async local storage -export { setNodeOpenTelemetryContextAsyncContextStrategy as setOpenTelemetryContextAsyncContextStrategy } from './nodeAsyncContextStrategy'; +export { getTraceContextForScope } from './trace'; + +export { setupEventContextTrace } from './setupEventContextTrace'; + +export { SentryPropagator } from './propagator'; +export { applyOtelSpanData } from './applyOtelSpanData'; +export { backfillStreamedSpanDataFromOtel } from './utils/backfillStreamedSpanData'; +export { SentryTracerProvider } from './tracerProvider'; + +export { getSentryResource } from './resource'; + +export { type AsyncLocalStorageLookup } from './asyncLocalStorageContextManager'; + +export { setOpenTelemetryContextAsyncContextStrategy } from './asyncContextStrategy'; diff --git a/packages/opentelemetry/src/nodeAsyncContextStrategy.ts b/packages/opentelemetry/src/nodeAsyncContextStrategy.ts deleted file mode 100644 index b4f64a7f305e..000000000000 --- a/packages/opentelemetry/src/nodeAsyncContextStrategy.ts +++ /dev/null @@ -1,73 +0,0 @@ -import * as api from '@opentelemetry/api'; -import { setOpenTelemetryContextAsyncContextStrategy } from './asyncContextStrategy'; -import { AsyncLocalStorage } from 'node:async_hooks'; -import { getRootSpan, spanIsIgnored, type TracingChannelBinding } from '@sentry/core'; -import { SENTRY_TRACE_STATE_CHILD_IGNORED } from './constants'; - -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, - } 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, - } satisfies TracingChannelBinding) - : undefined; - } catch { - return undefined; - } - }; -} - -function getStoreWithActiveSpan(span: Parameters[0]): api.Context { - const activeContext = api.context.active(); - - // Tracing channels bind directly to the context manager's AsyncLocalStorage and bypass - // SentryContextManager.with(), so ignored children must restore their parent here as well. - const isIgnoredChild = - (spanIsIgnored(span) && getRootSpan(span) !== span) || - span.spanContext().traceState?.get(SENTRY_TRACE_STATE_CHILD_IGNORED) === '1'; - - return isIgnoredChild ? activeContext : api.trace.setSpan(activeContext, span); -} diff --git a/packages/opentelemetry/test/asyncContextStrategy.test.ts b/packages/opentelemetry/test/asyncContextStrategy.test.ts index 1d32bf34e06c..c95932c3ecf6 100644 --- a/packages/opentelemetry/test/asyncContextStrategy.test.ts +++ b/packages/opentelemetry/test/asyncContextStrategy.test.ts @@ -12,25 +12,18 @@ import { withIsolationScope, withScope, } from '@sentry/core'; -import { afterAll, afterEach, beforeEach, describe, expect, it, test } from 'vitest'; -import { setOpenTelemetryContextAsyncContextStrategy } from '../src/asyncContextStrategy'; +import { afterAll, beforeEach, describe, expect, it, test } from 'vitest'; import { SENTRY_TRACE_STATE_CHILD_IGNORED } from '../src/constants'; -import { setNodeOpenTelemetryContextAsyncContextStrategy } from '../src/nodeAsyncContextStrategy'; +import { setOpenTelemetryContextAsyncContextStrategy } from '../src/asyncContextStrategy'; import { TraceState } from '../src/utils/TraceState'; -import { setupOtel } from './helpers/initOtel'; -import { cleanupOtel } from './helpers/mockSdkInit'; +import { mockSdkInit } from './helpers/mockSdkInit'; describe('asyncContextStrategy', () => { beforeEach(() => { getCurrentScope().clear(); getIsolationScope().clear(); - setupOtel(); - setOpenTelemetryContextAsyncContextStrategy(); - }); - - afterEach(() => { - cleanupOtel(); + mockSdkInit(); }); afterAll(() => { @@ -84,7 +77,7 @@ describe('asyncContextStrategy', () => { }); test('tracing channel binding keeps the parent active for an ignored child span', () => { - setNodeOpenTelemetryContextAsyncContextStrategy(); + setOpenTelemetryContextAsyncContextStrategy(); const parentSpan = trace.getTracer('test').startSpan('parent'); const ignoredSpan = trace.wrapSpanContext({ @@ -106,7 +99,7 @@ describe('asyncContextStrategy', () => { }); test('tracing channel binding keeps the parent active for a native ignored child span', () => { - setNodeOpenTelemetryContextAsyncContextStrategy(); + setOpenTelemetryContextAsyncContextStrategy(); const parentSpan = trace.getTracer('test').startSpan('parent'); const ignoredSpan = new SentryNonRecordingSpan({ @@ -127,7 +120,7 @@ describe('asyncContextStrategy', () => { }); test('tracing channel binding activates a native ignored root span with a remote parent', () => { - setNodeOpenTelemetryContextAsyncContextStrategy(); + setOpenTelemetryContextAsyncContextStrategy(); const traceId = '12345678901234567890123456789012'; const remoteParent = trace.wrapSpanContext({ diff --git a/packages/opentelemetry/test/asyncLocalStorageContextManager.test.ts b/packages/opentelemetry/test/asyncLocalStorageContextManager.test.ts new file mode 100644 index 000000000000..cefa4957970f --- /dev/null +++ b/packages/opentelemetry/test/asyncLocalStorageContextManager.test.ts @@ -0,0 +1,223 @@ +import { ROOT_CONTEXT } from '@opentelemetry/api'; +import { EventEmitter } from 'node:events'; +import { describe, expect, it, vi } from 'vitest'; +import { SentryAsyncLocalStorageContextManager } from '../src/asyncLocalStorageContextManager'; +import { AsyncLocalStorage } from 'node:async_hooks'; + +describe('SentryAsyncLocalStorageContextManager', () => { + describe('disable', () => { + it('disables the underlying async local storage', () => { + const contextManager = new SentryAsyncLocalStorageContextManager(new AsyncLocalStorage()); + const disable = vi.spyOn(contextManager['_asyncLocalStorage'], 'disable'); + + expect(contextManager.disable()).toBe(contextManager); + expect(disable).toHaveBeenCalledTimes(1); + }); + + // Cloudflare Workers' `nodejs_compat` AsyncLocalStorage does not implement `disable()` + it('does not throw when the async local storage has no disable method', () => { + const contextManager = new SentryAsyncLocalStorageContextManager(new AsyncLocalStorage()); + // @ts-expect-error -- simulating a runtime that omits `disable` + contextManager['_asyncLocalStorage'].disable = undefined; + + expect(() => contextManager.disable()).not.toThrow(); + }); + }); + + describe('bind', () => { + it('binds functions', () => { + const contextManager = new SentryAsyncLocalStorageContextManager(new AsyncLocalStorage()); + const context = ROOT_CONTEXT.setValue(Symbol.for('test'), 'value'); + + const fn = vi.fn((a: number, b: number) => a + b); + const bound = contextManager.bind(context, fn); + + expect(bound).not.toBe(fn); + expect(bound.length).toBe(2); + expect(bound(1, 2)).toBe(3); + }); + + it('patches event emitters and runs listeners in the bound context', () => { + const contextManager = new SentryAsyncLocalStorageContextManager(new AsyncLocalStorage()); + const key = Symbol.for('test'); + const context = ROOT_CONTEXT.setValue(key, 'value'); + + const ee = new EventEmitter(); + expect(contextManager.bind(context, ee)).toBe(ee); + + const seen: unknown[] = []; + ee.on('foo', () => { + seen.push(contextManager.active().getValue(key)); + }); + ee.emit('foo'); + + expect(seen).toEqual(['value']); + }); + + it('removes patched listeners via removeListener', () => { + const contextManager = new SentryAsyncLocalStorageContextManager(new AsyncLocalStorage()); + const ee = contextManager.bind(ROOT_CONTEXT, new EventEmitter()); + + const listener = vi.fn(); + ee.on('foo', listener); + ee.removeListener('foo', listener); + ee.emit('foo'); + + expect(listener).not.toHaveBeenCalled(); + expect(ee.listenerCount('foo')).toBe(0); + }); + + // These objects have an `on` property, so a presence-only check would treat them as emitters and + // later invoke a non-function as the original method. + it.each([ + ['boolean on', { on: true }], + ['string on', { on: 'yes' }], + ['numeric on', { on: 1 }], + ['object on', { on: {} }], + ['null on', { on: null }], + ['function on but no emit', { on: () => {} }], + ['emit but no on', { emit: () => {} }], + ['plain object', {}], + ])('returns non-emitter target untouched: %s', (_name, target) => { + const contextManager = new SentryAsyncLocalStorageContextManager(new AsyncLocalStorage()); + const snapshot = { ...target }; + + const result = contextManager.bind(ROOT_CONTEXT, target); + + expect(result).toBe(target); + expect({ ...target }).toEqual(snapshot); + // No patch map symbol was attached + expect(Object.getOwnPropertySymbols(target)).toEqual([]); + }); + + it.each([ + ['undefined', undefined], + ['null', null], + ['number', 42], + ['string', 'foo'], + ['boolean', true], + ['array', [1, 2, 3]], + ])('returns primitive target untouched: %s', (_name, target) => { + const contextManager = new SentryAsyncLocalStorageContextManager(new AsyncLocalStorage()); + + expect(contextManager.bind(ROOT_CONTEXT, target)).toBe(target); + }); + + it('does not call a non-function `on` when the object also has a callable emit', () => { + const contextManager = new SentryAsyncLocalStorageContextManager(new AsyncLocalStorage()); + // `on` is not callable – patching and later invoking it would throw + const target = { on: 'not-a-function', emit: () => {} }; + + contextManager.bind(ROOT_CONTEXT, target); + + expect(target.on).toBe('not-a-function'); + }); + + it('does not throw for frozen event emitters', () => { + const contextManager = new SentryAsyncLocalStorageContextManager(new AsyncLocalStorage()); + const ee = Object.freeze(new EventEmitter()); + + expect(() => contextManager.bind(ROOT_CONTEXT, ee)).not.toThrow(); + expect(ee.on).toBe(EventEmitter.prototype.on); + }); + + it('does not throw for frozen emitter-like objects and leaves them functional', () => { + const contextManager = new SentryAsyncLocalStorageContextManager(new AsyncLocalStorage()); + const listeners: Array<(...args: unknown[]) => unknown> = []; + const originalOn = (_event: string, listener: (...args: unknown[]) => unknown): void => { + listeners.push(listener); + }; + const target = Object.freeze({ + on: originalOn, + emit: (_event: string) => listeners.forEach(listener => listener()), + }); + + expect(() => contextManager.bind(ROOT_CONTEXT, target)).not.toThrow(); + expect(target.on).toBe(originalOn); + + const listener = vi.fn(); + target.on('foo', listener); + target.emit('foo'); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('does not throw for sealed event emitters', () => { + const contextManager = new SentryAsyncLocalStorageContextManager(new AsyncLocalStorage()); + const ee = Object.seal(new EventEmitter()); + + expect(() => contextManager.bind(ROOT_CONTEXT, ee)).not.toThrow(); + + const listener = vi.fn(); + ee.on('foo', listener); + ee.emit('foo'); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('does not throw for emitters with getter-only methods', () => { + const contextManager = new SentryAsyncLocalStorageContextManager(new AsyncLocalStorage()); + const ee = new EventEmitter(); + const originalOn = ee.on.bind(ee); + Object.defineProperty(ee, 'on', { + configurable: false, + get: () => originalOn, + }); + + expect(() => contextManager.bind(ROOT_CONTEXT, ee)).not.toThrow(); + + const listener = vi.fn(); + ee.on('foo', listener); + ee.emit('foo'); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('patches emitter-like objects that are not EventEmitter instances', () => { + const contextManager = new SentryAsyncLocalStorageContextManager(new AsyncLocalStorage()); + const key = Symbol.for('test'); + const context = ROOT_CONTEXT.setValue(key, 'value'); + + const listeners: Array<(...args: unknown[]) => unknown> = []; + const target = { + on(_event: string, listener: (...args: unknown[]) => unknown) { + listeners.push(listener); + return this; + }, + emit(_event: string) { + listeners.forEach(listener => listener()); + return true; + }, + }; + + contextManager.bind(context, target); + + const seen: unknown[] = []; + target.on('foo', () => { + seen.push(contextManager.active().getValue(key)); + }); + target.emit('foo'); + + expect(seen).toEqual(['value']); + }); + + it('leaves absent listener methods absent', () => { + const contextManager = new SentryAsyncLocalStorageContextManager(new AsyncLocalStorage()); + const target = { on: () => {}, emit: () => {} }; + + contextManager.bind(ROOT_CONTEXT, target); + + expect('once' in target).toBe(false); + expect('removeListener' in target).toBe(false); + expect('removeAllListeners' in target).toBe(false); + }); + + it('does not patch the same emitter twice', () => { + const contextManager = new SentryAsyncLocalStorageContextManager(new AsyncLocalStorage()); + const ee = new EventEmitter(); + + contextManager.bind(ROOT_CONTEXT, ee); + const patchedOn = ee.on; + + contextManager.bind(ROOT_CONTEXT, ee); + expect(ee.on).toBe(patchedOn); + }); + }); +}); diff --git a/packages/opentelemetry/test/contextManager.test.ts b/packages/opentelemetry/test/contextManager.test.ts index 795c017dd456..9e38714ed33f 100644 --- a/packages/opentelemetry/test/contextManager.test.ts +++ b/packages/opentelemetry/test/contextManager.test.ts @@ -1,14 +1,10 @@ import { context, trace, TraceFlags } from '@opentelemetry/api'; import { TraceState } from '../src/utils/TraceState'; -import { afterEach, describe, expect, it } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { SENTRY_TRACE_STATE_CHILD_IGNORED } from '../src/constants'; -import { cleanupOtel, mockSdkInit } from './helpers/mockSdkInit'; +import { mockSdkInit } from './helpers/mockSdkInit'; describe('SentryContextManager', () => { - afterEach(async () => { - await cleanupOtel(); - }); - it('removes ignored spans from context so children parent to grandparent', () => { mockSdkInit({ tracesSampleRate: 1 }); diff --git a/packages/opentelemetry/test/custom/client.test.ts b/packages/opentelemetry/test/custom/client.test.ts deleted file mode 100644 index b39f45d4919e..000000000000 --- a/packages/opentelemetry/test/custom/client.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { ProxyTracer } from '@opentelemetry/api'; -import { describe, expect, it } from 'vitest'; -import { getDefaultTestClientOptions, TestClient } from '../helpers/TestClient'; - -describe('OpenTelemetryClient', () => { - it('exposes a tracer', () => { - const options = getDefaultTestClientOptions(); - const client = new TestClient(options); - - const tracer = client.tracer; - expect(tracer).toBeDefined(); - expect(tracer).toBeInstanceOf(ProxyTracer); - - // Ensure we always get the same tracer instance - const tracer2 = client.tracer; - - expect(tracer2).toBe(tracer); - }); -}); diff --git a/packages/opentelemetry/test/helpers/TestClient.ts b/packages/opentelemetry/test/helpers/TestClient.ts index 8337ad4048a1..3567b378a0fc 100644 --- a/packages/opentelemetry/test/helpers/TestClient.ts +++ b/packages/opentelemetry/test/helpers/TestClient.ts @@ -1,41 +1,11 @@ -import { trace, type Tracer } from '@opentelemetry/api'; import type { ClientOptions, Event, Options, SeverityLevel } from '@sentry/core'; -import { Client, createTransport, getCurrentScope, resolvedSyncPromise, SDK_VERSION } from '@sentry/core'; -import type { SentrySpanProcessor } from '../../src/spanProcessor'; -import type { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; +import { Client, createTransport, getCurrentScope, resolvedSyncPromise } from '@sentry/core'; export class TestClient extends Client { - public traceProvider: BasicTracerProvider | undefined; - public spanProcessor: SentrySpanProcessor | undefined; - private _tracer: Tracer | undefined; - public constructor(options: ClientOptions) { super(options); } - /** Get the OTEL tracer. */ - public get tracer(): Tracer { - if (this._tracer) { - return this._tracer; - } - - const name = '@sentry/opentelemetry'; - const version = SDK_VERSION; - const tracer = (this.traceProvider || trace)?.getTracer(name, version); - this._tracer = tracer; - - return tracer; - } - - /** - * @inheritDoc - */ - public async flush(timeout?: number): Promise { - const provider = this.traceProvider; - await provider?.forceFlush(); - return super.flush(timeout); - } - public eventFromException(exception: any): PromiseLike { return resolvedSyncPromise({ exception: { @@ -54,7 +24,7 @@ export class TestClient extends Client { } } -export function init(options: Partial = {}): Client { +export function init(options: Partial = {}): TestClient { const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 1, ...options })); // The client is on the current scope, from where it generally is inherited diff --git a/packages/opentelemetry/test/helpers/initOtel.ts b/packages/opentelemetry/test/helpers/initOtel.ts index ee5d41a01ffc..c5dabe409a10 100644 --- a/packages/opentelemetry/test/helpers/initOtel.ts +++ b/packages/opentelemetry/test/helpers/initOtel.ts @@ -1,6 +1,5 @@ -import { context, diag, DiagLogLevel, propagation, trace } from '@opentelemetry/api'; +import { diag, DiagLogLevel, propagation, trace } from '@opentelemetry/api'; import { debug, getClient } from '@sentry/core'; -import { SentryAsyncLocalStorageContextManager } from '../../src/asyncLocalStorageContextManager'; import { DEBUG_BUILD } from '../../src/debug-build'; import { SentryPropagator } from '../../src/propagator'; import { getSentryResource } from '../../src/resource'; @@ -41,15 +40,8 @@ export function initOtel(): void { setupEventContextTrace(client); enhanceDscWithOpenTelemetryRootSpanName(client); - setupOtel(); -} - -export function setupOtel(): void { const provider = new SentryTracerProvider({ resource: getSentryResource('node') }); trace.setGlobalTracerProvider(provider); propagation.setGlobalPropagator(new SentryPropagator()); - - const ctxManager = new SentryAsyncLocalStorageContextManager(); - context.setGlobalContextManager(ctxManager); } diff --git a/packages/opentelemetry/test/helpers/mockSdkInit.ts b/packages/opentelemetry/test/helpers/mockSdkInit.ts index 5ad951fff98b..091bac1bc6b8 100644 --- a/packages/opentelemetry/test/helpers/mockSdkInit.ts +++ b/packages/opentelemetry/test/helpers/mockSdkInit.ts @@ -1,6 +1,5 @@ -import { context, propagation, trace } from '@opentelemetry/api'; import type { ClientOptions, Options } from '@sentry/core'; -import { flush, getCurrentScope, getGlobalScope, getIsolationScope } from '@sentry/core'; +import { getCurrentScope, getGlobalScope, getIsolationScope } from '@sentry/core'; import { setOpenTelemetryContextAsyncContextStrategy } from '../../src/asyncContextStrategy'; import { initOtel } from './initOtel'; import type { TestClient } from './TestClient'; @@ -31,12 +30,3 @@ export function mockSdkInit(options?: Partial): TestClient { return init({ dsn: PUBLIC_DSN, ...options })!; } - -export async function cleanupOtel(): Promise { - // Disable all globally registered APIs - trace.disable(); - context.disable(); - propagation.disable(); - - await flush(); -} diff --git a/packages/opentelemetry/test/integration/breadcrumbs.test.ts b/packages/opentelemetry/test/integration/breadcrumbs.test.ts index ee6a6b944424..45b50552b0a3 100644 --- a/packages/opentelemetry/test/integration/breadcrumbs.test.ts +++ b/packages/opentelemetry/test/integration/breadcrumbs.test.ts @@ -1,15 +1,11 @@ import { addBreadcrumb, captureException, withIsolationScope, withScope } from '@sentry/core'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { startSpan } from '../../src/trace'; -import { cleanupOtel, mockSdkInit } from '../helpers/mockSdkInit'; +import { mockSdkInit } from '../helpers/mockSdkInit'; describe('Integration | breadcrumbs', () => { const beforeSendTransaction = vi.fn(() => null); - afterEach(async () => { - await cleanupOtel(); - }); - describe('without tracing', () => { it('correctly adds & retrieves breadcrumbs', async () => { const beforeSend = vi.fn(() => null); diff --git a/packages/opentelemetry/test/integration/scope.test.ts b/packages/opentelemetry/test/integration/scope.test.ts index f8141f490dae..adebca6cd0fd 100644 --- a/packages/opentelemetry/test/integration/scope.test.ts +++ b/packages/opentelemetry/test/integration/scope.test.ts @@ -7,15 +7,11 @@ import { withIsolationScope, withScope, } from '@sentry/core'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { startSpan } from '../../src/trace'; -import { cleanupOtel, mockSdkInit } from '../helpers/mockSdkInit'; +import { mockSdkInit } from '../helpers/mockSdkInit'; describe('Integration | Scope', () => { - afterEach(async () => { - await cleanupOtel(); - }); - describe.each([ ['with tracing', true], ['without tracing', false], diff --git a/packages/opentelemetry/test/integration/transactions.test.ts b/packages/opentelemetry/test/integration/transactions.test.ts index 9a99ab89ea55..90ca06232dfa 100644 --- a/packages/opentelemetry/test/integration/transactions.test.ts +++ b/packages/opentelemetry/test/integration/transactions.test.ts @@ -13,13 +13,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { SENTRY_TRACE_STATE_DSC } from '../../src/constants'; import { startInactiveSpan, startSpan } from '../../src/trace'; import { makeTraceState } from '../../src/utils/makeTraceState'; -import { cleanupOtel, mockSdkInit } from '../helpers/mockSdkInit'; +import { mockSdkInit } from '../helpers/mockSdkInit'; describe('Integration | Transactions', () => { afterEach(async () => { vi.restoreAllMocks(); vi.useRealTimers(); - await cleanupOtel(); }); it('correctly creates transaction & spans', async () => { diff --git a/packages/opentelemetry/test/propagator.test.ts b/packages/opentelemetry/test/propagator.test.ts index 7610f040a72d..3b3ed8c01be0 100644 --- a/packages/opentelemetry/test/propagator.test.ts +++ b/packages/opentelemetry/test/propagator.test.ts @@ -9,12 +9,12 @@ import { } from '@opentelemetry/api'; import { suppressTracing } from '@opentelemetry/core'; import { getCurrentScope, withScope } from '@sentry/core'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it } from 'vitest'; import { SENTRY_BAGGAGE_HEADER, SENTRY_SCOPES_CONTEXT_KEY, SENTRY_TRACE_HEADER } from '../src/constants'; import { SentryPropagator } from '../src/propagator'; import { getSamplingDecision } from '../src/utils/getSamplingDecision'; import { makeTraceState } from '../src/utils/makeTraceState'; -import { cleanupOtel, mockSdkInit } from './helpers/mockSdkInit'; +import { mockSdkInit } from './helpers/mockSdkInit'; describe('SentryPropagator', () => { const propagator = new SentryPropagator(); @@ -30,10 +30,6 @@ describe('SentryPropagator', () => { }); }); - afterEach(async () => { - await cleanupOtel(); - }); - it('returns fields set', () => { expect(propagator.fields()).toEqual([SENTRY_TRACE_HEADER, SENTRY_BAGGAGE_HEADER, 'traceparent']); }); diff --git a/packages/opentelemetry/test/trace.test.ts b/packages/opentelemetry/test/trace.test.ts index a6bf138a847c..8372d5e5a497 100644 --- a/packages/opentelemetry/test/trace.test.ts +++ b/packages/opentelemetry/test/trace.test.ts @@ -19,23 +19,19 @@ import { suppressTracing, withScope, } from '@sentry/core'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { continueTrace, startInactiveSpan, startNewTrace, startSpan, startSpanManual } from '../src/trace'; import { getActiveSpan } from '../src/utils/getActiveSpan'; import { getSamplingDecision } from '../src/utils/getSamplingDecision'; import { makeTraceState } from '../src/utils/makeTraceState'; import { isSpan } from './helpers/isSpan'; -import { cleanupOtel, mockSdkInit } from './helpers/mockSdkInit'; +import { mockSdkInit } from './helpers/mockSdkInit'; describe('trace', () => { beforeEach(() => { mockSdkInit({ tracesSampleRate: 1 }); }); - afterEach(async () => { - await cleanupOtel(); - }); - describe('startSpan', () => { it('works with a sync callback', () => { const spans: Span[] = []; @@ -1437,10 +1433,6 @@ describe('trace (tracing disabled)', () => { mockSdkInit({ tracesSampleRate: 0 }); }); - afterEach(async () => { - await cleanupOtel(); - }); - it('startSpan calls callback without span', () => { const val = startSpan({ name: 'outer' }, outerSpan => { expect(outerSpan).toBeDefined(); @@ -1466,10 +1458,6 @@ describe('trace (spans disabled)', () => { mockSdkInit({ tracesSampleRate: undefined, tracesSampler: undefined }); }); - afterEach(async () => { - await cleanupOtel(); - }); - it('startSpan creates non-recording spans when hasSpansEnabled() === false', () => { const val = startSpan({ name: 'outer' }, outerSpan => { expect(outerSpan).toBeDefined(); @@ -1510,11 +1498,6 @@ describe('trace (spans disabled)', () => { }); describe('trace (sampling)', () => { - afterEach(async () => { - await cleanupOtel(); - vi.clearAllMocks(); - }); - it('samples with a tracesSampleRate, when Math.random() > tracesSampleRate', () => { vi.spyOn(Math, 'random').mockImplementation(() => 0.6); @@ -1841,10 +1824,6 @@ describe('continueTrace', () => { mockSdkInit({ tracesSampleRate: 1 }); }); - afterEach(async () => { - await cleanupOtel(); - }); - it('works without trace & baggage data', () => { const scope = continueTrace({ sentryTrace: undefined, baggage: undefined }, () => { const span = getActiveSpan()!; @@ -1946,10 +1925,6 @@ describe('suppressTracing', () => { mockSdkInit({ tracesSampleRate: 1 }); }); - afterEach(async () => { - await cleanupOtel(); - }); - it('works for a root span', () => { const span = suppressTracing(() => { return startInactiveSpan({ name: 'span' }); @@ -2030,10 +2005,6 @@ describe('isTracingSuppressed', () => { mockSdkInit({ tracesSampleRate: 1 }); }); - afterEach(async () => { - await cleanupOtel(); - }); - it('returns false when tracing is not suppressed', () => { expect(isTracingSuppressed()).toBe(false); }); @@ -2066,10 +2037,6 @@ describe('span.end() timestamp conversion', () => { mockSdkInit({ tracesSampleRate: 1 }); }); - afterEach(async () => { - await cleanupOtel(); - }); - it('converts seconds to milliseconds for startInactiveSpan', () => { // +10s buffer (see startInactiveSpan test above): avoids OTel's endTime-before-startTime clamp. const nowSec = Math.floor(Date.now() / 1000) + 10; @@ -2158,10 +2125,6 @@ describe('startNewTrace', () => { mockSdkInit({ tracesSampleRate: 1 }); }); - afterEach(async () => { - await cleanupOtel(); - }); - it('sequential startInactiveSpan calls share the same traceId', () => { startNewTrace(() => { const propagationContext = getCurrentScope().getPropagationContext(); diff --git a/packages/opentelemetry/test/tracerProvider.test.ts b/packages/opentelemetry/test/tracerProvider.test.ts index 672bd05fe807..6106aeb213f4 100644 --- a/packages/opentelemetry/test/tracerProvider.test.ts +++ b/packages/opentelemetry/test/tracerProvider.test.ts @@ -12,26 +12,15 @@ import { type Span, withIsolationScope, } from '@sentry/core'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { SentryAsyncLocalStorageContextManager } from '../src/asyncLocalStorageContextManager'; -import { setOpenTelemetryContextAsyncContextStrategy } from '../src/asyncContextStrategy'; +import { beforeEach, describe, expect, it } from 'vitest'; import { applyOtelSpanData } from '../src/applyOtelSpanData'; -import { SentryTracerProvider } from '../src/tracerProvider'; -import { cleanupOtel } from './helpers/mockSdkInit'; +import { mockSdkInit } from './helpers/mockSdkInit'; import { init as initTestClient } from './helpers/TestClient'; import { URL_FULL } from '@sentry/conventions/attributes'; describe('SentryTracerProvider', () => { beforeEach(() => { - (global as { __SENTRY__?: unknown }).__SENTRY__ = {}; - setOpenTelemetryContextAsyncContextStrategy(); - initTestClient({ tracesSampleRate: 1 }); - context.setGlobalContextManager(new SentryAsyncLocalStorageContextManager()); - trace.setGlobalTracerProvider(new SentryTracerProvider()); - }); - - afterEach(async () => { - await cleanupOtel(); + mockSdkInit({ tracesSampleRate: 1 }); }); it('creates Sentry spans from the global OpenTelemetry tracer', () => { diff --git a/packages/opentelemetry/test/utils/getActiveSpan.test.ts b/packages/opentelemetry/test/utils/getActiveSpan.test.ts index a01eccd84f3c..4b66a36b8e9d 100644 --- a/packages/opentelemetry/test/utils/getActiveSpan.test.ts +++ b/packages/opentelemetry/test/utils/getActiveSpan.test.ts @@ -1,17 +1,12 @@ import { trace } from '@opentelemetry/api'; import { getRootSpan } from '@sentry/core'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it } from 'vitest'; import { getActiveSpan } from '../../src/utils/getActiveSpan'; -import { setupOtel } from '../helpers/initOtel'; -import { cleanupOtel } from '../helpers/mockSdkInit'; +import { mockSdkInit } from '../helpers/mockSdkInit'; describe('getActiveSpan', () => { beforeEach(() => { - setupOtel(); - }); - - afterEach(() => { - return cleanupOtel(); + mockSdkInit(); }); it('returns undefined if no span is active', () => { @@ -80,11 +75,7 @@ describe('getActiveSpan', () => { describe('getRootSpan', () => { beforeEach(() => { - setupOtel(); - }); - - afterEach(() => { - return cleanupOtel(); + mockSdkInit(); }); it('returns currently active root span', () => { diff --git a/packages/opentelemetry/test/utils/getTraceData.test.ts b/packages/opentelemetry/test/utils/getTraceData.test.ts index d94e86930619..ddba9c332b4a 100644 --- a/packages/opentelemetry/test/utils/getTraceData.test.ts +++ b/packages/opentelemetry/test/utils/getTraceData.test.ts @@ -1,19 +1,17 @@ import { context, trace } from '@opentelemetry/api'; -import { getCurrentScope, Scope, setAsyncContextStrategy } from '@sentry/core'; +import { getCurrentScope, Scope } from '@sentry/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { getTraceData } from '../../src/utils/getTraceData'; import { makeTraceState } from '../../src/utils/makeTraceState'; -import { cleanupOtel, mockSdkInit } from '../helpers/mockSdkInit'; +import { mockSdkInit } from '../helpers/mockSdkInit'; import { getDefaultTestClientOptions, TestClient } from '../helpers/TestClient'; describe('getTraceData', () => { beforeEach(() => { - setAsyncContextStrategy(undefined); mockSdkInit(); }); afterEach(async () => { - await cleanupOtel(); vi.clearAllMocks(); }); diff --git a/packages/opentelemetry/test/utils/setupEventContextTrace.test.ts b/packages/opentelemetry/test/utils/setupEventContextTrace.test.ts index 4fea6381568c..9b5117a773de 100644 --- a/packages/opentelemetry/test/utils/setupEventContextTrace.test.ts +++ b/packages/opentelemetry/test/utils/setupEventContextTrace.test.ts @@ -1,37 +1,24 @@ -import { captureException, setCurrentClient } from '@sentry/core'; +import { captureException } from '@sentry/core'; import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { setupEventContextTrace } from '../../src/setupEventContextTrace'; -import { setupOtel } from '../helpers/initOtel'; -import { cleanupOtel } from '../helpers/mockSdkInit'; -import { getDefaultTestClientOptions, TestClient } from '../helpers/TestClient'; +import { mockSdkInit } from '../helpers/mockSdkInit'; +import type { TestClient } from '../helpers/TestClient'; +import { trace } from '@opentelemetry/api'; -const PUBLIC_DSN = 'https://username@domain/123'; +const tracer = trace.getTracer('test'); describe('setupEventContextTrace', () => { const beforeSend = vi.fn(() => null); let client: TestClient; beforeEach(() => { - client = new TestClient( - getDefaultTestClientOptions({ - sampleRate: 1, - tracesSampleRate: 1, - beforeSend, - debug: true, - dsn: PUBLIC_DSN, - }), - ); - - setCurrentClient(client); - client.init(); + client = mockSdkInit({ debug: true, beforeSend, tracesSampleRate: 1 }); setupEventContextTrace(client); - setupOtel(); }); afterEach(() => { beforeSend.mockReset(); - return cleanupOtel(); }); afterAll(() => { @@ -68,11 +55,11 @@ describe('setupEventContextTrace', () => { let innerId: string | undefined; let traceId: string | undefined; - client.tracer?.startActiveSpan('outer', outerSpan => { + tracer?.startActiveSpan('outer', outerSpan => { outerId = outerSpan.spanContext().spanId; traceId = outerSpan.spanContext().traceId; - client.tracer?.startActiveSpan('inner', innerSpan => { + tracer?.startActiveSpan('inner', innerSpan => { innerId = innerSpan.spanContext().spanId; captureException(error); }); diff --git a/packages/vercel-edge/src/sdk.ts b/packages/vercel-edge/src/sdk.ts index a845a9d77886..9f8699e208de 100644 --- a/packages/vercel-edge/src/sdk.ts +++ b/packages/vercel-edge/src/sdk.ts @@ -1,4 +1,4 @@ -import { context, diag, DiagLogLevel, propagation, trace } from '@opentelemetry/api'; +import { diag, DiagLogLevel, propagation, trace } from '@opentelemetry/api'; import type { Client, Integration, Options } from '@sentry/core'; import { consoleIntegration, @@ -23,14 +23,12 @@ import { SentryTracerProvider, setOpenTelemetryContextAsyncContextStrategy, setupEventContextTrace, - wrapContextManagerClass, } from '@sentry/opentelemetry'; import { VercelEdgeClient } from './client'; 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; @@ -57,9 +55,7 @@ export function getDefaultIntegrations(_options: Options): Integration[] { /** Inits the Sentry NextJS SDK on the Edge Runtime. */ export function init(options: VercelEdgeOptions = {}): Client { - // 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 }); + setOpenTelemetryContextAsyncContextStrategy(); const scope = getCurrentScope(); scope.update(options.initialScope); @@ -126,12 +122,6 @@ export function setupOtel(client: VercelEdgeClient): void { trace.setGlobalTracerProvider(provider); propagation.setGlobalPropagator(new SentryPropagator()); - // oxlint-disable-next-line typescript/no-deprecated - const SentryContextManager = wrapContextManagerClass(AsyncLocalStorageContextManager); - - const ctxManager = new SentryContextManager(); - context.setGlobalContextManager(ctxManager); - client.traceProvider = provider; } 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; - } -} diff --git a/packages/vercel-edge/test/async.test.ts b/packages/vercel-edge/test/async.test.ts index 2112a6b5b2e3..a869d5a99a36 100644 --- a/packages/vercel-edge/test/async.test.ts +++ b/packages/vercel-edge/test/async.test.ts @@ -1,31 +1,19 @@ -import { - getCurrentScope, - getGlobalScope, - getIsolationScope, - GLOBAL_OBJ, - Scope, - withIsolationScope, - withScope, -} from '@sentry/core'; -import { setOpenTelemetryContextAsyncContextStrategy } from '@sentry/opentelemetry'; -import { AsyncLocalStorage } from 'async_hooks'; +import { getCurrentScope, getGlobalScope, getIsolationScope, Scope, withIsolationScope, withScope } from '@sentry/core'; import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { VercelEdgeClient } from '../src'; import { setupOtel } from '../src/sdk'; import { makeEdgeTransport } from '../src/transports'; +import { setOpenTelemetryContextAsyncContextStrategy } from '@sentry/opentelemetry'; beforeAll(() => { - (GLOBAL_OBJ as any).AsyncLocalStorage = AsyncLocalStorage; - const client = new VercelEdgeClient({ stackParser: () => [], integrations: [], transport: makeEdgeTransport, }); - setupOtel(client); - setOpenTelemetryContextAsyncContextStrategy(); + setupOtel(client); }); beforeEach(() => { diff --git a/packages/vercel-edge/test/sdk.test.ts b/packages/vercel-edge/test/sdk.test.ts index 0da12197a5f4..c23848424290 100644 --- a/packages/vercel-edge/test/sdk.test.ts +++ b/packages/vercel-edge/test/sdk.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { init, spanStreamingIntegration } from '../src'; -import type { Integration } from '@sentry/core'; +import { type Integration } from '@sentry/core'; describe('init', () => { it('adds spanStreamingIntegration by default', () => {