From 7d682340f136f2a5603d441109de8888bc6d6e1e Mon Sep 17 00:00:00 2001 From: Maharshi Mishra Date: Fri, 25 Sep 2026 10:15:02 +0530 Subject: [PATCH] fix(otel): Share compatible async context storage between strategies Co-Authored-By: GPT-6 --- .../src/event-loop-block-integration.ts | 5 +- .../sdk/asyncContextCompatibility.test.ts | 185 +++++++++++++++ .../test/sdk/asyncContextSwitching.test.ts | 211 ++++++++++++++++++ packages/node/test/sdk/client.test.ts | 20 +- .../opentelemetry/src/asyncContextStrategy.ts | 12 +- .../src/asyncLocalStorageContextManager.ts | 36 ++- packages/server-utils/src/async-context.ts | 69 ++++-- 7 files changed, 501 insertions(+), 37 deletions(-) create mode 100644 packages/node/test/sdk/asyncContextCompatibility.test.ts create mode 100644 packages/node/test/sdk/asyncContextSwitching.test.ts diff --git a/packages/node-native/src/event-loop-block-integration.ts b/packages/node-native/src/event-loop-block-integration.ts index 57875e28129c..0bf270ade2f8 100644 --- a/packages/node-native/src/event-loop-block-integration.ts +++ b/packages/node-native/src/event-loop-block-integration.ts @@ -79,9 +79,8 @@ function startPolling( ): IntegrationInternal | undefined { if (client.asyncLocalStorageLookup) { const { asyncLocalStorage, contextSymbol } = client.asyncLocalStorageLookup; - // With the OpenTelemetry context strategy, scopes live under `contextSymbol` on the OTel context - // (`store._currentContext[contextSymbol]`). The pure AsyncLocalStorage strategy omits it because - // its store already is the `{ scope, isolationScope }` object, so no traversal is needed. + // A contextSymbol locates scopes inside an OpenTelemetry Context. Without it, the store + // contains `{ scope, isolationScope }` directly, so no traversal is needed. const stateLookup = contextSymbol ? ['_currentContext', contextSymbol] : []; registerThread({ asyncLocalStorage, stateLookup }); } else { diff --git a/packages/node/test/sdk/asyncContextCompatibility.test.ts b/packages/node/test/sdk/asyncContextCompatibility.test.ts new file mode 100644 index 000000000000..6a35941bc1e1 --- /dev/null +++ b/packages/node/test/sdk/asyncContextCompatibility.test.ts @@ -0,0 +1,185 @@ +import { setImmediate } from 'node:timers/promises'; +import type { Context } from '@opentelemetry/api'; +import { context, createContextKey, trace, TraceFlags } from '@opentelemetry/api'; +import { getCurrentScope, getIsolationScope, getMainCarrier, Scope, withIsolationScope, withScope } from '@sentry/core'; +import type { AsyncLocalStorageLookup } from '@sentry/opentelemetry'; +import { getScopesFromContext, setOpenTelemetryContextAsyncContextStrategy } from '@sentry/opentelemetry'; +import { setAsyncLocalStorageAsyncContextStrategy } from '@sentry/server-utils'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +function readScopes(lookup: AsyncLocalStorageLookup): { scope: Scope; isolationScope: Scope } { + const store = lookup.asyncLocalStorage.getStore(); + return (lookup.contextSymbol ? (store as Context).getValue(lookup.contextSymbol) : store) as { + scope: Scope; + isolationScope: Scope; + }; +} + +describe('async context strategy compatibility', () => { + beforeEach(() => { + context.disable(); + getMainCarrier().__SENTRY__ = undefined; + }); + + afterEach(() => { + context.disable(); + getMainCarrier().__SENTRY__ = undefined; + }); + + it('keeps the current scope when installing OpenTelemetry inside a plain isolation scope', () => { + expect.assertions(2); + setAsyncLocalStorageAsyncContextStrategy(); + + withIsolationScope(isolationScope => { + const scope = getCurrentScope(); + setOpenTelemetryContextAsyncContextStrategy(); + + expect(getCurrentScope()).toBe(scope); + expect(getIsolationScope()).toBe(isolationScope); + }); + }); + + it('allows an OpenTelemetry span read after installing the plain strategy', () => { + expect.assertions(1); + setOpenTelemetryContextAsyncContextStrategy(); + setAsyncLocalStorageAsyncContextStrategy(); + + withIsolationScope(() => { + expect(trace.getSpan(context.active())).toBeUndefined(); + }); + }); + + it.each([ + { + name: 'withScope', + run: (callback: (scope: Scope) => Promise) => withScope(callback), + currentScope: getCurrentScope, + }, + { + name: 'withScope with an explicit scope', + run: (callback: (scope: Scope) => Promise) => withScope(new Scope(), callback), + currentScope: getCurrentScope, + }, + { + name: 'withIsolationScope', + run: (callback: (scope: Scope) => Promise) => withIsolationScope(callback), + currentScope: getIsolationScope, + }, + { + name: 'withIsolationScope with an explicit scope', + run: (callback: (scope: Scope) => Promise) => withIsolationScope(new Scope(), callback), + currentScope: getIsolationScope, + }, + ])('preserves the active span and unrelated context through plain $name', async ({ run, currentScope }) => { + expect.assertions(9); + setOpenTelemetryContextAsyncContextStrategy(); + const requestKey = createContextKey('request-id'); + const span = trace.wrapSpanContext({ + traceId: '12345678901234567890123456789012', + spanId: '1234567890123456', + traceFlags: TraceFlags.SAMPLED, + }); + const parentContext = trace.setSpan(context.active().setValue(requestKey, 'request-42'), span); + + await context.with(parentContext, async () => { + const parentScope = getCurrentScope(); + const parentIsolationScope = getIsolationScope(); + setAsyncLocalStorageAsyncContextStrategy(); + + await run(async scope => { + await setImmediate(); + + expect(currentScope()).toBe(scope); + const contextScopes = getScopesFromContext(context.active()); + expect(contextScopes?.scope).toBe(getCurrentScope()); + expect(contextScopes?.isolationScope).toBe(getIsolationScope()); + expect(trace.getSpan(context.active())).toBe(span); + expect(context.active().getValue(requestKey)).toBe('request-42'); + }); + + expect(getCurrentScope()).toBe(parentScope); + expect(getIsolationScope()).toBe(parentIsolationScope); + expect(trace.getSpan(context.active())).toBe(span); + expect(context.active().getValue(requestKey)).toBe('request-42'); + }); + }); + + it('keeps captured lookups live across OpenTelemetry to plain to OpenTelemetry setup', async () => { + expect.assertions(8); + const firstLookup = setOpenTelemetryContextAsyncContextStrategy(); + const plainStorage = setAsyncLocalStorageAsyncContextStrategy(); + const lastLookup = setOpenTelemetryContextAsyncContextStrategy(); + + expect(plainStorage).toBe(firstLookup.asyncLocalStorage); + expect(lastLookup.asyncLocalStorage).toBe(firstLookup.asyncLocalStorage); + + await withIsolationScope(async isolationScope => { + const scope = getCurrentScope(); + await setImmediate(); + + expect(getCurrentScope()).toBe(scope); + expect(getIsolationScope()).toBe(isolationScope); + expect(readScopes(firstLookup).scope).toBe(scope); + expect(readScopes(firstLookup).isolationScope).toBe(isolationScope); + expect(readScopes(lastLookup).scope).toBe(scope); + expect(readScopes(lastLookup).isolationScope).toBe(isolationScope); + }); + }); + + it.each([ + { kind: 'original', capture: (scope: Scope) => scope }, + { kind: 'cloned', capture: (scope: Scope) => scope.clone() }, + ])( + 'preserves a captured $kind scope context after reusing the scope with the plain strategy', + async ({ capture }) => { + expect.assertions(4); + setOpenTelemetryContextAsyncContextStrategy(); + const requestKey = createContextKey('captured-request'); + const captured = context.with(context.active().setValue(requestKey, 'source'), () => + withIsolationScope(isolationScope => ({ scope: capture(getCurrentScope()), isolationScope })), + ); + + await context.with(context.active().setValue(requestKey, 'caller'), async () => { + setAsyncLocalStorageAsyncContextStrategy(); + await withScope(captured.scope, async () => { + // Reading the active context must not rebind the captured scope. + context.active(); + await setImmediate(); + }); + setOpenTelemetryContextAsyncContextStrategy(); + + await withScope(captured.scope, async scope => { + await setImmediate(); + + expect(scope).toBe(captured.scope); + expect(getCurrentScope()).toBe(captured.scope); + expect(getIsolationScope()).toBe(captured.isolationScope); + expect(context.active().getValue(requestKey)).toBe('source'); + }); + }); + }, + ); + + it.each([ + { + name: 'plain', + install: (): AsyncLocalStorageLookup => ({ asyncLocalStorage: setAsyncLocalStorageAsyncContextStrategy() }), + }, + { name: 'OpenTelemetry', install: setOpenTelemetryContextAsyncContextStrategy }, + ])('keeps captured $name lookups live after repeated setup inside an active scope', async ({ install }) => { + expect.assertions(5); + const firstLookup = install(); + + await withIsolationScope(async isolationScope => { + const scope = getCurrentScope(); + const secondLookup = install(); + await setImmediate(); + + expect(secondLookup.asyncLocalStorage).toBe(firstLookup.asyncLocalStorage); + expect(getCurrentScope()).toBe(scope); + expect(getIsolationScope()).toBe(isolationScope); + expect(readScopes(firstLookup).scope).toBe(scope); + expect(readScopes(firstLookup).isolationScope).toBe(isolationScope); + }); + }); +}); diff --git a/packages/node/test/sdk/asyncContextSwitching.test.ts b/packages/node/test/sdk/asyncContextSwitching.test.ts new file mode 100644 index 000000000000..f9925822096d --- /dev/null +++ b/packages/node/test/sdk/asyncContextSwitching.test.ts @@ -0,0 +1,211 @@ +import { setImmediate } from 'node:timers/promises'; +import { context, createContextKey, trace, TraceFlags } from '@opentelemetry/api'; +import { + getActiveSpan, + getCurrentScope, + getIsolationScope, + getMainCarrier, + Scope, + withIsolationScope, + withScope, +} from '@sentry/core'; +import { setOpenTelemetryContextAsyncContextStrategy } from '@sentry/opentelemetry'; +import { setAsyncLocalStorageAsyncContextStrategy } from '@sentry/server-utils'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +const span = trace.wrapSpanContext({ + traceId: '12345678901234567890123456789012', + spanId: '1234567890123456', + traceFlags: TraceFlags.SAMPLED, +}); + +describe('live async context strategy switching', () => { + beforeEach(() => { + context.disable(); + getMainCarrier().__SENTRY__ = undefined; + }); + + afterEach(() => { + context.disable(); + getMainCarrier().__SENTRY__ = undefined; + }); + + describe.each([ + { observation: 'without a context read', observe: () => undefined }, + { observation: 'after a context read', observe: () => context.active() }, + ])('$observation', ({ observe }) => { + it.each([ + { name: 'forked isolation', enter: (callback: (scope: Scope) => Promise) => withIsolationScope(callback) }, + { + name: 'explicit isolation', + enter: (callback: (scope: Scope) => Promise) => withIsolationScope(new Scope(), callback), + }, + { + name: 'nested current scope', + enter: (callback: (scope: Scope) => Promise) => + withIsolationScope(isolation => withScope(() => callback(isolation))), + }, + { + name: 'explicitly reused current scope', + enter: (callback: (scope: Scope) => Promise) => + withIsolationScope(isolation => withScope(getCurrentScope(), () => callback(isolation))), + }, + ])('keeps $name when rebinding the newly created current scope', async ({ enter }) => { + expect.assertions(8); + setOpenTelemetryContextAsyncContextStrategy(); + const key = createContextKey('live-request'); + const request = trace.setSpan(context.active().setValue(key, 'request-a'), span); + + await context.with(request, async () => { + const parentScope = getCurrentScope(); + const parentIsolation = getIsolationScope(); + setAsyncLocalStorageAsyncContextStrategy(); + + await enter(async isolation => { + const current = getCurrentScope(); + observe(); + setOpenTelemetryContextAsyncContextStrategy(); + expect(getActiveSpan(current)).toBe(span); + + await withScope(current, async supplied => { + await setImmediate(); + expect(supplied).toBe(current); + expect(getCurrentScope()).toBe(current); + expect(getIsolationScope()).toBe(isolation); + expect(getActiveSpan()).toBe(span); + expect(context.active().getValue(key)).toBe('request-a'); + }); + }); + + expect(getCurrentScope()).toBe(parentScope); + expect(getIsolationScope()).toBe(parentIsolation); + }); + }); + + it.each([ + { + name: 'original', + capture: (scope: Scope) => scope, + enter: (scope: Scope, callback: (scope: Scope) => Promise) => withScope(scope, callback), + }, + { + name: 'deliberate clone', + capture: (scope: Scope) => scope.clone(), + enter: (scope: Scope, callback: (scope: Scope) => Promise) => withScope(scope, callback), + }, + { + name: 'child of a borrowed scope', + capture: (scope: Scope) => scope, + enter: (scope: Scope, callback: (scope: Scope) => Promise) => withScope(scope, () => withScope(callback)), + }, + { + name: 'isolation child of a borrowed scope', + capture: (scope: Scope) => scope, + enter: (scope: Scope, callback: (scope: Scope) => Promise) => + withScope(scope, () => withIsolationScope(() => callback(getCurrentScope()))), + }, + ])('restores a captured $name while it is still borrowed by plain context', async ({ capture, enter }) => { + expect.assertions(8); + setOpenTelemetryContextAsyncContextStrategy(); + const key = createContextKey('captured-owner'); + const request = trace.setSpan(context.active().setValue(key, 'source'), span); + const captured = context.with(request, () => + withIsolationScope(isolation => ({ scope: capture(getCurrentScope()), isolation })), + ); + + await context.with(context.active().setValue(key, 'caller'), async () => { + const callerScope = getCurrentScope(); + const callerIsolation = getIsolationScope(); + setAsyncLocalStorageAsyncContextStrategy(); + + await enter(captured.scope, async current => { + observe(); + setOpenTelemetryContextAsyncContextStrategy(); + expect(getActiveSpan(current)).toBe(span); + await withScope(current, async supplied => { + await setImmediate(); + expect(supplied).toBe(current); + expect(getCurrentScope()).toBe(current); + expect(getIsolationScope()).toBe(captured.isolation); + expect(getActiveSpan()).toBe(span); + expect(context.active().getValue(key)).toBe('source'); + }); + }); + + expect(getCurrentScope()).toBe(callerScope); + expect(getIsolationScope()).toBe(callerIsolation); + }); + }); + }); + + it('preserves an escaped plain child of a captured scope across setup at root', async () => { + expect.assertions(4); + setOpenTelemetryContextAsyncContextStrategy(); + const key = createContextKey('escaped-owner'); + const request = trace.setSpan(context.active().setValue(key, 'source'), span); + const captured = context.with(request, () => + withIsolationScope(isolation => ({ scope: getCurrentScope(), isolation })), + ); + setAsyncLocalStorageAsyncContextStrategy(); + const child = withScope(captured.scope, () => withScope(scope => scope)); + setOpenTelemetryContextAsyncContextStrategy(); + + expect(getActiveSpan(child)).toBe(span); + await withScope(child, async scope => { + await setImmediate(); + expect(scope).toBe(child); + expect(getIsolationScope()).toBe(captured.isolation); + expect(context.active().getValue(key)).toBe('source'); + }); + }); + + it('preserves a captured context inherited from the default scope', async () => { + expect.assertions(4); + const rootScope = getCurrentScope(); + setOpenTelemetryContextAsyncContextStrategy(); + const key = createContextKey('default-owner'); + const request = trace.setSpan(context.active().setValue(key, 'source'), span); + const capturedContext = context.with(request, () => withScope(rootScope, () => context.active())); + const capturedIsolation = context.with(capturedContext, getIsolationScope); + setAsyncLocalStorageAsyncContextStrategy(); + + await withScope(async child => { + setOpenTelemetryContextAsyncContextStrategy(); + expect(getActiveSpan(child)).toBe(span); + await withScope(child, async scope => { + await setImmediate(); + expect(scope).toBe(child); + expect(getIsolationScope()).toBe(capturedIsolation); + expect(context.active().getValue(key)).toBe('source'); + }); + }); + }); + + it('restores the caller after the rebound plain scope rejects', async () => { + expect.assertions(6); + setOpenTelemetryContextAsyncContextStrategy(); + const request = trace.setSpan(context.active(), span); + const failure = new Error('request failed'); + + await context.with(request, async () => { + const parentScope = getCurrentScope(); + const parentIsolation = getIsolationScope(); + setAsyncLocalStorageAsyncContextStrategy(); + await withIsolationScope(async isolation => { + const current = getCurrentScope(); + setOpenTelemetryContextAsyncContextStrategy(); + await expect( + withScope(current, async () => { + await setImmediate(); + expect(getIsolationScope()).toBe(isolation); + throw failure; + }), + ).rejects.toBe(failure); + expect(getCurrentScope()).toBe(current); + expect(getIsolationScope()).toBe(isolation); + }); + expect(getCurrentScope()).toBe(parentScope); + expect(getIsolationScope()).toBe(parentIsolation); + }); + }); +}); diff --git a/packages/node/test/sdk/client.test.ts b/packages/node/test/sdk/client.test.ts index 1792dd6da0eb..9e6119398e09 100644 --- a/packages/node/test/sdk/client.test.ts +++ b/packages/node/test/sdk/client.test.ts @@ -1,7 +1,14 @@ import { AsyncLocalStorage } from 'node:async_hooks'; import { ProxyTracer } from '@opentelemetry/api'; import type { Event, EventHint, Log } from '@sentry/core'; -import { getAsyncContextStrategy, getMainCarrier, Scope, SDK_VERSION } from '@sentry/core'; +import { + getAsyncContextStrategy, + getCurrentScope, + getMainCarrier, + Scope, + SDK_VERSION, + withIsolationScope, +} from '@sentry/core'; import type { SentryTracerProvider } from '@sentry/opentelemetry'; import { setOpenTelemetryContextAsyncContextStrategy } from '@sentry/opentelemetry'; import * as SentryOpentelemetry from '@sentry/opentelemetry'; @@ -62,7 +69,16 @@ describe('NodeClient', () => { expect(otelStrategySpy).toHaveBeenCalledTimes(1); expect(alsStrategySpy).not.toHaveBeenCalled(); expect(client.asyncLocalStorageLookup?.asyncLocalStorage).toBeInstanceOf(AsyncLocalStorage); - expect(client.asyncLocalStorageLookup?.contextSymbol).toBeDefined(); + expect(client.asyncLocalStorageLookup?.contextSymbol).toBeUndefined(); + + withIsolationScope(isolationScope => { + const store = client.asyncLocalStorageLookup?.asyncLocalStorage.getStore() as { + scope: Scope; + isolationScope: Scope; + }; + expect(store.scope).toBe(getCurrentScope()); + expect(store.isolationScope).toBe(isolationScope); + }); }); }); diff --git a/packages/opentelemetry/src/asyncContextStrategy.ts b/packages/opentelemetry/src/asyncContextStrategy.ts index 07f20c26b9e5..23d464496a52 100644 --- a/packages/opentelemetry/src/asyncContextStrategy.ts +++ b/packages/opentelemetry/src/asyncContextStrategy.ts @@ -20,7 +20,7 @@ import type { CurrentScopes } from './types'; import { getContextFromScope, getScopesFromContext } from './utils/contextData'; import { getActiveSpan } from './utils/getActiveSpan'; import { AsyncLocalStorage } from 'node:async_hooks'; -import type { AsyncLocalStorageLookup } from './asyncLocalStorageContextManager'; +import type { AsyncContextStore, AsyncLocalStorageLookup } from './asyncLocalStorageContextManager'; import { SentryAsyncLocalStorageContextManager } from './asyncLocalStorageContextManager'; /** @@ -33,9 +33,9 @@ export function setOpenTelemetryContextAsyncContextStrategy(): AsyncLocalStorage // `getTracingChannelBinding().asyncLocalStorage`) keep reading the old one, breaking scope // propagation across async boundaries. const existingAsyncLocalStorage = getAsyncContextStrategy(getMainCarrier()).getTracingChannelBinding?.() - ?.asyncLocalStorage as AsyncLocalStorage | undefined; + ?.asyncLocalStorage as AsyncLocalStorage | undefined; - const asyncLocalStorage = existingAsyncLocalStorage ?? new AsyncLocalStorage(); + const asyncLocalStorage = existingAsyncLocalStorage ?? new AsyncLocalStorage(); function getScopes(): CurrentScopes { const ctx = api.context.active(); @@ -67,7 +67,11 @@ export function setOpenTelemetryContextAsyncContextStrategy(): AsyncLocalStorage } function withSetScope(scope: Scope, callback: (scope: Scope) => T): T { - const ctx = getContextFromScope(scope) || api.context.active(); + const store = asyncLocalStorage.getStore(); + const ctx = + store?.scope === scope && store.useActiveScopeContext + ? api.context.active() + : getContextFromScope(scope) || api.context.active(); // We depend on the otelContextManager to handle the context/hub // We set the `SENTRY_FORK_SET_SCOPE_CONTEXT_KEY` context value, which is picked up by diff --git a/packages/opentelemetry/src/asyncLocalStorageContextManager.ts b/packages/opentelemetry/src/asyncLocalStorageContextManager.ts index deb25078e588..fc2ce3dd74a5 100644 --- a/packages/opentelemetry/src/asyncLocalStorageContextManager.ts +++ b/packages/opentelemetry/src/asyncLocalStorageContextManager.ts @@ -25,15 +25,22 @@ import type { Context, ContextManager } from '@opentelemetry/api'; import { ROOT_CONTEXT } from '@opentelemetry/api'; import type { AsyncLocalStorage } from 'node:async_hooks'; import type { EventEmitter } from 'node:events'; -import { SENTRY_SCOPES_CONTEXT_KEY } from './constants'; +import type { CurrentScopes } from './types'; import { buildContextWithSentryScopes } from './utils/buildContextWithSentryScopes'; +import { getScopesFromContext, setScopesOnContext } from './utils/contextData'; + +export interface AsyncContextStore extends CurrentScopes { + context?: Context; + // Set by plain scope forks; absent on OpenTelemetry-bound stores. + useActiveScopeContext?: boolean; +} export type AsyncLocalStorageLookup = { asyncLocalStorage: AsyncLocalStorage; /** * The OpenTelemetry context key under which the `{ scope, isolationScope }` object is stored, for * native threads that read scope out of the AsyncLocalStorage (e.g. `@sentry/node-native`). Omitted - * for the pure AsyncLocalStorage strategy, whose store already is that object. + * when the store already contains that object directly. */ contextSymbol?: symbol; }; @@ -50,17 +57,31 @@ const ADD_LISTENER_METHODS = ['addListener', 'on', 'once', 'prependListener', 'p * OpenTelemetry-compatible context manager using Node.js `AsyncLocalStorage`. */ export class SentryAsyncLocalStorageContextManager implements ContextManager { - protected readonly _asyncLocalStorage: AsyncLocalStorage; + protected readonly _asyncLocalStorage: AsyncLocalStorage; private readonly _kOtListeners = Symbol('OtListeners'); private _wrapped = false; - public constructor(asyncLocalStorage: AsyncLocalStorage) { + public constructor(asyncLocalStorage: AsyncLocalStorage) { this._asyncLocalStorage = asyncLocalStorage; } public active(): Context { - return this._asyncLocalStorage.getStore() ?? ROOT_CONTEXT; + const store = this._asyncLocalStorage.getStore(); + if (!store) { + return ROOT_CONTEXT; + } + + const context = store.context ?? ROOT_CONTEXT; + const scopes = getScopesFromContext(context); + if (scopes?.scope === store.scope && scopes.isolationScope === store.isolationScope) { + return context; + } + + // The plain strategy can fork scopes without updating the OpenTelemetry context. + const updatedContext = setScopesOnContext(context, { scope: store.scope, isolationScope: store.isolationScope }); + store.context = updatedContext; + return updatedContext; } public with ReturnType>( @@ -71,7 +92,7 @@ export class SentryAsyncLocalStorageContextManager implements ContextManager { ): ReturnType { const ctx2 = buildContextWithSentryScopes(context); const cb = thisArg == null ? fn : fn.bind(thisArg); - return this._asyncLocalStorage.run(ctx2, cb as never, ...args); + return this._asyncLocalStorage.run({ ...getScopesFromContext(ctx2)!, context: ctx2 }, cb as never, ...args); } public enable(): this { @@ -98,13 +119,12 @@ export class SentryAsyncLocalStorageContextManager implements ContextManager { } /** - * Gets underlying AsyncLocalStorage and symbol to allow lookup of scope. + * Gets the underlying AsyncLocalStorage for direct scope lookup. * This is Sentry-specific. */ public getAsyncLocalStorageLookup(): AsyncLocalStorageLookup { return { asyncLocalStorage: this._asyncLocalStorage, - contextSymbol: SENTRY_SCOPES_CONTEXT_KEY, }; } diff --git a/packages/server-utils/src/async-context.ts b/packages/server-utils/src/async-context.ts index 681f4dbe46c4..3dfc5b359f08 100644 --- a/packages/server-utils/src/async-context.ts +++ b/packages/server-utils/src/async-context.ts @@ -11,7 +11,13 @@ import { setAsyncContextStrategy, } from '@sentry/core'; -type ScopeStore = { scope: Scope; isolationScope: Scope }; +type ScopeStore = { + scope: Scope; + isolationScope: Scope; + // Plain forks use the active context; borrowed/default scope ancestry keeps its saved context. + // Unset means an OpenTelemetry-bound store whose newly forked scopes can use the active context. + useActiveScopeContext?: boolean; +}; /** * Sets the async context strategy to use AsyncLocalStorage. @@ -29,7 +35,7 @@ export function setAsyncLocalStorageAsyncContextStrategy(): AsyncLocalStorage(); - function getScopes(): { scope: Scope; isolationScope: Scope } { + function getScopes(): ScopeStore { const scopes = asyncStorage.getStore(); if (scopes) { @@ -41,33 +47,49 @@ export function setAsyncLocalStorageAsyncContextStrategy(): AsyncLocalStorage(callback: (scope: Scope) => T): T { - const scope = getScopes().scope.clone(); - const isolationScope = getScopes().isolationScope; - return asyncStorage.run({ scope, isolationScope }, () => { - return callback(scope); - }); + const parent = getScopes(); + const scope = parent.scope.clone(); + const isolationScope = parent.isolationScope; + // Preserve context carried by another strategy sharing this storage. + return asyncStorage.run( + { ...parent, scope, isolationScope, useActiveScopeContext: parent.useActiveScopeContext !== false }, + () => { + return callback(scope); + }, + ); } // The isolation scope is shared, not forked, matching `withScope` above and the OpenTelemetry // strategy. Forking it would silently discard `setUser`/`setTag`/`setContext` calls made inside // the callback, as those write to the isolation scope. function withSetScope(scope: Scope, callback: (scope: Scope) => T): T { - const isolationScope = getScopes().isolationScope; - return asyncStorage.run({ scope, isolationScope }, () => { - return callback(scope); - }); + const parent = getScopes(); + const isolationScope = parent.isolationScope; + return asyncStorage.run( + { + ...parent, + scope, + isolationScope, + useActiveScopeContext: scope === parent.scope ? parent.useActiveScopeContext : false, + }, + () => { + return callback(scope); + }, + ); } // The current scope is forked alongside the isolation scope, matching the OpenTelemetry strategy // (`buildContextWithSentryScopes` clones it on every fork). Sharing it by reference would let // current-scope mutations inside the callback leak back out to the caller. function withIsolationScope(callback: (isolationScope: Scope) => T): T { - const scope = getScopes().scope.clone(); - const isolationScope = getScopes().isolationScope.clone(); + const parent = getScopes(); + const scope = parent.scope.clone(); + const isolationScope = parent.isolationScope.clone(); // When forking an isolation scope, unless we are continuing an incoming // trace, we give the freshly forked scope its own trace. This way, new @@ -82,16 +104,23 @@ export function setAsyncLocalStorageAsyncContextStrategy(): AsyncLocalStorage { - return callback(isolationScope); - }); + return asyncStorage.run( + { ...parent, scope, isolationScope, useActiveScopeContext: parent.useActiveScopeContext !== false }, + () => { + return callback(isolationScope); + }, + ); } function withSetIsolationScope(isolationScope: Scope, callback: (isolationScope: Scope) => T): T { - const scope = getScopes().scope.clone(); - return asyncStorage.run({ scope, isolationScope }, () => { - return callback(isolationScope); - }); + const parent = getScopes(); + const scope = parent.scope.clone(); + return asyncStorage.run( + { ...parent, scope, isolationScope, useActiveScopeContext: parent.useActiveScopeContext !== false }, + () => { + return callback(isolationScope); + }, + ); } setAsyncContextStrategy({