From 313d70f610b00c6e3e7a578d4a0f0d564e93ba23 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Fri, 31 Jul 2026 09:13:41 +0200 Subject: [PATCH 1/5] feat(server-utils): Implement `setAsyncLocalStorageAsyncContextStrategy` --- .../tracing/durableobject-sync-kv/test.ts | 1 + packages/cloudflare/.oxlintrc.json | 2 +- packages/cloudflare/src/async.ts | 81 ------------------- packages/cloudflare/src/durableobject.ts | 2 +- packages/cloudflare/src/index.ts | 2 +- .../instrumentWorkerEntrypoint.ts | 2 +- packages/cloudflare/src/pages-plugin.ts | 2 +- packages/cloudflare/src/withSentry.ts | 2 +- packages/cloudflare/src/workflows.ts | 2 +- packages/cloudflare/test/client.test.ts | 2 +- .../test/instrumentCloudflareAgent.test.ts | 2 +- .../instrumentWorkerEntrypoint.test.ts | 2 +- packages/cloudflare/test/request.test.ts | 2 +- .../integrations}/redis/redis-common.test.ts | 2 +- packages/deno/src/client.ts | 2 +- .../opentelemetry/src/asyncContextStrategy.ts | 11 ++- .../test/asyncContextStrategy.test.ts | 25 ++++++ packages/server-utils/package.json | 5 ++ packages/server-utils/rollup.npm.config.mjs | 1 + .../src/async-context.ts} | 29 +++---- packages/server-utils/src/exports.ts | 3 + .../src/index.no-diagnostic-channels.ts | 1 + packages/server-utils/src/index.ts | 9 +-- .../test/async-context.test.ts} | 63 ++++++++++++++- 24 files changed, 132 insertions(+), 123 deletions(-) delete mode 100644 packages/cloudflare/src/async.ts rename packages/{node/test/integrations/tracing => core/test/integrations}/redis/redis-common.test.ts (95%) rename packages/{deno/src/async.ts => server-utils/src/async-context.ts} (75%) create mode 100644 packages/server-utils/src/exports.ts create mode 100644 packages/server-utils/src/index.no-diagnostic-channels.ts rename packages/{cloudflare/test/async.test.ts => server-utils/test/async-context.test.ts} (68%) 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..22e2c9eed139 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 { 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/node/test/integrations/tracing/redis/redis-common.test.ts b/packages/core/test/integrations/redis/redis-common.test.ts similarity index 95% rename from packages/node/test/integrations/tracing/redis/redis-common.test.ts rename to packages/core/test/integrations/redis/redis-common.test.ts index 4334e1838f78..f25c6fa6b2a1 100644 --- a/packages/node/test/integrations/tracing/redis/redis-common.test.ts +++ b/packages/core/test/integrations/redis/redis-common.test.ts @@ -4,7 +4,7 @@ * Licensed under the Apache License, Version 2.0 */ -import { defaultDbStatementSerializer } from '@sentry/server-utils'; +import { defaultDbStatementSerializer } from '../../../../server-utils/src/redis/redis-statement-serializer'; import { describe, expect, it } from 'vitest'; describe('defaultDbStatementSerializer()', () => { 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 75% rename from packages/deno/src/async.ts rename to packages/server-utils/src/async-context.ts index 425905216162..76047929d2af 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(); @@ -65,6 +57,7 @@ export function setAsyncLocalStorageAsyncContextStrategy(): void { function withIsolationScope(callback: (isolationScope: Scope) => T): T { const scope = getScopes().scope; const isolationScope = getScopes().isolationScope.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..fc44fe376eeb 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'; @@ -19,7 +16,6 @@ export { instrumentPrisma, prismaIntegration } from './prisma'; export type { PrismaInstrumentationConfig, PrismaOptions } from './prisma'; export { redisIntegration, type RedisDiagnosticChannelsOptions } from './redis'; export type { RedisDiagnosticChannelResponseHook } from './redis/redis-dc-subscriber'; -export { defaultDbStatementSerializer } from './redis/redis-statement-serializer'; export { bindTracingChannelToSpan } from './tracing-channel'; export type { SentryTracingChannel, @@ -36,4 +32,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 68% rename from packages/cloudflare/test/async.test.ts rename to packages/server-utils/test/async-context.test.ts index 64a8d06ba29f..40c5dbd95cc3 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(() => { @@ -157,3 +167,50 @@ describe('withIsolationScope()', () => { }); })); }); + +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(); + }); +}); From 033bdceee50cf84cf9a1151e441ada71aab9ea71 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Fri, 31 Jul 2026 16:56:48 +0200 Subject: [PATCH 2/5] Restore defaultDbStatementSerializer export and move its test to server-utils The export was dropped when index.ts was split into exports.ts, which broke the redis-common test. It is published public API, so removing it is breaking. The test was relocated into packages/core with a cross-package relative import; it belongs next to the code it tests. --- packages/server-utils/src/index.ts | 1 + .../test}/redis/redis-common.test.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) rename packages/{core/test/integrations => server-utils/test}/redis/redis-common.test.ts (95%) diff --git a/packages/server-utils/src/index.ts b/packages/server-utils/src/index.ts index fc44fe376eeb..7667cc4b2b9f 100644 --- a/packages/server-utils/src/index.ts +++ b/packages/server-utils/src/index.ts @@ -16,6 +16,7 @@ export { instrumentPrisma, prismaIntegration } from './prisma'; export type { PrismaInstrumentationConfig, PrismaOptions } from './prisma'; export { redisIntegration, type RedisDiagnosticChannelsOptions } from './redis'; export type { RedisDiagnosticChannelResponseHook } from './redis/redis-dc-subscriber'; +export { defaultDbStatementSerializer } from './redis/redis-statement-serializer'; export { bindTracingChannelToSpan } from './tracing-channel'; export type { SentryTracingChannel, diff --git a/packages/core/test/integrations/redis/redis-common.test.ts b/packages/server-utils/test/redis/redis-common.test.ts similarity index 95% rename from packages/core/test/integrations/redis/redis-common.test.ts rename to packages/server-utils/test/redis/redis-common.test.ts index f25c6fa6b2a1..994657c87730 100644 --- a/packages/core/test/integrations/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 '../../../../server-utils/src/redis/redis-statement-serializer'; import { describe, expect, it } from 'vitest'; +import { defaultDbStatementSerializer } from '../../src/redis/redis-statement-serializer'; describe('defaultDbStatementSerializer()', () => { const testCases: Array<{ From eb0cf2c761923df7ec7975bc34dcf45b18261eeb Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Fri, 31 Jul 2026 16:57:24 +0200 Subject: [PATCH 3/5] Fork the current scope in withIsolationScope and withSetIsolationScope The strategy was derived from the deno/cloudflare one, which passes the current scope by reference. Both the OpenTelemetry strategy and v10's node-core light strategy clone it, so without this, current-scope mutations leak out of an isolation fork. --- packages/server-utils/src/async-context.ts | 7 ++-- .../server-utils/test/async-context.test.ts | 34 +++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/packages/server-utils/src/async-context.ts b/packages/server-utils/src/async-context.ts index 76047929d2af..c5f102519e47 100644 --- a/packages/server-utils/src/async-context.ts +++ b/packages/server-utils/src/async-context.ts @@ -54,8 +54,11 @@ export function setAsyncLocalStorageAsyncContextStrategy(): void { }); } + // 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 }, () => { @@ -64,7 +67,7 @@ export function setAsyncLocalStorageAsyncContextStrategy(): void { } 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/test/async-context.test.ts b/packages/server-utils/test/async-context.test.ts index 40c5dbd95cc3..34f97721db74 100644 --- a/packages/server-utils/test/async-context.test.ts +++ b/packages/server-utils/test/async-context.test.ts @@ -166,6 +166,40 @@ 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', () => { From 38d209a3cdd974973a7866eb7880d7086a3bb4e8 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Fri, 31 Jul 2026 17:09:24 +0200 Subject: [PATCH 4/5] Share the isolation scope in withSetScope Forking it discarded setUser/setTag/setContext calls made inside `withScope(scope, callback)`, since those write to the isolation scope. Both the OpenTelemetry strategy and the stack strategy share it here, as does `withScope` without an explicit scope. --- packages/server-utils/src/async-context.ts | 5 ++++- packages/server-utils/test/async-context.test.ts | 13 +++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/server-utils/src/async-context.ts b/packages/server-utils/src/async-context.ts index c5f102519e47..1a12ba1a898a 100644 --- a/packages/server-utils/src/async-context.ts +++ b/packages/server-utils/src/async-context.ts @@ -47,8 +47,11 @@ 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); }); diff --git a/packages/server-utils/test/async-context.test.ts b/packages/server-utils/test/async-context.test.ts index 34f97721db74..3e0566d58f06 100644 --- a/packages/server-utils/test/async-context.test.ts +++ b/packages/server-utils/test/async-context.test.ts @@ -88,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()', () => { From f10c7fe8eb27cd48daeeee3904d36dadef69674a Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Fri, 31 Jul 2026 17:35:21 +0200 Subject: [PATCH 5/5] Read the per-request client from inside the worker handler The Cloudflare SDK calls `init()` inside `withIsolationScope`, so the client is bound to the scope forked for that request. Reading it from outside the handler only worked while the current scope was shared by reference. --- .../test/instrumentations/instrumentWorkerEntrypoint.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts b/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts index 22e2c9eed139..7f23f4cbeff2 100644 --- a/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts +++ b/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts @@ -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();