From 6ada305f45122756a9e5079c133ab6f1016dd36f Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Wed, 30 Sep 2026 15:32:00 +0200 Subject: [PATCH 1/6] fix(nitro): Anchor both nitro integrations on h3 for cloudflare auto-injection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The orchestrion registration previously mapped one integration per module, so `nitroServerTimingIntegration` was anchored on `unstorage` while `nitroIntegration` took `h3`. `unstorage` is practically always evaluated in a Nitro app, but it is not the guaranteed HTTP module, so a Nitro app that did not load it would silently fail to register Server-Timing (breaking pageload trace propagation) on a bundler-only SDK like `@sentry/cloudflare`. Let a module register several integrations, and anchor both nitro integrations on `h3` — the module every HTTP-serving Nitro app loads. The global orchestrion marker now stores a Set of factories per module; the injected snippet emits one registration call per export; and cloudflare instantiates all of them. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/cloudflare/src/baseSdk.ts | 12 +++++---- packages/cloudflare/test/sdk.test.ts | 14 +++++++++++ packages/core/src/utils/worldwide.ts | 13 ++++++---- .../bundler/moduleInjectedTransform.ts | 25 +++++++++++++------ .../config/channel-integration-definitions.ts | 12 ++++++--- .../src/orchestrion/config/nitro.ts | 25 +++++++++---------- .../server-utils/src/utils/moduleInjected.ts | 13 +++++++++- .../moduleInjectedTransform.test.ts | 18 +++++++------ .../test/utils/moduleInjected.test.ts | 11 ++++++++ 9 files changed, 99 insertions(+), 44 deletions(-) diff --git a/packages/cloudflare/src/baseSdk.ts b/packages/cloudflare/src/baseSdk.ts index 0375e7f94601..b2eb5053d9ab 100644 --- a/packages/cloudflare/src/baseSdk.ts +++ b/packages/cloudflare/src/baseSdk.ts @@ -37,7 +37,10 @@ import { defaultStackParser } from './vendor/stacktrace'; function getRegisteredChannelIntegrations(): Integration[] { const registered = GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.integrations; - return registered ? [...registered.values()].map(factory => factory()) : []; + // A module holds a single factory, or a Set of them (once it registers more than one integration). + return registered + ? [...registered.values()].flatMap(entry => (entry instanceof Set ? [...entry] : [entry])).map(factory => factory()) + : []; } /** @@ -156,10 +159,9 @@ export function initWithDefaultIntegrations( // integration on the live client here. `addIntegration` dedupes by // integration name, so already-installed integrations are no-ops. client.on('orchestrion.module-injected', moduleName => { - const factory = GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.integrations?.get(moduleName); - if (factory) { - client.addIntegration(factory()); - } + const entry = GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.integrations?.get(moduleName); + const factories = entry instanceof Set ? entry : entry ? [entry] : []; + factories.forEach(factory => client.addIntegration(factory())); }); return client; diff --git a/packages/cloudflare/test/sdk.test.ts b/packages/cloudflare/test/sdk.test.ts index d7820ecc343e..0ec010e855c1 100644 --- a/packages/cloudflare/test/sdk.test.ts +++ b/packages/cloudflare/test/sdk.test.ts @@ -417,4 +417,18 @@ describe('getDefaultIntegrations', () => { expect(client?.getIntegrationByName('Mysql')).toBeDefined(); }); + + test('installs all integrations a single module registers', async () => { + const { mysqlIntegration, lruMemoizerIntegration } = await import('@sentry/server-utils'); + // One module registering more than one integration (like `h3` → span + Server-Timing). + globalThis.__SENTRY_ORCHESTRION__ = { + bundler: new Set(['some-module']), + integrations: new Map([['some-module', new Set([mysqlIntegration, lruMemoizerIntegration])]]), + }; + + const names = getDefaultIntegrations({}).map(i => i.name); + + expect(names).toContain('Mysql'); + expect(names).toContain('LruMemoizer'); + }); }); diff --git a/packages/core/src/utils/worldwide.ts b/packages/core/src/utils/worldwide.ts index 597174c3f94d..074a34dd11c5 100644 --- a/packages/core/src/utils/worldwide.ts +++ b/packages/core/src/utils/worldwide.ts @@ -80,12 +80,15 @@ export type InternalGlobal = { /** * Channel-subscriber integration factories stored by the snippet the * bundler transform splices into each instrumented module, keyed by module - * name. A factory shared by several packages (e.g. pg/pg-pool) appears - * under several keys; integration-name deduplication collapses them at - * setup. A bundler-only SDK (e.g. `@sentry/cloudflare`) reads these at - * `init()` and instantiates them. + * name. A module holds a single factory, or a Set of them once it registers + * more than one integration (e.g. `h3` → span + Server-Timing) — the single + * shape is kept for the common case so an older reader stays compatible. A + * factory shared by several packages (e.g. pg/pg-pool) appears under several + * keys; integration-name deduplication collapses them at setup. A + * bundler-only SDK (e.g. `@sentry/cloudflare`) reads these at `init()` and + * instantiates them. */ - integrations?: Map Integration>; + integrations?: Map Integration) | Set<() => Integration>>; /** * Set once `registerDiagnosticsChannelInjection()` has run but could not * install the runtime module hooks — most commonly because diff --git a/packages/server-utils/src/orchestrion/bundler/moduleInjectedTransform.ts b/packages/server-utils/src/orchestrion/bundler/moduleInjectedTransform.ts index 737d9be98f40..a4370ece84d7 100644 --- a/packages/server-utils/src/orchestrion/bundler/moduleInjectedTransform.ts +++ b/packages/server-utils/src/orchestrion/bundler/moduleInjectedTransform.ts @@ -1,6 +1,6 @@ import type { CustomTransform } from '../apmTypes'; import { parse } from 'meriyah'; -import { subscriberExportForModule } from '../config/channel-integration-definitions'; +import { subscriberExportsForModule } from '../config/channel-integration-definitions'; import { MODULE_REGISTRATION_TRANSFORM } from '../config/registration-only'; // Tracks Program nodes we already injected into, so a package with several @@ -67,24 +67,33 @@ export const ORCHESTRION_BUNDLER_MARKER_BANNER = */ function moduleInjectedSnippet( moduleName: string, - exportName: string | undefined, + exportNames: string[], esm: boolean, importSpecifier: string, ): string { - const bindings = exportName ? `orchestrionModuleInjected, ${exportName}` : 'orchestrionModuleInjected'; + const bindings = ['orchestrionModuleInjected', ...exportNames].join(', '); const importStmt = esm ? `import { ${bindings} } from ${JSON.stringify(importSpecifier)};` : `const { ${bindings} } = require(${JSON.stringify(importSpecifier)});`; - // `exportName` is itself an integration factory, invoked inside the arrow (`() => exportName()`), so + // A module can register several integrations (e.g. `h3` → `nitroIntegration` and + // `nitroServerTimingIntegration`), so emit one call per export. Each `exportName` is itself an + // integration factory, invoked inside the arrow (`() => exportName()`), so // `orchestrionModuleInjected`'s consumer still gets an Integration back from calling the stored // thunk. The point of the arrow is to defer the read of the `exportName` binding to when that thunk // runs (at `init()`), instead of reading it by reference the moment this snippet evaluates. A // provided-module integration (e.g. `flueIntegration`) is imported back into its own instrumented // package by `@sentry/*/vite`, so a direct reference here would close an import cycle and touch the // binding in its TDZ ("Cannot access '…' before initialization"); deferring the read breaks that. - const args = exportName ? `${JSON.stringify(moduleName)}, () => ${exportName}()` : JSON.stringify(moduleName); - return `${importStmt}\n${MODULE_INJECTED_SINK} = orchestrionModuleInjected(${args});`; + const calls = exportNames.length + ? exportNames + .map( + exportName => + `${MODULE_INJECTED_SINK} = orchestrionModuleInjected(${JSON.stringify(moduleName)}, () => ${exportName}());`, + ) + .join('\n') + : `${MODULE_INJECTED_SINK} = orchestrionModuleInjected(${JSON.stringify(moduleName)});`; + return `${importStmt}\n${calls}`; } /** @@ -133,8 +142,8 @@ export function moduleInjectedTransforms( const specifier = (typeof importSpecifier === 'function' ? importSpecifier() : importSpecifier) ?? DEFAULT_IMPORT_SPECIFIER; - const exportName = subscriberExportForModule(moduleName); - const statements = parse(moduleInjectedSnippet(moduleName, exportName, moduleType === 'esm', specifier), { + const exportNames = subscriberExportsForModule(moduleName); + const statements = parse(moduleInjectedSnippet(moduleName, exportNames, moduleType === 'esm', specifier), { module: moduleType === 'esm', next: true, }).body as ProgramNode['body']; diff --git a/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts b/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts index 685bc288e52b..b23f15af4c53 100644 --- a/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts +++ b/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts @@ -63,11 +63,15 @@ export const CHANNEL_INTEGRATION_DEFINITIONS = [ { exportName: 'kafkaIntegration', modules: ['kafkajs'] }, { exportName: 'redisIntegration', modules: ['redis', '@redis/client', 'ioredis'] }, { exportName: 'dataloaderIntegration', modules: ['dataloader'] }, + // Both anchored on `h3` — the module every HTTP-serving Nitro app loads — so a single module can + // register more than one integration. { exportName: 'nitroIntegration', modules: ['h3'] }, - { exportName: 'nitroServerTimingIntegration', modules: ['unstorage'] }, + { exportName: 'nitroServerTimingIntegration', modules: ['h3'] }, ] as const satisfies ReadonlyArray<{ exportName: string; modules: readonly string[] }>; -/** Look up the subscriber export name for an instrumented package, if any. */ -export function subscriberExportForModule(moduleName: string): string | undefined { - return CHANNEL_INTEGRATION_DEFINITIONS.find(d => (d.modules as readonly string[]).includes(moduleName))?.exportName; +/** Look up the subscriber export names for an instrumented package (a module may register several). */ +export function subscriberExportsForModule(moduleName: string): string[] { + return CHANNEL_INTEGRATION_DEFINITIONS.filter(d => (d.modules as readonly string[]).includes(moduleName)).map( + d => d.exportName, + ); } diff --git a/packages/server-utils/src/orchestrion/config/nitro.ts b/packages/server-utils/src/orchestrion/config/nitro.ts index 101b99d11ddc..9cf525eb510c 100644 --- a/packages/server-utils/src/orchestrion/config/nitro.ts +++ b/packages/server-utils/src/orchestrion/config/nitro.ts @@ -3,24 +3,23 @@ import { registrationOnly } from './registration-only'; /** * Nitro's instrumentation consumes tracing channels that h3, srvx and unstorage publish natively, so - * no channels are injected: transforming these entry files only registers the Nitro integrations at + * no channels are injected: transforming this entry file only registers the Nitro integrations at * evaluation time, which is what installs them on a bundler-only SDK like `@sentry/cloudflare`. On * `@sentry/node` (and deno/bun) the integrations are registered statically, so this is a no-op there. * - * Two anchors because the registration snippet maps a single integration per module (see - * `channel-integration-definitions.ts`): `h3` — which loads whenever the server runs — registers the - * span instrumentation, and `unstorage` — a core Nitro storage dependency — registers the - * Server-Timing trace-propagation headers. Both are core Nitro dependencies evaluated at startup, so - * a real Nitro app triggers both. The anchor only triggers registration; each integration then - * subscribes to all of its own channels, independent of which module loaded it. + * Anchored on `h3` — the module every HTTP-serving Nitro app loads — which registers BOTH + * `nitroIntegration` (spans) and `nitroServerTimingIntegration` (trace-propagation headers); see + * `channel-integration-definitions.ts`. Using the guaranteed HTTP module (rather than, say, + * `unstorage`) avoids a silent failure to register on a Nitro app that happens not to load that other + * module. The anchor only triggers registration; each integration then subscribes to all of its own + * channels, independent of which module loaded it. * - * The `-0` in the version range matters: h3 and unstorage ship the versions Nitro 3 uses as - * prereleases (h3 `2.0.1-rc.*`, unstorage `2.0.0-alpha.*`). The orchestrion matcher is the vendored - * `semifies` (used by both the runtime loader and the bundler plugins), where a range carrying a - * prerelease tag enables prerelease matching across patch tuples — so `>=2.0.0-0` matches - * `2.0.1-rc.*`. (Plain node-semver would not: it drops prereleases of a higher patch tuple.) + * The `-0` in the version range matters: h3 ships the version Nitro 3 uses as a prerelease + * (`2.0.1-rc.*`). The orchestrion matcher is the vendored `semifies` (used by both the runtime loader + * and the bundler plugins), where a range carrying a prerelease tag enables prerelease matching + * across patch tuples — so `>=2.0.0-0` matches `2.0.1-rc.*`. (Plain node-semver would not: it drops + * prereleases of a higher patch tuple.) */ export const nitroConfig = [ registrationOnly({ name: 'h3', versionRange: '>=2.0.0-0', filePath: 'dist/h3.mjs' }), - registrationOnly({ name: 'unstorage', versionRange: '>=2.0.0-0', filePath: 'dist/index.mjs' }), ] satisfies InstrumentationConfig[]; diff --git a/packages/server-utils/src/utils/moduleInjected.ts b/packages/server-utils/src/utils/moduleInjected.ts index 599d6292d825..9076e3bc39b3 100644 --- a/packages/server-utils/src/utils/moduleInjected.ts +++ b/packages/server-utils/src/utils/moduleInjected.ts @@ -38,7 +38,18 @@ export function orchestrionModuleInjected(moduleName: string, integrationFn?: () } if (integrationFn) { - (marker.integrations ??= new Map()).set(moduleName, integrationFn); + // A module can register several integrations (e.g. `h3` → span + Server-Timing). Keep a single + // factory for the common one-integration case (so an older reader that expects a plain factory + // stays compatible), and upgrade to a Set only once a second integration is added. + const integrations = (marker.integrations ??= new Map()); + const existing = integrations.get(moduleName); + if (existing instanceof Set) { + existing.add(integrationFn); + } else if (existing) { + integrations.set(moduleName, new Set([existing, integrationFn])); + } else { + integrations.set(moduleName, integrationFn); + } } getClient()?.emit('orchestrion.module-injected', moduleName); diff --git a/packages/server-utils/test/orchestrion/moduleInjectedTransform.test.ts b/packages/server-utils/test/orchestrion/moduleInjectedTransform.test.ts index 750ffeaa8d19..e570386fcdcb 100644 --- a/packages/server-utils/test/orchestrion/moduleInjectedTransform.test.ts +++ b/packages/server-utils/test/orchestrion/moduleInjectedTransform.test.ts @@ -7,7 +7,7 @@ import * as barrel from '../../src/index'; import { SENTRY_INSTRUMENTATIONS } from '../../src/orchestrion/config'; import { CHANNEL_INTEGRATION_DEFINITIONS, - subscriberExportForModule, + subscriberExportsForModule, } from '../../src/orchestrion/config/channel-integration-definitions'; import { moduleInjectedTransforms } from '../../src/orchestrion/bundler/moduleInjectedTransform'; import { orchestrionTransformOptions } from '../../src/orchestrion/bundler/options'; @@ -21,13 +21,15 @@ function makePackage(root: string, name: string, version: string, type?: 'module } describe('channel integration definitions', () => { - it('maps every module to a defined subscriber export', () => { - expect(subscriberExportForModule('mysql')).toBe('mysqlIntegration'); - expect(subscriberExportForModule('pg')).toBe('postgresIntegration'); - expect(subscriberExportForModule('pg-pool')).toBe('postgresIntegration'); - expect(subscriberExportForModule('@redis/client')).toBe('redisIntegration'); - expect(subscriberExportForModule('ioredis')).toBe('redisIntegration'); - expect(subscriberExportForModule('not-a-package')).toBeUndefined(); + it('maps every module to its defined subscriber exports', () => { + expect(subscriberExportsForModule('mysql')).toEqual(['mysqlIntegration']); + expect(subscriberExportsForModule('pg')).toEqual(['postgresIntegration']); + expect(subscriberExportsForModule('pg-pool')).toEqual(['postgresIntegration']); + expect(subscriberExportsForModule('@redis/client')).toEqual(['redisIntegration']); + expect(subscriberExportsForModule('ioredis')).toEqual(['redisIntegration']); + // A single module can map to several integrations. + expect(subscriberExportsForModule('h3')).toEqual(['nitroIntegration', 'nitroServerTimingIntegration']); + expect(subscriberExportsForModule('not-a-package')).toEqual([]); }); it('references only real named exports of @sentry/server-utils', () => { diff --git a/packages/server-utils/test/utils/moduleInjected.test.ts b/packages/server-utils/test/utils/moduleInjected.test.ts index 4e2b70bd59a9..83d22c969e47 100644 --- a/packages/server-utils/test/utils/moduleInjected.test.ts +++ b/packages/server-utils/test/utils/moduleInjected.test.ts @@ -41,6 +41,17 @@ describe('orchestrionModuleInjected', () => { expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.integrations?.get('mysql')).toBe(fn); }); + it('upgrades to a Set of factories when a module registers more than one integration', () => { + const fn1 = factory('Nitro'); + const fn2 = factory('NitroServerTiming'); + orchestrionModuleInjected('h3', fn1); + // A single integration is stored as a plain factory (kept compatible with older readers). + expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.integrations?.get('h3')).toBe(fn1); + orchestrionModuleInjected('h3', fn2); + // A second integration for the same module upgrades the entry to a Set of both. + expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.integrations?.get('h3')).toEqual(new Set([fn1, fn2])); + }); + it('stores no factory when none is given', () => { orchestrionModuleInjected('mongodb'); expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.integrations).toBeUndefined(); From d15ba9c01c4ab367724a061f5f6e48e90933bf3f Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Wed, 30 Sep 2026 15:53:34 +0200 Subject: [PATCH 2/6] test(cloudflare): Cover the module-injected listener with multiple factories The prior test only exercised the init-time path with a Set of factories. Add a test that emits `orchestrion.module-injected` for a module holding a Set, so the late-registration listener's Set-unwrapping is covered with more than one factory too. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/cloudflare/test/sdk.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/cloudflare/test/sdk.test.ts b/packages/cloudflare/test/sdk.test.ts index 0ec010e855c1..d19bcec07aa1 100644 --- a/packages/cloudflare/test/sdk.test.ts +++ b/packages/cloudflare/test/sdk.test.ts @@ -418,6 +418,25 @@ describe('getDefaultIntegrations', () => { expect(client?.getIntegrationByName('Mysql')).toBeDefined(); }); + test('installs all integrations for a module registered after init via the module-injected event', async () => { + const { mysqlIntegration, lruMemoizerIntegration } = await import('@sentry/server-utils'); + // `cacheClient: false` so this gets its own client rather than one a previous `init()` cached. + const client = init({ cacheClient: false }); + expect(client?.getIntegrationByName('Mysql')).toBeUndefined(); + expect(client?.getIntegrationByName('LruMemoizer')).toBeUndefined(); + + // A module registering more than one integration (like `h3` → span + Server-Timing) evaluates + // after init: store the Set of factories on the marker, then emit the event. + globalThis.__SENTRY_ORCHESTRION__ = { + bundler: new Set(['some-module']), + integrations: new Map([['some-module', new Set([mysqlIntegration, lruMemoizerIntegration])]]), + }; + client?.emit('orchestrion.module-injected', 'some-module'); + + expect(client?.getIntegrationByName('Mysql')).toBeDefined(); + expect(client?.getIntegrationByName('LruMemoizer')).toBeDefined(); + }); + test('installs all integrations a single module registers', async () => { const { mysqlIntegration, lruMemoizerIntegration } = await import('@sentry/server-utils'); // One module registering more than one integration (like `h3` → span + Server-Timing). From 800b14e129302eb30d0a5687d1da0bb915bcf6a6 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Thu, 1 Oct 2026 10:27:07 +0200 Subject: [PATCH 3/6] fix(server-utils): Register a module's integration factories in one call Emitting one `orchestrionModuleInjected` call per export made the helper merge factories across calls, but every instrumented file of a package injects its own fresh thunk for the same integration. Packages split across files (pg, amqplib, ...) therefore accumulated duplicate factories and were upgraded to a Set despite having a single integration, breaking the plain-factory shape kept for older readers. The snippet now passes all of a module's factories in a single call, and the helper treats each call as the module's complete list and replaces the entry: one factory stays a plain factory, several become a Set. This also announces `h3` once per file instead of once per integration. Adds a transform test for the multi-export `h3` injection. Co-Authored-By: Claude Opus 5.5 --- .../bundler/moduleInjectedTransform.ts | 18 +++++-------- .../server-utils/src/utils/moduleInjected.ts | 24 +++++++----------- .../moduleInjectedTransform.test.ts | 17 +++++++++++++ .../test/utils/moduleInjected.test.ts | 25 ++++++++++++++----- 4 files changed, 51 insertions(+), 33 deletions(-) diff --git a/packages/server-utils/src/orchestrion/bundler/moduleInjectedTransform.ts b/packages/server-utils/src/orchestrion/bundler/moduleInjectedTransform.ts index a4370ece84d7..ebed1c347ad8 100644 --- a/packages/server-utils/src/orchestrion/bundler/moduleInjectedTransform.ts +++ b/packages/server-utils/src/orchestrion/bundler/moduleInjectedTransform.ts @@ -76,23 +76,17 @@ function moduleInjectedSnippet( ? `import { ${bindings} } from ${JSON.stringify(importSpecifier)};` : `const { ${bindings} } = require(${JSON.stringify(importSpecifier)});`; - // A module can register several integrations (e.g. `h3` → `nitroIntegration` and - // `nitroServerTimingIntegration`), so emit one call per export. Each `exportName` is itself an - // integration factory, invoked inside the arrow (`() => exportName()`), so - // `orchestrionModuleInjected`'s consumer still gets an Integration back from calling the stored + // Each `exportName` is itself an integration factory, invoked inside an arrow (`() => exportName()`), + // so `orchestrionModuleInjected`'s consumer still gets an Integration back from calling the stored // thunk. The point of the arrow is to defer the read of the `exportName` binding to when that thunk // runs (at `init()`), instead of reading it by reference the moment this snippet evaluates. A // provided-module integration (e.g. `flueIntegration`) is imported back into its own instrumented // package by `@sentry/*/vite`, so a direct reference here would close an import cycle and touch the // binding in its TDZ ("Cannot access '…' before initialization"); deferring the read breaks that. - const calls = exportNames.length - ? exportNames - .map( - exportName => - `${MODULE_INJECTED_SINK} = orchestrionModuleInjected(${JSON.stringify(moduleName)}, () => ${exportName}());`, - ) - .join('\n') - : `${MODULE_INJECTED_SINK} = orchestrionModuleInjected(${JSON.stringify(moduleName)});`; + // A module with several integrations (e.g. `h3`) passes them all in one call, since the helper + // treats each call as the module's complete list. + const args = [JSON.stringify(moduleName), ...exportNames.map(exportName => `() => ${exportName}()`)].join(', '); + const calls = `${MODULE_INJECTED_SINK} = orchestrionModuleInjected(${args});`; return `${importStmt}\n${calls}`; } diff --git a/packages/server-utils/src/utils/moduleInjected.ts b/packages/server-utils/src/utils/moduleInjected.ts index 9076e3bc39b3..47e847975006 100644 --- a/packages/server-utils/src/utils/moduleInjected.ts +++ b/packages/server-utils/src/utils/moduleInjected.ts @@ -14,7 +14,7 @@ import { getClient, GLOBAL_OBJ } from '@sentry/core'; * imports it from the same entry as the subscriber factories. * * It records the module name on the global orchestrion marker, stores the - * module's channel-subscriber integration factory (when the module has one) + * module's channel-subscriber integration factories (when the module has any) * keyed by module name, and emits the `orchestrion.module-injected` client * event. Recording happens BEFORE the emit so listeners triggered by the event * can read the marker. @@ -28,7 +28,7 @@ import { getClient, GLOBAL_OBJ } from '@sentry/core'; * that evaluate later (e.g. a lazily-required driver after a per-request * `init()` already snapshotted the marker). */ -export function orchestrionModuleInjected(moduleName: string, integrationFn?: () => Integration): void { +export function orchestrionModuleInjected(moduleName: string, ...integrationFns: Array<() => Integration>): void { const marker = (GLOBAL_OBJ.__SENTRY_ORCHESTRION__ ??= {}); // Runtime guard, not just type narrowing: a banner from another SDK copy or @@ -37,19 +37,13 @@ export function orchestrionModuleInjected(moduleName: string, integrationFn?: () (marker.bundler ??= new Set()).add(moduleName); } - if (integrationFn) { - // A module can register several integrations (e.g. `h3` → span + Server-Timing). Keep a single - // factory for the common one-integration case (so an older reader that expects a plain factory - // stays compatible), and upgrade to a Set only once a second integration is added. - const integrations = (marker.integrations ??= new Map()); - const existing = integrations.get(moduleName); - if (existing instanceof Set) { - existing.add(integrationFn); - } else if (existing) { - integrations.set(moduleName, new Set([existing, integrationFn])); - } else { - integrations.set(moduleName, integrationFn); - } + // Each call carries the module's complete factory list, so it replaces rather than merges: every + // instrumented file of a package (e.g. pg, amqplib) passes fresh thunks for the same integrations, + // which identity can't dedupe. A single factory keeps the plain shape older readers expect; only a + // module with several integrations (e.g. `h3` → span + Server-Timing) is stored as a Set. + const [first, ...rest] = integrationFns; + if (first) { + (marker.integrations ??= new Map()).set(moduleName, rest.length ? new Set(integrationFns) : first); } getClient()?.emit('orchestrion.module-injected', moduleName); diff --git a/packages/server-utils/test/orchestrion/moduleInjectedTransform.test.ts b/packages/server-utils/test/orchestrion/moduleInjectedTransform.test.ts index e570386fcdcb..bc839fe16b64 100644 --- a/packages/server-utils/test/orchestrion/moduleInjectedTransform.test.ts +++ b/packages/server-utils/test/orchestrion/moduleInjectedTransform.test.ts @@ -62,6 +62,7 @@ describe('module-injected transform', () => { makePackage(root, 'my-lib', '1.0.0', 'commonjs'); makePackage(root, 'ioredis', '5.11.0', 'commonjs', 'built'); makePackage(root, 'ai', '7.0.0', 'module', 'dist'); + makePackage(root, 'h3', '2.0.1-rc.1', 'module', 'dist'); }); afterAll(() => { @@ -180,6 +181,22 @@ describe('module-injected transform', () => { expect(result!.code).not.toContain('tr_ch_apm'); }); + it('registers every factory of a multi-integration module in a single call', () => { + const t = createCodeTransformer(orchestrionTransformOptions({})); + const result = t.transform('export function H3() {}\n', join(root, 'node_modules/h3/dist/h3.mjs')); + + expect(result).not.toBeNull(); + expect(result!.code).toMatch( + /import\s*\{\s*orchestrionModuleInjected,\s*nitroIntegration,\s*nitroServerTimingIntegration\s*\}\s*from\s*["']@sentry\/server-utils["']/, + ); + // One call with the module's complete list, so the module is announced once and the helper + // can replace its entry instead of accumulating thunks across files. + expect(result!.code).toContain( + 'orchestrionModuleInjected("h3", () => nitroIntegration(), () => nitroServerTimingIntegration())', + ); + expect(result!.code.match(/orchestrionModuleInjected\(/g)).toHaveLength(1); + }); + it('honors a custom import specifier (Turbopack passes an absolute path)', () => { const t = createCodeTransformer({ ...orchestrionTransformOptions({}), diff --git a/packages/server-utils/test/utils/moduleInjected.test.ts b/packages/server-utils/test/utils/moduleInjected.test.ts index 83d22c969e47..bf71880223df 100644 --- a/packages/server-utils/test/utils/moduleInjected.test.ts +++ b/packages/server-utils/test/utils/moduleInjected.test.ts @@ -41,14 +41,27 @@ describe('orchestrionModuleInjected', () => { expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.integrations?.get('mysql')).toBe(fn); }); - it('upgrades to a Set of factories when a module registers more than one integration', () => { + it('stores a Set when a module registers several integrations in one call', () => { const fn1 = factory('Nitro'); const fn2 = factory('NitroServerTiming'); - orchestrionModuleInjected('h3', fn1); - // A single integration is stored as a plain factory (kept compatible with older readers). - expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.integrations?.get('h3')).toBe(fn1); - orchestrionModuleInjected('h3', fn2); - // A second integration for the same module upgrades the entry to a Set of both. + orchestrionModuleInjected('h3', fn1, fn2); + expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.integrations?.get('h3')).toEqual(new Set([fn1, fn2])); + }); + + it('keeps a single factory when several files of one package register the same integration', () => { + // Each instrumented file of a package injects its own thunk for the same integration. + const fromClient = factory('Postgres'); + const fromPool = factory('Postgres'); + orchestrionModuleInjected('pg', fromClient); + orchestrionModuleInjected('pg', fromPool); + expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.integrations?.get('pg')).toBe(fromPool); + }); + + it('does not accumulate factories when several files register a multi-integration module', () => { + orchestrionModuleInjected('h3', factory('Nitro'), factory('NitroServerTiming')); + const fn1 = factory('Nitro'); + const fn2 = factory('NitroServerTiming'); + orchestrionModuleInjected('h3', fn1, fn2); expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.integrations?.get('h3')).toEqual(new Set([fn1, fn2])); }); From 84ebaa3d04846d0f3a7593dacc196d8fb94fe4d1 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Wed, 14 Oct 2026 08:45:37 +0200 Subject: [PATCH 4/6] ref(server-utils): Store a module's integration factories as an array Writer/reader version skew is already unsupported (`@sentry/cloudflare` pins `@sentry/server-utils` exactly), and the dual factory | Set shape did not keep older readers working for `h3` anyway. Always store the module's factory list as an array so the readers no longer need to branch on the shape. Co-Authored-By: Claude Opus 5.5 --- packages/cloudflare/src/baseSdk.ts | 12 ++++-------- packages/cloudflare/test/sdk.test.ts | 14 +++++++------- packages/core/src/utils/worldwide.ts | 14 ++++++-------- packages/server-utils/src/utils/moduleInjected.ts | 8 +++----- .../server-utils/test/utils/moduleInjected.test.ts | 10 +++++----- 5 files changed, 25 insertions(+), 33 deletions(-) diff --git a/packages/cloudflare/src/baseSdk.ts b/packages/cloudflare/src/baseSdk.ts index b2eb5053d9ab..bddba5e2a643 100644 --- a/packages/cloudflare/src/baseSdk.ts +++ b/packages/cloudflare/src/baseSdk.ts @@ -27,8 +27,8 @@ import { defaultStackParser } from './vendor/stacktrace'; /** * Instantiate the channel-subscriber factories the `@sentry/cloudflare/vite` * plugin registered on the global marker. The plugin splices a small snippet - * into each instrumented module that `.set`s its factory here (keyed by module - * name), so the marker holds one factory per package actually bundled. + * into each instrumented module that `.set`s its factories here (keyed by module + * name), so the marker holds one entry per package actually bundled. * * The marker is read directly instead of importing the factories, so a worker * built without the plugin — where the channels never fire — ships none of this @@ -37,10 +37,7 @@ import { defaultStackParser } from './vendor/stacktrace'; function getRegisteredChannelIntegrations(): Integration[] { const registered = GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.integrations; - // A module holds a single factory, or a Set of them (once it registers more than one integration). - return registered - ? [...registered.values()].flatMap(entry => (entry instanceof Set ? [...entry] : [entry])).map(factory => factory()) - : []; + return registered ? [...registered.values()].flat().map(factory => factory()) : []; } /** @@ -159,8 +156,7 @@ export function initWithDefaultIntegrations( // integration on the live client here. `addIntegration` dedupes by // integration name, so already-installed integrations are no-ops. client.on('orchestrion.module-injected', moduleName => { - const entry = GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.integrations?.get(moduleName); - const factories = entry instanceof Set ? entry : entry ? [entry] : []; + const factories = GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.integrations?.get(moduleName) ?? []; factories.forEach(factory => client.addIntegration(factory())); }); diff --git a/packages/cloudflare/test/sdk.test.ts b/packages/cloudflare/test/sdk.test.ts index d19bcec07aa1..4f3fa9e0a394 100644 --- a/packages/cloudflare/test/sdk.test.ts +++ b/packages/cloudflare/test/sdk.test.ts @@ -388,9 +388,9 @@ describe('getDefaultIntegrations', () => { globalThis.__SENTRY_ORCHESTRION__ = { bundler: new Set(['mysql', 'pg', 'lru-memoizer']), integrations: new Map([ - ['mysql', mysqlIntegration], - ['pg', postgresIntegration], - ['lru-memoizer', lruMemoizerIntegration], + ['mysql', [mysqlIntegration]], + ['pg', [postgresIntegration]], + ['lru-memoizer', [lruMemoizerIntegration]], ]), }; @@ -411,7 +411,7 @@ describe('getDefaultIntegrations', () => { // after init: store the factory on the marker, then emit the event. globalThis.__SENTRY_ORCHESTRION__ = { bundler: new Set(['mysql']), - integrations: new Map([['mysql', mysqlIntegration]]), + integrations: new Map([['mysql', [mysqlIntegration]]]), }; client?.emit('orchestrion.module-injected', 'mysql'); @@ -426,10 +426,10 @@ describe('getDefaultIntegrations', () => { expect(client?.getIntegrationByName('LruMemoizer')).toBeUndefined(); // A module registering more than one integration (like `h3` → span + Server-Timing) evaluates - // after init: store the Set of factories on the marker, then emit the event. + // after init: store its factories on the marker, then emit the event. globalThis.__SENTRY_ORCHESTRION__ = { bundler: new Set(['some-module']), - integrations: new Map([['some-module', new Set([mysqlIntegration, lruMemoizerIntegration])]]), + integrations: new Map([['some-module', [mysqlIntegration, lruMemoizerIntegration]]]), }; client?.emit('orchestrion.module-injected', 'some-module'); @@ -442,7 +442,7 @@ describe('getDefaultIntegrations', () => { // One module registering more than one integration (like `h3` → span + Server-Timing). globalThis.__SENTRY_ORCHESTRION__ = { bundler: new Set(['some-module']), - integrations: new Map([['some-module', new Set([mysqlIntegration, lruMemoizerIntegration])]]), + integrations: new Map([['some-module', [mysqlIntegration, lruMemoizerIntegration]]]), }; const names = getDefaultIntegrations({}).map(i => i.name); diff --git a/packages/core/src/utils/worldwide.ts b/packages/core/src/utils/worldwide.ts index 074a34dd11c5..9ae6df2c516a 100644 --- a/packages/core/src/utils/worldwide.ts +++ b/packages/core/src/utils/worldwide.ts @@ -80,15 +80,13 @@ export type InternalGlobal = { /** * Channel-subscriber integration factories stored by the snippet the * bundler transform splices into each instrumented module, keyed by module - * name. A module holds a single factory, or a Set of them once it registers - * more than one integration (e.g. `h3` → span + Server-Timing) — the single - * shape is kept for the common case so an older reader stays compatible. A - * factory shared by several packages (e.g. pg/pg-pool) appears under several - * keys; integration-name deduplication collapses them at setup. A - * bundler-only SDK (e.g. `@sentry/cloudflare`) reads these at `init()` and - * instantiates them. + * name. A module may register several integrations (e.g. `h3` → span + + * Server-Timing). A factory shared by several packages (e.g. pg/pg-pool) + * appears under several keys; integration-name deduplication collapses them + * at setup. A bundler-only SDK (e.g. `@sentry/cloudflare`) reads these at + * `init()` and instantiates them. */ - integrations?: Map Integration) | Set<() => Integration>>; + integrations?: Map Integration>>; /** * Set once `registerDiagnosticsChannelInjection()` has run but could not * install the runtime module hooks — most commonly because diff --git a/packages/server-utils/src/utils/moduleInjected.ts b/packages/server-utils/src/utils/moduleInjected.ts index 47e847975006..2eb4483690b7 100644 --- a/packages/server-utils/src/utils/moduleInjected.ts +++ b/packages/server-utils/src/utils/moduleInjected.ts @@ -39,11 +39,9 @@ export function orchestrionModuleInjected(moduleName: string, ...integrationFns: // Each call carries the module's complete factory list, so it replaces rather than merges: every // instrumented file of a package (e.g. pg, amqplib) passes fresh thunks for the same integrations, - // which identity can't dedupe. A single factory keeps the plain shape older readers expect; only a - // module with several integrations (e.g. `h3` → span + Server-Timing) is stored as a Set. - const [first, ...rest] = integrationFns; - if (first) { - (marker.integrations ??= new Map()).set(moduleName, rest.length ? new Set(integrationFns) : first); + // which identity can't dedupe. + if (integrationFns.length) { + (marker.integrations ??= new Map()).set(moduleName, integrationFns); } getClient()?.emit('orchestrion.module-injected', moduleName); diff --git a/packages/server-utils/test/utils/moduleInjected.test.ts b/packages/server-utils/test/utils/moduleInjected.test.ts index bf71880223df..e6a7bf90f797 100644 --- a/packages/server-utils/test/utils/moduleInjected.test.ts +++ b/packages/server-utils/test/utils/moduleInjected.test.ts @@ -38,14 +38,14 @@ describe('orchestrionModuleInjected', () => { it('stores the factory on the global marker keyed by module name', () => { const fn = factory('Mysql'); orchestrionModuleInjected('mysql', fn); - expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.integrations?.get('mysql')).toBe(fn); + expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.integrations?.get('mysql')).toEqual([fn]); }); - it('stores a Set when a module registers several integrations in one call', () => { + it('stores all factories a module registers in one call', () => { const fn1 = factory('Nitro'); const fn2 = factory('NitroServerTiming'); orchestrionModuleInjected('h3', fn1, fn2); - expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.integrations?.get('h3')).toEqual(new Set([fn1, fn2])); + expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.integrations?.get('h3')).toEqual([fn1, fn2]); }); it('keeps a single factory when several files of one package register the same integration', () => { @@ -54,7 +54,7 @@ describe('orchestrionModuleInjected', () => { const fromPool = factory('Postgres'); orchestrionModuleInjected('pg', fromClient); orchestrionModuleInjected('pg', fromPool); - expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.integrations?.get('pg')).toBe(fromPool); + expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.integrations?.get('pg')).toEqual([fromPool]); }); it('does not accumulate factories when several files register a multi-integration module', () => { @@ -62,7 +62,7 @@ describe('orchestrionModuleInjected', () => { const fn1 = factory('Nitro'); const fn2 = factory('NitroServerTiming'); orchestrionModuleInjected('h3', fn1, fn2); - expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.integrations?.get('h3')).toEqual(new Set([fn1, fn2])); + expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.integrations?.get('h3')).toEqual([fn1, fn2]); }); it('stores no factory when none is given', () => { From 04f19be36ea1823ebab23442e62f4bf82076c1b2 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Wed, 14 Oct 2026 08:47:58 +0200 Subject: [PATCH 5/6] docs(server-utils): Describe the single registration call in the module-injected snippet Co-Authored-By: Claude Opus 5.5 --- .../bundler/moduleInjectedTransform.ts | 29 ++++++++++--------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/packages/server-utils/src/orchestrion/bundler/moduleInjectedTransform.ts b/packages/server-utils/src/orchestrion/bundler/moduleInjectedTransform.ts index ebed1c347ad8..9eb991e62051 100644 --- a/packages/server-utils/src/orchestrion/bundler/moduleInjectedTransform.ts +++ b/packages/server-utils/src/orchestrion/bundler/moduleInjectedTransform.ts @@ -14,9 +14,9 @@ interface ProgramNode { } // Where the injected snippet imports from: the `orchestrionModuleInjected` -// helper and the module's channel-subscriber factory both live on the main -// `@sentry/server-utils` entry, so a single import serves both. The ESM barrel -// tree-shakes to just the helper and the factories actually referenced. +// helper and the module's channel-subscriber factories all live on the main +// `@sentry/server-utils` entry, so a single import serves them all. The ESM +// barrel tree-shakes to just the helper and the factories actually referenced. const DEFAULT_IMPORT_SPECIFIER = '@sentry/server-utils'; /** @@ -40,18 +40,19 @@ export const ORCHESTRION_BUNDLER_MARKER_BANNER = /** * Snippet injected into each instrumented module. It imports the * `orchestrionModuleInjected` helper — plus the module's channel-subscriber - * factory, when it has one — from `@sentry/server-utils` and calls the helper - * with the module's real name. The helper records the module on the global - * marker, stores the factory, and emits the `orchestrion.module-injected` client - * event, so it runs exactly when the module is evaluated — the moment its - * channels can start publishing. That per-module timing is what keeps - * subscriptions lazy (Node caps diagnostics channels in use at 1024). + * factories, when it has any — from `@sentry/server-utils` and calls the helper + * once with the module's real name and all of its factories. The helper records + * the module on the global marker, stores the factories, and emits the + * `orchestrion.module-injected` client event, so it runs exactly when the + * module is evaluated — the moment its channels can start publishing. That + * per-module timing is what keeps subscriptions lazy (Node caps diagnostics + * channels in use at 1024). * - * Importing the single named factory (rather than a central dispatch that pulls - * in every subscriber) is what makes this tree-shake: a bundle carries only the - * subscriber code for packages actually transformed into it. The helper is - * generic (references no factory), so importing it alongside doesn't pull - * siblings. + * Importing only the module's named factories (rather than a central dispatch + * that pulls in every subscriber) is what makes this tree-shake: a bundle + * carries only the subscriber code for packages actually transformed into it. + * The helper is generic (references no factory), so importing it alongside + * doesn't pull siblings. * * The specifier is parameterized for bundlers where the bare import emitted * inside a transformed `node_modules` file can't resolve (Turbopack under From 9db5e4ea2de84943d623607bded1345d5196a3f6 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Wed, 14 Oct 2026 08:53:44 +0200 Subject: [PATCH 6/6] Apply suggestion from @isaacs Co-authored-by: isaacs --- .../src/orchestrion/bundler/moduleInjectedTransform.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/server-utils/src/orchestrion/bundler/moduleInjectedTransform.ts b/packages/server-utils/src/orchestrion/bundler/moduleInjectedTransform.ts index 9eb991e62051..5c43d7fe364b 100644 --- a/packages/server-utils/src/orchestrion/bundler/moduleInjectedTransform.ts +++ b/packages/server-utils/src/orchestrion/bundler/moduleInjectedTransform.ts @@ -87,8 +87,7 @@ function moduleInjectedSnippet( // A module with several integrations (e.g. `h3`) passes them all in one call, since the helper // treats each call as the module's complete list. const args = [JSON.stringify(moduleName), ...exportNames.map(exportName => `() => ${exportName}()`)].join(', '); - const calls = `${MODULE_INJECTED_SINK} = orchestrionModuleInjected(${args});`; - return `${importStmt}\n${calls}`; + return `${importStmt}\n${MODULE_INJECTED_SINK} = orchestrionModuleInjected(${args});`; } /**