diff --git a/packages/cloudflare/src/baseSdk.ts b/packages/cloudflare/src/baseSdk.ts index 0375e7f94601..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,7 +37,7 @@ import { defaultStackParser } from './vendor/stacktrace'; function getRegisteredChannelIntegrations(): Integration[] { const registered = GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.integrations; - return registered ? [...registered.values()].map(factory => factory()) : []; + return registered ? [...registered.values()].flat().map(factory => factory()) : []; } /** @@ -156,10 +156,8 @@ 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 factories = GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.integrations?.get(moduleName) ?? []; + 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..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,10 +411,43 @@ 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'); 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 its factories on the marker, then emit the event. + globalThis.__SENTRY_ORCHESTRION__ = { + bundler: new Set(['some-module']), + integrations: new Map([['some-module', [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). + globalThis.__SENTRY_ORCHESTRION__ = { + bundler: new Set(['some-module']), + integrations: new Map([['some-module', [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..9ae6df2c516a 100644 --- a/packages/core/src/utils/worldwide.ts +++ b/packages/core/src/utils/worldwide.ts @@ -80,12 +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 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 + * 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>; + 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/orchestrion/bundler/moduleInjectedTransform.ts b/packages/server-utils/src/orchestrion/bundler/moduleInjectedTransform.ts index 737d9be98f40..5c43d7fe364b 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 @@ -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 @@ -67,23 +68,25 @@ 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 - // `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 args = exportName ? `${JSON.stringify(moduleName)}, () => ${exportName}()` : 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(', '); return `${importStmt}\n${MODULE_INJECTED_SINK} = orchestrionModuleInjected(${args});`; } @@ -133,8 +136,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..2eb4483690b7 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,8 +37,11 @@ export function orchestrionModuleInjected(moduleName: string, integrationFn?: () (marker.bundler ??= new Set()).add(moduleName); } - if (integrationFn) { - (marker.integrations ??= new Map()).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. + if (integrationFns.length) { + (marker.integrations ??= new Map()).set(moduleName, integrationFns); } 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..bc839fe16b64 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', () => { @@ -60,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(() => { @@ -178,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 4e2b70bd59a9..e6a7bf90f797 100644 --- a/packages/server-utils/test/utils/moduleInjected.test.ts +++ b/packages/server-utils/test/utils/moduleInjected.test.ts @@ -38,7 +38,31 @@ 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 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([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')).toEqual([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([fn1, fn2]); }); it('stores no factory when none is given', () => {