From 996a0ff0d037bf0931e36320adbe374bb6f7d130 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Wed, 5 Aug 2026 23:32:26 +0100 Subject: [PATCH 1/4] ref(server-utils): Unify orchestrion injection into a single `tracingChannelImport` transform --- .../suites/orchestrion-mysql/scenario.ts | 2 +- .../suites/orchestrion-mysql/test.ts | 4 +- .../suites/orchestrion-postgres/scenario.ts | 2 +- .../suites/orchestrion-postgres/test.ts | 5 +- .../nuxt-4/tests/build-injection.test.ts | 3 + .../tests/performance/build-injection.test.ts | 3 + .../scenario-bundler.mjs | 35 +++-- .../orchestrion-lazy-registration/test.ts | 8 +- packages/astro/src/integration/index.ts | 17 +-- packages/astro/test/integration/index.test.ts | 15 +- packages/bun/package.json | 2 +- packages/bun/src/plugin.ts | 28 ++-- packages/cloudflare/src/baseSdk.ts | 19 ++- packages/cloudflare/src/vite/index.ts | 1 - packages/cloudflare/test/sdk.test.ts | 29 +++- packages/core/src/client.ts | 16 ++- packages/core/src/utils/worldwide.ts | 26 ++-- .../turbopack/constructTurbopackConfig.ts | 8 ++ .../constructTurbopackConfig.test.ts | 19 +++ packages/nuxt/src/vite/orchestrion.ts | 10 +- packages/nuxt/test/vite/orchestrion.test.ts | 6 +- packages/server-utils/package.json | 2 +- .../src/orchestrion/bundler/esbuild.ts | 25 ++++ .../bundler/moduleInjectedTransform.ts | 114 +++++++++++++++ .../src/orchestrion/bundler/options.ts | 83 +++-------- .../src/orchestrion/bundler/resolve.ts | 45 ++++++ .../src/orchestrion/bundler/rollup.ts | 16 +++ .../orchestrion/bundler/subscribeInjection.ts | 95 ------------- .../src/orchestrion/bundler/vite.ts | 16 +++ .../src/orchestrion/bundler/webpack-loader.ts | 113 +++++---------- .../src/orchestrion/bundler/webpack.ts | 93 +++++-------- .../config/channel-integration-definitions.ts | 7 +- .../src/orchestrion/config/index.ts | 5 + .../server-utils/src/orchestrion/detect.ts | 16 ++- .../server-utils/src/orchestrion/index.ts | 8 +- .../src/orchestrion/instrumentation.ts | 12 +- .../src/orchestrion/moduleInjected.ts | 43 ++++++ .../orchestrion/registerChannelIntegration.ts | 58 -------- .../src/orchestrion/runtime/register.ts | 15 +- .../test/orchestrion/bundler.test.ts | 65 +++++---- .../test/orchestrion/detect.test.ts | 10 +- .../test/orchestrion/instrumentation.test.ts | 2 +- .../test/orchestrion/moduleInjected.test.ts | 83 +++++++++++ ...est.ts => moduleInjectedTransform.test.ts} | 87 ++++++++---- .../registerChannelIntegration.test.ts | 69 ---------- .../test/orchestrion/webpack-loader.test.ts | 130 +++++++++--------- .../sveltekit/src/vite/sentryVitePlugins.ts | 2 - .../test/vite/sentrySvelteKitPlugins.test.ts | 21 +-- yarn.lock | 12 +- 49 files changed, 791 insertions(+), 714 deletions(-) create mode 100644 packages/server-utils/src/orchestrion/bundler/moduleInjectedTransform.ts create mode 100644 packages/server-utils/src/orchestrion/bundler/resolve.ts delete mode 100644 packages/server-utils/src/orchestrion/bundler/subscribeInjection.ts create mode 100644 packages/server-utils/src/orchestrion/moduleInjected.ts delete mode 100644 packages/server-utils/src/orchestrion/registerChannelIntegration.ts create mode 100644 packages/server-utils/test/orchestrion/moduleInjected.test.ts rename packages/server-utils/test/orchestrion/{subscribeInjection.test.ts => moduleInjectedTransform.test.ts} (54%) delete mode 100644 packages/server-utils/test/orchestrion/registerChannelIntegration.test.ts diff --git a/dev-packages/bun-integration-tests/suites/orchestrion-mysql/scenario.ts b/dev-packages/bun-integration-tests/suites/orchestrion-mysql/scenario.ts index 0afcd19579f0..cfefc47d9001 100644 --- a/dev-packages/bun-integration-tests/suites/orchestrion-mysql/scenario.ts +++ b/dev-packages/bun-integration-tests/suites/orchestrion-mysql/scenario.ts @@ -56,7 +56,7 @@ try { // ignore } -const marker = (globalThis as { __SENTRY_ORCHESTRION__?: { runtime?: boolean; bundler?: boolean } }) +const marker = (globalThis as { __SENTRY_ORCHESTRION__?: { runtime?: string[]; bundler?: string[] } }) .__SENTRY_ORCHESTRION__; setTimeout(() => { diff --git a/dev-packages/bun-integration-tests/suites/orchestrion-mysql/test.ts b/dev-packages/bun-integration-tests/suites/orchestrion-mysql/test.ts index d29903b76970..f391faf8e0f9 100644 --- a/dev-packages/bun-integration-tests/suites/orchestrion-mysql/test.ts +++ b/dev-packages/bun-integration-tests/suites/orchestrion-mysql/test.ts @@ -48,8 +48,8 @@ describe('orchestrion mysql instrumentation (Bun)', () => { expect(line).toContain('events=start'); // with the expected SQL expect(line).toContain('statement=SELECT 1 AS solution'); - // injected banner ran at bundle boot - expect(line).toContain('"bundler":true'); + // the transformed module's injected snippet recorded it on the marker + expect(line).toContain('"bundler":["mysql"]'); } finally { if (outfile) { rmSync(dirname(outfile), { recursive: true, force: true }); diff --git a/dev-packages/bun-integration-tests/suites/orchestrion-postgres/scenario.ts b/dev-packages/bun-integration-tests/suites/orchestrion-postgres/scenario.ts index 72a779d9cf90..58068545c98c 100644 --- a/dev-packages/bun-integration-tests/suites/orchestrion-postgres/scenario.ts +++ b/dev-packages/bun-integration-tests/suites/orchestrion-postgres/scenario.ts @@ -51,7 +51,7 @@ try { // `start` has already published synchronously by this point. } -const marker = (globalThis as { __SENTRY_ORCHESTRION__?: { runtime?: boolean; bundler?: boolean } }) +const marker = (globalThis as { __SENTRY_ORCHESTRION__?: { runtime?: string[]; bundler?: string[] } }) .__SENTRY_ORCHESTRION__; setTimeout(() => { diff --git a/dev-packages/bun-integration-tests/suites/orchestrion-postgres/test.ts b/dev-packages/bun-integration-tests/suites/orchestrion-postgres/test.ts index 89d6cfb49b19..c8e6f5ad6376 100644 --- a/dev-packages/bun-integration-tests/suites/orchestrion-postgres/test.ts +++ b/dev-packages/bun-integration-tests/suites/orchestrion-postgres/test.ts @@ -53,8 +53,9 @@ describe('orchestrion pg instrumentation (Bun)', () => { expect(line).toContain('events=start'); // with the expected SQL expect(line).toContain('statement=SELECT 1 AS solution'); - // injected banner ran at bundle boot - expect(line).toContain('"bundler":true'); + // the transformed module's injected snippet recorded it on the marker + // (pg-pool may be recorded alongside, in evaluation order) + expect(line).toMatch(/"bundler":\[[^\]]*"pg"/); } finally { if (outfile) { rmSync(dirname(outfile), { recursive: true, force: true }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/build-injection.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/build-injection.test.ts index 3cc7b960feee..796a7538c0ee 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/build-injection.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/build-injection.test.ts @@ -30,6 +30,9 @@ test.describe('Orchestrion build-time injection', () => { test('injects diagnostics-channel publishers into the server build', () => { expect(serverBundle).toContain('__SENTRY_ORCHESTRION__'); + // Each transformed module carries the module-injected snippet that records it + // on the global marker (and registers its subscriber factory) when evaluated. + expect(serverBundle).toContain('orchestrionModuleInjected'); expect(serverBundle).toMatch(/tracingChannel\(["']orchestrion:mysql:query["']\)/); expect(serverBundle).toMatch(/tracingChannel\(["']orchestrion:ioredis:command["']\)/); expect(serverBundle).toMatch(/tracingChannel\(["']orchestrion:ioredis:connect["']\)/); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/build-injection.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/build-injection.test.ts index d66ff8436650..00404f077bbf 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/build-injection.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/build-injection.test.ts @@ -21,6 +21,9 @@ test.describe('Orchestrion build-time injection', () => { test('injects diagnostics-channel publishers into the server build', () => { expect(serverBundle).toContain('__SENTRY_ORCHESTRION__'); + // Each transformed module carries the module-injected snippet that records it + // on the global marker (and registers its subscriber factory) when evaluated. + expect(serverBundle).toContain('orchestrionModuleInjected'); expect(serverBundle).toMatch(/tracingChannel\(["']orchestrion:mysql:query["']\)/); expect(serverBundle).toMatch(/tracingChannel\(["']orchestrion:ioredis:command["']\)/); expect(serverBundle).toMatch(/tracingChannel\(["']orchestrion:ioredis:connect["']\)/); diff --git a/dev-packages/node-integration-tests/suites/tracing/orchestrion-lazy-registration/scenario-bundler.mjs b/dev-packages/node-integration-tests/suites/tracing/orchestrion-lazy-registration/scenario-bundler.mjs index ff2e912ee0f2..5eca4672935a 100644 --- a/dev-packages/node-integration-tests/suites/tracing/orchestrion-lazy-registration/scenario-bundler.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/orchestrion-lazy-registration/scenario-bundler.mjs @@ -1,38 +1,37 @@ import { strict as assert } from 'node:assert'; import { tracingChannel } from 'node:diagnostics_channel'; +import { orchestrionModuleInjected } from '@sentry/server-utils/orchestrion'; // Reproduces the force-bundled path (vite SSR, nextjs's bundle-safe packages): // the module is transformed at BUILD time and inlined, so it is never loaded -// through the runtime module hook and its `orchestrion.module-runtime-injected` -// event never fires. Instead the bundler's `injectDiagnostics` boot banner sets -// `.bundler` and calls the on-inject bridge, which must trigger the lazy -// channel subscription. We simulate that banner here, WITHOUT ever importing -// generic-pool. +// through the runtime module hook. Instead, the bundler transform splices a +// snippet into the module that calls `orchestrionModuleInjected` when the +// module is evaluated, which must trigger the lazy channel subscription. We +// simulate that snippet here, WITHOUT ever importing generic-pool. const channel = tracingChannel('orchestrion:generic-pool:acquire'); -// `init()` (in instrument.mjs) installed the bridge and registered the lazy -// listener, but nothing is injected yet, so the channel has no subscriber. +// `init()` (in instrument.mjs) registered the lazy listener, but nothing is +// injected yet, so the channel has no subscriber. const marker = globalThis.__SENTRY_ORCHESTRION__; assert.ok(marker, 'expected __SENTRY_ORCHESTRION__ marker to exist after init'); -assert.equal(typeof marker.onInject, 'function', 'expected the on-inject bridge to be installed by init()'); assert.equal( channel.start.hasSubscribers, false, - 'expected NO subscribers before the bundler banner announces the module', + 'expected NO subscribers before the injected snippet announces the module', ); -// Simulate the bundler's `injectDiagnostics` boot banner: record the bundled -// module and fire the bridge for it. In a real build this runs when the app -// bundle boots, after `init()`. -marker.bundler = ['generic-pool']; -marker.onInject('generic-pool'); +// Simulate the snippet the bundler transform injected into generic-pool: in a +// real build this runs when the bundled module is first evaluated. +orchestrionModuleInjected('generic-pool'); -// The bridge re-emitted `orchestrion.module-runtime-injected`, so the -// GenericPool integration must have subscribed, even though generic-pool was -// never loaded through the module hook. +assert.ok(marker.bundler?.includes('generic-pool'), 'expected the module to be recorded as bundler-injected'); + +// The helper emitted `orchestrion.module-injected`, so the GenericPool +// integration must have subscribed, even though generic-pool was never loaded +// through the module hook. assert.equal( channel.start.hasSubscribers, true, - 'expected subscribers after the bundler banner fired the on-inject bridge', + 'expected subscribers after the injected snippet announced the module', ); diff --git a/dev-packages/node-integration-tests/suites/tracing/orchestrion-lazy-registration/test.ts b/dev-packages/node-integration-tests/suites/tracing/orchestrion-lazy-registration/test.ts index 2dea68ab1ce7..2a1fb02c8088 100644 --- a/dev-packages/node-integration-tests/suites/tracing/orchestrion-lazy-registration/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/orchestrion-lazy-registration/test.ts @@ -24,10 +24,10 @@ conditionalTest({ min: 20 })('orchestrion lazy channel registration', () => { // A force-bundled module (vite SSR / nextjs bundle-safe packages) is never // loaded through the runtime hook, so it can only trigger subscription via - // the bundler's boot banner → on-inject bridge. The scenario simulates that - // banner and asserts the channel subscribes without the module ever being - // loaded through the hook. - test('subscribes for a bundler-announced module via the on-inject bridge', async () => { + // the module-injected snippet the bundler transform splices into it. The + // scenario simulates that snippet and asserts the channel subscribes without + // the module ever being loaded through the hook. + test('subscribes for a bundler-announced module via the module-injected snippet', async () => { await createRunner(__dirname, 'scenario-bundler.mjs') .withInstrument(path.join(__dirname, 'instrument.mjs')) .ensureNoErrorOutput() diff --git a/packages/astro/src/integration/index.ts b/packages/astro/src/integration/index.ts index 752c4bf4f4b1..5e2f4a76f479 100644 --- a/packages/astro/src/integration/index.ts +++ b/packages/astro/src/integration/index.ts @@ -173,24 +173,15 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => { // Wire up the orchestrion code transform so instrumented server-side dependencies (e.g. // `mysql`, `ioredis`) get `diagnostics_channel` publishers injected into the SSR bundle at // build time, with no manual plugin setup. The plugin opts out internally when - // `buildTimeInstrumentation` is `false`. - if (sdkEnabled.server && !isCloudflare) { + // `buildTimeInstrumentation` is `false`. Cloudflare Pages is skipped: it gets no + // `withSentry` wrap, so nothing would read the marker the injected snippets write, and + // keeping the transform off avoids bundling dead subscriber code. + if (sdkEnabled.server && (!isCloudflare || isCloudflareWorkers)) { updateConfig({ vite: { plugins: [sentryOrchestrionPlugin({ buildTimeInstrumentation }) as VitePlugin], }, }); - } else if (sdkEnabled.server && isCloudflareWorkers) { - // On Cloudflare Workers, subscribers are wired via a build-time marker the SDK reads at - // runtime (through the `withSentry` wrap added below). Cloudflare Pages is skipped: it gets - // no `withSentry` wrap, so there'd be nothing to read the marker. - updateConfig({ - vite: { - plugins: [ - sentryOrchestrionPlugin({ buildTimeInstrumentation, injectChannelSubscribers: true }) as VitePlugin, - ], - }, - }); } if (isCloudflare) { diff --git a/packages/astro/test/integration/index.test.ts b/packages/astro/test/integration/index.test.ts index a6d76ee2f97d..992ec9832144 100644 --- a/packages/astro/test/integration/index.test.ts +++ b/packages/astro/test/integration/index.test.ts @@ -13,14 +13,11 @@ vi.mock('@sentry/bundler-plugins/vite', () => ({ // Stub the orchestrion plugin so these stay pure wiring tests (no apm code transformer pulled in). // Mirror the real plugin's contract: `buildTimeInstrumentation: false` yields the inert variant. -const orchestrionVite = vi.fn( - (options?: { buildTimeInstrumentation?: boolean; injectChannelSubscribers?: boolean }) => ({ - name: options?.buildTimeInstrumentation === false ? 'sentry-orchestrion-disabled' : 'sentry-orchestrion-vite', - }), -); +const orchestrionVite = vi.fn((options?: { buildTimeInstrumentation?: boolean }) => ({ + name: options?.buildTimeInstrumentation === false ? 'sentry-orchestrion-disabled' : 'sentry-orchestrion-vite', +})); vi.mock('@sentry/server-utils/orchestrion/vite', () => ({ - sentryOrchestrionPlugin: (options?: { buildTimeInstrumentation?: boolean; injectChannelSubscribers?: boolean }) => - orchestrionVite(options), + sentryOrchestrionPlugin: (options?: { buildTimeInstrumentation?: boolean }) => orchestrionVite(options), })); // The cloudflare adapter path resolves `@sentry/cloudflare` via `createRequire` and calls @@ -451,7 +448,7 @@ describe('sentryAstro integration', () => { }); }); - it('adds the orchestrion plugin with channel-subscriber injection for the cloudflare workers adapter', async () => { + it('adds the orchestrion plugin for the cloudflare workers adapter', async () => { const integration = sentryAstro({}); const cloudflareConfig = { ...config, adapter: { name: '@astrojs/cloudflare' } } as AstroConfig; @@ -466,7 +463,7 @@ describe('sentryAstro integration', () => { }); // No wrangler config with `pages_build_output_dir` is present, so this resolves as Workers. - expect(orchestrionVite).toHaveBeenCalledWith(expect.objectContaining({ injectChannelSubscribers: true })); + expect(orchestrionVite).toHaveBeenCalledWith({ buildTimeInstrumentation: undefined }); expect(updateConfig).toHaveBeenCalledWith({ vite: { plugins: [{ name: 'sentry-orchestrion-vite' }], diff --git a/packages/bun/package.json b/packages/bun/package.json index eaa6d93d8ee0..3d0435c4b25c 100644 --- a/packages/bun/package.json +++ b/packages/bun/package.json @@ -42,7 +42,7 @@ "access": "public" }, "dependencies": { - "@apm-js-collab/code-transformer-bundler-plugins": "^0.7.3", + "@apm-js-collab/code-transformer-bundler-plugins": "^0.7.4", "@sentry/core": "10.67.0", "@sentry/conventions": "^0.16.0", "@sentry/node": "10.67.0", diff --git a/packages/bun/src/plugin.ts b/packages/bun/src/plugin.ts index c0c9ac73fa79..a3ed11f4f72d 100644 --- a/packages/bun/src/plugin.ts +++ b/packages/bun/src/plugin.ts @@ -39,13 +39,12 @@ type UnknownPlugin = any; import codeTransformer from '@apm-js-collab/code-transformer-bundler-plugins/bun'; import { INSTRUMENTED_MODULE_NAMES, + moduleInjectedTransforms, + ORCHESTRION_BUNDLER_MARKER_BANNER, SENTRY_INSTRUMENTATIONS, withoutInstrumentedExternals, } from '@sentry/server-utils/orchestrion/config'; -const BUNDLER_MARKER_BANNER = - ';(globalThis.__SENTRY_ORCHESTRION__=(globalThis.__SENTRY_ORCHESTRION__||{})).bundler=true;'; - // Minimal shape of Bun's `PluginBuilder` that we touch. Typed locally instead // of depending on `bun-types`, which would pull Bun's globals. interface BunPluginBuilder { @@ -56,8 +55,11 @@ interface BunPluginBuilder { * Returns the orchestrion code-transform plugin for Bun's bundler, configured * with the central `SENTRY_INSTRUMENTATIONS`. The plugin injects * `diagnostics_channel.tracingChannel` calls into the instrumented libraries as - * `bun build` bundles them, and injects a banner that sets - * `globalThis.__SENTRY_ORCHESTRION__.bundler = true` when the bundle boots + * `bun build` bundles them — plus, via the module-injected transform, the + * snippet that records each module on `globalThis.__SENTRY_ORCHESTRION__` when + * it is evaluated — and injects the marker banner so `bundler` is set (to `[]`) + * from boot, which is what gates the SDK's channel-integration setup at + * `init()`. * * Pass the result to `Bun.build({ plugins: [...] })`. * @@ -72,19 +74,25 @@ export function sentryBunPlugin(): UnknownPlugin { // `PluginBuilder` (which has the `onLoad` the transform uses) to `setup`. // Cast to the Bun-compatible shape so we can forward Bun's builder to its // `setup`. - const transformer = codeTransformer({ instrumentations: SENTRY_INSTRUMENTATIONS }) as unknown as { + const transformer = codeTransformer({ + instrumentations: SENTRY_INSTRUMENTATIONS, + customTransforms: moduleInjectedTransforms(), + }) as unknown as { setup: (build: BunPluginBuilder) => void; }; return { name: 'sentry-orchestrion', setup(build: BunPluginBuilder): void { - // Inject a banner so the bundled output sets `bundler: true` at boot. - // `config` is the `Bun.build` config and is present when this plugin - // is passed to `Bun.build({ plugins: [...] })`. + // Inject the marker banner via Bun's native `banner` config (unlike the + // upstream `injectDiagnostics` path, it needs no `outdir`). `config` is + // the `Bun.build` config and is present when this plugin is passed to + // `Bun.build({ plugins: [...] })`. if (build.config) { const existing = build.config.banner ?? ''; - build.config.banner = existing ? `${existing}\n${BUNDLER_MARKER_BANNER}` : BUNDLER_MARKER_BANNER; + build.config.banner = existing + ? `${existing}\n${ORCHESTRION_BUNDLER_MARKER_BANNER}` + : ORCHESTRION_BUNDLER_MARKER_BANNER; // Force-bundle every instrumented package. An externalized dependency // is resolved from `node_modules` at runtime and never passes throug diff --git a/packages/cloudflare/src/baseSdk.ts b/packages/cloudflare/src/baseSdk.ts index 19a900fb23d3..3fdf6958f112 100644 --- a/packages/cloudflare/src/baseSdk.ts +++ b/packages/cloudflare/src/baseSdk.ts @@ -25,7 +25,7 @@ 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 export + * into each instrumented module that `.set`s its factory here (keyed by module * name), so the marker holds one factory per package actually bundled. * * The marker is read directly instead of importing the factories, so a worker @@ -116,7 +116,22 @@ export function initWithDefaultIntegrations( setupOpenTelemetryTracer(); } - return initAndBind(CloudflareClient, clientOptions) as CloudflareClient; + const client = initAndBind(CloudflareClient, clientOptions) as CloudflareClient; + + // An instrumented module that first evaluates AFTER this init (e.g. a driver + // lazily required on first use) stores its subscriber factory on the global + // marker too late for the default-integrations snapshot above. Its injected + // snippet emits this event right after storing the factory, so install the + // 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()); + } + }); + + return client; } /** diff --git a/packages/cloudflare/src/vite/index.ts b/packages/cloudflare/src/vite/index.ts index 52ba8fcab5db..b87b5ebef763 100644 --- a/packages/cloudflare/src/vite/index.ts +++ b/packages/cloudflare/src/vite/index.ts @@ -80,7 +80,6 @@ export interface SentryCloudflareVitePluginOptions { export function sentryCloudflareVitePlugin(options: SentryCloudflareVitePluginOptions = {}): Array<{ name: string }> { return [ sentryOrchestrionPlugin({ - injectChannelSubscribers: true, buildTimeInstrumentation: options.buildTimeInstrumentation, }), ...(options._experimental?.autoInstrumentation diff --git a/packages/cloudflare/test/sdk.test.ts b/packages/cloudflare/test/sdk.test.ts index aebf1469f9f2..23f057d4eee3 100644 --- a/packages/cloudflare/test/sdk.test.ts +++ b/packages/cloudflare/test/sdk.test.ts @@ -76,7 +76,8 @@ describe('getDefaultIntegrations', () => { }); test('does not add orchestrion channel integrations when only the bundler marker is set', () => { - globalThis.__SENTRY_ORCHESTRION__ = { bundler: true }; + // The plugin's entry banner ran, but no instrumented module has loaded yet. + globalThis.__SENTRY_ORCHESTRION__ = { bundler: [] }; const names = getDefaultIntegrations({}).map(i => i.name); @@ -88,15 +89,15 @@ describe('getDefaultIntegrations', () => { test('adds orchestrion channel integrations registered on the marker by injected modules', async () => { // Mirror what the snippet the vite plugin injects into each instrumented // module does at runtime: import its factory and `.set` it on the marker map, - // keyed by export name (so a package split across files registers once). + // keyed by module name (so a package split across files registers once). const { mysqlIntegration, postgresIntegration, lruMemoizerIntegration } = await import('@sentry/server-utils/orchestrion'); globalThis.__SENTRY_ORCHESTRION__ = { - bundler: true, + bundler: ['mysql', 'pg', 'lru-memoizer'], integrations: new Map([ - ['mysqlIntegration', mysqlIntegration], - ['postgresIntegration', postgresIntegration], - ['lruMemoizerIntegration', lruMemoizerIntegration], + ['mysql', mysqlIntegration], + ['pg', postgresIntegration], + ['lru-memoizer', lruMemoizerIntegration], ]), }; @@ -106,4 +107,20 @@ describe('getDefaultIntegrations', () => { expect(names).toContain('Postgres'); expect(names).toContain('LruMemoizer'); }); + + test('installs an integration registered after init via the module-injected event', async () => { + const { mysqlIntegration } = await import('@sentry/server-utils/orchestrion'); + const client = init({}); + expect(client?.getIntegrationByName('Mysql')).toBeUndefined(); + + // Mirror `orchestrionModuleInjected` for a driver that first evaluates + // after init: store the factory on the marker, then emit the event. + globalThis.__SENTRY_ORCHESTRION__ = { + bundler: ['mysql'], + integrations: new Map([['mysql', mysqlIntegration]]), + }; + client?.emit('orchestrion.module-injected', 'mysql'); + + expect(client?.getIntegrationByName('Mysql')).toBeDefined(); + }); }); diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index f9a20a930705..778d03b7541c 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -959,14 +959,15 @@ export abstract class Client { public on(hook: 'stopUIProfiler', callback: () => void): () => void; /** - * A hook that is called when an orchestrion-instrumented module is injected at - * runtime (by the `--import` module hook). Channel-based integrations use it to - * subscribe their diagnostics-channel listeners lazily, only once the module - * they instrument is actually loaded. Receives the injected module name. + * A hook that is called when an orchestrion-instrumented module is injected — + * at runtime by the module hook, or at load of a bundler-transformed module. + * Channel-based integrations use it to subscribe their diagnostics-channel + * listeners lazily, only once the module they instrument is actually loaded. + * Receives the injected module name. * * @returns {() => void} A function that, when executed, removes the registered callback. */ - public on(hook: 'orchestrion.module-runtime-injected', callback: (moduleName: string) => void): () => void; + public on(hook: 'orchestrion.module-injected', callback: (moduleName: string) => void): () => void; /** * Register a hook on this client. @@ -1233,9 +1234,10 @@ export abstract class Client { public emit(hook: 'stopUIProfiler'): void; /** - * Emit a hook when an orchestrion-instrumented module is injected at runtime. + * Emit a hook when an orchestrion-instrumented module is injected (runtime + * module hook or bundler-transformed module load). */ - public emit(hook: 'orchestrion.module-runtime-injected', moduleName: string): void; + public emit(hook: 'orchestrion.module-injected', moduleName: string): void; /** * Emit a hook that was previously registered via `on()`. diff --git a/packages/core/src/utils/worldwide.ts b/packages/core/src/utils/worldwide.ts index 97baf61c23f9..e3cb23d5b4a0 100644 --- a/packages/core/src/utils/worldwide.ts +++ b/packages/core/src/utils/worldwide.ts @@ -61,26 +61,22 @@ export type InternalGlobal = { __SENTRY_ORCHESTRION__?: { /** Empty array signifies runtime hooked */ runtime?: string[]; - /** Empty array signifies bundler plugin ran */ + /** + * Module names recorded as each bundler-transformed module loads (the + * injected snippet calls `orchestrionModuleInjected`). The bundler plugin's + * entry banner ensures `[]` at boot, so a defined array — even empty — + * signifies the plugin ran. + */ bundler?: string[]; /** - * Channel-subscriber integration factories a bundler plugin's - * subscribe-injection stored here, keyed by export name (one per instrumented - * package actually bundled; the key dedupes packages split across several - * files). A bundler-only SDK (e.g. `@sentry/cloudflare`) reads these at + * 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. */ integrations?: Map Integration>; - /** - * Bridge installed at `init()` by `registerDiagnosticsChannelInjection`. - * The bundler's `injectDiagnostics` boot banner calls it for each - * transformed module, emitting the `orchestrion.module-runtime-injected` - * client event so channel integrations subscribe for force-bundled modules - * (which the runtime module hook never sees). Absent on bundler-only - * runtimes (e.g. `@sentry/cloudflare`), where the banner's call is a - * guarded no-op. - */ - onInject?: (moduleName: string) => void; }; } & Carrier; diff --git a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts index 60880163df74..78f5000dcb2d 100644 --- a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts +++ b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts @@ -3,6 +3,7 @@ import * as path from 'path'; import { getOrchestrionLoaderPath, getSentryInstrumentations, + resolveOrchestrionRuntimeRequest, serializeInstrumentations, } from '@sentry/server-utils/orchestrion/webpack'; import type { VercelCronsConfig } from '../../common/types'; @@ -138,6 +139,12 @@ function maybeAddOrchestrionRule( return rules; } + // The loader's transform splices an import of `@sentry/server-utils/orchestrion` into each + // instrumented module. Turbopack has no externals-function seam, and under isolated installs + // (pnpm) the bare specifier emitted inside a bundled package doesn't resolve from that + // package's location — so pass the helper's absolute on-disk path for the snippet to import. + const importSpecifier = resolveOrchestrionRuntimeRequest('@sentry/server-utils/orchestrion'); + return safelyAddTurbopackRule(rules, { matcher: '*.{js,mjs,cjs}', rule: { @@ -148,6 +155,7 @@ function maybeAddOrchestrionRule( // Turbopack JSON-serializes loader options, so a RegExp `filePath` must be encoded first. options: { instrumentations: serializeInstrumentations(getSentryInstrumentations()) as unknown as JSONValue[], + ...(importSpecifier ? { importSpecifier } : {}), }, }, ], diff --git a/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts b/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts index 9b4a6c6b1d66..c84141ca0413 100644 --- a/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts +++ b/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts @@ -1337,6 +1337,25 @@ describe('orchestrion build-time instrumentation', () => { expect(JSON.parse(JSON.stringify(firestore!.module.filePath))).not.toEqual({}); }); + it('passes the helper module as an absolute-path importSpecifier', () => { + const result = constructTurbopackConfig({ + userNextConfig: {}, + userSentryOptions: {}, + nextJsVersion: '16.0.0', + }); + + const rule = result.rules!['*.{js,mjs,cjs}'] as { + loaders: Array<{ options: { importSpecifier?: string } }>; + }; + const importSpecifier = rule.loaders[0]!.options.importSpecifier; + + // Turbopack has no externals-function seam: the snippet's import must be an + // absolute path so it resolves under isolated installs (pnpm). + expect(importSpecifier).toBeDefined(); + expect(path.isAbsolute(importSpecifier!)).toBe(true); + expect(importSpecifier).toContain('orchestrion'); + }); + it('restricts the orchestrion rule to the node environment', () => { const result = constructTurbopackConfig({ userNextConfig: {}, diff --git a/packages/nuxt/src/vite/orchestrion.ts b/packages/nuxt/src/vite/orchestrion.ts index 6cbeeff68e0d..5ed4d8d23df2 100644 --- a/packages/nuxt/src/vite/orchestrion.ts +++ b/packages/nuxt/src/vite/orchestrion.ts @@ -26,9 +26,9 @@ export function setupOrchestrion(nuxt: Nuxt, hasServerConfig: boolean, buildTime return; } - // On Cloudflare (workerd), subscribers are wired via a build-time marker that `@sentry/cloudflare` - // reads at runtime (through `sentryCloudflareNitroPlugin`); on Node they register at init. Nitro - // normalizes preset names, so match any `cloudflare*` spelling. + // On Cloudflare (workerd) the SDK is initialized through `sentryCloudflareNitroPlugin` (no + // server config file), so the transform must still run there — detected via the Nitro preset. + // Nitro normalizes preset names, so match any `cloudflare*` spelling. const isCloudflare = !!nitroConfig.preset?.replace(/-/g, '_').startsWith('cloudflare'); if (!hasServerConfig && !isCloudflare) { @@ -43,9 +43,7 @@ export function setupOrchestrion(nuxt: Nuxt, hasServerConfig: boolean, buildTime nitroConfig.rollupConfig.plugins = [nitroConfig.rollupConfig.plugins]; } - nitroConfig.rollupConfig.plugins.push( - sentryOrchestrionPlugin(isCloudflare ? { injectChannelSubscribers: true } : {}), - ); + nitroConfig.rollupConfig.plugins.push(sentryOrchestrionPlugin({})); const externals = (nitroConfig.externals ||= {}); const inline = externals.inline; diff --git a/packages/nuxt/test/vite/orchestrion.test.ts b/packages/nuxt/test/vite/orchestrion.test.ts index f2d314bcb720..2da6ec668be0 100644 --- a/packages/nuxt/test/vite/orchestrion.test.ts +++ b/packages/nuxt/test/vite/orchestrion.test.ts @@ -56,7 +56,7 @@ describe('setupOrchestrion', () => { expect(nitroConfig.externals.inline).toEqual(['ioredis', 'custom-dependency', 'mysql', 'standard-as-callback']); }); - it('injects channel subscribers on a Cloudflare preset even without a server config file', async () => { + it('adds the plugin on a Cloudflare preset even without a server config file', async () => { const { setupOrchestrion } = await import('../../src/vite/orchestrion'); const mockNuxt = createMockNuxt(); const nitroConfig = { preset: 'cloudflare_module' }; @@ -64,10 +64,10 @@ describe('setupOrchestrion', () => { setupOrchestrion(mockNuxt as unknown as Nuxt, false); await mockNuxt.triggerHook('nitro:config', nitroConfig); - expect(mockSentryOrchestrionPlugin).toHaveBeenCalledWith({ injectChannelSubscribers: true }); + expect(mockSentryOrchestrionPlugin).toHaveBeenCalledWith({}); }); - it('does not inject channel subscribers on a non-Cloudflare preset', async () => { + it('adds the plugin on a non-Cloudflare preset when a server config exists', async () => { const { setupOrchestrion } = await import('../../src/vite/orchestrion'); const mockNuxt = createMockNuxt(); const nitroConfig = { preset: 'node-server' }; diff --git a/packages/server-utils/package.json b/packages/server-utils/package.json index c327942b7bc6..7ac508f3e6b6 100644 --- a/packages/server-utils/package.json +++ b/packages/server-utils/package.json @@ -106,7 +106,7 @@ "@sentry/core": "10.67.0" }, "devDependencies": { - "@apm-js-collab/code-transformer-bundler-plugins": "^0.7.3", + "@apm-js-collab/code-transformer-bundler-plugins": "^0.7.4", "@apm-js-collab/tracing-hooks": "^0.13.0", "@types/node": "^18.19.1", "meriyah": "^6.1.4", diff --git a/packages/server-utils/src/orchestrion/bundler/esbuild.ts b/packages/server-utils/src/orchestrion/bundler/esbuild.ts index a11f1154a111..6a329554c8c8 100644 --- a/packages/server-utils/src/orchestrion/bundler/esbuild.ts +++ b/packages/server-utils/src/orchestrion/bundler/esbuild.ts @@ -4,6 +4,7 @@ import { escapeStringForRegex } from '@sentry/core'; import { instrumentedModuleNames } from '../config'; import type { PluginOptions } from './options'; import { externalEntryMatchesModule, externalizedModulesWarning, orchestrionTransformOptions } from './options'; +import { resolveOrchestrionRuntimeRequest } from './resolve'; // esbuild `external` entries may contain `*` wildcards. function matchesEsbuildExternal(entry: string, moduleName: string): boolean { @@ -49,6 +50,30 @@ export function sentryOrchestrionPlugin(options: PluginOptions = {}): Plugin { if (externalizedModules.length > 0) { build.onStart(() => ({ warnings: [{ text: externalizedModulesWarning(externalizedModules) }] })); } + + // The module-injected snippet imports `@sentry/server-utils/orchestrion` + // from INSIDE transformed `node_modules` files. Under isolated installs + // (pnpm) that bare specifier doesn't resolve from an instrumented + // package's location, so try esbuild's own resolution first (the + // `pluginData` marker stops the recursion back into this callback) and + // fall back to this package's own resolution. + build.onResolve({ filter: /^@sentry\/server-utils\/orchestrion$/ }, async args => { + if (args.pluginData === 'sentry-orchestrion-resolving') { + return null; + } + const result = await build.resolve(args.path, { + resolveDir: args.resolveDir, + importer: args.importer, + kind: args.kind, + pluginData: 'sentry-orchestrion-resolving', + }); + if (result.errors.length === 0) { + return result; + } + const fallback = resolveOrchestrionRuntimeRequest(args.path); + return fallback ? { path: fallback, errors: [] } : null; + }); + return setup(build); }, }; diff --git a/packages/server-utils/src/orchestrion/bundler/moduleInjectedTransform.ts b/packages/server-utils/src/orchestrion/bundler/moduleInjectedTransform.ts new file mode 100644 index 000000000000..48e6dc92f57c --- /dev/null +++ b/packages/server-utils/src/orchestrion/bundler/moduleInjectedTransform.ts @@ -0,0 +1,114 @@ +import type { CustomTransform } from '../apmTypes'; +import { parse } from 'meriyah'; +import { subscriberExportForModule } from '../config/channel-integration-definitions'; + +// Tracks Program nodes we already injected into, so a package with several +// instrumented files (or several configs pointing at one file) is injected only +// once per file. A `WeakSet` keyed by the node avoids mutating the emitted AST. +const injectedPrograms = new WeakSet(); + +interface ProgramNode { + type: string; + body: Array<{ type: string; directive?: string }>; +} + +const DEFAULT_IMPORT_SPECIFIER = '@sentry/server-utils/orchestrion'; + +/** + * Entry-chunk banner that marks "the bundler plugin ran" for + * `detectOrchestrionSetup()`. Merge-only (`g.bundler = g.bundler || []`) so it + * can never clobber module names already recorded by an injected snippet that + * happened to run first; the names themselves arrive per module, when each + * transformed module is evaluated and its snippet calls + * `orchestrionModuleInjected`. + */ +export const ORCHESTRION_BUNDLER_MARKER_BANNER = + ';(function(){var g=globalThis.__SENTRY_ORCHESTRION__=globalThis.__SENTRY_ORCHESTRION__||{};g.bundler=g.bundler||[];})();'; + +/** + * 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/orchestrion` 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). + * + * 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. + * + * The specifier is parameterized for bundlers where the bare import emitted + * inside a transformed `node_modules` file can't resolve (Turbopack under + * isolated installs); it's embedded via `JSON.stringify` so absolute Windows + * paths survive. + */ +function moduleInjectedSnippet( + moduleName: string, + exportName: string | undefined, + esm: boolean, + importSpecifier: string, +): string { + const bindings = exportName ? `orchestrionModuleInjected, ${exportName}` : 'orchestrionModuleInjected'; + const importStmt = esm + ? `import { ${bindings} } from ${JSON.stringify(importSpecifier)};` + : `const { ${bindings} } = require(${JSON.stringify(importSpecifier)});`; + + const args = exportName ? `${JSON.stringify(moduleName)}, ${exportName}` : JSON.stringify(moduleName); + return `${importStmt}\norchestrionModuleInjected(${args});`; +} + +/** + * The unified `customTransforms` every orchestrion bundler plugin (and the + * webpack/Turbopack loader) applies: an override for orchestrion's built-in + * `tracingChannelImport` transform, which runs (via `tracingChannelDeclaration`) + * for every file that gets a channel wrapped — the one hook that reaches every + * instrumented module without any extra instrumentation configs. It chains the + * default (which splices the `diagnostics_channel` import and bails when it is + * already present), then splices the module-injected snippet in after any + * `'use strict'` directive. + * + * Invoked once per wrapped channel, so the `WeakSet` keeps the snippet to one + * per file. Requires `@apm-js-collab/code-transformer` >= 0.18.1, where + * built-ins dispatch through the override map and expose the originals on + * `state.transforms.defaults`. + */ +export function moduleInjectedTransforms( + importSpecifier: string = DEFAULT_IMPORT_SPECIFIER, +): Record { + const injectModuleInjected: CustomTransform = (state, program, parent, ancestry) => { + const { moduleType, module, transforms } = state as { + moduleType?: string; + module?: { name?: string }; + transforms: { defaults: { tracingChannelImport: CustomTransform } }; + }; + + transforms.defaults.tracingChannelImport(state, program, parent, ancestry); + + const node = program as ProgramNode; + if (injectedPrograms.has(node)) { + return; + } + + const moduleName = module?.name; + if (!moduleName) { + return; + } + + injectedPrograms.add(node); + + const exportName = subscriberExportForModule(moduleName); + const statements = parse(moduleInjectedSnippet(moduleName, exportName, moduleType === 'esm', importSpecifier), { + module: moduleType === 'esm', + next: true, + }).body as ProgramNode['body']; + + const directiveIndex = node.body.findIndex(n => n.type === 'ExpressionStatement' && n.directive === 'use strict'); + node.body.splice(directiveIndex + 1, 0, ...statements); + }; + + return { tracingChannelImport: injectModuleInjected }; +} diff --git a/packages/server-utils/src/orchestrion/bundler/options.ts b/packages/server-utils/src/orchestrion/bundler/options.ts index c348c1f18056..845d25329507 100644 --- a/packages/server-utils/src/orchestrion/bundler/options.ts +++ b/packages/server-utils/src/orchestrion/bundler/options.ts @@ -1,8 +1,10 @@ import type { InstrumentationConfig, CustomTransform } from '..'; import { SENTRY_INSTRUMENTATIONS } from '../config'; -import { subscribeInjectionOptions } from './subscribeInjection'; +import { moduleInjectedTransforms, ORCHESTRION_BUNDLER_MARKER_BANNER } from './moduleInjectedTransform'; import type { CodeTransformerPluginOptions } from '../apmTypes'; +export { ORCHESTRION_BUNDLER_MARKER_BANNER }; + export type PluginOptions = { /** * Additional instrumentations to include with the default instrumentation. @@ -17,35 +19,15 @@ export type PluginOptions = { */ buildTimeInstrumentation?: boolean; /** - * Custom transforms that can be applied using the `transform` option in each `InstrumentationConfig`. - */ - customTransforms?: Record; - /** - * Whether to inject the global diagnostics. + * Custom transforms that can be applied using the `transform` option in each + * `InstrumentationConfig`. * - * Defaults to `true`. + * Only applied by the vite/rollup/esbuild/webpack plugins. Turbopack + * serializes loader options as JSON, so functions can't reach its loader; + * there, only the built-in Sentry transforms (baked into the loader module) + * run. */ - shouldInjectDiagnostics?: boolean; - /** - * Inject a small marker-push into each instrumented module that imports only - * that package's channel-subscriber factory and pushes it onto - * `globalThis.__SENTRY_ORCHESTRION__.integrations`. A bundler-only SDK reads - * the marker at `init()` and instantiates the collected factories, so every - * transformed package's subscriber is wired up with no runtime module hook. - * - * Because each site imports a single named factory, it tree-shakes: a bundle - * carries subscriber code only for the packages actually transformed into it. - * - * This is what lets a bundler-only SDK (e.g. `@sentry/cloudflare`, which runs - * in workerd where requires can't be monkey-patched) record channel spans, - * but it is bundler-agnostic: any orchestrion bundler plugin can enable it. - * Leave it off for SDKs that wire the integrations up through a static import - * instead (e.g. `@sentry/node`, which registers them at init time), so the - * subscribers aren't registered twice. - * - * Defaults to `false`. - */ - injectChannelSubscribers?: boolean; + customTransforms?: Record; }; /** @@ -76,46 +58,15 @@ export function externalizedModulesWarning(externalizedModules: string[]): strin * The `@apm-js-collab/code-transformer-bundler-plugins` options shared by every * orchestrion bundler plugin. * - * `injectDiagnostics` sets `globalThis.__SENTRY_ORCHESTRION__.bundler = ["mysql"]` at - * app boot so the `detectOrchestrionSetup()` detector can confirm the - * bundler path ran (rather than relying on a build-time flag that wouldn't be - * visible to the runtime). + * The module-injected `tracingChannelImport` override is always on: it is how + * every transformed module announces itself (and its channel-subscriber + * factory) at evaluation time, on every bundler. It is spread last so a user + * transform can't clobber it. */ export function orchestrionTransformOptions(options: PluginOptions): CodeTransformerPluginOptions { - const instrumentations = [...SENTRY_INSTRUMENTATIONS, ...(options.instrumentations || [])]; - const customTransforms = { - ...options.customTransforms, - ...(options.injectChannelSubscribers ? subscribeInjectionOptions().customTransforms : undefined), - }; - - if (options.shouldInjectDiagnostics === false) { - return { - instrumentations, - customTransforms, - }; - } - return { - instrumentations, - customTransforms, - injectDiagnostics: (diag: { transformedModules: string[]; failedModules: string[] }) => { - // Record the transformed modules for detection AND fire the on-inject - // bridge for each, so channel integrations subscribe. These modules are - // bundled, so the runtime module hook never sees them. The bridge - // (installed by `registerDiagnosticsChannelInjection`) re-emits the - // `orchestrion.module-runtime-injected` event the subscription waits on. - // When the bridge isn't installed (bundler-only runtimes, or the banner - // runs before `init()`), the guarded call is a no-op and the recorded - // `.bundler` list still drives subscription at `init()`. - const modules = JSON.stringify(diag.transformedModules); - return ( - '(function(){' + - 'var g=globalThis.__SENTRY_ORCHESTRION__=globalThis.__SENTRY_ORCHESTRION__||{};' + - `var m=${modules};` + - 'g.bundler=m;' + - "if(typeof g.onInject==='function')m.forEach(function(n){g.onInject(n);});" + - '})();' - ); - }, + instrumentations: [...SENTRY_INSTRUMENTATIONS, ...(options.instrumentations || [])], + customTransforms: { ...options.customTransforms, ...moduleInjectedTransforms() }, + injectDiagnostics: () => ORCHESTRION_BUNDLER_MARKER_BANNER, }; } diff --git a/packages/server-utils/src/orchestrion/bundler/resolve.ts b/packages/server-utils/src/orchestrion/bundler/resolve.ts new file mode 100644 index 000000000000..d4d81229dceb --- /dev/null +++ b/packages/server-utils/src/orchestrion/bundler/resolve.ts @@ -0,0 +1,45 @@ +import { createRequire } from 'node:module'; + +// Both branches use `createRequire` (never alias the CJS `require`) so bundlers consuming this +// module don't emit a "Critical dependency" warning. +function getOrchestrionRequire(): ReturnType { + let nodeRequire: ReturnType; + /*! rollup-include-cjs-only */ + nodeRequire = createRequire(__filename); + /*! rollup-include-cjs-only-end */ + /*! rollup-include-esm-only */ + nodeRequire = createRequire(import.meta.url); + /*! rollup-include-esm-only-end */ + return nodeRequire; +} + +/** + * Absolute path to the code-transform loader (a webpack loader; also usable as a Turbopack loader). + * Resolved via self-reference to this package's own bundled copy — the `@apm-js-collab` packages + * are bundled devDependencies and not resolvable on user installs. + */ +export function getOrchestrionLoaderPath(): string { + return getOrchestrionRequire().resolve('@sentry/server-utils/orchestrion/webpack-loader'); +} + +/** + * Resolves a request for one of the orchestrion runtime packages (`@sentry/server-utils` itself, via + * self-reference, or its `@apm-js-collab/*` dependencies) to an absolute path, from this package's + * own on-disk location — where the whole dependency graph always resolves, regardless of the + * consuming app's install layout. Returns `undefined` when the request can't be resolved. + * + * Bundler configs use this in two ways: + * - to emit absolute-path `commonjs` externals: a bare-specifier external emitted into a bundled + * chunk resolves from the chunk's output location at runtime, which fails under isolated + * installs (pnpm) where these packages are transitive dependencies; + * - as a build-time resolution fallback for the `@sentry/server-utils/orchestrion` import the + * module-injected snippet places INSIDE transformed `node_modules` files, which a bundler + * resolving from the importing file's location can't find under isolated installs either. + */ +export function resolveOrchestrionRuntimeRequest(request: string): string | undefined { + try { + return getOrchestrionRequire().resolve(request); + } catch { + return undefined; + } +} diff --git a/packages/server-utils/src/orchestrion/bundler/rollup.ts b/packages/server-utils/src/orchestrion/bundler/rollup.ts index ffa96ce86085..104e7adaca12 100644 --- a/packages/server-utils/src/orchestrion/bundler/rollup.ts +++ b/packages/server-utils/src/orchestrion/bundler/rollup.ts @@ -3,6 +3,7 @@ import type { NormalizedInputOptions, Plugin, PluginContext } from 'rollup'; import { instrumentedModuleNames } from '../config'; import type { PluginOptions } from './options'; import { externalizedModulesWarning, orchestrionTransformOptions } from './options'; +import { resolveOrchestrionRuntimeRequest } from './resolve'; /** * Rollup plugin that runs the orchestrion code transform on the bundled output. @@ -27,6 +28,21 @@ export function sentryOrchestrionPlugin(options: PluginOptions = {}): Plugin { return { ...codeTransformer(orchestrionTransformOptions(options)), + // The module-injected snippet imports `@sentry/server-utils/orchestrion` + // from INSIDE transformed `node_modules` files. Under isolated installs + // (pnpm) that bare specifier doesn't resolve from an instrumented package's + // location, so when normal resolution fails, fall back to this package's + // own resolution so the helper gets bundled from its real on-disk path. + async resolveId(this: PluginContext, source: string, importer: string | undefined) { + if (source !== '@sentry/server-utils/orchestrion') { + return null; + } + const resolved = await this.resolve(source, importer, { skipSelf: true }); + if (resolved) { + return resolved; + } + return resolveOrchestrionRuntimeRequest(source) ?? null; + }, buildStart(this: PluginContext, rollupOptions: NormalizedInputOptions): void { // An externalized dependency never passes through the code transform, so // its diagnostics_channel calls are silently never injected. By the time diff --git a/packages/server-utils/src/orchestrion/bundler/subscribeInjection.ts b/packages/server-utils/src/orchestrion/bundler/subscribeInjection.ts deleted file mode 100644 index 2d0022aad107..000000000000 --- a/packages/server-utils/src/orchestrion/bundler/subscribeInjection.ts +++ /dev/null @@ -1,95 +0,0 @@ -import type { CustomTransform } from '../apmTypes'; -import { parse } from 'meriyah'; -import { subscriberExportForModule } from '../config/channel-integration-definitions'; -import type { PluginOptions } from './options'; - -// Tracks Program nodes we already injected into, so a package with several -// instrumented files (or several configs pointing at one file) is injected only -// once per file. A `WeakSet` keyed by the node avoids mutating the emitted AST. -const injectedPrograms = new WeakSet(); - -interface ProgramNode { - type: string; - body: Array<{ type: string; directive?: string }>; -} - -/** - * Snippet injected into each instrumented module. It imports ONLY that package's - * channel-subscriber factory (plus the `registerOrchestrionChannelIntegration` - * helper) from `@sentry/server-utils/orchestrion`, and hands both to the helper, - * which stores the factory on the global marker and live-registers it on any - * existing client (see that helper for the load-order and dedup rationale). - * - * 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 same - * "only-active-when-bundled" property the runtime module hook gives unbundled - * Node, but without a hook (workerd can't monkey-patch requires). The helper is - * generic (references no factory), so importing it alongside doesn't pull siblings. - */ -function subscribeSnippet(exportName: string, esm: boolean): string { - const importStmt = esm - ? `import { ${exportName}, registerOrchestrionChannelIntegration } from '@sentry/server-utils/orchestrion';` - : `const { ${exportName}, registerOrchestrionChannelIntegration } = require('@sentry/server-utils/orchestrion');`; - - return `${importStmt}\nregisterOrchestrionChannelIntegration(${JSON.stringify(exportName)}, ${exportName});`; -} - -/** - * Override for orchestrion's built-in `tracingChannelImport` transform, which - * runs (via `tracingChannelDeclaration`) for every file that gets a channel - * wrapped — the one hook that reaches every instrumented module without any - * extra instrumentation configs. It chains the default (which splices the - * `diagnostics_channel` import and bails when it is already present), then - * splices the marker-push snippet in after any `'use strict'` directive. - * - * Invoked once per wrapped channel, so the `WeakSet` keeps the snippet to one - * per file. Requires `@apm-js-collab/code-transformer` >= 0.18.1, where - * built-ins dispatch through the override map and expose the originals on - * `state.transforms.defaults`. - */ -const injectSubscribe: CustomTransform = (state, program, parent, ancestry) => { - const { moduleType, module, transforms } = state as { - moduleType?: string; - module?: { name?: string }; - transforms: { defaults: { tracingChannelImport: CustomTransform } }; - }; - - transforms.defaults.tracingChannelImport(state, program, parent, ancestry); - - const node = program as ProgramNode; - if (injectedPrograms.has(node)) { - return; - } - - const exportName = module?.name ? subscriberExportForModule(module.name) : undefined; - if (!exportName) { - return; - } - - injectedPrograms.add(node); - - const statements = parse(subscribeSnippet(exportName, moduleType === 'esm'), { - module: moduleType === 'esm', - next: true, - }).body as ProgramNode['body']; - - const directiveIndex = node.body.findIndex(n => n.type === 'ExpressionStatement' && n.directive === 'use strict'); - node.body.splice(directiveIndex + 1, 0, ...statements); -}; - -/** - * The `customTransforms` a bundler plugin passes to - * {@link orchestrionTransformOptions} to enable the marker-push subscribe - * injection used by bundler-only SDKs (e.g. `@sentry/cloudflare`). - * - * Overriding the built-in `tracingChannelImport` transform makes - * `injectSubscribe` run on every instrumented module, so every transformed - * package self-registers its subscriber on the global marker without a runtime - * module hook — and without a parallel set of injection configs. - */ -export function subscribeInjectionOptions(): Pick { - return { - customTransforms: { tracingChannelImport: injectSubscribe }, - }; -} diff --git a/packages/server-utils/src/orchestrion/bundler/vite.ts b/packages/server-utils/src/orchestrion/bundler/vite.ts index 8c4cca0c5216..6bd318414857 100644 --- a/packages/server-utils/src/orchestrion/bundler/vite.ts +++ b/packages/server-utils/src/orchestrion/bundler/vite.ts @@ -3,6 +3,7 @@ import type { Plugin, ResolvedConfig } from 'vite'; import { instrumentedModuleNames } from '../config'; import type { PluginOptions } from './options'; import { externalEntryMatchesModule, externalizedModulesWarning, orchestrionTransformOptions } from './options'; +import { resolveOrchestrionRuntimeRequest } from './resolve'; /** * Vite plugin that runs the orchestrion code transform on the bundled output. @@ -28,6 +29,21 @@ export function sentryOrchestrionPlugin(options: PluginOptions = {}): Plugin { return { ...codeTransformer(orchestrionTransformOptions(options)), + // The module-injected snippet imports `@sentry/server-utils/orchestrion` + // from INSIDE transformed `node_modules` files. Under isolated installs + // (pnpm) that bare specifier doesn't resolve from an instrumented package's + // location, so when normal resolution fails, fall back to this package's + // own resolution so the helper gets bundled from its real on-disk path. + async resolveId(source, importer, resolveOptions) { + if (source !== '@sentry/server-utils/orchestrion') { + return null; + } + const resolved = await this.resolve(source, importer, { ...resolveOptions, skipSelf: true }); + if (resolved) { + return resolved; + } + return resolveOrchestrionRuntimeRequest(source) ?? null; + }, applyToEnvironment(environment) { // Orchestrion splices `node:diagnostics_channel` calls into instrumented modules, which only // exist server-side. Only apply to server-consumed environments so injected `tracingChannel` diff --git a/packages/server-utils/src/orchestrion/bundler/webpack-loader.ts b/packages/server-utils/src/orchestrion/bundler/webpack-loader.ts index 11d2d180414a..5d0087559463 100644 --- a/packages/server-utils/src/orchestrion/bundler/webpack-loader.ts +++ b/packages/server-utils/src/orchestrion/bundler/webpack-loader.ts @@ -1,95 +1,46 @@ -// The webpack/Turbopack code-transform loader, re-exported so it compiles into this -// package's build (the `@apm-js-collab` packages are bundled devDependencies and not resolvable on -// user installs). Bundlers reference it by on-disk path via `getOrchestrionLoaderPath()`, so it -// needs its own entrypoint/subpath rather than being reachable from another module. -import codeTransformerLoaderImpl from '@apm-js-collab/code-transformer-bundler-plugins/webpack-loader'; - -// The loader context we rely on beyond the transform itself: `resourcePath` to -// name the module, `async` for the transformed result, and `_compilation` to -// tell webpack (present) from Turbopack (absent). +// The webpack/Turbopack code-transform loader. Built with the upstream loader +// factory so the module-injected custom transform is baked into the loader +// module itself: Turbopack (and worker-based loaders like `thread-loader`) +// serialize loader options as JSON, so function-valued options can never reach +// a loader — everything that crosses that boundary must stay serializable. +// Compiled into this package's build (the `@apm-js-collab` packages are bundled +// devDependencies and not resolvable on user installs); bundlers reference it +// by on-disk path via `getOrchestrionLoaderPath()`, so it needs its own +// entrypoint/subpath rather than being reachable from another module. +import { createLoader } from '@apm-js-collab/code-transformer-bundler-plugins/webpack-loader-factory'; +import { moduleInjectedTransforms } from './moduleInjectedTransform'; + +// The slice of the loader context we touch ourselves; everything else is the +// factory-built loader's business. interface LoaderContext { - resourcePath: string; - async: () => (error: unknown, code?: string, map?: unknown) => void; - _compilation?: unknown; + getOptions: () => { importSpecifier?: string }; } type LoaderFn = (this: LoaderContext, code: string, inputSourceMap?: unknown) => void; -const upstreamLoader: LoaderFn = codeTransformerLoaderImpl; - -/** - * The npm package name for a module path. - * Reads the segment after the LAST `node_modules`, so pnpm's nested layout - * resolves to the real package. Matches the name that channel integrations - * await. - */ -function packageNameFromPath(resourcePath: string): string | undefined { - const marker = '/node_modules/'; - const normalized = resourcePath.replace(/\\/g, '/'); - const index = normalized.lastIndexOf(marker); - if (index === -1) { - return undefined; - } - - const [scopeOrName, name] = normalized.slice(index + marker.length).split('/'); - if (!scopeOrName) { - return undefined; +// One factory-built loader per import specifier. The specifier is a per-rule +// (JSON) loader option, but the transforms capturing it must be baked in at +// module scope — so bind lazily and cache, keyed by specifier. In practice a +// build uses a single specifier, so this holds one entry. +const loaders = new Map(); + +function loaderFor(importSpecifier: string | undefined): LoaderFn { + let loader = loaders.get(importSpecifier); + if (!loader) { + loader = createLoader({ customTransforms: moduleInjectedTransforms(importSpecifier) }) as LoaderFn; + loaders.set(importSpecifier, loader); } - - return scopeOrName.startsWith('@') && name ? `${scopeOrName}/${name}` : scopeOrName; + return loader; } /** - * Announce a runtime-injected module the way the banner and runtime `--import` - * hook do, so the lazily-registered channel integrations subscribe. Appended to - * each transformed module's code, it runs when that module loads. - */ -function onInjectSnippet(moduleName: string): string { - const name = JSON.stringify(moduleName); - return ( - ';(function(){' + - 'var g=globalThis.__SENTRY_ORCHESTRION__||={};' + - 'if(!Array.isArray(g.bundler))g.bundler=[];' + - `if(g.bundler.indexOf(${name})<0)g.bundler.push(${name});` + - `if(typeof g.onInject==='function')g.onInject(${name});` + - '})();\n' - ); -} - -/** - * Wraps the upstream code-transform loader. - * - * Under Turbopack the transform runs as a loader, but the webpack *plugin* that - * emits the `injectDiagnostics` boot banner never runs, because Turbopack takes - * loaders, not plugins. That banner is what calls `onInject` for bundled - * modules, so without it the channel integrations never learn their module - * loaded and never subscribe. When there is no webpack compilation (Turbopack - * case), append the `onInject` call to each transformed module here instead. - * Under webpack leave it to the banner, so signal fires exactly once per module + * Reads the Sentry-specific `importSpecifier` option (unknown to the upstream + * loader, which reads only its own fields) and delegates to the matching + * factory-built loader. `instrumentations` stays a plain per-rule loader + * option, read by the upstream loader itself. */ const codeTransformerLoader: LoaderFn = function (code, inputSourceMap) { - if (this._compilation) { - upstreamLoader.call(this, code, inputSourceMap); - return; - } - - const realAsync = this.async.bind(this); - const { resourcePath } = this; - - this.async = () => { - const callback = realAsync(); - return (error: unknown, outputCode?: string, outputMap?: unknown): void => { - // The upstream loader returns the input code unchanged when it did not - // transform the module, so a changed string means a channel-publishing - // module we must announce. - const transformed = !error && typeof outputCode === 'string' && outputCode !== code; - const moduleName = transformed ? packageNameFromPath(resourcePath) : undefined; - const finalCode = moduleName ? `${outputCode}${onInjectSnippet(moduleName)}` : outputCode; - callback(error, finalCode, outputMap); - }; - }; - - upstreamLoader.call(this, code, inputSourceMap); + return loaderFor(this.getOptions().importSpecifier).call(this, code, inputSourceMap); }; export default codeTransformerLoader; diff --git a/packages/server-utils/src/orchestrion/bundler/webpack.ts b/packages/server-utils/src/orchestrion/bundler/webpack.ts index a44f2ffa5e34..74fbfaf58550 100644 --- a/packages/server-utils/src/orchestrion/bundler/webpack.ts +++ b/packages/server-utils/src/orchestrion/bundler/webpack.ts @@ -1,12 +1,13 @@ // Orchestrion code-transform loader + webpack plugin. The loader is exposed // separately because Turbopack can only take webpack loaders (via `turbopack.rules`), not plugins. -import { createRequire } from 'node:module'; +import { SDK_VERSION } from '@sentry/core'; import type { Compiler } from 'webpack'; import type { InstrumentationConfig } from '..'; import { instrumentedModuleNames, SENTRY_INSTRUMENTATIONS } from '../config'; import codeTransformerWebpack from '@apm-js-collab/code-transformer-bundler-plugins/webpack'; import type { PluginOptions } from './options'; +import { getOrchestrionLoaderPath, resolveOrchestrionRuntimeRequest } from './resolve'; import { serializeInstrumentations as serializeInstrumentationsImpl } from '@apm-js-collab/code-transformer-bundler-plugins/core'; import type { AnyInstrumentationConfig, SerializableInstrumentationConfig } from '../apmTypes'; @@ -18,45 +19,7 @@ export const serializeInstrumentations: (configs: AnyInstrumentationConfig[]) => serializeInstrumentationsImpl; export type { SerializableInstrumentationConfig } from '../apmTypes'; -// Both branches use `createRequire` (never alias the CJS `require`) so bundlers consuming this -// module don't emit a "Critical dependency" warning. -function getOrchestrionRequire(): ReturnType { - let nodeRequire: ReturnType; - /*! rollup-include-cjs-only */ - nodeRequire = createRequire(__filename); - /*! rollup-include-cjs-only-end */ - /*! rollup-include-esm-only */ - nodeRequire = createRequire(import.meta.url); - /*! rollup-include-esm-only-end */ - return nodeRequire; -} - -/** - * Absolute path to the code-transform loader (a webpack loader; also usable as a Turbopack loader). - * Resolved via self-reference to this package's own bundled copy — the `@apm-js-collab` packages - * are bundled devDependencies and not resolvable on user installs. - */ -export function getOrchestrionLoaderPath(): string { - return getOrchestrionRequire().resolve('@sentry/server-utils/orchestrion/webpack-loader'); -} - -/** - * Resolves a request for one of the orchestrion runtime packages (`@sentry/server-utils` itself, via - * self-reference, or its `@apm-js-collab/*` dependencies) to an absolute path, from this package's - * own on-disk location — where the whole dependency graph always resolves, regardless of the - * consuming app's install layout. Returns `undefined` when the request can't be resolved. - * - * Bundler configs use this to emit absolute-path `commonjs` externals: a bare-specifier external - * emitted into a bundled chunk resolves from the chunk's output location at runtime, which fails - * under isolated installs (pnpm) where these packages are transitive dependencies. - */ -export function resolveOrchestrionRuntimeRequest(request: string): string | undefined { - try { - return getOrchestrionRequire().resolve(request); - } catch { - return undefined; - } -} +export { getOrchestrionLoaderPath, resolveOrchestrionRuntimeRequest }; /** The central instrumentation config, to pass as the loader's `instrumentations` option. */ export function getSentryInstrumentations(): InstrumentationConfig[] { @@ -84,23 +47,24 @@ function externalizedWebpackModules(externals: unknown, moduleNames: string[]): ); } -// The upstream plugin computes its loader path relative to its own file location, which after -// bundling points into our `vendored/` tree at a file rollup never emitted. Replace it in the -// rule the plugin just unshifted with our own bundled loader entrypoint. -function fixupLoaderPath(compiler: Compiler): void { - for (const rule of compiler.options.module?.rules ?? []) { - if (!rule || typeof rule !== 'object' || !('use' in rule) || !Array.isArray(rule.use)) { - continue; - } - for (const use of rule.use) { - if ( - use && - typeof use === 'object' && - typeof use.loader === 'string' && - use.loader.endsWith('webpack-loader.cjs') - ) { - use.loader = getOrchestrionLoaderPath(); - } +// The injected module-injected snippet imports `@sentry/server-utils/orchestrion` +// from INSIDE transformed `node_modules` files. Under isolated installs (pnpm) +// that bare specifier doesn't resolve from an instrumented package's location, +// so map it (exact-match, hence the `$`) to this package's own resolution. +// Externals still win — webpack consults `externals` before resolving — so +// setups that externalize the runtime (e.g. Next.js) are unaffected. +function addOrchestrionResolveAlias(compiler: Compiler): void { + const resolveOptions = (compiler.options.resolve ??= {}); + const alias = resolveOptions.alias; + if (Array.isArray(alias)) { + return; + } + + const aliasMap = (resolveOptions.alias = alias ?? {}); + if (!('@sentry/server-utils/orchestrion$' in aliasMap)) { + const resolved = resolveOrchestrionRuntimeRequest('@sentry/server-utils/orchestrion'); + if (resolved) { + aliasMap['@sentry/server-utils/orchestrion$'] = resolved; } } } @@ -117,7 +81,18 @@ export function sentryOrchestrionWebpackPlugin(options: PluginOptions = {}): { a return { apply: () => undefined }; } - const plugin = codeTransformerWebpack(orchestrionTransformOptions(options)); + const plugin = codeTransformerWebpack({ + ...orchestrionTransformOptions(options), + // The upstream plugin's own loader path points into our `vendored/` tree at + // a file rollup never emitted; use our bundled loader entrypoint instead + // (which also bakes in the module-injected transform for Turbopack). + loaderPath: getOrchestrionLoaderPath(), + // The loader ident hashes the instrumentations and each custom transform's + // source text, but not data a transform reads without naming it — our + // subscriber-definitions table. Key persistent caches on the SDK version so + // a release changing that table busts them. + cacheVersion: SDK_VERSION, + }); const moduleNames = instrumentedModuleNames(options.instrumentations); // The upstream plugin is a class instance, so `apply` is overridden in place // rather than spread into a new object (which would lose prototype methods). @@ -129,8 +104,8 @@ export function sentryOrchestrionWebpackPlugin(options: PluginOptions = {}): { a compilation.warnings.push(new compiler.webpack.WebpackError(externalizedModulesWarning(externalizedModules))); }); } + addOrchestrionResolveAlias(compiler); apply(compiler); - fixupLoaderPath(compiler); }; return plugin; } 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 29aa2fe1fa18..f08fe37019ce 100644 --- a/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts +++ b/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts @@ -4,7 +4,7 @@ * channels injected into it — by the `exportName` it is published under from * `@sentry/server-utils/orchestrion`. * - * Kept in a separate, factory-free module on purpose: the subscribe-injection + * Kept in a separate, factory-free module on purpose: the module-injected * transform (reachable from every orchestrion bundler plugin) reads this to * generate the tiny snippet it injects into each instrumented file, and must * not drag any subscriber code — or its `@sentry/core` span machinery — into @@ -46,8 +46,3 @@ export const CHANNEL_INTEGRATION_DEFINITIONS = [ export function subscriberExportForModule(moduleName: string): string | undefined { return CHANNEL_INTEGRATION_DEFINITIONS.find(d => (d.modules as readonly string[]).includes(moduleName))?.exportName; } - -/** Look up the instrumented package names a subscriber export covers. */ -export function modulesForSubscriberExport(exportName: string): readonly string[] { - return CHANNEL_INTEGRATION_DEFINITIONS.find(d => d.exportName === exportName)?.modules ?? []; -} diff --git a/packages/server-utils/src/orchestrion/config/index.ts b/packages/server-utils/src/orchestrion/config/index.ts index 7d43cd6c60ed..261eb32d993a 100644 --- a/packages/server-utils/src/orchestrion/config/index.ts +++ b/packages/server-utils/src/orchestrion/config/index.ts @@ -33,6 +33,11 @@ import { vercelAiConfig } from './vercel-ai'; // Kept sorted alphabetically by module so concurrent additions insert at different // points rather than all appending to the end (fewer merge conflicts). +// Re-exported here for bundler integrations that compose the upstream +// code-transformer plugin themselves instead of using one of our wrappers +// (`@sentry/bun`'s plugin uses the upstream `/bun` entry directly). +export { moduleInjectedTransforms, ORCHESTRION_BUNDLER_MARKER_BANNER } from '../bundler/moduleInjectedTransform'; + /** * The orchestrion code-transform configs. Every instrumentable library is here * so the transform is all-or-nothing: whenever orchestrion is enabled, all of diff --git a/packages/server-utils/src/orchestrion/detect.ts b/packages/server-utils/src/orchestrion/detect.ts index 8fbf69a22aa5..4545cbc56ed3 100644 --- a/packages/server-utils/src/orchestrion/detect.ts +++ b/packages/server-utils/src/orchestrion/detect.ts @@ -16,13 +16,13 @@ export function isOrchestrionInjected(): boolean { /** * The module names (e.g. `mysql`, `@hapi/hapi`) orchestrion has already injected - * into this process — from the runtime `--import` hook (`runtime`) and/or a - * bundler plugin (`bundler`). Channel-based integrations use it to decide whether - * to subscribe now (their module is already loaded) or wait for the runtime - * injection event. + * into this process — from the runtime `--import` hook (`runtime`) and/or the + * snippets a bundler transform spliced into each transformed module (`bundler`). + * Channel-based integrations use it to decide whether to subscribe now (their + * module is already loaded) or wait for the module-injected event. * - * `bundler` can be `true` rather than an array (Bun's banner sets a plain flag); - * that carries no module names, so it contributes nothing here. + * The `Array.isArray` guard is runtime safety, not typing: a banner from + * another SDK copy or version may have written a non-array flag here. */ export function getOrchestrionInjectedModules(): string[] { const { runtime, bundler } = GLOBAL_OBJ.__SENTRY_ORCHESTRION__ ?? {}; @@ -34,7 +34,9 @@ export function getOrchestrionInjectedModules(): string[] { * runtime `--import` hook (or init-time registration), a bundler plugin, or * both, and warns if not. When at least one injector is active, logs for each * mechanism whether it hooked (a defined array, even empty, means it did) and - * which libraries it injected. + * which libraries it injected. For the bundler path, the entry banner ensures + * `[]` at boot; module names arrive as each transformed module is evaluated, + * so an empty list can also just mean none has loaded yet. * * Both injectors being active at once is fine: they operate on disjoint module * sets (a module is either loaded through Node's loader and transformed by the diff --git a/packages/server-utils/src/orchestrion/index.ts b/packages/server-utils/src/orchestrion/index.ts index 7122adb0e04b..dbf9be2cba2a 100644 --- a/packages/server-utils/src/orchestrion/index.ts +++ b/packages/server-utils/src/orchestrion/index.ts @@ -26,10 +26,10 @@ import { expressIntegration } from '../integrations/tracing-channel/express'; import { firebaseIntegration } from '../integrations/tracing-channel/firebase'; export { detectOrchestrionSetup, isOrchestrionInjected } from './detect'; -// The runtime target of the subscribe-injection snippet: instrumented modules -// import this to self-register their channel subscriber on the global marker -// (used by bundler-only SDKs). -export { registerOrchestrionChannelIntegration } from './registerChannelIntegration'; +// The runtime target of the snippet the bundler transform splices into every +// instrumented module: records the module on the global marker (plus its +// subscriber factory, when it has one) and emits the module-injected event. +export { orchestrionModuleInjected } from './moduleInjected'; // The `@nestjs/*` channel names live here alongside their transform config; the // listener that subscribes to them lives in `@sentry/nestjs`, which imports this. export { nestjsChannels } from './config/nestjs'; diff --git a/packages/server-utils/src/orchestrion/instrumentation.ts b/packages/server-utils/src/orchestrion/instrumentation.ts index f355fb5da257..7a548ca634f7 100644 --- a/packages/server-utils/src/orchestrion/instrumentation.ts +++ b/packages/server-utils/src/orchestrion/instrumentation.ts @@ -27,11 +27,11 @@ const isDeno = typeof globalAny.Deno !== 'undefined'; * for modules the app never loads. So we defer: * * - If a module is already injected, then a bundler transformed and loaded it - * (which records it via `registerOrchestrionChannelIntegration`), or the - * runtime hook injected it before `init()`, so subscribe right away. - * - Otherwise wait for the runtime hook's `orchestrion.module-runtime-injected` - * event, which fires when the module is loaded and transformed, before it can - * publish to its channels. + * (which records it via `orchestrionModuleInjected`), or the runtime hook + * injected it before `init()`, so subscribe right away. + * - Otherwise wait for the `orchestrion.module-injected` event, which fires + * when the module is loaded and transformed, before it can publish to its + * channels. * * Bun and Deno have no such channel limit and no reliable per-module injection * tracking, so there we just subscribe immediately. @@ -115,7 +115,7 @@ export function invokeOrchestrionInstrumentation { + const cleanup = client.on('orchestrion.module-injected', (moduleName: string) => { if (hasBeenInstrumented(callback)) { cleanup(); return; diff --git a/packages/server-utils/src/orchestrion/moduleInjected.ts b/packages/server-utils/src/orchestrion/moduleInjected.ts new file mode 100644 index 000000000000..0e78afe91e06 --- /dev/null +++ b/packages/server-utils/src/orchestrion/moduleInjected.ts @@ -0,0 +1,43 @@ +import type { Integration } from '@sentry/core'; +import { getClient, GLOBAL_OBJ } from '@sentry/core'; + +/** + * Record a bundler-injected module and notify channel integrations. This is the + * runtime target of the snippet the `tracingChannelImport` override splices + * into every instrumented module (see `bundler/moduleInjectedTransform.ts`), so + * it runs when that module is first evaluated — the moment its diagnostics + * channels can start publishing. + * + * It records the module name on the global orchestrion marker, stores the + * module's channel-subscriber integration factory (when the module has one) + * 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. + * + * Deliberately record-and-emit only — no `addIntegration` here. Whether an + * integration is *installed* is each SDK's policy: `@sentry/node` registers its + * channel integrations statically and only needs the event to trigger their + * lazy subscription (so a user who removed one from `integrations` stays opted + * out), while a bundler-only SDK like `@sentry/cloudflare` instantiates the + * stored factories at `init()` and listens for this event to pick up modules + * 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 { + const marker = (GLOBAL_OBJ.__SENTRY_ORCHESTRION__ ??= {}); + + // Runtime guard, not just type narrowing: a banner from another SDK copy or + // version may have written a non-array flag here; leave that untouched. + if (marker.bundler === undefined || Array.isArray(marker.bundler)) { + const bundler = (marker.bundler ??= []); + if (!bundler.includes(moduleName)) { + bundler.push(moduleName); + } + } + + if (integrationFn) { + (marker.integrations ??= new Map()).set(moduleName, integrationFn); + } + + getClient()?.emit('orchestrion.module-injected', moduleName); +} diff --git a/packages/server-utils/src/orchestrion/registerChannelIntegration.ts b/packages/server-utils/src/orchestrion/registerChannelIntegration.ts deleted file mode 100644 index 026c1005d9ce..000000000000 --- a/packages/server-utils/src/orchestrion/registerChannelIntegration.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { Integration } from '@sentry/core'; -import { getClient, GLOBAL_OBJ } from '@sentry/core'; -import { modulesForSubscriberExport } from './config/channel-integration-definitions'; - -/** - * Register an orchestrion channel-subscriber integration from an instrumented - * module. This is the runtime target of the snippet the subscribe-injection - * transform splices into each transformed package (see - * `bundler/subscribeInjection.ts`), so a bundler-only SDK (e.g. - * `@sentry/cloudflare`, running in workerd where requires can't be - * monkey-patched) wires up subscribers with no runtime module hook. - * - * It does two things, covering the two disjoint timing cases: - * - * 1. Stores the factory on the global orchestrion marker under `name`, so a - * later `init()` (a fresh isolate, or a client created after this module - * loads) picks it up via `getDefaultIntegrations()`. - * 2. If a client already exists, registers the integration on it right away. - * This is what makes the mechanism robust against module load order: - * bundler-only SDKs call `init()` per request, but a package like `mysql` - * loads its instrumented file lazily on first use, i.e. AFTER that request's - * `init()` already snapshotted the marker. Without the live add, the first - * request that touches such a package would publish to a channel nobody - * subscribed to yet. - * - * `addIntegration` dedupes by integration name and only runs `setupOnce` once, - * so storing AND live-adding never double-subscribes. - * - * The marker is a `Map` keyed by `name` (the factory's export name) so a package - * split across several instrumented files (e.g. `pg`'s JS and native clients, or - * openai's per-resource `.js`/`.mjs` files) registers its one subscriber once, - * no matter how many of its files land in the bundle. `.set` on the shared key - * is idempotent. - */ -export function registerOrchestrionChannelIntegration(name: string, integrationFn: () => Integration): void { - const marker = (GLOBAL_OBJ.__SENTRY_ORCHESTRION__ ??= {}); - - // Record the instrumented package(s) as bundler-injected BEFORE adding the - // integration. The integration subscribes lazily, gated on its module showing - // up as injected (see `invokeOrchestrionInstrumentation`); this is that signal - // for the bundler path. Its module was just transformed and loaded, so the - // integration must subscribe immediately rather than wait for a runtime event - // that (being bundled, not loaded through the module hook) never fires. Skip - // when `bundler` is a non-array flag (Bun's banner sets `true`), which carries - // no module names. Those runtimes subscribe eagerly anyway. - const modules = modulesForSubscriberExport(name); - if (modules.length && (Array.isArray(marker.bundler) || marker.bundler === undefined)) { - const bundler = (marker.bundler ??= []); - for (const moduleName of modules) { - if (!bundler.includes(moduleName)) { - bundler.push(moduleName); - } - } - } - - (marker.integrations ??= new Map()).set(name, integrationFn); - getClient()?.addIntegration(integrationFn()); -} diff --git a/packages/server-utils/src/orchestrion/runtime/register.ts b/packages/server-utils/src/orchestrion/runtime/register.ts index d4d6cb3f831f..585eca8b6ce4 100644 --- a/packages/server-utils/src/orchestrion/runtime/register.ts +++ b/packages/server-utils/src/orchestrion/runtime/register.ts @@ -36,19 +36,6 @@ function hasStableSyncModuleHooks(isDeno: boolean): boolean { * the channel-based integrations subscribe to. */ export function registerDiagnosticsChannelInjection(): void { - // Install the on-inject bridge. Force-bundled modules (vite SSR, nextjs's - // bundle-safe packages) are transformed at build time and never loaded - // through the module hook, so the hook's own client event never fires for - // them. Instead, the bundler's `injectDiagnostics` boot banner (see - // `bundler/options.ts`) calls this bridge for each such module once the - // bundle boots, which re-emits that same event so the lazily-registered - // channel integrations subscribe. `getClient()` is read lazily since the - // banner runs after `init()`. - const marker = (GLOBAL_OBJ.__SENTRY_ORCHESTRION__ ??= {}); - marker.onInject ??= (moduleName: string): void => { - getClient()?.emit('orchestrion.module-runtime-injected', moduleName); - }; - if (GLOBAL_OBJ?.__SENTRY_ORCHESTRION__?.runtime) { return; } @@ -70,7 +57,7 @@ export function registerDiagnosticsChannelInjection(): void { // Tell channel integrations their module just loaded, so they subscribe // now. They hold off at `init()` to avoid claiming channel slots for // modules that never load, because Node caps channels in use at 1024. - getClient()?.emit('orchestrion.module-runtime-injected', moduleName); + getClient()?.emit('orchestrion.module-injected', moduleName); } }); diff --git a/packages/server-utils/test/orchestrion/bundler.test.ts b/packages/server-utils/test/orchestrion/bundler.test.ts index 56c324b1fec6..edc3ed8fb6e8 100644 --- a/packages/server-utils/test/orchestrion/bundler.test.ts +++ b/packages/server-utils/test/orchestrion/bundler.test.ts @@ -60,6 +60,7 @@ describe('sentryOrchestrionPlugin (esbuild)', () => { const build = { initialOptions: { external }, onStart: (callback: () => OnStartResult) => onStartCallbacks.push(callback), + onResolve: vi.fn(), } as unknown as PluginBuild; void esbuildPlugin().setup(build); return onStartCallbacks.map(callback => callback()); @@ -210,36 +211,52 @@ describe('resolveOrchestrionRuntimeRequest', () => { }); }); -describe('orchestrionTransformOptions injectDiagnostics banner', () => { - // Evaluate the emitted boot-banner snippet against a fake global, mirroring - // what runs when a bundled app boots. The banner must record `.bundler` for - // detection AND fire the on-inject bridge for each transformed module - // (force-bundled modules never reach the runtime hook, so the bridge is the - // only thing that triggers their channel subscription). - function runBanner(transformedModules: string[], global: Record): void { +describe('orchestrionTransformOptions', () => { + it('always includes the module-injected tracingChannelImport override', () => { const opts = orchestrionTransformOptions({}); - const banner = opts.injectDiagnostics?.({ transformedModules, failedModules: [] }); - expect(typeof banner).toBe('string'); - // `globalThis` inside the snippet resolves to the sandbox object we pass in. - // oxlint-disable-next-line typescript/no-implied-eval -- executing the generated injection snippet is the behavior under test - new Function('globalThis', banner as string)(global); - } - it('records `.bundler` and fires the on-inject bridge for each module', () => { - const onInject = vi.fn(); - const global: Record = { __SENTRY_ORCHESTRION__: { onInject } }; + expect(typeof opts.customTransforms?.tracingChannelImport).toBe('function'); + }); + + it('keeps user custom transforms and lets the module-injected override win a name clash', () => { + const userTransform = vi.fn(); + const clashing = vi.fn(); - runBanner(['mysql', 'pg'], global); + const opts = orchestrionTransformOptions({ + customTransforms: { myTransform: userTransform, tracingChannelImport: clashing }, + }); - expect((global.__SENTRY_ORCHESTRION__ as { bundler?: string[] }).bundler).toEqual(['mysql', 'pg']); - expect(onInject.mock.calls.map(c => c[0])).toEqual(['mysql', 'pg']); + expect(opts.customTransforms?.myTransform).toBe(userTransform); + expect(opts.customTransforms?.tracingChannelImport).not.toBe(clashing); }); - it('is a guarded no-op for the bridge when none is installed (bundler-only runtimes)', () => { - const global: Record = {}; + describe('marker banner', () => { + // Evaluate the emitted boot-banner snippet against a fake global, mirroring + // what runs when a bundled app boots. The banner only marks "the bundler + // plugin ran"; module names arrive per module via the injected snippets. + function runBanner(global: Record): void { + const opts = orchestrionTransformOptions({}); + const banner = opts.injectDiagnostics?.({ transformedModules: [], failedModules: [] }); + expect(typeof banner).toBe('string'); + // `globalThis` inside the snippet resolves to the sandbox object we pass in. + // oxlint-disable-next-line typescript/no-implied-eval -- executing the generated injection snippet is the behavior under test + new Function('globalThis', banner as string)(global); + } + + it('marks the plugin as ran with an empty module list', () => { + const global: Record = {}; + + runBanner(global); + + expect((global.__SENTRY_ORCHESTRION__ as { bundler?: string[] }).bundler).toEqual([]); + }); + + it('never clobbers module names an injected snippet already recorded', () => { + const global: Record = { __SENTRY_ORCHESTRION__: { bundler: ['mysql'] } }; + + runBanner(global); - expect(() => runBanner(['mysql'], global)).not.toThrow(); - // `.bundler` is still recorded so `init()` can drive subscription from it. - expect((global.__SENTRY_ORCHESTRION__ as { bundler?: string[] }).bundler).toEqual(['mysql']); + expect((global.__SENTRY_ORCHESTRION__ as { bundler?: string[] }).bundler).toEqual(['mysql']); + }); }); }); diff --git a/packages/server-utils/test/orchestrion/detect.test.ts b/packages/server-utils/test/orchestrion/detect.test.ts index b578340f47fa..972cc7b0af37 100644 --- a/packages/server-utils/test/orchestrion/detect.test.ts +++ b/packages/server-utils/test/orchestrion/detect.test.ts @@ -22,16 +22,8 @@ describe('isOrchestrionInjected', () => { ['integrations', { integrations: new Map() }], ] as const)('is true when %s injection is present', (_label, marker) => { // Cast through `unknown`: rows are `as const` (readonly) and `bundler: true` - // is a valid runtime shape the marker type doesn't spell out. + // is a legacy runtime shape the marker type no longer spells out. GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = marker as unknown as typeof GLOBAL_OBJ.__SENTRY_ORCHESTRION__; expect(isOrchestrionInjected()).toBe(true); }); - - // The bridge is installed before hook registration succeeds, so a marker - // carrying only `onInject` means nothing will publish channels. Opt-in paths - // (knex, dataloader, Nest) must still fall back to OTel in that case. - it('is false when only the on-inject bridge is installed', () => { - GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = { onInject: () => {} }; - expect(isOrchestrionInjected()).toBe(false); - }); }); diff --git a/packages/server-utils/test/orchestrion/instrumentation.test.ts b/packages/server-utils/test/orchestrion/instrumentation.test.ts index b93e2311e993..2815c607ee76 100644 --- a/packages/server-utils/test/orchestrion/instrumentation.test.ts +++ b/packages/server-utils/test/orchestrion/instrumentation.test.ts @@ -14,7 +14,7 @@ function installBinding(): void { } as unknown as AsyncContextStrategy); } -// client that emits `orchestrion.module-runtime-injected` +// client that emits `orchestrion.module-injected` function makeClient(): { on: (hook: string, cb: (moduleName: string) => void) => () => void; inject: (moduleName: string) => void; diff --git a/packages/server-utils/test/orchestrion/moduleInjected.test.ts b/packages/server-utils/test/orchestrion/moduleInjected.test.ts new file mode 100644 index 000000000000..d64fda3d60a5 --- /dev/null +++ b/packages/server-utils/test/orchestrion/moduleInjected.test.ts @@ -0,0 +1,83 @@ +import type { Client, Integration } from '@sentry/core'; +import { getCurrentScope, GLOBAL_OBJ } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { orchestrionModuleInjected } from '../../src/orchestrion/moduleInjected'; + +describe('orchestrionModuleInjected', () => { + const factory = (name: string) => (): Integration => ({ name, setupOnce: () => undefined }); + + beforeEach(() => { + delete GLOBAL_OBJ.__SENTRY_ORCHESTRION__; + getCurrentScope().setClient(undefined); + }); + + afterEach(() => { + delete GLOBAL_OBJ.__SENTRY_ORCHESTRION__; + getCurrentScope().setClient(undefined); + }); + + it('records the module name as bundler-injected', () => { + orchestrionModuleInjected('mysql'); + expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.bundler).toEqual(['mysql']); + }); + + it('deduplicates the recorded module across repeated calls', () => { + orchestrionModuleInjected('mysql'); + orchestrionModuleInjected('mysql'); + expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.bundler).toEqual(['mysql']); + }); + + 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); + }); + + it('stores no factory when none is given', () => { + orchestrionModuleInjected('mongodb'); + expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.integrations).toBeUndefined(); + }); + + it('emits the module-injected event on the current client, after recording', () => { + const emit = vi.fn(() => { + // Listeners react by reading the marker, so it must be recorded by now. + expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.bundler).toEqual(['mysql']); + expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.integrations?.has('mysql')).toBe(true); + }); + getCurrentScope().setClient({ emit } as unknown as Client); + + orchestrionModuleInjected('mysql', factory('Mysql')); + + expect(emit).toHaveBeenCalledWith('orchestrion.module-injected', 'mysql'); + }); + + it('does not throw when no client is set yet', () => { + expect(() => orchestrionModuleInjected('mysql', factory('Mysql'))).not.toThrow(); + // still recorded for the next init() to pick up + expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.bundler).toEqual(['mysql']); + expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.integrations?.has('mysql')).toBe(true); + }); + + it('does not install the integration itself — installing is SDK policy', () => { + const addIntegration = vi.fn(); + const emit = vi.fn(); + getCurrentScope().setClient({ addIntegration, emit } as unknown as Client); + + orchestrionModuleInjected('mysql', factory('Mysql')); + + expect(addIntegration).not.toHaveBeenCalled(); + expect(emit).toHaveBeenCalledWith('orchestrion.module-injected', 'mysql'); + }); + + it('leaves a foreign non-array bundler flag untouched but still stores and emits', () => { + GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = { bundler: true as unknown as string[] }; + const emit = vi.fn(); + getCurrentScope().setClient({ emit } as unknown as Client); + + orchestrionModuleInjected('mysql', factory('Mysql')); + + expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.bundler).toBe(true); + expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.integrations?.has('mysql')).toBe(true); + expect(emit).toHaveBeenCalledWith('orchestrion.module-injected', 'mysql'); + }); +}); diff --git a/packages/server-utils/test/orchestrion/subscribeInjection.test.ts b/packages/server-utils/test/orchestrion/moduleInjectedTransform.test.ts similarity index 54% rename from packages/server-utils/test/orchestrion/subscribeInjection.test.ts rename to packages/server-utils/test/orchestrion/moduleInjectedTransform.test.ts index 0c4002c81234..e2c1f4db0db1 100644 --- a/packages/server-utils/test/orchestrion/subscribeInjection.test.ts +++ b/packages/server-utils/test/orchestrion/moduleInjectedTransform.test.ts @@ -7,6 +7,7 @@ import { CHANNEL_INTEGRATION_DEFINITIONS, subscriberExportForModule, } from '../../src/orchestrion/config/channel-integration-definitions'; +import { moduleInjectedTransforms } from '../../src/orchestrion/bundler/moduleInjectedTransform'; import { orchestrionTransformOptions } from '../../src/orchestrion/bundler/options'; // The code transformer reads the instrumented package's version from its @@ -34,31 +35,22 @@ describe('channel integration definitions', () => { }); }); -describe('subscribe-injection transform option', () => { +describe('module-injected transform', () => { let root: string; beforeAll(() => { - root = mkdtempSync(join(tmpdir(), 'orch-subscribe-')); + root = mkdtempSync(join(tmpdir(), 'orch-module-injected-')); makePackage(root, 'mysql', '2.18.1', 'commonjs'); makePackage(root, 'pg', '8.11.0', 'module'); + makePackage(root, 'my-lib', '1.0.0', 'commonjs'); }); afterAll(() => { rmSync(root, { recursive: true, force: true }); }); - it('registers the tracingChannelImport override only when opted in', () => { - const off = orchestrionTransformOptions({}); - expect(off.customTransforms).toEqual({}); - - const on = orchestrionTransformOptions({ injectChannelSubscribers: true }); - expect(Object.keys(on.customTransforms || {})).toContain('tracingChannelImport'); - // The override rides the real channel configs — opting in adds no extra ones. - expect(on.instrumentations).toEqual(off.instrumentations); - }); - - it('injects a CJS marker-push importing only that package factory, after "use strict"', () => { - const t = createCodeTransformer(orchestrionTransformOptions({ injectChannelSubscribers: true })); + it('injects a CJS snippet importing only that package factory, after "use strict"', () => { + const t = createCodeTransformer(orchestrionTransformOptions({})); const code = "'use strict';\nfunction Connection(){}\nConnection.prototype.query = function query(sql, cb){ return cb(); };\n"; const result = t.transform(code, join(root, 'node_modules/mysql/lib/Connection.js')); @@ -67,23 +59,22 @@ describe('subscribe-injection transform option', () => { expect(result!.code.split('\n')[0]).toContain("'use strict'"); // Imports ONLY the mysql factory plus the generic helper, from a single require. expect(result!.code).toMatch( - /const\s*\{\s*mysqlIntegration,\s*registerOrchestrionChannelIntegration\s*\}\s*=\s*require\(["']@sentry\/server-utils\/orchestrion["']\)/, + /const\s*\{\s*orchestrionModuleInjected,\s*mysqlIntegration\s*\}\s*=\s*require\(["']@sentry\/server-utils\/orchestrion["']\)/, ); - // The helper stores the factory on the marker AND live-registers it on an existing client, so a - // module that loads AFTER `init()` (mysql loads its instrumented file lazily) still subscribes - // for the in-flight request instead of only the next `init()`. - expect(result!.code).toContain('registerOrchestrionChannelIntegration("mysqlIntegration", mysqlIntegration)'); + // The helper is called with the REAL module name, so no reverse lookup is + // needed at runtime and the lazy-subscription event matches what channel + // integrations wait for. + expect(result!.code).toContain('orchestrionModuleInjected("mysql", mysqlIntegration)'); // No separate @sentry/core import at the injection site — the helper owns that. expect(result!.code).not.toContain('@sentry/core'); // It imports ONLY the mysql factory — no central dispatch pulling in others. - expect(result!.code).not.toContain('pgChannelIntegration'); - expect(result!.code).not.toContain('subscribeOrchestrionChannel'); + expect(result!.code).not.toContain('postgresIntegration'); // The real channel-publishing transform still ran alongside the injection. expect(result!.code).toContain('orchestrion:mysql:query'); }); - it('injects an ESM marker-push for an instrumented ESM module', () => { - const t = createCodeTransformer(orchestrionTransformOptions({ injectChannelSubscribers: true })); + it('injects an ESM snippet for an instrumented ESM module', () => { + const t = createCodeTransformer(orchestrionTransformOptions({})); const result = t.transform( 'export class Client { query(){} connect(){} }\n', join(root, 'node_modules/pg/lib/client.js'), @@ -91,21 +82,59 @@ describe('subscribe-injection transform option', () => { expect(result).not.toBeNull(); expect(result!.code).toMatch( - /import\s*\{\s*postgresIntegration,\s*registerOrchestrionChannelIntegration\s*\}\s*from\s*["']@sentry\/server-utils\/orchestrion["']/, + /import\s*\{\s*orchestrionModuleInjected,\s*postgresIntegration\s*\}\s*from\s*["']@sentry\/server-utils\/orchestrion["']/, ); expect(result!.code).not.toContain('@sentry/core'); - expect(result!.code).toContain('registerOrchestrionChannelIntegration("postgresIntegration", postgresIntegration)'); + expect(result!.code).toContain('orchestrionModuleInjected("pg", postgresIntegration)'); + }); + + it('injects a helper-only snippet for a module with no subscriber factory', () => { + // A custom instrumentation for a package outside CHANNEL_INTEGRATION_DEFINITIONS — + // the marker/event coverage the banner used to provide now comes from this snippet. + const t = createCodeTransformer( + orchestrionTransformOptions({ + instrumentations: [ + { + channelName: 'work', + module: { name: 'my-lib', versionRange: '>=1', filePath: 'lib/index.js' }, + functionQuery: { functionName: 'doWork', kind: 'Sync' }, + }, + ], + }), + ); + const result = t.transform('function doWork(){ return 1; }\n', join(root, 'node_modules/my-lib/lib/index.js')); + + expect(result).not.toBeNull(); + expect(result!.code).toMatch( + /const\s*\{\s*orchestrionModuleInjected\s*\}\s*=\s*require\(["']@sentry\/server-utils\/orchestrion["']\)/, + ); + expect(result!.code).toContain('orchestrionModuleInjected("my-lib")'); }); - it('registers the factory at most once per file', () => { - const t = createCodeTransformer(orchestrionTransformOptions({ injectChannelSubscribers: true })); + it('injects at most once per file', () => { + const t = createCodeTransformer(orchestrionTransformOptions({})); // `pg`'s `lib/client.js` is matched by both the `query` and `connect` configs. const result = t.transform( 'export class Client { query(){} connect(){} }\n', join(root, 'node_modules/pg/lib/client.js'), ); - const registrations = result!.code.match(/registerOrchestrionChannelIntegration\("postgresIntegration"/g) ?? []; - expect(registrations).toHaveLength(1); + const calls = result!.code.match(/orchestrionModuleInjected\("pg"/g) ?? []; + expect(calls).toHaveLength(1); + }); + + it('honors a custom import specifier (Turbopack passes an absolute path)', () => { + const t = createCodeTransformer({ + ...orchestrionTransformOptions({}), + customTransforms: moduleInjectedTransforms('/abs/path/to/orchestrion/index.js'), + }); + const result = t.transform( + "'use strict';\nfunction Connection(){}\nConnection.prototype.query = function query(sql, cb){ return cb(); };\n", + join(root, 'node_modules/mysql/lib/Connection.js'), + ); + + expect(result).not.toBeNull(); + expect(result!.code).toContain('require("/abs/path/to/orchestrion/index.js")'); + expect(result!.code).not.toContain('require("@sentry/server-utils/orchestrion")'); }); }); diff --git a/packages/server-utils/test/orchestrion/registerChannelIntegration.test.ts b/packages/server-utils/test/orchestrion/registerChannelIntegration.test.ts deleted file mode 100644 index b5ba2533d232..000000000000 --- a/packages/server-utils/test/orchestrion/registerChannelIntegration.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { Client, Integration } from '@sentry/core'; -import { getCurrentScope, GLOBAL_OBJ } from '@sentry/core'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { registerOrchestrionChannelIntegration } from '../../src/orchestrion/registerChannelIntegration'; - -describe('registerOrchestrionChannelIntegration', () => { - const factory = (name: string) => (): Integration => ({ name, setupOnce: () => undefined }); - - beforeEach(() => { - delete GLOBAL_OBJ.__SENTRY_ORCHESTRION__; - getCurrentScope().setClient(undefined); - }); - - afterEach(() => { - delete GLOBAL_OBJ.__SENTRY_ORCHESTRION__; - getCurrentScope().setClient(undefined); - }); - - it('stores the factory on the global marker keyed by its export name', () => { - const fn = factory('MyIntegration'); - registerOrchestrionChannelIntegration('myChannelIntegration', fn); - expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.integrations?.get('myChannelIntegration')).toBe(fn); - }); - - it('keeps one entry per export name (a package split across files registers once)', () => { - registerOrchestrionChannelIntegration('myChannelIntegration', factory('MyIntegration')); - registerOrchestrionChannelIntegration('myChannelIntegration', factory('MyIntegration')); - expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.integrations?.size).toBe(1); - }); - - it('live-registers the integration on an already-set client', () => { - const addIntegration = vi.fn(); - getCurrentScope().setClient({ addIntegration } as unknown as Client); - - registerOrchestrionChannelIntegration('myChannelIntegration', factory('MyIntegration')); - - expect(addIntegration).toHaveBeenCalledTimes(1); - expect(addIntegration.mock.calls[0]?.[0]).toMatchObject({ name: 'MyIntegration' }); - }); - - it('does not throw the live add when no client is set yet', () => { - expect(() => registerOrchestrionChannelIntegration('myChannelIntegration', factory('X'))).not.toThrow(); - // still stored for the next init() to pick up - expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.integrations?.has('myChannelIntegration')).toBe(true); - }); - - it('records the export name`s instrumented package(s) as bundler-injected', () => { - // `postgresIntegration` covers both `pg` and `pg-pool`. - registerOrchestrionChannelIntegration('postgresIntegration', factory('Postgres')); - expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.bundler).toEqual(['pg', 'pg-pool']); - }); - - it('deduplicates recorded modules across repeated registration', () => { - registerOrchestrionChannelIntegration('mysqlIntegration', factory('Mysql')); - registerOrchestrionChannelIntegration('mysqlIntegration', factory('Mysql')); - expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.bundler).toEqual(['mysql']); - }); - - it('leaves a non-array bundler flag (Bun sets `true`) untouched', () => { - GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = { bundler: true as unknown as string[] }; - registerOrchestrionChannelIntegration('mysqlChannelIntegration', factory('Mysql')); - expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.bundler).toBe(true); - }); - - it('records nothing for an export name with no instrumented package mapping', () => { - registerOrchestrionChannelIntegration('myChannelIntegration', factory('X')); - expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.bundler).toBeUndefined(); - }); -}); diff --git a/packages/server-utils/test/orchestrion/webpack-loader.test.ts b/packages/server-utils/test/orchestrion/webpack-loader.test.ts index 6ce4b9b375f8..c81355aca92c 100644 --- a/packages/server-utils/test/orchestrion/webpack-loader.test.ts +++ b/packages/server-utils/test/orchestrion/webpack-loader.test.ts @@ -1,88 +1,94 @@ -import { describe, expect, it, vi } from 'vitest'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import loader from '../../src/orchestrion/bundler/webpack-loader'; +import { getSentryInstrumentations, serializeInstrumentations } from '../../src/orchestrion/bundler/webpack'; + +// Runs the real factory-built loader end-to-end: the loader must transform +// instrumented files (and splice the module-injected snippet) from nothing but +// JSON-serializable options — the exact contract Turbopack holds it to. + +// The code transformer reads the instrumented package's version from its +// on-disk `package.json`, so each test package needs a real directory. +function makePackage(root: string, name: string, version: string): void { + const dir = join(root, 'node_modules', name); + mkdirSync(join(dir, 'lib'), { recursive: true }); + writeFileSync(join(dir, 'package.json'), JSON.stringify({ name, version })); +} + +const MYSQL_CONNECTION_SOURCE = + "'use strict';\nfunction Connection(){}\nConnection.prototype.query = function query(sql, cb){ return cb(); };\n"; -// Stand in for the upstream code-transform loader: a changed string signals a -// transformed module, the input unchanged signals a pass-through. -vi.mock('@apm-js-collab/code-transformer-bundler-plugins/webpack-loader', () => ({ - default: function (this: { async: () => (e: unknown, c?: string, m?: unknown) => void }, code: string, map: unknown) { - const callback = this.async(); - callback(null, code === 'PASS' ? code : `${code};//transformed`, map); - }, -})); - -interface Ctx { - resourcePath: string; - _compilation?: unknown; +interface LoaderResult { + error: unknown; + code: string | undefined; } -function runLoader(ctx: Ctx, code: string): string | undefined { - let output: string | undefined; +function runLoader(resourcePath: string, code: string, options: Record): LoaderResult { + const result: LoaderResult = { error: undefined, code: undefined }; const context = { - ...ctx, - async: () => (_error: unknown, outCode?: string) => { - output = outCode; + resourcePath, + getOptions: () => options, + async: () => (error: unknown, outCode?: string) => { + result.error = error; + result.code = outCode; }, }; (loader as (this: unknown, code: string) => void).call(context, code); - return output; + return result; } -const IOREDIS = '/app/node_modules/ioredis/built/Redis.js'; - describe('orchestrion webpack/Turbopack loader', () => { - it('appends the onInject call for a transformed module under Turbopack (no compilation)', () => { - const output = runLoader({ resourcePath: IOREDIS }, 'code'); - - expect(output).toContain('code;//transformed'); - expect(output).toContain('g.onInject("ioredis")'); + let root: string; + // The JSON-safe form Turbopack loader options must use (RegExp `filePath`s encoded). + const instrumentations = JSON.parse(JSON.stringify(serializeInstrumentations(getSentryInstrumentations()))); + + beforeAll(() => { + root = mkdtempSync(join(tmpdir(), 'orch-webpack-loader-')); + makePackage(root, 'mysql', '2.18.1'); + makePackage(root, 'left-pad', '1.3.0'); }); - it('records the module on `.bundler` even when the bridge is not installed yet', () => { - const output = runLoader({ resourcePath: IOREDIS }, 'code') as string; - const snippet = output.slice('code;//transformed'.length); - - // Bridge absent (early load): records `.bundler`, no throw. - const early: { __SENTRY_ORCHESTRION__?: { bundler?: string[]; onInject?: (name: string) => void } } = {}; - // oxlint-disable-next-line typescript/no-implied-eval -- executing the generated injection snippet is the behavior under test - new Function('globalThis', snippet)(early); - expect(early.__SENTRY_ORCHESTRION__?.bundler).toEqual(['ioredis']); - - // Bridge present (loaded after init): records AND fires the bridge. - const injected: string[] = []; - const late = { __SENTRY_ORCHESTRION__: { onInject: (name: string) => injected.push(name) } }; - // oxlint-disable-next-line typescript/no-implied-eval -- executing the generated injection snippet is the behavior under test - new Function('globalThis', snippet)(late); - expect(injected).toEqual(['ioredis']); + afterAll(() => { + rmSync(root, { recursive: true, force: true }); }); - it('does not append when webpack runs the plugin banner (compilation present)', () => { - const output = runLoader({ resourcePath: IOREDIS, _compilation: {} }, 'code'); - - expect(output).toBe('code;//transformed'); - expect(output).not.toContain('onInject'); + it('transforms an instrumented module and splices the module-injected snippet', () => { + const { error, code } = runLoader(join(root, 'node_modules/mysql/lib/Connection.js'), MYSQL_CONNECTION_SOURCE, { + instrumentations, + }); + + expect(error).toBeNull(); + expect(code).toContain('orchestrion:mysql:query'); + expect(code).toMatch( + /const\s*\{\s*orchestrionModuleInjected,\s*mysqlIntegration\s*\}\s*=\s*require\(["']@sentry\/server-utils\/orchestrion["']\)/, + ); + expect(code).toContain('orchestrionModuleInjected("mysql", mysqlIntegration)'); }); - it('does not append for a pass-through (untransformed) module', () => { - const output = runLoader({ resourcePath: IOREDIS }, 'PASS'); + it('honors the importSpecifier option (Turbopack passes an absolute path)', () => { + const { code } = runLoader(join(root, 'node_modules/mysql/lib/Connection.js'), MYSQL_CONNECTION_SOURCE, { + instrumentations, + importSpecifier: '/abs/path/to/orchestrion/index.js', + }); - expect(output).toBe('PASS'); + expect(code).toContain('require("/abs/path/to/orchestrion/index.js")'); + expect(code).not.toContain('require("@sentry/server-utils/orchestrion")'); }); - it.each([ - ['/app/node_modules/ioredis/built/Redis.js', 'ioredis'], - ['/app/node_modules/@redis/client/dist/lib/client/index.js', '@redis/client'], - // pnpm's nested layout: the real package is after the LAST node_modules. - ['/app/node_modules/.pnpm/ioredis@5.10.1/node_modules/ioredis/built/Redis.js', 'ioredis'], - ])('derives the package name from %s as %s', (resourcePath, expected) => { - const output = runLoader({ resourcePath }, 'code'); + it('passes through files of packages that are not instrumented', () => { + const source = 'module.exports = function leftPad(){};\n'; + const { error, code } = runLoader(join(root, 'node_modules/left-pad/lib/index.js'), source, { instrumentations }); - expect(output).toContain(`g.onInject("${expected}")`); + expect(error).toBeNull(); + expect(code).toBe(source); }); - it('does not append when the path has no node_modules segment', () => { - const output = runLoader({ resourcePath: '/app/src/index.js' }, 'code'); + it('passes through files with no node_modules package context', () => { + const source = 'export const app = 1;\n'; + const { code } = runLoader('/app/src/index.js', source, { instrumentations }); - expect(output).toBe('code;//transformed'); - expect(output).not.toContain('onInject'); + expect(code).toBe(source); }); }); diff --git a/packages/sveltekit/src/vite/sentryVitePlugins.ts b/packages/sveltekit/src/vite/sentryVitePlugins.ts index 171ca5e128c1..614e660fcb7b 100644 --- a/packages/sveltekit/src/vite/sentryVitePlugins.ts +++ b/packages/sveltekit/src/vite/sentryVitePlugins.ts @@ -58,8 +58,6 @@ export async function sentrySvelteKit(options: SentrySvelteKitPluginOptions = {} sentryPlugins.push( sentryOrchestrionPlugin({ buildTimeInstrumentation: mergedOptions.buildTimeInstrumentation, - // On Cloudflare, subscribers are wired via a build-time marker the SDK reads at runtime; - ...(mergedOptions.adapter === 'cloudflare' ? { injectChannelSubscribers: true } : {}), }), ); diff --git a/packages/sveltekit/test/vite/sentrySvelteKitPlugins.test.ts b/packages/sveltekit/test/vite/sentrySvelteKitPlugins.test.ts index 931ab6d8d419..47138168cdd9 100644 --- a/packages/sveltekit/test/vite/sentrySvelteKitPlugins.test.ts +++ b/packages/sveltekit/test/vite/sentrySvelteKitPlugins.test.ts @@ -19,14 +19,11 @@ vi.mock('fs', async () => { // Stub the orchestrion plugin so these stay pure wiring tests (no apm code transformer pulled in). // Mirror the real plugin's contract: `buildTimeInstrumentation: false` yields the inert variant. -const orchestrionVite = vi.fn( - (options?: { buildTimeInstrumentation?: boolean; injectChannelSubscribers?: boolean }) => ({ - name: options?.buildTimeInstrumentation === false ? 'sentry-orchestrion-disabled' : 'sentry-orchestrion-vite', - }), -); +const orchestrionVite = vi.fn((options?: { buildTimeInstrumentation?: boolean }) => ({ + name: options?.buildTimeInstrumentation === false ? 'sentry-orchestrion-disabled' : 'sentry-orchestrion-vite', +})); vi.mock('@sentry/server-utils/orchestrion/vite', () => ({ - sentryOrchestrionPlugin: (options?: { buildTimeInstrumentation?: boolean; injectChannelSubscribers?: boolean }) => - orchestrionVite(options), + sentryOrchestrionPlugin: (options?: { buildTimeInstrumentation?: boolean }) => orchestrionVite(options), })); vi.spyOn(console, 'log').mockImplementation(() => { @@ -112,19 +109,15 @@ describe('sentrySvelteKit()', () => { expect(pluginNames).not.toContain('sentry-orchestrion-vite'); }); - it('adds the orchestrion plugin with channel-subscriber injection for the cloudflare adapter', async () => { + it('adds the orchestrion plugin with the same options regardless of adapter', async () => { orchestrionVite.mockClear(); const plugins = await getSentrySvelteKitPlugins({ adapter: 'cloudflare' }); - expect(orchestrionVite).toHaveBeenCalledWith(expect.objectContaining({ injectChannelSubscribers: true })); + expect(orchestrionVite).toHaveBeenCalledWith({ buildTimeInstrumentation: undefined }); expect(plugins.map(plugin => plugin.name)).toContain('sentry-orchestrion-vite'); - }); - it("doesn't inject channel subscribers for non-cloudflare adapters", async () => { orchestrionVite.mockClear(); await getSentrySvelteKitPlugins({ adapter: 'node' }); - expect(orchestrionVite).toHaveBeenCalledWith( - expect.not.objectContaining({ injectChannelSubscribers: expect.anything() }), - ); + expect(orchestrionVite).toHaveBeenCalledWith({ buildTimeInstrumentation: undefined }); }); it('passes user-specified vite plugin options to the custom sentry source maps plugin', async () => { diff --git a/yarn.lock b/yarn.lock index 90fce03d6135..2597cc291ae4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -404,17 +404,17 @@ dependencies: json-schema-to-ts "^3.1.1" -"@apm-js-collab/code-transformer-bundler-plugins@^0.7.3": - version "0.7.3" - resolved "https://registry.yarnpkg.com/@apm-js-collab/code-transformer-bundler-plugins/-/code-transformer-bundler-plugins-0.7.3.tgz#c439f6d63306c1800a430733aac3af6e496d9fe8" - integrity sha512-qNbPwuMZ8f5ZuGj/ttPeB7a6C/S1bB6tNYaEL5vNiRKydSAxa4AU0gxCWgaP4fVju+AuwhcumSFjrEcGF9Dv7Q== +"@apm-js-collab/code-transformer-bundler-plugins@^0.7.4": + version "0.7.4" + resolved "https://registry.yarnpkg.com/@apm-js-collab/code-transformer-bundler-plugins/-/code-transformer-bundler-plugins-0.7.4.tgz#f323d5f723565e34f1409aa96e445ad489020f76" + integrity sha512-nAfOeZPSUAQvJa1iFT/5oCrTm5YQhMMrfCNthNnaXHZiOQhu1KGuLoIx7HtbAi3wfwaBYLaICPIeenIaEwcXIg== dependencies: - "@apm-js-collab/code-transformer" "^0.18.0" + "@apm-js-collab/code-transformer" "^0.18.1" es-module-lexer "^2.1.0" magic-string "^0.30.21" module-details-from-path "^1.0.4" -"@apm-js-collab/code-transformer@^0.18.0": +"@apm-js-collab/code-transformer@^0.18.0", "@apm-js-collab/code-transformer@^0.18.1": version "0.18.1" resolved "https://registry.yarnpkg.com/@apm-js-collab/code-transformer/-/code-transformer-0.18.1.tgz#66ce01cfe9607779b4abebb4f54c49a46e8b48ca" integrity sha512-u1Hb6bHjWtkSpiprwVP6YaHC1DTN4RAU3zYkUDUe7WMnJwdyU1pwTL9dFKiSJB9IiLue/EQovmyx6xhU7FFtAQ== From aad35070466ec6d4442093ddffa6c2008d96e434 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Thu, 6 Aug 2026 00:15:20 +0100 Subject: [PATCH 2/4] fix tests --- .../turbopack/constructTurbopackConfig.ts | 14 +++-- .../constructTurbopackConfig.test.ts | 19 +++--- .../bundler/moduleInjectedTransform.ts | 10 +++- .../src/orchestrion/bundler/vite.ts | 35 ++++++++++- .../src/orchestrion/bundler/webpack-loader.ts | 59 +++++++++++++------ .../test/orchestrion/bundler.test.ts | 48 ++++++++++++++- .../test/orchestrion/webpack-loader.test.ts | 19 +++++- 7 files changed, 162 insertions(+), 42 deletions(-) diff --git a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts index 78f5000dcb2d..fd58c4e95216 100644 --- a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts +++ b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts @@ -139,11 +139,13 @@ function maybeAddOrchestrionRule( return rules; } - // The loader's transform splices an import of `@sentry/server-utils/orchestrion` into each - // instrumented module. Turbopack has no externals-function seam, and under isolated installs - // (pnpm) the bare specifier emitted inside a bundled package doesn't resolve from that - // package's location — so pass the helper's absolute on-disk path for the snippet to import. - const importSpecifier = resolveOrchestrionRuntimeRequest('@sentry/server-utils/orchestrion'); + // The loader's transform splices an import of the `@sentry/server-utils/orchestrion` helper into + // each instrumented module. Turbopack rejects absolute-path imports ("server relative imports are + // not implemented yet"), and under isolated installs (pnpm) the bare specifier emitted inside a + // bundled package doesn't resolve from that package's location — so pass the helper's absolute + // on-disk path and let the loader derive a per-file RELATIVE specifier, which Turbopack resolves + // from the importing file and bundles at build time. + const importHelperPath = resolveOrchestrionRuntimeRequest('@sentry/server-utils/orchestrion'); return safelyAddTurbopackRule(rules, { matcher: '*.{js,mjs,cjs}', @@ -155,7 +157,7 @@ function maybeAddOrchestrionRule( // Turbopack JSON-serializes loader options, so a RegExp `filePath` must be encoded first. options: { instrumentations: serializeInstrumentations(getSentryInstrumentations()) as unknown as JSONValue[], - ...(importSpecifier ? { importSpecifier } : {}), + ...(importHelperPath ? { importHelperPath } : {}), }, }, ], diff --git a/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts b/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts index c84141ca0413..cdf496da0745 100644 --- a/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts +++ b/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts @@ -1337,7 +1337,7 @@ describe('orchestrion build-time instrumentation', () => { expect(JSON.parse(JSON.stringify(firestore!.module.filePath))).not.toEqual({}); }); - it('passes the helper module as an absolute-path importSpecifier', () => { + it('passes the helper module as an absolute importHelperPath', () => { const result = constructTurbopackConfig({ userNextConfig: {}, userSentryOptions: {}, @@ -1345,15 +1345,16 @@ describe('orchestrion build-time instrumentation', () => { }); const rule = result.rules!['*.{js,mjs,cjs}'] as { - loaders: Array<{ options: { importSpecifier?: string } }>; + loaders: Array<{ options: { importHelperPath?: string } }>; }; - const importSpecifier = rule.loaders[0]!.options.importSpecifier; - - // Turbopack has no externals-function seam: the snippet's import must be an - // absolute path so it resolves under isolated installs (pnpm). - expect(importSpecifier).toBeDefined(); - expect(path.isAbsolute(importSpecifier!)).toBe(true); - expect(importSpecifier).toContain('orchestrion'); + const importHelperPath = rule.loaders[0]!.options.importHelperPath; + + // The loader derives a per-file RELATIVE specifier from this path — + // Turbopack rejects absolute-path imports, and a bare specifier doesn't + // resolve from inside a transformed package under isolated installs (pnpm). + expect(importHelperPath).toBeDefined(); + expect(path.isAbsolute(importHelperPath!)).toBe(true); + expect(importHelperPath).toContain('orchestrion'); }); it('restricts the orchestrion rule to the node environment', () => { diff --git a/packages/server-utils/src/orchestrion/bundler/moduleInjectedTransform.ts b/packages/server-utils/src/orchestrion/bundler/moduleInjectedTransform.ts index 48e6dc92f57c..781ba5fd8a0c 100644 --- a/packages/server-utils/src/orchestrion/bundler/moduleInjectedTransform.ts +++ b/packages/server-utils/src/orchestrion/bundler/moduleInjectedTransform.ts @@ -77,7 +77,11 @@ function moduleInjectedSnippet( * `state.transforms.defaults`. */ export function moduleInjectedTransforms( - importSpecifier: string = DEFAULT_IMPORT_SPECIFIER, + // A function is read per injected file — the webpack/Turbopack loader uses it + // to supply a per-file relative specifier (Turbopack supports neither + // absolute-path imports nor bare specifiers that don't resolve from the + // importing file's location). + importSpecifier?: string | (() => string | undefined), ): Record { const injectModuleInjected: CustomTransform = (state, program, parent, ancestry) => { const { moduleType, module, transforms } = state as { @@ -100,8 +104,10 @@ export function moduleInjectedTransforms( injectedPrograms.add(node); + const specifier = + (typeof importSpecifier === 'function' ? importSpecifier() : importSpecifier) ?? DEFAULT_IMPORT_SPECIFIER; const exportName = subscriberExportForModule(moduleName); - const statements = parse(moduleInjectedSnippet(moduleName, exportName, moduleType === 'esm', importSpecifier), { + const statements = parse(moduleInjectedSnippet(moduleName, exportName, moduleType === 'esm', specifier), { module: moduleType === 'esm', next: true, }).body as ProgramNode['body']; diff --git a/packages/server-utils/src/orchestrion/bundler/vite.ts b/packages/server-utils/src/orchestrion/bundler/vite.ts index 6bd318414857..66e1261f6446 100644 --- a/packages/server-utils/src/orchestrion/bundler/vite.ts +++ b/packages/server-utils/src/orchestrion/bundler/vite.ts @@ -5,6 +5,33 @@ import type { PluginOptions } from './options'; import { externalEntryMatchesModule, externalizedModulesWarning, orchestrionTransformOptions } from './options'; import { resolveOrchestrionRuntimeRequest } from './resolve'; +type TransformHandler = (this: unknown, code: string, id: string, opts?: { ssr?: boolean }) => unknown; + +// On Vite >= 6 `applyToEnvironment` (below) keeps the whole plugin out of +// client environments. Vite 5 (e.g. Remix v2) ignores that hook, so without +// this gate the transform would also run in the CLIENT build — where modules +// like `@remix-run/server-runtime` sit in the client graph, and the injected +// snippet's import of the subscriber factories (which import +// `node:diagnostics_channel`) breaks against Vite's browser builtin shim. Gate +// on the `ssr` flag, which Vite passes on both major versions. +function ssrOnlyTransform(transform: Plugin['transform']): Plugin['transform'] { + const gate = (handler: TransformHandler): TransformHandler => + function (code, id, opts) { + if (!opts?.ssr) { + return null; + } + return handler.call(this, code, id, opts); + }; + + if (typeof transform === 'function') { + return gate(transform as TransformHandler) as Plugin['transform']; + } + if (transform && typeof transform === 'object') { + return { ...transform, handler: gate(transform.handler as TransformHandler) } as Plugin['transform']; + } + return transform; +} + /** * Vite plugin that runs the orchestrion code transform on the bundled output. * @@ -27,15 +54,19 @@ export function sentryOrchestrionPlugin(options: PluginOptions = {}): Plugin { return { name: 'sentry-orchestrion-disabled' }; } + const upstream = codeTransformer(orchestrionTransformOptions(options)); + return { - ...codeTransformer(orchestrionTransformOptions(options)), + ...upstream, + transform: ssrOnlyTransform(upstream.transform), // The module-injected snippet imports `@sentry/server-utils/orchestrion` // from INSIDE transformed `node_modules` files. Under isolated installs // (pnpm) that bare specifier doesn't resolve from an instrumented package's // location, so when normal resolution fails, fall back to this package's // own resolution so the helper gets bundled from its real on-disk path. + // SSR-gated like the transform: the specifier only exists in SSR modules. async resolveId(source, importer, resolveOptions) { - if (source !== '@sentry/server-utils/orchestrion') { + if (source !== '@sentry/server-utils/orchestrion' || !resolveOptions?.ssr) { return null; } const resolved = await this.resolve(source, importer, { ...resolveOptions, skipSelf: true }); diff --git a/packages/server-utils/src/orchestrion/bundler/webpack-loader.ts b/packages/server-utils/src/orchestrion/bundler/webpack-loader.ts index 5d0087559463..34006c30483a 100644 --- a/packages/server-utils/src/orchestrion/bundler/webpack-loader.ts +++ b/packages/server-utils/src/orchestrion/bundler/webpack-loader.ts @@ -8,39 +8,60 @@ // by on-disk path via `getOrchestrionLoaderPath()`, so it needs its own // entrypoint/subpath rather than being reachable from another module. import { createLoader } from '@apm-js-collab/code-transformer-bundler-plugins/webpack-loader-factory'; +import { dirname, relative } from 'node:path'; import { moduleInjectedTransforms } from './moduleInjectedTransform'; +interface LoaderOptions { + /** Fixed import specifier for the module-injected snippet. */ + importSpecifier?: string; + /** + * Absolute path to the `@sentry/server-utils/orchestrion` helper module. When + * set, the snippet imports a PER-FILE RELATIVE path to it: Turbopack rejects + * absolute-path imports ("server relative imports are not implemented yet"), + * and a bare specifier emitted inside a transformed package doesn't resolve + * from that package's location under isolated installs (pnpm). A relative + * specifier is resolved from the importing file and consumed entirely at + * build time. Takes precedence over `importSpecifier`. + */ + importHelperPath?: string; +} + // The slice of the loader context we touch ourselves; everything else is the // factory-built loader's business. interface LoaderContext { - getOptions: () => { importSpecifier?: string }; + resourcePath: string; + getOptions: () => LoaderOptions; } type LoaderFn = (this: LoaderContext, code: string, inputSourceMap?: unknown) => void; -// One factory-built loader per import specifier. The specifier is a per-rule -// (JSON) loader option, but the transforms capturing it must be baked in at -// module scope — so bind lazily and cache, keyed by specifier. In practice a -// build uses a single specifier, so this holds one entry. -const loaders = new Map(); - -function loaderFor(importSpecifier: string | undefined): LoaderFn { - let loader = loaders.get(importSpecifier); - if (!loader) { - loader = createLoader({ customTransforms: moduleInjectedTransforms(importSpecifier) }) as LoaderFn; - loaders.set(importSpecifier, loader); - } - return loader; +// Read lazily by the baked-in transform each time it splices a snippet, so ONE +// loader (and one upstream matcher) serves every per-file specifier. Safe as a +// module-level slot: the write below and the factory loader's transform run +// synchronously within a single loader invocation. +let currentImportSpecifier: string | undefined; + +const factoryLoader = createLoader({ + customTransforms: moduleInjectedTransforms(() => currentImportSpecifier), +}) as LoaderFn; + +function relativeImportSpecifier(fromFile: string, toFile: string): string { + const rel = relative(dirname(fromFile), toFile).replace(/\\/g, '/'); + return rel.startsWith('.') ? rel : `./${rel}`; } /** - * Reads the Sentry-specific `importSpecifier` option (unknown to the upstream - * loader, which reads only its own fields) and delegates to the matching - * factory-built loader. `instrumentations` stays a plain per-rule loader - * option, read by the upstream loader itself. + * Reads the Sentry-specific options (unknown to the upstream loader, which + * reads only its own fields), stages the snippet specifier for this file, and + * delegates to the factory-built loader. `instrumentations` stays a plain + * per-rule loader option, read by the upstream loader itself. */ const codeTransformerLoader: LoaderFn = function (code, inputSourceMap) { - return loaderFor(this.getOptions().importSpecifier).call(this, code, inputSourceMap); + const { importSpecifier, importHelperPath } = this.getOptions(); + currentImportSpecifier = importHelperPath + ? relativeImportSpecifier(this.resourcePath, importHelperPath) + : importSpecifier; + return factoryLoader.call(this, code, inputSourceMap); }; export default codeTransformerLoader; diff --git a/packages/server-utils/test/orchestrion/bundler.test.ts b/packages/server-utils/test/orchestrion/bundler.test.ts index edc3ed8fb6e8..64026b234548 100644 --- a/packages/server-utils/test/orchestrion/bundler.test.ts +++ b/packages/server-utils/test/orchestrion/bundler.test.ts @@ -20,7 +20,7 @@ vi.mock('@apm-js-collab/code-transformer-bundler-plugins/esbuild', () => ({ default: () => ({ name: 'code-transformer', setup: vi.fn() }), })); vi.mock('@apm-js-collab/code-transformer-bundler-plugins/vite', () => ({ - default: () => ({ name: 'code-transformer' }), + default: () => ({ name: 'code-transformer', transform: () => 'transformed' }), })); vi.mock('@apm-js-collab/code-transformer-bundler-plugins/webpack', () => ({ default: () => ({ apply: vi.fn() }), @@ -139,6 +139,52 @@ describe('sentryOrchestrionPlugin (vite)', () => { expect(runConfigResolved(['lodash'])).not.toHaveBeenCalled(); expect(runConfigResolved(undefined)).not.toHaveBeenCalled(); }); + + it('gates the transform on the ssr flag (Vite 5 ignores applyToEnvironment)', () => { + const plugin = vitePlugin(); + const transform = plugin.transform as ( + this: unknown, + code: string, + id: string, + opts?: { ssr?: boolean }, + ) => unknown; + + // Client-build transforms must be skipped: transformed modules in the + // client graph would import the subscriber factories, whose + // `node:diagnostics_channel` imports break against the browser shim. + expect(transform.call({}, 'code', 'id', { ssr: false })).toBeNull(); + expect(transform.call({}, 'code', 'id', undefined)).toBeNull(); + expect(transform.call({}, 'code', 'id', { ssr: true })).toBe('transformed'); + }); + + it('gates resolveId on the ssr flag and falls back to self-resolution', async () => { + const plugin = vitePlugin(); + const resolveId = plugin.resolveId as ( + this: unknown, + source: string, + importer: string | undefined, + opts?: { ssr?: boolean }, + ) => Promise; + + const resolve = vi.fn().mockResolvedValue(null); + await expect( + resolveId.call({ resolve }, '@sentry/server-utils/orchestrion', '/x.js', { ssr: false }), + ).resolves.toBeNull(); + expect(resolve).not.toHaveBeenCalled(); + + // Normal resolution wins when it succeeds. + resolve.mockResolvedValueOnce({ id: '/resolved.js' }); + await expect( + resolveId.call({ resolve }, '@sentry/server-utils/orchestrion', '/x.js', { ssr: true }), + ).resolves.toEqual({ + id: '/resolved.js', + }); + + // When it fails (pnpm isolation), fall back to this package's own resolution. + const fallback = await resolveId.call({ resolve }, '@sentry/server-utils/orchestrion', '/x.js', { ssr: true }); + expect(typeof fallback).toBe('string'); + expect(fallback).toContain('orchestrion'); + }); }); describe('buildTimeInstrumentation: false', () => { diff --git a/packages/server-utils/test/orchestrion/webpack-loader.test.ts b/packages/server-utils/test/orchestrion/webpack-loader.test.ts index c81355aca92c..ca56a6993730 100644 --- a/packages/server-utils/test/orchestrion/webpack-loader.test.ts +++ b/packages/server-utils/test/orchestrion/webpack-loader.test.ts @@ -67,13 +67,26 @@ describe('orchestrion webpack/Turbopack loader', () => { expect(code).toContain('orchestrionModuleInjected("mysql", mysqlIntegration)'); }); - it('honors the importSpecifier option (Turbopack passes an absolute path)', () => { + it('honors a fixed importSpecifier option', () => { const { code } = runLoader(join(root, 'node_modules/mysql/lib/Connection.js'), MYSQL_CONNECTION_SOURCE, { instrumentations, - importSpecifier: '/abs/path/to/orchestrion/index.js', + importSpecifier: 'my-custom-orchestrion-helper', }); - expect(code).toContain('require("/abs/path/to/orchestrion/index.js")'); + expect(code).toContain('require("my-custom-orchestrion-helper")'); + expect(code).not.toContain('require("@sentry/server-utils/orchestrion")'); + }); + + it('derives a per-file relative specifier from importHelperPath (Turbopack)', () => { + // Turbopack rejects absolute-path imports and bare specifiers that don't + // resolve from the importing file, so the snippet must import relatively. + const importHelperPath = join(root, 'node_modules/@sentry/server-utils/build/cjs/orchestrion/index.js'); + const { code } = runLoader(join(root, 'node_modules/mysql/lib/Connection.js'), MYSQL_CONNECTION_SOURCE, { + instrumentations, + importHelperPath, + }); + + expect(code).toContain('require("../../@sentry/server-utils/build/cjs/orchestrion/index.js")'); expect(code).not.toContain('require("@sentry/server-utils/orchestrion")'); }); From 9e28794f00788e510403a4f1a6f32518e06d84ed Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Thu, 6 Aug 2026 01:12:49 +0100 Subject: [PATCH 3/4] Fix --- .../server-utils/src/orchestrion/bundler/vite.ts | 12 +++++++++++- .../server-utils/test/orchestrion/bundler.test.ts | 11 +++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/server-utils/src/orchestrion/bundler/vite.ts b/packages/server-utils/src/orchestrion/bundler/vite.ts index 66e1261f6446..3e3a31662a16 100644 --- a/packages/server-utils/src/orchestrion/bundler/vite.ts +++ b/packages/server-utils/src/orchestrion/bundler/vite.ts @@ -89,7 +89,17 @@ export function sentryOrchestrionPlugin(options: PluginOptions = {}): Plugin { // diagnostics_channel calls never get injected. Vite merges array // `noExternal` entries with the user's config, so we don't overwrite // their additions. - return { ssr: { noExternal: instrumentedModuleNames(options.instrumentations) } }; + // + // `@sentry/server-utils` must be bundled too: the module-injected snippet + // `require()`s it from inside transformed CJS deps, and when the package + // stays external, Vite 5's CommonJS interop (`esmExternals: false`) + // rewrites that require into a DEFAULT import of our named-exports-only + // ESM entry — a link-time crash at server startup. Bundling sidesteps + // external ESM/CJS interop on both Vite majors, and the ESM barrel + // tree-shakes to just the helper and the factories actually referenced. + return { + ssr: { noExternal: [...instrumentedModuleNames(options.instrumentations), '@sentry/server-utils'] }, + }; }, configResolved(config: ResolvedConfig): void { // Explicit `ssr.external` string entries take priority over `noExternal` diff --git a/packages/server-utils/test/orchestrion/bundler.test.ts b/packages/server-utils/test/orchestrion/bundler.test.ts index 64026b234548..6633852ea76c 100644 --- a/packages/server-utils/test/orchestrion/bundler.test.ts +++ b/packages/server-utils/test/orchestrion/bundler.test.ts @@ -140,6 +140,17 @@ describe('sentryOrchestrionPlugin (vite)', () => { expect(runConfigResolved(undefined)).not.toHaveBeenCalled(); }); + it('force-bundles the orchestrion helper package alongside instrumented modules', () => { + const plugin = vitePlugin(); + const config = (plugin.config as () => { ssr: { noExternal: string[] } })(); + + // Left external, Vite 5's CommonJS interop turns the snippet's `require` + // into a default import of the named-exports-only ESM entry — a link-time + // crash at server startup. + expect(config.ssr.noExternal).toContain('@sentry/server-utils'); + expect(config.ssr.noExternal).toContain('mysql'); + }); + it('gates the transform on the ssr flag (Vite 5 ignores applyToEnvironment)', () => { const plugin = vitePlugin(); const transform = plugin.transform as ( From ffd24a54d74359be8c05760c5bc1a274445af44a Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Thu, 6 Aug 2026 08:04:57 +0100 Subject: [PATCH 4/4] Fix PR review --- .../src/orchestrion/bundler/webpack.ts | 25 ++++++---- .../test/orchestrion/bundler.test.ts | 47 +++++++++++++++++++ 2 files changed, 64 insertions(+), 8 deletions(-) diff --git a/packages/server-utils/src/orchestrion/bundler/webpack.ts b/packages/server-utils/src/orchestrion/bundler/webpack.ts index 74fbfaf58550..1e4cd2334bb6 100644 --- a/packages/server-utils/src/orchestrion/bundler/webpack.ts +++ b/packages/server-utils/src/orchestrion/bundler/webpack.ts @@ -47,25 +47,34 @@ function externalizedWebpackModules(externals: unknown, moduleNames: string[]): ); } +const ORCHESTRION_HELPER_SPECIFIER = '@sentry/server-utils/orchestrion'; + // The injected module-injected snippet imports `@sentry/server-utils/orchestrion` // from INSIDE transformed `node_modules` files. Under isolated installs (pnpm) // that bare specifier doesn't resolve from an instrumented package's location, -// so map it (exact-match, hence the `$`) to this package's own resolution. -// Externals still win — webpack consults `externals` before resolving — so -// setups that externalize the runtime (e.g. Next.js) are unaffected. +// so map it — exact-match only (the `$` suffix / `onlyModule`) — to this +// package's own resolution, in whichever alias form the config already uses. +// A user's own alias for the specifier is left untouched, and externals still +// win — webpack consults `externals` before resolving — so setups that +// externalize the runtime (e.g. Next.js) are unaffected. function addOrchestrionResolveAlias(compiler: Compiler): void { + const resolved = resolveOrchestrionRuntimeRequest(ORCHESTRION_HELPER_SPECIFIER); + if (!resolved) { + return; + } + const resolveOptions = (compiler.options.resolve ??= {}); const alias = resolveOptions.alias; if (Array.isArray(alias)) { + if (!alias.some(entry => entry.name === ORCHESTRION_HELPER_SPECIFIER)) { + alias.push({ name: ORCHESTRION_HELPER_SPECIFIER, alias: resolved, onlyModule: true }); + } return; } const aliasMap = (resolveOptions.alias = alias ?? {}); - if (!('@sentry/server-utils/orchestrion$' in aliasMap)) { - const resolved = resolveOrchestrionRuntimeRequest('@sentry/server-utils/orchestrion'); - if (resolved) { - aliasMap['@sentry/server-utils/orchestrion$'] = resolved; - } + if (!(`${ORCHESTRION_HELPER_SPECIFIER}$` in aliasMap) && !(ORCHESTRION_HELPER_SPECIFIER in aliasMap)) { + aliasMap[`${ORCHESTRION_HELPER_SPECIFIER}$`] = resolved; } } diff --git a/packages/server-utils/test/orchestrion/bundler.test.ts b/packages/server-utils/test/orchestrion/bundler.test.ts index 6633852ea76c..83f7e49db37c 100644 --- a/packages/server-utils/test/orchestrion/bundler.test.ts +++ b/packages/server-utils/test/orchestrion/bundler.test.ts @@ -113,6 +113,53 @@ describe('sentryOrchestrionWebpackPlugin', () => { expect(runApply(() => undefined)).toHaveLength(0); expect(runApply(undefined)).toHaveLength(0); }); + + describe('snippet resolve alias', () => { + // The snippet's `@sentry/server-utils/orchestrion` import is emitted inside + // transformed node_modules files, where it doesn't resolve under isolated + // installs (pnpm) — the plugin maps it to this package's own resolution. + function applyWithResolve(resolve: unknown): { alias?: unknown } { + const options = { externals: undefined, resolve } as { resolve?: { alias?: unknown } }; + const compiler = { + options, + hooks: { thisCompilation: { tap: vi.fn() } }, + webpack: { WebpackError: Error }, + } as unknown as Compiler; + sentryOrchestrionWebpackPlugin().apply(compiler); + return options.resolve ?? {}; + } + + it('adds an exact-match alias to object-form (and absent) alias config', () => { + const { alias } = applyWithResolve(undefined); + const target = (alias as Record)['@sentry/server-utils/orchestrion$']; + + expect(target).toBeDefined(); + expect(isAbsolute(target!)).toBe(true); + }); + + it('appends an onlyModule entry to array-form alias config', () => { + const existing = { name: 'other', alias: '/other' }; + const { alias } = applyWithResolve({ alias: [existing] }); + + expect(alias).toEqual([ + existing, + { + name: '@sentry/server-utils/orchestrion', + alias: expect.stringMatching(/orchestrion/), + onlyModule: true, + }, + ]); + }); + + it('leaves an existing user alias for the specifier untouched', () => { + const { alias: objectAlias } = applyWithResolve({ alias: { '@sentry/server-utils/orchestrion': '/user' } }); + expect(objectAlias).toEqual({ '@sentry/server-utils/orchestrion': '/user' }); + + const userEntry = { name: '@sentry/server-utils/orchestrion', alias: '/user' }; + const { alias: arrayAlias } = applyWithResolve({ alias: [userEntry] }); + expect(arrayAlias).toEqual([userEntry]); + }); + }); }); describe('sentryOrchestrionPlugin (vite)', () => {