diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-sync-kv/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-sync-kv/test.ts index 25103e4e0ceb..4a3064871f93 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-sync-kv/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-sync-kv/test.ts @@ -13,6 +13,7 @@ const flushMarkerMatcher = (envelope: Envelope): void => { it('instruments sync KV operations on Durable Object storage', async ({ signal }) => { const runner = createRunner(__dirname) + .unordered() .expect(envelope => { const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent | undefined; const spans = transactionEvent?.spans ?? []; diff --git a/packages/cloudflare/.oxlintrc.json b/packages/cloudflare/.oxlintrc.json index 3ec49c6228c2..05cf6fc19fc6 100644 --- a/packages/cloudflare/.oxlintrc.json +++ b/packages/cloudflare/.oxlintrc.json @@ -34,7 +34,7 @@ "message": "Do not import from `@sentry/node` in the Cloudflare SDK. It relies on Node.js APIs that are only available when the `nodejs_compat` flag is set. The only allowed importers are files in `src/nodejs_compat/`, which are exposed via the `@sentry/cloudflare/nodejs_compat/*` entry points." }, { - "group": ["@sentry/server-utils/**"], + "group": ["@sentry/server-utils/**", "!@sentry/server-utils/no-diagnostic-channels"], "message": "Do not import from `@sentry/server-utils` in the Cloudflare SDK. It relies on Node.js APIs that are only available when the `nodejs_compat` flag is set. The only allowed importers are files in `src/nodejs_compat/`, which are exposed via the `@sentry/cloudflare/nodejs_compat/*` entry points." } ] diff --git a/packages/cloudflare/src/async.ts b/packages/cloudflare/src/async.ts deleted file mode 100644 index bde7bbfc8f47..000000000000 --- a/packages/cloudflare/src/async.ts +++ /dev/null @@ -1,81 +0,0 @@ -// Need to use node: prefix for cloudflare workers compatibility -// Note: Because we are using node:async_hooks, we need to set `node_compat` in the wrangler.toml -import { AsyncLocalStorage } from 'node:async_hooks'; -import type { Scope } from '@sentry/core'; -import { - _INTERNAL_createTracingChannelBinding, - getDefaultCurrentScope, - getDefaultIsolationScope, - setAsyncContextStrategy, -} from '@sentry/core'; - -/** - * Sets the async context strategy to use AsyncLocalStorage. - * - * AsyncLocalStorage is only available in the cloudflare workers runtime if you set - * compatibility_flags = ["nodejs_compat"] - * - * @internal Only exported to be used in higher-level Sentry packages - * @hidden Only exported to be used in higher-level Sentry packages - */ -export function setAsyncLocalStorageAsyncContextStrategy(): void { - const asyncStorage = new AsyncLocalStorage<{ - scope: Scope; - isolationScope: Scope; - }>(); - - function getScopes(): { scope: Scope; isolationScope: Scope } { - const scopes = asyncStorage.getStore(); - - if (scopes) { - return scopes; - } - - // fallback behavior: - // if, for whatever reason, we can't find scopes on the context here, we have to fix this somehow - return { - scope: getDefaultCurrentScope(), - isolationScope: getDefaultIsolationScope(), - }; - } - - function withScope(callback: (scope: Scope) => T): T { - const scope = getScopes().scope.clone(); - const isolationScope = getScopes().isolationScope; - return asyncStorage.run({ scope, isolationScope }, () => { - return callback(scope); - }); - } - - function withSetScope(scope: Scope, callback: (scope: Scope) => T): T { - const isolationScope = getScopes().isolationScope.clone(); - return asyncStorage.run({ scope, isolationScope }, () => { - return callback(scope); - }); - } - - function withIsolationScope(callback: (isolationScope: Scope) => T): T { - const scope = getScopes().scope; - const isolationScope = getScopes().isolationScope.clone(); - return asyncStorage.run({ scope, isolationScope }, () => { - return callback(isolationScope); - }); - } - - function withSetIsolationScope(isolationScope: Scope, callback: (isolationScope: Scope) => T): T { - const scope = getScopes().scope; - return asyncStorage.run({ scope, isolationScope }, () => { - return callback(isolationScope); - }); - } - - setAsyncContextStrategy({ - withScope, - withSetScope, - withIsolationScope, - withSetIsolationScope, - getCurrentScope: () => getScopes().scope, - getIsolationScope: () => getScopes().isolationScope, - getTracingChannelBinding: () => _INTERNAL_createTracingChannelBinding(asyncStorage, getScopes), - }); -} diff --git a/packages/cloudflare/src/durableobject.ts b/packages/cloudflare/src/durableobject.ts index 1e18e18ac059..ff4ae839f0a0 100644 --- a/packages/cloudflare/src/durableobject.ts +++ b/packages/cloudflare/src/durableobject.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/unbound-method */ import { captureException } from '@sentry/core'; import type { DurableObject } from 'cloudflare:workers'; -import { setAsyncLocalStorageAsyncContextStrategy } from './async'; +import { setAsyncLocalStorageAsyncContextStrategy } from '@sentry/server-utils/no-diagnostic-channels'; import type { CloudflareOptions } from './client'; import { ensureInstrumented } from './instrument'; import { instrumentEnv } from './instrumentations/worker/instrumentEnv'; diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index de232a39751e..20124b3b8554 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -134,4 +134,4 @@ export { instrumentD1WithSentry } from './instrumentations/worker/instrumentD1'; export { instrumentWorkflowWithSentry } from './workflows'; -export { setAsyncLocalStorageAsyncContextStrategy } from './async'; +export { setAsyncLocalStorageAsyncContextStrategy } from '@sentry/server-utils/no-diagnostic-channels'; diff --git a/packages/cloudflare/src/instrumentations/instrumentWorkerEntrypoint.ts b/packages/cloudflare/src/instrumentations/instrumentWorkerEntrypoint.ts index bb53e124568e..c0b46f027be8 100644 --- a/packages/cloudflare/src/instrumentations/instrumentWorkerEntrypoint.ts +++ b/packages/cloudflare/src/instrumentations/instrumentWorkerEntrypoint.ts @@ -1,5 +1,5 @@ import type { RpcStub, WorkerEntrypoint } from 'cloudflare:workers'; -import { setAsyncLocalStorageAsyncContextStrategy } from '../async'; +import { setAsyncLocalStorageAsyncContextStrategy } from '@sentry/server-utils/no-diagnostic-channels'; import type { CloudflareOptions } from '../client'; import { getFinalOptions } from '../options'; import { instrumentContext } from '../utils/instrumentContext'; diff --git a/packages/cloudflare/src/pages-plugin.ts b/packages/cloudflare/src/pages-plugin.ts index de67eafd5506..2f9c966cb53b 100644 --- a/packages/cloudflare/src/pages-plugin.ts +++ b/packages/cloudflare/src/pages-plugin.ts @@ -1,4 +1,4 @@ -import { setAsyncLocalStorageAsyncContextStrategy } from './async'; +import { setAsyncLocalStorageAsyncContextStrategy } from '@sentry/server-utils/no-diagnostic-channels'; import type { CloudflareOptions } from './client'; import type { ExecutionContextCompat } from './executionContext'; import { wrapRequestHandler } from './request'; diff --git a/packages/cloudflare/src/withSentry.ts b/packages/cloudflare/src/withSentry.ts index b71fde33b30a..50acb67541dc 100644 --- a/packages/cloudflare/src/withSentry.ts +++ b/packages/cloudflare/src/withSentry.ts @@ -1,5 +1,5 @@ import type { env as cloudflareEnv } from 'cloudflare:workers'; -import { setAsyncLocalStorageAsyncContextStrategy } from './async'; +import { setAsyncLocalStorageAsyncContextStrategy } from '@sentry/server-utils/no-diagnostic-channels'; import type { CloudflareOptions } from './client'; import { instrumentExportedHandlerEmail } from './instrumentations/worker/instrumentEmail'; import { instrumentExportedHandlerFetch } from './instrumentations/worker/instrumentFetch'; diff --git a/packages/cloudflare/src/workflows.ts b/packages/cloudflare/src/workflows.ts index c7c54e424d5c..721cb752d1ba 100644 --- a/packages/cloudflare/src/workflows.ts +++ b/packages/cloudflare/src/workflows.ts @@ -23,7 +23,7 @@ import type { WorkflowStepRollbackOptions, WorkflowTimeoutDuration, } from 'cloudflare:workers'; -import { setAsyncLocalStorageAsyncContextStrategy } from './async'; +import { setAsyncLocalStorageAsyncContextStrategy } from '@sentry/server-utils/no-diagnostic-channels'; import type { CloudflareOptions } from './client'; import { flushAndDispose } from './flush'; import { instrumentEnv } from './instrumentations/worker/instrumentEnv'; diff --git a/packages/cloudflare/test/client.test.ts b/packages/cloudflare/test/client.test.ts index 8a6ac65175a0..7e305f6aec65 100644 --- a/packages/cloudflare/test/client.test.ts +++ b/packages/cloudflare/test/client.test.ts @@ -1,5 +1,5 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; -import { setAsyncLocalStorageAsyncContextStrategy } from '../src/async'; +import { setAsyncLocalStorageAsyncContextStrategy } from '@sentry/server-utils/no-diagnostic-channels'; import { CloudflareClient, type CloudflareClientOptions } from '../src/client'; import { makeFlushLock } from '../src/flush'; diff --git a/packages/cloudflare/test/instrumentCloudflareAgent.test.ts b/packages/cloudflare/test/instrumentCloudflareAgent.test.ts index 64bd417f5192..314efb3c1e3c 100644 --- a/packages/cloudflare/test/instrumentCloudflareAgent.test.ts +++ b/packages/cloudflare/test/instrumentCloudflareAgent.test.ts @@ -1,7 +1,7 @@ import type { Event } from '@sentry/core'; import { getCurrentScope, setCurrentClient } from '@sentry/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { setAsyncLocalStorageAsyncContextStrategy } from '../src/async'; +import { setAsyncLocalStorageAsyncContextStrategy } from '@sentry/server-utils/no-diagnostic-channels'; import { CloudflareClient, type CloudflareClientOptions } from '../src/client'; import { instrumentCloudflareAgent } from '../src/instrumentations/agents'; import { resetSdk } from './testUtils'; diff --git a/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts b/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts index 353eabebe474..7f23f4cbeff2 100644 --- a/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts +++ b/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts @@ -176,7 +176,7 @@ describe('instrumentWorkerEntrypoint', () => { }); it('Calls setAsyncLocalStorageAsyncContextStrategy outside Proxy (at instrumentation time), not inside construct', async () => { - const asyncModule = await import('../../src/async'); + const asyncModule = await import('@sentry/server-utils/no-diagnostic-channels'); const setStrategy = vi.spyOn(asyncModule, 'setAsyncLocalStorageAsyncContextStrategy'); const mockContext = createMockExecutionContext(); const TestClass = class extends WorkerEntrypoint { @@ -216,6 +216,9 @@ describe('instrumentWorkerEntrypoint', () => { const waitUntil = vi.fn(); const TestClass = vi.fn((context: ExecutionContext) => ({ fetch: () => { + // The client is created per request, on the scope forked for that request, so it is only + // reachable from inside the handler. + testClient = SentryCore.getClient(); context.waitUntil(deferred); return new Response('test'); }, @@ -225,7 +228,6 @@ describe('instrumentWorkerEntrypoint', () => { const worker = Reflect.construct(instrumented, [context, {}]); const responsePromise = worker.fetch(new Request('https://example.com')); - testClient = SentryCore.getClient(); const response = await responsePromise; await response.text(); diff --git a/packages/cloudflare/test/request.test.ts b/packages/cloudflare/test/request.test.ts index a517c5b9ef39..8dc80ecda3ae 100644 --- a/packages/cloudflare/test/request.test.ts +++ b/packages/cloudflare/test/request.test.ts @@ -5,7 +5,7 @@ import type { ExecutionContext } from '@cloudflare/workers-types'; import type { Event } from '@sentry/core'; import * as SentryCore from '@sentry/core'; import { beforeAll, beforeEach, describe, expect, onTestFinished, test, vi } from 'vitest'; -import { setAsyncLocalStorageAsyncContextStrategy } from '../src/async'; +import { setAsyncLocalStorageAsyncContextStrategy } from '@sentry/server-utils/no-diagnostic-channels'; import type { CloudflareOptions } from '../src/client'; import { CloudflareClient } from '../src/client'; import { wrapRequestHandler } from '../src/request'; diff --git a/packages/deno/src/client.ts b/packages/deno/src/client.ts index f403b9ba6f0d..b6ab892200db 100644 --- a/packages/deno/src/client.ts +++ b/packages/deno/src/client.ts @@ -1,6 +1,6 @@ import type { ServerRuntimeClientOptions } from '@sentry/core'; import { _INTERNAL_flushLogsBuffer, SDK_VERSION, ServerRuntimeClient } from '@sentry/core'; -import { setAsyncLocalStorageAsyncContextStrategy } from './async'; +import { setAsyncLocalStorageAsyncContextStrategy } from '@sentry/server-utils'; import type { DenoClientOptions } from './types'; function getHostName(): string | undefined { diff --git a/packages/opentelemetry/src/asyncContextStrategy.ts b/packages/opentelemetry/src/asyncContextStrategy.ts index 41cddace030e..e13b62087aee 100644 --- a/packages/opentelemetry/src/asyncContextStrategy.ts +++ b/packages/opentelemetry/src/asyncContextStrategy.ts @@ -1,8 +1,10 @@ import * as api from '@opentelemetry/api'; import type { Scope, TracingChannelBinding } from '@sentry/core'; import { + getAsyncContextStrategy, getDefaultCurrentScope, getDefaultIsolationScope, + getMainCarrier, getRootSpan, setAsyncContextStrategy, spanIsIgnored, @@ -27,7 +29,14 @@ import { SentryAsyncLocalStorageContextManager } from './asyncLocalStorageContex * We handle forking a hub inside of our custom OTEL Context Manager (./otelContextManager.ts) */ export function setOpenTelemetryContextAsyncContextStrategy(): AsyncLocalStorageLookup { - const asyncLocalStorage = new AsyncLocalStorage(); + // Re-use the AsyncLocalStorage of an already-installed strategy, if any. Otherwise a repeated + // setup would swap in a new store while integrations that captured the previous one (via + // `getTracingChannelBinding().asyncLocalStorage`) keep reading the old one, breaking scope + // propagation across async boundaries. + const existingAsyncLocalStorage = getAsyncContextStrategy(getMainCarrier()).getTracingChannelBinding?.() + ?.asyncLocalStorage as AsyncLocalStorage | undefined; + + const asyncLocalStorage = existingAsyncLocalStorage ?? new AsyncLocalStorage(); function getScopes(): CurrentScopes { const ctx = api.context.active(); diff --git a/packages/opentelemetry/test/asyncContextStrategy.test.ts b/packages/opentelemetry/test/asyncContextStrategy.test.ts index c95932c3ecf6..a36ae15180a7 100644 --- a/packages/opentelemetry/test/asyncContextStrategy.test.ts +++ b/packages/opentelemetry/test/asyncContextStrategy.test.ts @@ -362,6 +362,31 @@ describe('asyncContextStrategy', () => { }); }); + describe('AsyncLocalStorage re-use', () => { + it('re-uses the AsyncLocalStorage of an already-installed strategy on repeated setup', () => { + setOpenTelemetryContextAsyncContextStrategy(); + const firstStorage = getAsyncContextStrategy(getMainCarrier()).getTracingChannelBinding?.()?.asyncLocalStorage; + expect(firstStorage).toBeDefined(); + + setOpenTelemetryContextAsyncContextStrategy(); + const secondStorage = getAsyncContextStrategy(getMainCarrier()).getTracingChannelBinding?.()?.asyncLocalStorage; + + expect(secondStorage).toBe(firstStorage); + }); + + it('returns a context manager backed by the re-used AsyncLocalStorage', () => { + const firstLookup = setOpenTelemetryContextAsyncContextStrategy(); + const secondLookup = setOpenTelemetryContextAsyncContextStrategy(); + + // The context manager returned on the second setup wraps the same AsyncLocalStorage instance, + // so consumers that captured the lookup from the first setup keep observing the active context. + expect(secondLookup.asyncLocalStorage).toBe(firstLookup.asyncLocalStorage); + expect(getAsyncContextStrategy(getMainCarrier()).getTracingChannelBinding?.()?.asyncLocalStorage).toBe( + firstLookup.asyncLocalStorage, + ); + }); + }); + describe('withScope()', () => { it('will make the passed scope the active scope within the callback', () => new Promise(done => { diff --git a/packages/server-utils/package.json b/packages/server-utils/package.json index 9bef9afa58df..c327942b7bc6 100644 --- a/packages/server-utils/package.json +++ b/packages/server-utils/package.json @@ -22,6 +22,11 @@ "import": "./build/esm/index.js", "require": "./build/cjs/index.js" }, + "./no-diagnostic-channels": { + "types": "./build/types/index.no-diagnostic-channels.d.ts", + "import": "./build/esm/index.no-diagnostic-channels.js", + "require": "./build/cjs/index.no-diagnostic-channels.js" + }, "./orchestrion": { "types": "./build/types/orchestrion/index.d.ts", "import": "./build/esm/orchestrion/index.js", diff --git a/packages/server-utils/rollup.npm.config.mjs b/packages/server-utils/rollup.npm.config.mjs index 1a7bf99b5d12..e79218e1cd83 100644 --- a/packages/server-utils/rollup.npm.config.mjs +++ b/packages/server-utils/rollup.npm.config.mjs @@ -104,6 +104,7 @@ export default [ // subpath export. entrypoints: [ 'src/index.ts', + 'src/index.no-diagnostic-channels.ts', 'src/orchestrion/index.ts', 'src/orchestrion/config/index.ts', // `src/orchestrion/runtime/register.ts` backs the `./orchestrion/register` diff --git a/packages/deno/src/async.ts b/packages/server-utils/src/async-context.ts similarity index 59% rename from packages/deno/src/async.ts rename to packages/server-utils/src/async-context.ts index 425905216162..1a12ba1a898a 100644 --- a/packages/deno/src/async.ts +++ b/packages/server-utils/src/async-context.ts @@ -1,36 +1,28 @@ -// Need to use node: prefix for deno compatibility import { AsyncLocalStorage } from 'node:async_hooks'; import type { Scope } from '@sentry/core'; import { _INTERNAL_createTracingChannelBinding, + getAsyncContextStrategy, getDefaultCurrentScope, getDefaultIsolationScope, + getMainCarrier, setAsyncContextStrategy, } from '@sentry/core'; -let installed = false; +type ScopeStore = { scope: Scope; isolationScope: Scope }; /** * Sets the async context strategy to use AsyncLocalStorage. - * - * Idempotent: multiple integrations each call this from their `setupOnce`, - * but they must all share a single `AsyncLocalStorage` so context propagates - * between them. The first call wins, later calls are no-ops. This prevents - * orphaning an in-flight context if an integration is set up asynchronously. - * - * @internal Only exported to be used in higher-level Sentry packages - * @hidden Only exported to be used in higher-level Sentry packages */ export function setAsyncLocalStorageAsyncContextStrategy(): void { - if (installed) { - return; - } - installed = true; + // Re-use the AsyncLocalStorage of an already-installed strategy, if any. Otherwise a repeated + // setup (e.g. a second `Sentry.init()`) would swap in a new store while integrations that captured + // the previous one (via `getTracingChannelBinding().asyncLocalStorage`) keep reading the old one, + // breaking scope propagation across async boundaries. + const existingAsyncStorage = getAsyncContextStrategy(getMainCarrier()).getTracingChannelBinding?.() + ?.asyncLocalStorage as AsyncLocalStorage | undefined; - const asyncStorage = new AsyncLocalStorage<{ - scope: Scope; - isolationScope: Scope; - }>(); + const asyncStorage = existingAsyncStorage ?? new AsyncLocalStorage(); function getScopes(): { scope: Scope; isolationScope: Scope } { const scopes = asyncStorage.getStore(); @@ -55,23 +47,30 @@ export function setAsyncLocalStorageAsyncContextStrategy(): void { }); } + // 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.clone(); + const isolationScope = getScopes().isolationScope; return asyncStorage.run({ scope, isolationScope }, () => { 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; + const scope = getScopes().scope.clone(); const isolationScope = getScopes().isolationScope.clone(); + return asyncStorage.run({ scope, isolationScope }, () => { return callback(isolationScope); }); } function withSetIsolationScope(isolationScope: Scope, callback: (isolationScope: Scope) => T): T { - const scope = getScopes().scope; + const scope = getScopes().scope.clone(); return asyncStorage.run({ scope, isolationScope }, () => { return callback(isolationScope); }); diff --git a/packages/server-utils/src/exports.ts b/packages/server-utils/src/exports.ts new file mode 100644 index 000000000000..95c01cc80606 --- /dev/null +++ b/packages/server-utils/src/exports.ts @@ -0,0 +1,3 @@ +// Shared exports not using diagnostics channels +export { setHttpServerSpanRouteAttribute } from './utils/setHttpServerSpanRouteAttribute'; +export { setAsyncLocalStorageAsyncContextStrategy } from './async-context'; diff --git a/packages/server-utils/src/index.no-diagnostic-channels.ts b/packages/server-utils/src/index.no-diagnostic-channels.ts new file mode 100644 index 000000000000..8570a426a7e7 --- /dev/null +++ b/packages/server-utils/src/index.no-diagnostic-channels.ts @@ -0,0 +1 @@ +export * from './exports'; diff --git a/packages/server-utils/src/index.ts b/packages/server-utils/src/index.ts index 06352b7a0601..7667cc4b2b9f 100644 --- a/packages/server-utils/src/index.ts +++ b/packages/server-utils/src/index.ts @@ -1,9 +1,6 @@ -/** - * Server-only utilities shared across Sentry server SDKs. - * - * @module - */ +export * from './exports'; +// Exports using diagnostics channels export { graphqlIntegration } from './graphql'; export { mongooseIntegration, startMongooseLegacySpan } from './mongoose'; export type { MongooseLegacyCollection, StartMongooseLegacySpanOptions } from './mongoose'; @@ -36,4 +33,3 @@ export { // oxlint-disable-next-line typescript/no-deprecated instrumentFastify, } from './integrations/tracing-channel/fastify'; -export { setHttpServerSpanRouteAttribute } from './utils/setHttpServerSpanRouteAttribute'; diff --git a/packages/cloudflare/test/async.test.ts b/packages/server-utils/test/async-context.test.ts similarity index 56% rename from packages/cloudflare/test/async.test.ts rename to packages/server-utils/test/async-context.test.ts index 64a8d06ba29f..3e0566d58f06 100644 --- a/packages/cloudflare/test/async.test.ts +++ b/packages/server-utils/test/async-context.test.ts @@ -1,6 +1,16 @@ -import { getCurrentScope, getGlobalScope, getIsolationScope, Scope, withIsolationScope, withScope } from '@sentry/core'; -import { beforeEach, describe, expect, it } from 'vitest'; -import { setAsyncLocalStorageAsyncContextStrategy } from '../src/async'; +import { + getAsyncContextStrategy, + getCurrentScope, + getGlobalScope, + getIsolationScope, + getMainCarrier, + Scope, + setAsyncContextStrategy, + withIsolationScope, + withScope, +} from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { setAsyncLocalStorageAsyncContextStrategy } from '../src/async-context'; describe('withScope()', () => { beforeEach(() => { @@ -78,6 +88,19 @@ describe('withScope()', () => { done(); }); })); + + it('does not fork the isolation scope when passing a scope', () => + new Promise(done => { + const initialIsolationScope = getIsolationScope(); + + withScope(new Scope(), () => { + expect(getIsolationScope()).toBe(initialIsolationScope); + getIsolationScope().setTag('bb', 'bb'); + done(); + }); + + expect(initialIsolationScope.getScopeData().tags).toEqual({ bb: 'bb' }); + })); }); describe('withIsolationScope()', () => { @@ -156,4 +179,85 @@ describe('withIsolationScope()', () => { done(); }); })); + + it('forks the current scope as well, so mutations do not leak out', () => + new Promise(done => { + const initialScope = getCurrentScope(); + initialScope.setTag('aa', 'aa'); + + withIsolationScope(() => { + const scope = getCurrentScope(); + expect(scope).not.toBe(initialScope); + expect(scope.getScopeData().tags).toEqual({ aa: 'aa' }); + + scope.setTag('bb', 'bb'); + done(); + }); + + expect(initialScope.getScopeData().tags).toEqual({ aa: 'aa' }); + })); + + it('forks the current scope as well when passing an isolation scope', () => + new Promise(done => { + const initialScope = getCurrentScope(); + initialScope.setTag('aa', 'aa'); + + withIsolationScope(new Scope(), () => { + const scope = getCurrentScope(); + expect(scope).not.toBe(initialScope); + expect(scope.getScopeData().tags).toEqual({ aa: 'aa' }); + + scope.setTag('bb', 'bb'); + done(); + }); + + expect(initialScope.getScopeData().tags).toEqual({ aa: 'aa' }); + })); +}); + +describe('AsyncLocalStorage re-use', () => { + afterEach(() => { + setAsyncContextStrategy(undefined); + }); + + it('re-uses the AsyncLocalStorage of an already-installed strategy on repeated setup', () => { + setAsyncLocalStorageAsyncContextStrategy(); + const firstStorage = getAsyncContextStrategy(getMainCarrier()).getTracingChannelBinding?.()?.asyncLocalStorage; + expect(firstStorage).toBeDefined(); + + setAsyncLocalStorageAsyncContextStrategy(); + const secondStorage = getAsyncContextStrategy(getMainCarrier()).getTracingChannelBinding?.()?.asyncLocalStorage; + + expect(secondStorage).toBe(firstStorage); + }); + + it('keeps scope propagation working after a repeated setup', () => + new Promise(done => { + setAsyncLocalStorageAsyncContextStrategy(); + + // A consumer captures the store from the initial setup... + const capturedStorage = getAsyncContextStrategy(getMainCarrier()).getTracingChannelBinding?.()?.asyncLocalStorage; + + // ...and then a second setup happens (e.g. a second `Sentry.init()`). + setAsyncLocalStorageAsyncContextStrategy(); + + withIsolationScope(isolationScope => { + // The store captured before the second setup still observes the active scopes, + // because the same AsyncLocalStorage instance was re-used. + expect((capturedStorage as { getStore: () => { isolationScope: Scope } }).getStore().isolationScope).toBe( + isolationScope, + ); + expect(getIsolationScope()).toBe(isolationScope); + done(); + }); + })); + + it('creates a new AsyncLocalStorage when no strategy is installed yet', () => { + setAsyncContextStrategy(undefined); + + setAsyncLocalStorageAsyncContextStrategy(); + const storage = getAsyncContextStrategy(getMainCarrier()).getTracingChannelBinding?.()?.asyncLocalStorage; + + expect(storage).toBeDefined(); + }); }); diff --git a/packages/node/test/integrations/tracing/redis/redis-common.test.ts b/packages/server-utils/test/redis/redis-common.test.ts similarity index 96% rename from packages/node/test/integrations/tracing/redis/redis-common.test.ts rename to packages/server-utils/test/redis/redis-common.test.ts index 4334e1838f78..994657c87730 100644 --- a/packages/node/test/integrations/tracing/redis/redis-common.test.ts +++ b/packages/server-utils/test/redis/redis-common.test.ts @@ -4,8 +4,8 @@ * Licensed under the Apache License, Version 2.0 */ -import { defaultDbStatementSerializer } from '@sentry/server-utils'; import { describe, expect, it } from 'vitest'; +import { defaultDbStatementSerializer } from '../../src/redis/redis-statement-serializer'; describe('defaultDbStatementSerializer()', () => { const testCases: Array<{