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..7344e37c221c 100644 --- a/dev-packages/bun-integration-tests/suites/orchestrion-mysql/test.ts +++ b/dev-packages/bun-integration-tests/suites/orchestrion-mysql/test.ts @@ -49,7 +49,7 @@ describe('orchestrion mysql instrumentation (Bun)', () => { // with the expected SQL expect(line).toContain('statement=SELECT 1 AS solution'); // injected banner ran at bundle boot - expect(line).toContain('"bundler":true'); + expect(line).toContain('"bundler":[]'); } finally { if (outfile) { rmSync(dirname(outfile), { recursive: true, force: true }); 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..c129f4e86f4e 100644 --- a/dev-packages/bun-integration-tests/suites/orchestrion-postgres/test.ts +++ b/dev-packages/bun-integration-tests/suites/orchestrion-postgres/test.ts @@ -54,7 +54,7 @@ describe('orchestrion pg instrumentation (Bun)', () => { // with the expected SQL expect(line).toContain('statement=SELECT 1 AS solution'); // injected banner ran at bundle boot - expect(line).toContain('"bundler":true'); + expect(line).toContain('"bundler":[]'); } finally { if (outfile) { rmSync(dirname(outfile), { recursive: true, force: true }); diff --git a/packages/bun/src/plugin.ts b/packages/bun/src/plugin.ts index c0c9ac73fa79..f0dba86a54e6 100644 --- a/packages/bun/src/plugin.ts +++ b/packages/bun/src/plugin.ts @@ -44,7 +44,7 @@ import { } from '@sentry/server-utils/orchestrion/config'; const BUNDLER_MARKER_BANNER = - ';(globalThis.__SENTRY_ORCHESTRION__=(globalThis.__SENTRY_ORCHESTRION__||{})).bundler=true;'; + ';(globalThis.__SENTRY_ORCHESTRION__=(globalThis.__SENTRY_ORCHESTRION__||{})).bundler=[];'; // Minimal shape of Bun's `PluginBuilder` that we touch. Typed locally instead // of depending on `bun-types`, which would pull Bun's globals. @@ -57,7 +57,7 @@ interface BunPluginBuilder { * 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 + * `globalThis.__SENTRY_ORCHESTRION__.bundler = []` when the bundle boots * * Pass the result to `Bun.build({ plugins: [...] })`. * diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 87b62479d18e..97b1b00c10ff 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -927,6 +927,16 @@ export abstract class Client { */ public on(hook: 'stopUIProfiler', callback: () => void): () => void; + /** + * A hook that is called when the orchestrion runtime hook injects diagnostics + * channels into a module as it is loaded. + * + * The callback receives the name of the instrumented module. + * + * @returns {() => void} A function that, when executed, removes the registered callback. + */ + public on(hook: 'orchestrion.module-runtime-injected', callback: (moduleName: string) => void): () => void; + /** * Register a hook on this client. */ @@ -1188,6 +1198,12 @@ export abstract class Client { */ public emit(hook: 'stopUIProfiler'): void; + /** + * Emit a hook event when the orchestrion runtime hook injects diagnostics + * channels into a module as it is loaded. + */ + public emit(hook: 'orchestrion.module-runtime-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 42a7ffdfaec4..bbb09aab7c87 100644 --- a/packages/core/src/utils/worldwide.ts +++ b/packages/core/src/utils/worldwide.ts @@ -64,6 +64,14 @@ export type InternalGlobal = { /** Empty array signifies bundler plugin ran */ bundler?: string[]; }; + /** + * Bridge set by the SDK during `init` so that build-time (bundler) injected modules can announce + * themselves the moment they load. Bundlers that transform modules but provide no runtime boot hook + * (e.g. Turbopack, which only takes loaders) prepend a prologue to each instrumented module that + * calls this with the module name; the bridge emits `orchestrion.module-runtime-injected` so channel + * subscribers get wired up before the module publishes. Mirrors the runtime module hook's own event. + */ + __SENTRY_ORCHESTRION_ON_INJECT__?: (moduleName: string) => void; } & Carrier; /** Get's the global object for the current JavaScript runtime */ diff --git a/packages/nextjs/src/config/loaders/valueInjectionLoader.ts b/packages/nextjs/src/config/loaders/valueInjectionLoader.ts index 0040e9f4543d..2662e4834645 100644 --- a/packages/nextjs/src/config/loaders/valueInjectionLoader.ts +++ b/packages/nextjs/src/config/loaders/valueInjectionLoader.ts @@ -2,6 +2,10 @@ import type { LoaderThis } from './types'; export type ValueInjectionLoaderOptions = { values: Record; + // Raw JS prepended before the value assignments. Unlike `values` (plain `globalThis[key] = ...` + // assignments), this is emitted verbatim, so it can run merge-safe logic that a plain assignment + // can't express (e.g. seeding a global without clobbering an existing one). + prefixCode?: string; }; /** @@ -154,7 +158,7 @@ function findDirectiveTerminator(userCode: string, startIndex: number): number | */ export default function valueInjectionLoader(this: LoaderThis, userCode: string): string { // We know one or the other will be defined, depending on the version of webpack being used - const { values } = 'getOptions' in this ? this.getOptions() : this.query; + const { values, prefixCode } = 'getOptions' in this ? this.getOptions() : this.query; // We do not want to cache injected values across builds this.cacheable(false); @@ -163,6 +167,7 @@ export default function valueInjectionLoader(this: LoaderThis `globalThis["${key}"] = ${JSON.stringify(value)};`) .join(''); diff --git a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts index 31298b6067a6..c3a11de5bd48 100644 --- a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts +++ b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts @@ -1,15 +1,9 @@ import { debug } from '@sentry/core'; import * as path from 'path'; -import { getOrchestrionLoaderPath, getSentryInstrumentations } from '@sentry/server-utils/orchestrion/webpack'; +import { getSentryOrchestrionLoaderPath } from '@sentry/server-utils/orchestrion/webpack'; import type { VercelCronsConfig } from '../../common/types'; import type { RouteManifest } from '../manifest/types'; -import type { - JSONValue, - NextConfigObject, - SentryBuildOptions, - TurbopackMatcherWithRule, - TurbopackOptions, -} from '../types'; +import type { NextConfigObject, SentryBuildOptions, TurbopackMatcherWithRule, TurbopackOptions } from '../types'; import { supportsNativeDebugIds, supportsTurbopackRuleCondition } from '../util'; import { generateValueInjectionRules } from './generateValueInjectionRules'; @@ -36,6 +30,15 @@ export function constructTurbopackConfig({ nextJsVersion?: string; vercelCronsConfig?: VercelCronsConfig; }): TurbopackOptions { + // The orchestrion code-transform loader only runs on Next.js versions that support Turbopack rule + // conditions. Gate the boot-time marker on the same condition so `isOrchestrionInjected()` can't + // report `true` when no module was actually transformed (which would make integrations subscribe to + // diagnostics channels that never publish). + const orchestrionInjectionActive = + !!userSentryOptions?._experimental?.useDiagnosticsChannelInjection && + !!nextJsVersion && + supportsTurbopackRuleCondition(nextJsVersion); + // If sourcemaps are disabled, we don't need to enable native debug ids as this will add build time. const shouldEnableNativeDebugIds = (supportsNativeDebugIds(nextJsVersion ?? '') && userNextConfig?.turbopack?.debugIds) ?? @@ -58,6 +61,10 @@ export function constructTurbopackConfig({ nextJsVersion, tunnelPath, vercelCronsConfig, + // Mark the bundler injection path as active at boot (before `Sentry.init()`), so + // `isOrchestrionInjected()` is reliable even without the runtime hook. Individual modules add + // themselves to this list as they load; see the orchestrion loader's injected prologue. + injectOrchestrionBundlerMarker: orchestrionInjectionActive, }); for (const { matcher, rule } of valueInjectionRules) { @@ -113,24 +120,19 @@ export function constructTurbopackConfig({ }); } - newConfig.rules = maybeAddOrchestrionRule(newConfig.rules, userSentryOptions, nextJsVersion); + newConfig.rules = maybeAddOrchestrionRule(newConfig.rules, orchestrionInjectionActive); return newConfig; } /** - * Adds the orchestrion code-transform loader rule when diagnostics-channel injection is enabled. + * Adds the orchestrion code-transform loader rule when diagnostics-channel injection is active. */ function maybeAddOrchestrionRule( rules: TurbopackOptions['rules'], - userSentryOptions: SentryBuildOptions | undefined, - nextJsVersion: string | undefined, + orchestrionInjectionActive: boolean, ): TurbopackOptions['rules'] { - if ( - !userSentryOptions?._experimental?.useDiagnosticsChannelInjection || - !nextJsVersion || - !supportsTurbopackRuleCondition(nextJsVersion) - ) { + if (!orchestrionInjectionActive) { return rules; } @@ -138,13 +140,9 @@ function maybeAddOrchestrionRule( matcher: '*.{js,mjs,cjs}', rule: { condition: 'node', - loaders: [ - { - loader: getOrchestrionLoaderPath(), - // `instrumentations` is JSON-serializable - options: { instrumentations: getSentryInstrumentations() as unknown as JSONValue[] }, - }, - ], + // The Sentry loader reads the instrumentation config itself and appends a self-registration + // prologue to each transformed module, so it needs no `options` — use the string shortcut. + loaders: [getSentryOrchestrionLoaderPath()], }, }); } diff --git a/packages/nextjs/src/config/turbopack/generateValueInjectionRules.ts b/packages/nextjs/src/config/turbopack/generateValueInjectionRules.ts index 8bca01cb2ce4..9b8fa689a801 100644 --- a/packages/nextjs/src/config/turbopack/generateValueInjectionRules.ts +++ b/packages/nextjs/src/config/turbopack/generateValueInjectionRules.ts @@ -4,6 +4,14 @@ import type { RouteManifest } from '../manifest/types'; import type { JSONValue, TurbopackMatcherWithRule } from '../types'; import { getPackageModules, supportsTurbopackRuleCondition } from '../util'; +// Marks the bundler (build-time) orchestrion injection as active at boot, without clobbering any +// marker an earlier injector already set. It only creates `__SENTRY_ORCHESTRION__`/`.bundler` if +// absent, so in a hybrid setup the runtime hook's `runtime` list (and any module already recorded by +// the per-module loader prologue) survives. Mirrors the merge-safe pattern of the Bun banner and +// `buildInjectPrologue`. Emitted as a single line so it never shifts the module's source-map mappings. +const ORCHESTRION_BUNDLER_MARKER = + ';(function(){try{var g=(globalThis.__SENTRY_ORCHESTRION__=globalThis.__SENTRY_ORCHESTRION__||{});g.bundler=g.bundler||[];}catch(e){}})();'; + /** * Generate the value injection rules for client and server in turbopack config. */ @@ -12,11 +20,13 @@ export function generateValueInjectionRules({ nextJsVersion, tunnelPath, vercelCronsConfig, + injectOrchestrionBundlerMarker, }: { routeManifest?: RouteManifest; nextJsVersion?: string; tunnelPath?: string; vercelCronsConfig?: VercelCronsConfig; + injectOrchestrionBundlerMarker?: boolean; }): TurbopackMatcherWithRule[] { const rules: TurbopackMatcherWithRule[] = []; const isomorphicValues: Record = {}; @@ -85,6 +95,11 @@ export function generateValueInjectionRules({ loader: path.resolve(__dirname, '..', 'loaders', 'valueInjectionLoader.js'), options: { values: serverValues, + // Runs at the top of the server `instrumentation` file — before `Sentry.init()` — so + // `isOrchestrionInjected()` is reliable for bundler-only setups too. Turbopack has no + // plugin/boot hook to emit the full transformed-module list the way the webpack plugin + // does; each transformed module appends itself as it loads (see the orchestrion loader). + ...(injectOrchestrionBundlerMarker ? { prefixCode: ORCHESTRION_BUNDLER_MARKER } : {}), }, }, ], diff --git a/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts b/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts index 242174f015de..0d8510b556d3 100644 --- a/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts +++ b/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts @@ -1574,4 +1574,64 @@ describe('safelyAddTurbopackRule', () => { }); }); }); + + describe('orchestrion diagnostics-channel injection', () => { + it('does not add the orchestrion loader rule by default', () => { + const result = constructTurbopackConfig({ + userNextConfig: {}, + userSentryOptions: {}, + nextJsVersion: '16.0.0', + }); + + expect(result.rules?.['*.{js,mjs,cjs}']).toBeUndefined(); + }); + + it('adds the Sentry orchestrion loader rule (node server only) when injection is enabled', () => { + const result = constructTurbopackConfig({ + userNextConfig: {}, + userSentryOptions: { _experimental: { useDiagnosticsChannelInjection: true } }, + nextJsVersion: '16.0.0', + }); + + const rule = result.rules?.['*.{js,mjs,cjs}'] as { condition?: unknown; loaders?: unknown[] } | undefined; + expect(rule?.condition).toBe('node'); + // A single loader referenced by path (string shortcut, no options). + expect(rule?.loaders).toHaveLength(1); + expect(typeof rule?.loaders?.[0]).toBe('string'); + }); + + const getServerPrefixCode = (result: ReturnType): string | undefined => { + const serverRule = result.rules?.['**/instrumentation.*'] as + | { loaders?: [{ options?: { prefixCode?: string } }] } + | undefined; + return serverRule?.loaders?.[0]?.options?.prefixCode; + }; + + it('seeds the bundler marker when the loader rule is actually added', () => { + const result = constructTurbopackConfig({ + userNextConfig: {}, + userSentryOptions: { _experimental: { useDiagnosticsChannelInjection: true } }, + nextJsVersion: '16.0.0', + }); + + expect(result.rules?.['*.{js,mjs,cjs}']).toBeDefined(); + expect(getServerPrefixCode(result)).toContain('__SENTRY_ORCHESTRION__'); + }); + + it.each([ + ['an unsupported Next.js version', '15.0.0'], + ['an unknown Next.js version', undefined], + ])('does not seed the bundler marker with %s (loader rule cannot run)', (_label, nextJsVersion) => { + const result = constructTurbopackConfig({ + userNextConfig: {}, + userSentryOptions: { _experimental: { useDiagnosticsChannelInjection: true } }, + nextJsVersion, + }); + + // The code-transform loader rule is not added, so `isOrchestrionInjected()` must not be + // seeded either - otherwise integrations subscribe to channels that never publish. + expect(result.rules?.['*.{js,mjs,cjs}']).toBeUndefined(); + expect(getServerPrefixCode(result)).toBeUndefined(); + }); + }); }); diff --git a/packages/nextjs/test/config/turbopack/generateValueInjectionRules.test.ts b/packages/nextjs/test/config/turbopack/generateValueInjectionRules.test.ts index d4fed2368101..153bcef93c8a 100644 --- a/packages/nextjs/test/config/turbopack/generateValueInjectionRules.test.ts +++ b/packages/nextjs/test/config/turbopack/generateValueInjectionRules.test.ts @@ -70,6 +70,35 @@ describe('generateValueInjectionRules', () => { }); }); + describe('orchestrion bundler marker', () => { + function getServerLoaderOptions(result: ReturnType): { + values: Record; + prefixCode?: string; + } { + const serverRule = result.find(rule => rule.matcher === '**/instrumentation.*'); + return (serverRule?.rule as { loaders: [{ options: { values: Record; prefixCode?: string } }] }) + .loaders[0].options; + } + + it('does not inject the orchestrion marker by default', () => { + const options = getServerLoaderOptions(generateValueInjectionRules({})); + + expect(options.values).not.toHaveProperty('__SENTRY_ORCHESTRION__'); + expect(options).not.toHaveProperty('prefixCode'); + }); + + it('seeds the bundler marker merge-safely at boot when injectOrchestrionBundlerMarker is set', () => { + const options = getServerLoaderOptions(generateValueInjectionRules({ injectOrchestrionBundlerMarker: true })); + + // The marker must not be a plain assignment (which would clobber an existing marker), so it is + // never emitted as a `values` entry. + expect(options.values).not.toHaveProperty('__SENTRY_ORCHESTRION__'); + // It only creates the global if absent, preserving a runtime injector's `runtime` list. + expect(options.prefixCode).toContain('globalThis.__SENTRY_ORCHESTRION__=globalThis.__SENTRY_ORCHESTRION__||{}'); + expect(options.prefixCode).toContain('g.bundler=g.bundler||[]'); + }); + }); + describe('with nextJsVersion only', () => { it('should generate client and server rules when nextJsVersion is provided', () => { const result = generateValueInjectionRules({ diff --git a/packages/nextjs/test/config/valueInjectionLoader.test.ts b/packages/nextjs/test/config/valueInjectionLoader.test.ts index 83c0c1d5e0f9..802346a671f0 100644 --- a/packages/nextjs/test/config/valueInjectionLoader.test.ts +++ b/packages/nextjs/test/config/valueInjectionLoader.test.ts @@ -219,6 +219,40 @@ describe.each([[clientConfigLoaderThis], [instrumentationLoaderThis]])('valueInj }); }); +describe('valueInjectionLoader prefixCode', () => { + const prefixLoaderThis = { + ...defaultLoaderThis, + resourcePath: './instrumentation.js', + getOptions() { + return { + values: { foo: 'bar' }, + prefixCode: ';globalThis.__MARKER__ = globalThis.__MARKER__ || {};', + }; + }, + } satisfies LoaderThis; + + it('emits prefixCode verbatim before the value assignments', () => { + const userCode = "import * as Sentry from '@sentry/nextjs';\nSentry.init();"; + + const result = valueInjectionLoader.call(prefixLoaderThis, userCode); + + const prefixIndex = result.indexOf('globalThis.__MARKER__ = globalThis.__MARKER__ || {};'); + const valueIndex = result.indexOf('globalThis["foo"] = "bar";'); + + expect(prefixIndex).toBeGreaterThanOrEqual(0); + expect(valueIndex).toBeGreaterThan(prefixIndex); + }); + + it('does not emit any prefix when prefixCode is omitted', () => { + const userCode = "import * as Sentry from '@sentry/nextjs';\nSentry.init();"; + + const result = valueInjectionLoader.call(clientConfigLoaderThis, userCode); + + expect(result).toContain(';globalThis["foo"] = "bar";'); + expect(result).not.toContain('__MARKER__'); + }); +}); + describe('findInjectionIndexAfterDirectives', () => { it('returns the position immediately after the last directive', () => { const userCode = '"use strict";\n"use client";\nimport React from \'react\';'; diff --git a/packages/server-utils/package.json b/packages/server-utils/package.json index b38501033e57..c65fa4470c6f 100644 --- a/packages/server-utils/package.json +++ b/packages/server-utils/package.json @@ -57,6 +57,11 @@ "import": "./build/esm/orchestrion/bundler/esbuild.js", "require": "./build/cjs/orchestrion/bundler/esbuild.js" }, + "./orchestrion/turbopack-loader": { + "types": "./build/types/orchestrion/bundler/turbopack-loader.d.ts", + "import": "./build/esm/orchestrion/bundler/turbopack-loader.js", + "require": "./build/cjs/orchestrion/bundler/turbopack-loader.js" + }, "./orchestrion/import-hook": { "import": "./build/orchestrion/import-hook.mjs" } @@ -86,6 +91,9 @@ ], "orchestrion/esbuild": [ "build/types-ts3.8/orchestrion/bundler/esbuild.d.ts" + ], + "orchestrion/turbopack-loader": [ + "build/types-ts3.8/orchestrion/bundler/turbopack-loader.d.ts" ] }, "*": { @@ -109,6 +117,9 @@ ], "orchestrion/esbuild": [ "build/types/orchestrion/bundler/esbuild.d.ts" + ], + "orchestrion/turbopack-loader": [ + "build/types/orchestrion/bundler/turbopack-loader.d.ts" ] } }, diff --git a/packages/server-utils/rollup.npm.config.mjs b/packages/server-utils/rollup.npm.config.mjs index f1b3a19655a7..c2ab0e61941c 100644 --- a/packages/server-utils/rollup.npm.config.mjs +++ b/packages/server-utils/rollup.npm.config.mjs @@ -37,6 +37,7 @@ export default [ 'src/orchestrion/bundler/rollup.ts', 'src/orchestrion/bundler/webpack.ts', 'src/orchestrion/bundler/esbuild.ts', + 'src/orchestrion/bundler/turbopack-loader.ts', ], packageSpecificConfig: { output: { diff --git a/packages/server-utils/src/orchestrion/bundler/inject.ts b/packages/server-utils/src/orchestrion/bundler/inject.ts new file mode 100644 index 000000000000..485faf2a923c --- /dev/null +++ b/packages/server-utils/src/orchestrion/bundler/inject.ts @@ -0,0 +1,55 @@ +// Shared source of truth for the runtime globals the build-time (bundler) injection uses to +// announce itself. Kept dependency-free so the Turbopack loader can import it in the build process +// without pulling in the SDK runtime. The matching runtime side lives in `../instrumentation.ts` +// (the `__SENTRY_ORCHESTRION_ON_INJECT__` bridge) and `../detect.ts` (`__SENTRY_ORCHESTRION__`). + +/** + * Prologue appended to every module the bundler actually instruments. It runs when the module is + * first loaded — for lazily-loaded server deps that is during request handling, after `Sentry.init()` + * — and does two things: records the module in `__SENTRY_ORCHESTRION__.bundler` (so detection/reporting + * see it) and calls the SDK bridge so channel subscribers get wired up before the module publishes. + * + * This exists because some bundlers (notably Turbopack) accept only loaders, not plugins, so there is + * no build lifecycle hook to emit the aggregate module list at boot the way the webpack plugin does. + * Announcing per-module on load mirrors how the runtime module hook already reports injected modules. + * + * Emitted as a single line (no newlines) so it never shifts the module's source-map mappings, and + * wrapped in try/catch so an injection failure can never break the instrumented module. + */ +export function buildInjectPrologue(moduleName: string): string { + const name = JSON.stringify(moduleName); + return ( + ';(function(){try{' + + 'var g=(globalThis.__SENTRY_ORCHESTRION__=globalThis.__SENTRY_ORCHESTRION__||{});' + + 'g.bundler=g.bundler||[];' + + `if(g.bundler.indexOf(${name})===-1)g.bundler.push(${name});` + + 'var cb=globalThis.__SENTRY_ORCHESTRION_ON_INJECT__;' + + `if(typeof cb==="function")cb(${name});` + + '}catch(e){}})();' + ); +} + +/** + * Boot-time snippet for bundlers that expose a build lifecycle hook (webpack/rollup/vite/esbuild via + * the `injectDiagnostics` plugin option). Unlike Turbopack — where {@link buildInjectPrologue} runs + * per-module as each loads — this runs once at bundle boot with the full list of transformed modules. + * + * It records the list in `__SENTRY_ORCHESTRION__.bundler` (so detection/reporting see it) AND calls + * the SDK bridge for each module. The bridge call is essential: when `Sentry.init()` runs before this + * snippet (e.g. loaded via `--require` ahead of the bundled server), the channel integrations have + * already set up and are waiting on `orchestrion.module-runtime-injected`; without the bridge call + * they never wire up their subscribers and no spans are recorded. Setting `.bundler` alone only covers + * the reverse ordering (snippet before init), where init's own detection check picks it up. + */ +export function buildInjectBootSnippet(moduleNames: string[]): string { + const names = JSON.stringify(moduleNames); + return ( + ';(function(){try{' + + 'var g=(globalThis.__SENTRY_ORCHESTRION__=globalThis.__SENTRY_ORCHESTRION__||{});' + + `var m=${names};` + + 'g.bundler=m;' + + 'var cb=globalThis.__SENTRY_ORCHESTRION_ON_INJECT__;' + + 'if(typeof cb==="function")for(var i=0;i { - return `(globalThis.__SENTRY_ORCHESTRION__=globalThis.__SENTRY_ORCHESTRION__||{}).bundler=${JSON.stringify(diag.transformedModules)};`; + return buildInjectBootSnippet(diag.transformedModules); }, }; } diff --git a/packages/server-utils/src/orchestrion/bundler/turbopack-loader.ts b/packages/server-utils/src/orchestrion/bundler/turbopack-loader.ts new file mode 100644 index 000000000000..e7f580456189 --- /dev/null +++ b/packages/server-utils/src/orchestrion/bundler/turbopack-loader.ts @@ -0,0 +1,60 @@ +// EXPERIMENTAL — orchestrion code-transform loader for Turbopack. +// +// Turbopack accepts webpack loaders but not plugins, so — unlike the webpack path, where the plugin +// injects one boot-time snippet listing every transformed module — there is no build lifecycle hook +// to surface the injected-module list at runtime. This loader wraps the same code transform and, for +// each module it actually instruments, appends a self-registration prologue (see `./inject`) so the +// module announces itself to the SDK when it loads. That gives the *precise* set of build-time +// instrumented modules, matching how the runtime module hook reports the ones it injects. + +import { createCodeTransformer } from '@apm-js-collab/code-transformer-bundler-plugins/core'; +import { SENTRY_INSTRUMENTATIONS } from '../config'; +import { buildInjectPrologue } from './inject'; + +// The subset of the webpack loader context we rely on (Turbopack provides the same shape). +interface LoaderContext { + resourcePath: string; + async: () => (error: Error | null, code?: string, map?: unknown) => void; +} + +// Built once per loader process; the transform is pure config, so it is safe to reuse. +let transformer: ReturnType | undefined; + +function getTransformer(): ReturnType { + return (transformer ??= createCodeTransformer({ instrumentations: SENTRY_INSTRUMENTATIONS })); +} + +// The package name a module path belongs to, e.g. `.../node_modules/ioredis/built/Redis.js` → `ioredis` +// and `.../node_modules/@scope/pkg/x.js` → `@scope/pkg`. Uses the last `node_modules` segment so nested +// dependencies resolve to their own name, and normalizes Windows separators. +function moduleNameFromPath(id: string): string | undefined { + const re = /(?:^|[\\/])node_modules[\\/]((?:@[^\\/]+[\\/])?[^\\/]+)/g; + let name: string | undefined; + for (let match = re.exec(id); match; match = re.exec(id)) { + name = match[1]; + } + return name?.replace(/\\/g, '/'); +} + +/** + * Webpack/Turbopack loader that instruments supported modules and appends the self-registration + * prologue to the ones it transforms. Non-matching modules pass through untouched; any failure falls + * back to the original source so a build never breaks. + */ +export default function sentryOrchestrionLoader(this: LoaderContext, code: string, map?: unknown): void { + const callback = this.async(); + + try { + const result = getTransformer().transform(code, this.resourcePath, map as string | undefined); + if (!result) { + callback(null, code, map); + return; + } + + const moduleName = moduleNameFromPath(this.resourcePath); + const prologue = moduleName ? buildInjectPrologue(moduleName) : ''; + callback(null, `${result.code}${prologue}`, result.map); + } catch { + callback(null, code, map); + } +} diff --git a/packages/server-utils/src/orchestrion/bundler/webpack.ts b/packages/server-utils/src/orchestrion/bundler/webpack.ts index a20daf49b7b9..5d7e4bd13357 100644 --- a/packages/server-utils/src/orchestrion/bundler/webpack.ts +++ b/packages/server-utils/src/orchestrion/bundler/webpack.ts @@ -28,6 +28,16 @@ export function getOrchestrionLoaderPath(): string { return getOrchestrionRequire().resolve('@apm-js-collab/code-transformer-bundler-plugins/webpack-loader'); } +/** + * Absolute path to the Sentry orchestrion loader — the code-transform loader wrapped so each + * instrumented module announces itself at runtime (see `./turbopack-loader`). Turbopack builds use + * this instead of the raw loader because Turbopack has no plugin/boot hook to emit the injected-module + * list. `createRequire().resolve` always picks the CJS build, which is what a loader must be. + */ +export function getSentryOrchestrionLoaderPath(): string { + return getOrchestrionRequire().resolve('@sentry/server-utils/orchestrion/turbopack-loader'); +} + /** * Absolute path to the `@apm-js-collab/tracing-hooks` package directory, resolved from this * package's own dependency graph. SDKs inject it at build time so the runtime module hook can diff --git a/packages/server-utils/src/orchestrion/config/utils.ts b/packages/server-utils/src/orchestrion/config/utils.ts new file mode 100644 index 000000000000..4edac964f267 --- /dev/null +++ b/packages/server-utils/src/orchestrion/config/utils.ts @@ -0,0 +1,6 @@ +import type { InstrumentationConfig } from '..'; +import { uniq } from '@sentry/core'; + +export function getModuleNames(config: InstrumentationConfig[]): string[] { + return uniq(config.map(config => config.module.name)); +} diff --git a/packages/server-utils/src/orchestrion/detect.ts b/packages/server-utils/src/orchestrion/detect.ts index f6b7cb1c9e73..5a26a4504b87 100644 --- a/packages/server-utils/src/orchestrion/detect.ts +++ b/packages/server-utils/src/orchestrion/detect.ts @@ -50,3 +50,11 @@ export function detectOrchestrionSetup(): void { : '[Sentry] Bundler plugin did not run', ); } + +/** + * Get a list of all module names (e.g. express, @nestjs/core, ...) that have been injected by orchestrion, either at runtime or via a bundler plugin. + */ +export function getOrchestrionInjectedModules(): string[] { + const { runtime, bundler } = GLOBAL_OBJ.__SENTRY_ORCHESTRION__ ?? {}; + return [...(runtime ?? []), ...(bundler ?? [])]; +} diff --git a/packages/server-utils/src/orchestrion/instrumentation.ts b/packages/server-utils/src/orchestrion/instrumentation.ts new file mode 100644 index 000000000000..8ac2571c6602 --- /dev/null +++ b/packages/server-utils/src/orchestrion/instrumentation.ts @@ -0,0 +1,98 @@ +import type { Client } from '@sentry/core'; +import { addNonEnumerableProperty, getClient, GLOBAL_OBJ, waitForTracingChannelBinding } from '@sentry/core'; +import { getOrchestrionInjectedModules } from './detect'; +import * as diagnosticsChannel from 'node:diagnostics_channel'; + +const INSTRUMENTATION_FN_SYMBOL = Symbol.for('InstrumentationFn'); +// oxlint-disable-next-line typescript/no-explicit-any +type InstrumentationFn = ((...args: any[]) => void) & { [INSTRUMENTATION_FN_SYMBOL]?: boolean }; + +const globalAny = globalThis as { Bun?: unknown; Deno?: unknown }; +const isBun = typeof globalAny.Bun !== 'undefined'; +const isDeno = typeof globalAny.Deno !== 'undefined'; + +/** + * Run the provided instrumentation callback when one of the provided module names is orchestrion-injected. + * If it is already injected, it will invoce the callback immediately (e.g. when build-time injection is used). + * If runtime injection is used, it may invoke the callback at a later point in time, when the injection actually happens. + * The callback will never be invoked more than once. + */ +export function invokeOrchestrionInstrumentation( + client: Client, + moduleNames: string[], + callback: Callback, + args: Parameters, +) { + // `tracingChannel` is unavailable before Node 18.19 so do nothing in that case. + if (!diagnosticsChannel.tracingChannel) { + return; + } + + // Set up the bridge build-time (bundler) injected modules call to announce themselves. Runs on the + // first integration's setup, i.e. during `Sentry.init()`, before any lazily-loaded bundled module. + installOrchestrionInjectBridge(); + + // If already injected, skip + if (hasBeenInjected(callback)) { + return; + } + + const instrumentationFn = () => { + markAsInjected(callback); + + waitForTracingChannelBinding(() => { + callback(...args); + }); + }; + + // We do not have working module tracking in Deno or Bun + // Additionally, the channel limits do not apply to these runtimes, so it is safe to just register all instrumentation immediately. + if (isBun || isDeno) { + instrumentationFn(); + return; + } + + // First, check if the modules have been injected already + const modules = getOrchestrionInjectedModules(); + if (moduleNames.some(name => modules.includes(name))) { + instrumentationFn(); + return; + } + + // Then, register a listener for the `orchestrion.module-runtime-injected` event + const cleanup = client.on('orchestrion.module-runtime-injected', (moduleName: string) => { + if (hasBeenInjected(callback)) { + cleanup(); + return; + } + + if (moduleNames.includes(moduleName)) { + instrumentationFn(); + cleanup(); + } + }); +} + +/** + * Install the `__SENTRY_ORCHESTRION_ON_INJECT__` bridge (idempotent). Build-time injected modules call + * it from their appended prologue (see `bundler/inject.ts`) when they load; it re-emits as the same + * `orchestrion.module-runtime-injected` event the runtime module hook uses, so the listener registered + * above wires up the channel subscriber. Uses `getClient()` lazily since the module loads after init. + */ +function installOrchestrionInjectBridge(): void { + if (GLOBAL_OBJ.__SENTRY_ORCHESTRION_ON_INJECT__) { + return; + } + + GLOBAL_OBJ.__SENTRY_ORCHESTRION_ON_INJECT__ = (moduleName: string) => { + getClient()?.emit('orchestrion.module-runtime-injected', moduleName); + }; +} + +function hasBeenInjected(callback: InstrumentationFn) { + return callback[INSTRUMENTATION_FN_SYMBOL] ?? false; +} + +function markAsInjected(callback: InstrumentationFn) { + addNonEnumerableProperty(callback, INSTRUMENTATION_FN_SYMBOL, true); +} diff --git a/packages/server-utils/src/orchestrion/runtime/register.ts b/packages/server-utils/src/orchestrion/runtime/register.ts index bd559b2a6e48..0cf8fcba56af 100644 --- a/packages/server-utils/src/orchestrion/runtime/register.ts +++ b/packages/server-utils/src/orchestrion/runtime/register.ts @@ -1,4 +1,4 @@ -import { debug, GLOBAL_OBJ } from '@sentry/core'; +import { debug, getClient, GLOBAL_OBJ } from '@sentry/core'; import { createRequire } from 'node:module'; import * as Module from 'node:module'; import { pathToFileURL } from 'node:url'; @@ -134,6 +134,7 @@ export function registerDiagnosticsChannelInjection(options?: RegisterDiagnostic GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = GLOBAL_OBJ.__SENTRY_ORCHESTRION__ || {}; GLOBAL_OBJ.__SENTRY_ORCHESTRION__.runtime = GLOBAL_OBJ.__SENTRY_ORCHESTRION__.runtime || []; GLOBAL_OBJ.__SENTRY_ORCHESTRION__.runtime.push(moduleName); + getClient()?.emit('orchestrion.module-runtime-injected', moduleName); } }; diff --git a/packages/server-utils/test/orchestrion/inject.test.ts b/packages/server-utils/test/orchestrion/inject.test.ts new file mode 100644 index 000000000000..5a1098b138ec --- /dev/null +++ b/packages/server-utils/test/orchestrion/inject.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from 'vitest'; + +// Mock the third-party transform so the test covers OUR loader logic (appending the prologue, module +// name derivation, passthrough) rather than the real code transform. Returns non-null for everything +// except paths containing `skip`, so we can exercise both branches. +vi.mock('@apm-js-collab/code-transformer-bundler-plugins/core', () => ({ + createCodeTransformer: () => ({ + transform: (code: string, id: string) => + id.includes('skip') ? null : { code: `/*instrumented*/${code}`, map: undefined }, + getCodeToInject: () => undefined, + }), +})); + +import { buildInjectBootSnippet, buildInjectPrologue } from '../../src/orchestrion/bundler/inject'; + +describe('buildInjectPrologue', () => { + it('records the module in `.bundler` and calls the on-inject bridge, guarded by try/catch', () => { + const prologue = buildInjectPrologue('ioredis'); + + expect(prologue).toContain('globalThis.__SENTRY_ORCHESTRION__'); + expect(prologue).toContain('g.bundler.push("ioredis")'); + expect(prologue).toContain('globalThis.__SENTRY_ORCHESTRION_ON_INJECT__'); + expect(prologue).toContain('cb("ioredis")'); + expect(prologue).toContain('try{'); + // Single line so it never shifts source-map mappings. + expect(prologue).not.toContain('\n'); + }); +}); + +describe('buildInjectBootSnippet', () => { + it('records the modules in `.bundler` and announces each via the on-inject bridge', () => { + const injected: string[] = []; + const globalObj: { + __SENTRY_ORCHESTRION__?: { bundler?: string[] }; + __SENTRY_ORCHESTRION_ON_INJECT__?: (name: string) => void; + } = { __SENTRY_ORCHESTRION_ON_INJECT__: name => injected.push(name) }; + + const snippet = buildInjectBootSnippet(['mysql', 'ioredis']); + // The snippet references `globalThis`, so evaluate it with a `globalThis` bound to our stub. + // oxlint-disable-next-line typescript/no-implied-eval + new Function('globalThis', snippet)(globalObj); + + expect(globalObj.__SENTRY_ORCHESTRION__?.bundler).toEqual(['mysql', 'ioredis']); + expect(injected).toEqual(['mysql', 'ioredis']); + }); + + it('sets `.bundler` even when the bridge is not installed yet, without throwing', () => { + const globalObj: { __SENTRY_ORCHESTRION__?: { bundler?: string[] } } = {}; + + const snippet = buildInjectBootSnippet(['mysql']); + // oxlint-disable-next-line typescript/no-implied-eval + expect(() => new Function('globalThis', snippet)(globalObj)).not.toThrow(); + + expect(globalObj.__SENTRY_ORCHESTRION__?.bundler).toEqual(['mysql']); + }); + + it('is a single line so it never shifts source-map mappings', () => { + expect(buildInjectBootSnippet(['mysql'])).not.toContain('\n'); + }); +}); diff --git a/packages/server-utils/test/orchestrion/instrumentation.test.ts b/packages/server-utils/test/orchestrion/instrumentation.test.ts new file mode 100644 index 000000000000..c0c1f9d259fe --- /dev/null +++ b/packages/server-utils/test/orchestrion/instrumentation.test.ts @@ -0,0 +1,73 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type * as SentryCore from '@sentry/core'; +import { GLOBAL_OBJ } from '@sentry/core'; + +// A minimal event bus standing in for the client, plus synchronous binding resolution so the whole +// bundler runtime path (bridge -> event -> listener -> instrumentation callback) runs inline. +const handlers: Record void>> = {}; +const fakeClient = { + on(hook: string, cb: (...args: unknown[]) => void) { + (handlers[hook] ??= []).push(cb); + return () => { + handlers[hook] = (handlers[hook] ?? []).filter(h => h !== cb); + }; + }, + emit(hook: string, ...args: unknown[]) { + (handlers[hook] ?? []).slice().forEach(h => h(...args)); + }, +}; + +vi.mock('@sentry/core', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + getClient: () => fakeClient, + waitForTracingChannelBinding: (cb: () => void) => cb(), + }; +}); + +import { invokeOrchestrionInstrumentation } from '../../src/orchestrion/instrumentation'; + +describe('invokeOrchestrionInstrumentation — bundler runtime path', () => { + afterEach(() => { + for (const key of Object.keys(handlers)) { + handlers[key] = []; + } + delete GLOBAL_OBJ.__SENTRY_ORCHESTRION__; + delete GLOBAL_OBJ.__SENTRY_ORCHESTRION_ON_INJECT__; + }); + + it('installs the on-inject bridge during setup', () => { + invokeOrchestrionInstrumentation(fakeClient as never, ['ioredis'], vi.fn(), []); + expect(typeof GLOBAL_OBJ.__SENTRY_ORCHESTRION_ON_INJECT__).toBe('function'); + }); + + it('subscribes when a build-time injected module announces itself via the bridge', () => { + const callback = vi.fn(); + invokeOrchestrionInstrumentation(fakeClient as never, ['ioredis'], callback as never, []); + + // Not injected yet at init → callback must not have run. + expect(callback).not.toHaveBeenCalled(); + + // The module's appended prologue calls this on load; it must trigger the subscription. + GLOBAL_OBJ.__SENTRY_ORCHESTRION_ON_INJECT__?.('ioredis'); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('ignores announcements for modules the integration does not handle', () => { + const callback = vi.fn(); + invokeOrchestrionInstrumentation(fakeClient as never, ['ioredis'], callback as never, []); + + GLOBAL_OBJ.__SENTRY_ORCHESTRION_ON_INJECT__?.('some-other-package'); + expect(callback).not.toHaveBeenCalled(); + }); + + it('does not invoke the callback more than once across repeated announcements', () => { + const callback = vi.fn(); + invokeOrchestrionInstrumentation(fakeClient as never, ['ioredis'], callback as never, []); + + GLOBAL_OBJ.__SENTRY_ORCHESTRION_ON_INJECT__?.('ioredis'); + GLOBAL_OBJ.__SENTRY_ORCHESTRION_ON_INJECT__?.('ioredis'); + expect(callback).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/server-utils/test/orchestrion/turbopack-loader.test.ts b/packages/server-utils/test/orchestrion/turbopack-loader.test.ts new file mode 100644 index 000000000000..aeec4ada3612 --- /dev/null +++ b/packages/server-utils/test/orchestrion/turbopack-loader.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from 'vitest'; + +// Mock the third-party transform so the test covers OUR loader logic (appending the prologue, module +// name derivation, passthrough) rather than the real code transform. Returns non-null for everything +// except paths containing `skip`, so we can exercise both branches. +vi.mock('@apm-js-collab/code-transformer-bundler-plugins/core', () => ({ + createCodeTransformer: () => ({ + transform: (code: string, id: string) => + id.includes('skip') ? null : { code: `/*instrumented*/${code}`, map: undefined }, + getCodeToInject: () => undefined, + }), +})); + +import { buildInjectPrologue } from '../../src/orchestrion/bundler/inject'; +import loader from '../../src/orchestrion/bundler/turbopack-loader'; + +function runLoader(resourcePath: string, code: string): string | undefined { + let out: string | undefined; + const ctx = { + resourcePath, + async: () => (_error: Error | null, transformed?: string) => { + out = transformed; + }, + }; + (loader as (this: unknown, code: string, map?: unknown) => void).call(ctx, code, null); + return out; +} + +describe('sentry orchestrion turbopack loader', () => { + it('appends the self-registration prologue to instrumented modules', () => { + const out = runLoader('/app/node_modules/ioredis/built/Redis.js', 'ORIGINAL'); + + expect(out).toContain('/*instrumented*/ORIGINAL'); + expect(out).toContain(buildInjectPrologue('ioredis')); + }); + + it('passes non-instrumented modules through untouched', () => { + const out = runLoader('/app/node_modules/skip-me/index.js', 'ORIGINAL'); + + expect(out).toBe('ORIGINAL'); + }); + + it('derives scoped package names for the prologue', () => { + const out = runLoader('/app/node_modules/@scope/pkg/index.js', 'ORIGINAL'); + + expect(out).toContain(buildInjectPrologue('@scope/pkg')); + }); + + it('uses the innermost node_modules segment for nested dependencies', () => { + const out = runLoader('/app/node_modules/a/node_modules/ioredis/built/Redis.js', 'ORIGINAL'); + + expect(out).toContain(buildInjectPrologue('ioredis')); + }); +});