Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 5 additions & 7 deletions packages/cloudflare/src/baseSdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()) : [];
}

/**
Expand Down Expand Up @@ -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;
Expand Down
41 changes: 37 additions & 4 deletions packages/cloudflare/test/sdk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]],
]),
};

Expand All @@ -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');
});
Comment thread
cursor[bot] marked this conversation as resolved.
});
9 changes: 5 additions & 4 deletions packages/core/src/utils/worldwide.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, () => Integration>;
integrations?: Map<string, Array<() => Integration>>;
/**
* Set once `registerDiagnosticsChannelInjection()` has run but could not
* install the runtime module hooks — most commonly because
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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';

/**
Expand All @@ -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
Expand All @@ -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.
Comment thread
mydea marked this conversation as resolved.
const args = [JSON.stringify(moduleName), ...exportNames.map(exportName => `() => ${exportName}()`)].join(', ');
return `${importStmt}\n${MODULE_INJECTED_SINK} = orchestrionModuleInjected(${args});`;
}

Expand Down Expand Up @@ -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'];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
}
25 changes: 12 additions & 13 deletions packages/server-utils/src/orchestrion/config/nitro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }),
Comment thread
cursor[bot] marked this conversation as resolved.
] satisfies InstrumentationConfig[];
11 changes: 7 additions & 4 deletions packages/server-utils/src/utils/moduleInjected.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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);
Expand Down
Loading
Loading