From e908fcb365afdfb042e9231067c83ecb193d7f6b Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 8 Jul 2026 09:59:24 +0200 Subject: [PATCH 1/9] add orchestrion loader to nextjs --- packages/nextjs/package.json | 1 + .../src/config/diagnosticsChannelInjection.ts | 63 +++++++++++++++++++ .../turbopack/constructTurbopackConfig.ts | 42 ++++++++++++- packages/nextjs/src/config/types.ts | 17 ++++- packages/nextjs/src/config/webpack.ts | 9 +++ .../src/config/withSentryConfig/buildTime.ts | 5 ++ .../withSentryConfig/getFinalConfigObject.ts | 24 ++++++- .../getFinalConfigObjectBundlerUtils.ts | 25 +++++--- packages/nextjs/src/server/index.ts | 19 +++++- .../diagnosticsChannelInjection.test.ts | 36 +++++++++++ packages/node/src/index.ts | 1 + packages/server-utils/package.json | 11 ++++ packages/server-utils/rollup.npm.config.mjs | 1 + .../src/orchestrion/bundler/webpack.ts | 54 ++++++++++++++++ 14 files changed, 294 insertions(+), 14 deletions(-) create mode 100644 packages/nextjs/src/config/diagnosticsChannelInjection.ts create mode 100644 packages/nextjs/test/config/diagnosticsChannelInjection.test.ts create mode 100644 packages/server-utils/src/orchestrion/bundler/webpack.ts diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index fb121d30e514..fea112e64cb0 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -85,6 +85,7 @@ "@sentry/node": "10.63.0", "@sentry/opentelemetry": "10.63.0", "@sentry/react": "10.63.0", + "@sentry/server-utils": "10.63.0", "@sentry/vercel-edge": "10.63.0", "@sentry/webpack-plugin": "^5.3.0", "rollup": "^4.60.3", diff --git a/packages/nextjs/src/config/diagnosticsChannelInjection.ts b/packages/nextjs/src/config/diagnosticsChannelInjection.ts new file mode 100644 index 000000000000..e9562479cb19 --- /dev/null +++ b/packages/nextjs/src/config/diagnosticsChannelInjection.ts @@ -0,0 +1,63 @@ +import * as fs from 'node:fs'; +import { createRequire } from 'node:module'; +import * as path from 'node:path'; + + +/** Remove orchestrion-instrumented packages from a `serverExternalPackages` list. */ +export function filterInstrumentedExternals(externals: string[], instrumented: string[]): string[] { + const set = new Set(instrumented); + return externals.filter(name => !set.has(name)); +} + +/** + * Next's own default-external package list. Resolved from the project (Next isn't a dep of + * `@sentry/nextjs`), reading the file relative to `next/package.json` since the deep path isn't an + * exports subpath. The file is `.json` or `.jsonc` (with comments) depending on the Next version. + */ +export function getNextDefaultExternals(projectDir: string): string[] { + try { + const nextDir = path.dirname(createRequire(path.join(projectDir, 'package.json')).resolve('next/package.json')); + for (const ext of ['json', 'jsonc']) { + const p = path.join(nextDir, 'dist', 'lib', `server-external-packages.${ext}`); + if (fs.existsSync(p)) { + const jsonc = fs + .readFileSync(p, 'utf8') + .replace(/\/\*[\s\S]*?\*\//g, '') // block comments + .replace(/^\s*\/\/.*$/gm, '') // line comments + .replace(/,(\s*[\]}])/g, '$1'); // trailing commas + return JSON.parse(jsonc) as string[]; + } + } + return []; + } catch { + return []; + } +} + +// Only packages Next externalizes by default need `transpilePackages` to be bundled; the rest are +// bundled just by being absent from `serverExternalPackages`. +export function getTranspilePackages({ + instrumented, + nextDefaultExternals, + isInstalled, +}: { + instrumented: string[]; + nextDefaultExternals: string[]; + isInstalled: (name: string) => boolean; +}): string[] { + const defaults = new Set(nextDefaultExternals); + return instrumented.filter(name => defaults.has(name) && isInstalled(name)); +} + +/** Whether a package is resolvable from the project directory. */ +export function makeIsInstalled(projectDir: string): (name: string) => boolean { + const req = createRequire(path.join(projectDir, 'package.json')); + return (name: string) => { + try { + req.resolve(`${name}/package.json`); + return true; + } catch { + return false; + } + }; +} diff --git a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts index 9b25e9fe2061..31298b6067a6 100644 --- a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts +++ b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts @@ -1,8 +1,15 @@ import { debug } from '@sentry/core'; import * as path from 'path'; +import { getOrchestrionLoaderPath, getSentryInstrumentations } from '@sentry/server-utils/orchestrion/webpack'; import type { VercelCronsConfig } from '../../common/types'; import type { RouteManifest } from '../manifest/types'; -import type { NextConfigObject, SentryBuildOptions, TurbopackMatcherWithRule, TurbopackOptions } from '../types'; +import type { + JSONValue, + NextConfigObject, + SentryBuildOptions, + TurbopackMatcherWithRule, + TurbopackOptions, +} from '../types'; import { supportsNativeDebugIds, supportsTurbopackRuleCondition } from '../util'; import { generateValueInjectionRules } from './generateValueInjectionRules'; @@ -106,9 +113,42 @@ export function constructTurbopackConfig({ }); } + newConfig.rules = maybeAddOrchestrionRule(newConfig.rules, userSentryOptions, nextJsVersion); + return newConfig; } +/** + * Adds the orchestrion code-transform loader rule when diagnostics-channel injection is enabled. + */ +function maybeAddOrchestrionRule( + rules: TurbopackOptions['rules'], + userSentryOptions: SentryBuildOptions | undefined, + nextJsVersion: string | undefined, +): TurbopackOptions['rules'] { + if ( + !userSentryOptions?._experimental?.useDiagnosticsChannelInjection || + !nextJsVersion || + !supportsTurbopackRuleCondition(nextJsVersion) + ) { + return rules; + } + + return safelyAddTurbopackRule(rules, { + matcher: '*.{js,mjs,cjs}', + rule: { + condition: 'node', + loaders: [ + { + loader: getOrchestrionLoaderPath(), + // `instrumentations` is JSON-serializable + options: { instrumentations: getSentryInstrumentations() as unknown as JSONValue[] }, + }, + ], + }, + }); +} + /** * Safely add a Turbopack rule to the existing rules. * diff --git a/packages/nextjs/src/config/types.ts b/packages/nextjs/src/config/types.ts index 7d500e65420e..50c0988c5ee6 100644 --- a/packages/nextjs/src/config/types.ts +++ b/packages/nextjs/src/config/types.ts @@ -10,7 +10,7 @@ type NextRewrite = { destination: string; }; -interface WebpackPluginInstance { +export interface WebpackPluginInstance { [index: string]: unknown; apply: (compiler: unknown) => void; } @@ -53,6 +53,7 @@ export type NextConfigObject = { // https://nextjs.org/docs/pages/api-reference/next-config-js/env env?: Record; serverExternalPackages?: string[]; // next >= v15.0.0 + transpilePackages?: string[]; turbopack?: TurbopackOptions; compiler?: { runAfterProductionCompile?: (context: { distDir: string; projectDir: string }) => Promise | void; @@ -765,6 +766,19 @@ export type SentryBuildOptions = { enabled?: boolean; ignoredComponents?: string[]; }; + /** + * EXPERIMENTAL: Wire up orchestrion diagnostics-channel instrumentation at build time. + * + * When enabled, `withSentryConfig` injects the orchestrion code-transform loader and bundles + * the instrumented server packages (e.g. `pg`, `ioredis`, `mysql`) so that + * `Sentry.experimentalUseDiagnosticsChannelInjection()` (which you must still call in your + * server config) can produce spans for them. + * + * Turbopack support requires Next.js 16+; the webpack path works on earlier versions. + * + * @experimental May change or be removed in any release. + */ + useDiagnosticsChannelInjection?: boolean; }>; /** @@ -835,6 +849,7 @@ export type BuildContext = { version: string; DefinePlugin: new (values: Record) => WebpackPluginInstance; ProvidePlugin: new (values: Record) => WebpackPluginInstance; + IgnorePlugin: new (options: { resourceRegExp: RegExp; contextRegExp?: RegExp }) => WebpackPluginInstance; }; // eslint-disable-next-line @typescript-eslint/no-explicit-any defaultLoaders: any; // needed for type tests (test:types) diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts index 73e5184abc01..8c97390425fd 100644 --- a/packages/nextjs/src/config/webpack.ts +++ b/packages/nextjs/src/config/webpack.ts @@ -20,7 +20,9 @@ import type { WebpackConfigObject, WebpackConfigObjectWithModuleRules, WebpackEntryProperty, + WebpackPluginInstance, } from './types'; +import { sentryOrchestrionWebpackPlugin } from '@sentry/server-utils/orchestrion/webpack'; import { getNextjsVersion, getPackageModules } from './util'; import type { VercelCronsConfigResult } from './withSentryConfig/getFinalConfigObjectUtils'; @@ -429,6 +431,13 @@ export function constructWebpackConfigFunction({ }), ); + // Orchestrion code-transform loader + if (isServer && userSentryOptions._experimental?.useDiagnosticsChannelInjection) { + // stops the webpack build from failing on pg's optional (uninstalled) pg-native native binding now that we bundle pg. + newConfig.plugins.push(new buildContext.webpack.IgnorePlugin({ resourceRegExp: /^pg-native$/ })); + newConfig.plugins.push(sentryOrchestrionWebpackPlugin() as WebpackPluginInstance); + } + return newConfig; }; } diff --git a/packages/nextjs/src/config/withSentryConfig/buildTime.ts b/packages/nextjs/src/config/withSentryConfig/buildTime.ts index c468b4a1f18e..b799c9becfaa 100644 --- a/packages/nextjs/src/config/withSentryConfig/buildTime.ts +++ b/packages/nextjs/src/config/withSentryConfig/buildTime.ts @@ -50,6 +50,11 @@ export function setUpBuildTimeVariables( buildTimeVariables._sentryRewritesTunnelPath = rewritesTunnelPath; } + // Marker read by the server SDK to warn if the runtime opt-in call is missing. + if (userSentryOptions._experimental?.useDiagnosticsChannelInjection) { + buildTimeVariables._sentryUseDiagnosticsChannelInjection = 'true'; + } + if (basePath) { buildTimeVariables._sentryBasePath = basePath; } diff --git a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObject.ts b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObject.ts index 6d71e53595a7..04ad7d4f0f88 100644 --- a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObject.ts +++ b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObject.ts @@ -1,3 +1,5 @@ +import { INSTRUMENTED_MODULE_NAMES } from '@sentry/server-utils/orchestrion/config'; +import { getNextDefaultExternals, getTranspilePackages, makeIsInstalled } from '../diagnosticsChannelInjection'; import type { NextConfigObject, SentryBuildOptions } from '../types'; import { getNextjsVersion } from '../util'; import { setUpBuildTimeVariables } from './buildTime'; @@ -85,9 +87,29 @@ export function getFinalConfigObject( maybeEnableTurbopackSourcemaps(incomingUserNextConfigObject, userSentryOptions, bundlerInfo); + const useDiagnosticsChannelInjection = userSentryOptions._experimental?.useDiagnosticsChannelInjection ?? false; + + // Force-bundle the instrumented packages Next externalizes by default, so the code transform + // reaches them (removing them from `serverExternalPackages` isn't enough for Next's own defaults). + const transpilePackagesPatch = useDiagnosticsChannelInjection + ? { + transpilePackages: Array.from( + new Set([ + ...(incomingUserNextConfigObject.transpilePackages ?? []), + ...getTranspilePackages({ + instrumented: INSTRUMENTED_MODULE_NAMES, + nextDefaultExternals: getNextDefaultExternals(process.cwd()), + isInstalled: makeIsInstalled(process.cwd()), + }), + ]), + ), + } + : {}; + return { ...incomingUserNextConfigObject, - ...getServerExternalPackagesPatch(incomingUserNextConfigObject, nextMajor), + ...transpilePackagesPatch, + ...getServerExternalPackagesPatch(incomingUserNextConfigObject, nextMajor, useDiagnosticsChannelInjection), ...getWebpackPatch({ incomingUserNextConfigObject, userSentryOptions, diff --git a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectBundlerUtils.ts b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectBundlerUtils.ts index edd62b8ba8c3..3894140d9a8f 100644 --- a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectBundlerUtils.ts +++ b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectBundlerUtils.ts @@ -1,3 +1,5 @@ +import { INSTRUMENTED_MODULE_NAMES } from '@sentry/server-utils/orchestrion/config'; +import { filterInstrumentedExternals } from '../diagnosticsChannelInjection'; import { handleRunAfterProductionCompile } from '../handleRunAfterProductionCompile'; import type { RouteManifest } from '../manifest/types'; import { constructTurbopackConfig } from '../turbopack'; @@ -227,23 +229,26 @@ export function maybeEnableTurbopackSourcemaps( export function getServerExternalPackagesPatch( incomingUserNextConfigObject: NextConfigObject, nextMajor: number | undefined, + useDiagnosticsChannelInjection = false, ): Partial { + // Diagnostics-channel injection needs the instrumented packages bundled (so the code transform + // reaches them), so drop them from the external list — the rest stay external for the OTel path. + const instrumented = useDiagnosticsChannelInjection ? INSTRUMENTED_MODULE_NAMES : []; + const mergeExternals = (userProvided: string[] | undefined): string[] => { + const merged = [...(userProvided || []), ...DEFAULT_SERVER_EXTERNAL_PACKAGES]; + return useDiagnosticsChannelInjection ? filterInstrumentedExternals(merged, instrumented) : merged; + }; + if (nextMajor && nextMajor >= 15) { - return { - serverExternalPackages: [ - ...(incomingUserNextConfigObject.serverExternalPackages || []), - ...DEFAULT_SERVER_EXTERNAL_PACKAGES, - ], - }; + return { serverExternalPackages: mergeExternals(incomingUserNextConfigObject.serverExternalPackages) }; } return { experimental: { ...incomingUserNextConfigObject.experimental, - serverComponentsExternalPackages: [ - ...(incomingUserNextConfigObject.experimental?.serverComponentsExternalPackages || []), - ...DEFAULT_SERVER_EXTERNAL_PACKAGES, - ], + serverComponentsExternalPackages: mergeExternals( + incomingUserNextConfigObject.experimental?.serverComponentsExternalPackages, + ), }, }; } diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index cd70361506c8..ec5e19a257bb 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -12,7 +12,12 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_OP, } from '@sentry/core'; import type { NodeClient, NodeOptions } from '@sentry/node'; -import { getDefaultIntegrations, httpIntegration, init as nodeInit } from '@sentry/node'; +import { + getDefaultIntegrations, + httpIntegration, + init as nodeInit, + isDiagnosticsChannelInjectionEnabled, +} from '@sentry/node'; import { DEBUG_BUILD } from '../common/debug-build'; import { devErrorSymbolicationEventProcessor } from '../common/devErrorSymbolicationEventProcessor'; import { getVercelEnv } from '../common/getVercelEnv'; @@ -40,6 +45,7 @@ export { startSpan, startSpanManual, startInactiveSpan } from '../common/utils/n const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryRewriteFramesDistDir?: string; _sentryRelease?: string; + _sentryUseDiagnosticsChannelInjection?: string; }; // Call at module level so `next build` prerender workers still register the runner without `init` @@ -137,6 +143,17 @@ export function init(options: NodeOptions): NodeClient | undefined { customDefaultIntegrations.push(distDirRewriteFramesIntegration({ distDirName })); } + // The build wired the orchestrion loader but the runtime opt-in is missing → no DB spans. + const useDiagnosticsChannelInjection = + process.env._sentryUseDiagnosticsChannelInjection || globalWithInjectedValues._sentryUseDiagnosticsChannelInjection; + if (DEBUG_BUILD && useDiagnosticsChannelInjection && !isDiagnosticsChannelInjectionEnabled()) { + debug.warn( + '[@sentry/nextjs] `useDiagnosticsChannelInjection` is enabled in `withSentryConfig`, but ' + + '`Sentry.experimentalUseDiagnosticsChannelInjection()` was not called before `Sentry.init()`. ' + + 'Server DB spans will not be recorded.', + ); + } + // Detect if running on OpenNext/Cloudflare and get runtime config const cloudflareConfig = getCloudflareRuntimeConfig(); diff --git a/packages/nextjs/test/config/diagnosticsChannelInjection.test.ts b/packages/nextjs/test/config/diagnosticsChannelInjection.test.ts new file mode 100644 index 000000000000..0142a2a32cae --- /dev/null +++ b/packages/nextjs/test/config/diagnosticsChannelInjection.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { filterInstrumentedExternals, getTranspilePackages } from '../../src/config/diagnosticsChannelInjection'; + +describe('filterInstrumentedExternals', () => { + it('removes orchestrion-instrumented packages, keeps the rest', () => { + expect( + filterInstrumentedExternals(['express', 'pg', 'pg-pool', 'ioredis', 'mongodb'], ['pg', 'pg-pool', 'ioredis']), + ).toEqual(['express', 'mongodb']); + }); + + it('is a no-op with an empty instrumented list', () => { + expect(filterInstrumentedExternals(['express', 'pg'], [])).toEqual(['express', 'pg']); + }); +}); + +describe('getTranspilePackages', () => { + it('returns installed instrumented packages that Next externalizes by default', () => { + const result = getTranspilePackages({ + instrumented: ['pg', 'pg-pool', 'ioredis', 'mysql', 'openai'], + nextDefaultExternals: ['pg', 'pg-pool', 'mysql', 'mysql2'], + isInstalled: name => name !== 'mysql', // pretend mysql is not installed + }); + // ioredis/openai aren't Next-default-external → not needed; mysql not installed → excluded + expect(result.sort()).toEqual(['pg', 'pg-pool']); + }); + + it('returns nothing when none of the instrumented packages are Next-default-external', () => { + expect( + getTranspilePackages({ + instrumented: ['ioredis', 'openai'], + nextDefaultExternals: ['pg', 'mysql'], + isInstalled: () => true, + }), + ).toEqual([]); + }); +}); diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index a03f9e239525..907adfa6b012 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -51,6 +51,7 @@ export { experimentalUseDiagnosticsChannelInjection, diagnosticsChannelInjectionIntegrations, } from './sdk/experimentalUseDiagnosticsChannelInjection'; +export { isDiagnosticsChannelInjectionEnabled } from './sdk/diagnosticsChannelInjection'; export { initOpenTelemetry, preloadOpenTelemetry } from './sdk/initOtel'; export { getAutoPerformanceIntegrations } from './integrations/tracing'; diff --git a/packages/server-utils/package.json b/packages/server-utils/package.json index d42372b89ad0..3bb656d692ff 100644 --- a/packages/server-utils/package.json +++ b/packages/server-utils/package.json @@ -41,6 +41,11 @@ "types": "./build/types/orchestrion/bundler/vite.d.ts", "import": "./build/esm/orchestrion/bundler/vite.js" }, + "./orchestrion/webpack": { + "types": "./build/types/orchestrion/bundler/webpack.d.ts", + "import": "./build/esm/orchestrion/bundler/webpack.js", + "require": "./build/cjs/orchestrion/bundler/webpack.js" + }, "./orchestrion/import-hook": { "import": "./build/orchestrion/import-hook.mjs" } @@ -61,6 +66,9 @@ ], "orchestrion/vite": [ "build/types-ts3.8/orchestrion/bundler/vite.d.ts" + ], + "orchestrion/webpack": [ + "build/types-ts3.8/orchestrion/bundler/webpack.d.ts" ] }, "*": { @@ -75,6 +83,9 @@ ], "orchestrion/vite": [ "build/types/orchestrion/bundler/vite.d.ts" + ], + "orchestrion/webpack": [ + "build/types/orchestrion/bundler/webpack.d.ts" ] } }, diff --git a/packages/server-utils/rollup.npm.config.mjs b/packages/server-utils/rollup.npm.config.mjs index ca2c6cf19486..5f02614f716d 100644 --- a/packages/server-utils/rollup.npm.config.mjs +++ b/packages/server-utils/rollup.npm.config.mjs @@ -34,6 +34,7 @@ export default [ // `Sentry.init()` to install the channel-injection hooks. 'src/orchestrion/runtime/register.ts', 'src/orchestrion/bundler/vite.ts', + 'src/orchestrion/bundler/webpack.ts', ], packageSpecificConfig: { output: { diff --git a/packages/server-utils/src/orchestrion/bundler/webpack.ts b/packages/server-utils/src/orchestrion/bundler/webpack.ts new file mode 100644 index 000000000000..ca1a0fee2e4e --- /dev/null +++ b/packages/server-utils/src/orchestrion/bundler/webpack.ts @@ -0,0 +1,54 @@ +// EXPERIMENTAL — orchestrion code-transform loader + webpack plugin. Consumed by SDKs whose bundler +// takes a webpack-compatible loader: webpack directly, and Turbopack via `turbopack.rules` (Turbopack +// can't load webpack *plugins*, only loaders, so the loader path is exposed for that path). +// +// Published dual (ESM + CJS) via `@sentry/server-utils/orchestrion/webpack` since consumers (e.g. a +// Next.js `next.config`) may load either format. The `@apm-js-collab/code-transformer-bundler-plugins` +// deps are resolved from THIS package's own graph (they're direct deps here), so consumers don't have +// to reach into a transitive dependency. + +import { createRequire } from 'node:module'; +import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import { SENTRY_INSTRUMENTATIONS } from '../config'; + +// Sets `globalThis.__SENTRY_ORCHESTRION__.bundler = true` at app boot so the runtime detector can +// confirm the bundler transform ran. Only the webpack plugin can inject this; the bare loader can't. +const BUNDLER_MARKER = [ + 'globalThis.__SENTRY_ORCHESTRION__ = (globalThis.__SENTRY_ORCHESTRION__ || {});', + 'globalThis.__SENTRY_ORCHESTRION__.bundler = true;', + '', +].join('\n'); + +// Resolve the bundler-plugins package from this module regardless of ESM/CJS output. +function getOrchestrionRequire(): ReturnType { + let nodeRequire: ReturnType; + /*! rollup-include-cjs-only */ + nodeRequire = require; + /*! 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). */ +export function getOrchestrionLoaderPath(): string { + return getOrchestrionRequire().resolve('@apm-js-collab/code-transformer-bundler-plugins/webpack-loader'); +} + +/** The central instrumentation config, to pass as the loader's `instrumentations` option. */ +export function getSentryInstrumentations(): InstrumentationConfig[] { + return SENTRY_INSTRUMENTATIONS; +} + +/** The code-transform webpack plugin (for `next build --webpack`), pre-fed the instrumentation config. */ +export function sentryOrchestrionWebpackPlugin(): unknown { + const mod = getOrchestrionRequire()('@apm-js-collab/code-transformer-bundler-plugins/webpack') as { + default?: (options: { instrumentations: InstrumentationConfig[]; injectDiagnostics?: () => string }) => unknown; + }; + const codeTransformerWebpack = mod.default ?? (mod as unknown as NonNullable); + return codeTransformerWebpack({ + instrumentations: SENTRY_INSTRUMENTATIONS, + injectDiagnostics: () => BUNDLER_MARKER, + }); +} From 8e64adb98f452b049d9d97a510af5f138230234d Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 8 Jul 2026 10:07:04 +0200 Subject: [PATCH 2/9] fmt --- packages/nextjs/src/config/diagnosticsChannelInjection.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/nextjs/src/config/diagnosticsChannelInjection.ts b/packages/nextjs/src/config/diagnosticsChannelInjection.ts index e9562479cb19..bfb4f28bcf51 100644 --- a/packages/nextjs/src/config/diagnosticsChannelInjection.ts +++ b/packages/nextjs/src/config/diagnosticsChannelInjection.ts @@ -2,7 +2,6 @@ import * as fs from 'node:fs'; import { createRequire } from 'node:module'; import * as path from 'node:path'; - /** Remove orchestrion-instrumented packages from a `serverExternalPackages` list. */ export function filterInstrumentedExternals(externals: string[], instrumented: string[]): string[] { const set = new Set(instrumented); From 608f5931f5703f33837522587c0f091a5fcf4469 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 8 Jul 2026 12:28:48 +0200 Subject: [PATCH 3/9] fix(nextjs): Keep orchestrion runtime machinery and mysql external Two fixes for diagnostics-channel injection under Turbopack: Externalize @apm-js-collab/tracing-hooks and @apm-js-collab/code-transformer when the flag is on. Bundled, the code transformer's parser breaks ('a.parse is not a function'), so the runtime module hook silently returned untransformed sources and externalized packages never produced spans. Keep mysql in serverExternalPackages instead of force-bundling it. Turbopack cannot bundle mysql 2.x correctly (the wire-protocol handshake fails with 'Received packet in the wrong sequence' even untransformed). External, it is now instrumented by the working runtime hook instead of the build-time loader. Also drop the bundler marker from the orchestrion webpack plugin: it made registerDiagnosticsChannelInjection() skip the runtime hook, but the hybrid setup (loader for bundled deps, runtime hook for external ones) needs both. Co-Authored-By: Claude Fable 5 --- .../src/config/diagnosticsChannelInjection.ts | 26 +++++++++++++++++++ .../withSentryConfig/getFinalConfigObject.ts | 9 +++++-- .../getFinalConfigObjectBundlerUtils.ts | 18 ++++++++++--- .../diagnosticsChannelInjection.test.ts | 12 ++++++++- .../src/orchestrion/bundler/webpack.ts | 24 +++++++---------- 5 files changed, 68 insertions(+), 21 deletions(-) diff --git a/packages/nextjs/src/config/diagnosticsChannelInjection.ts b/packages/nextjs/src/config/diagnosticsChannelInjection.ts index bfb4f28bcf51..68a641e611a2 100644 --- a/packages/nextjs/src/config/diagnosticsChannelInjection.ts +++ b/packages/nextjs/src/config/diagnosticsChannelInjection.ts @@ -2,12 +2,38 @@ import * as fs from 'node:fs'; import { createRequire } from 'node:module'; import * as path from 'node:path'; +/** + * Instrumented packages that MUST stay externalized (and thus get instrumented by the runtime + * module hook instead of the build-time loader): Turbopack cannot bundle them correctly. + * `mysql` (2.x) corrupts its wire protocol when bundled ("Received packet in the wrong sequence" + * during the handshake) — even completely untransformed, so this is a bundling incompatibility, + * not a transform issue. + */ +export const BUNDLE_UNSAFE_INSTRUMENTED_PACKAGES = ['mysql']; + +/** + * The orchestrion runtime machinery, which must NOT be bundled: the code transformer's parser + * breaks when bundled ("a.parse is not a function"), making the runtime module hook silently + * return untransformed sources. Externalizing these keeps the hook running from real + * `node_modules`, so externalized instrumented packages (e.g. `mysql`) get transformed on require. + */ +export const ORCHESTRION_RUNTIME_EXTERNAL_PACKAGES = [ + '@apm-js-collab/tracing-hooks', + '@apm-js-collab/code-transformer', +]; + /** Remove orchestrion-instrumented packages from a `serverExternalPackages` list. */ export function filterInstrumentedExternals(externals: string[], instrumented: string[]): string[] { const set = new Set(instrumented); return externals.filter(name => !set.has(name)); } +/** The instrumented packages that should be force-bundled (i.e. reached by the build-time loader). */ +export function getBundleableInstrumented(instrumented: string[]): string[] { + const unsafe = new Set(BUNDLE_UNSAFE_INSTRUMENTED_PACKAGES); + return instrumented.filter(name => !unsafe.has(name)); +} + /** * Next's own default-external package list. Resolved from the project (Next isn't a dep of * `@sentry/nextjs`), reading the file relative to `next/package.json` since the deep path isn't an diff --git a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObject.ts b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObject.ts index 04ad7d4f0f88..e2b33dd902c2 100644 --- a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObject.ts +++ b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObject.ts @@ -1,5 +1,10 @@ import { INSTRUMENTED_MODULE_NAMES } from '@sentry/server-utils/orchestrion/config'; -import { getNextDefaultExternals, getTranspilePackages, makeIsInstalled } from '../diagnosticsChannelInjection'; +import { + getBundleableInstrumented, + getNextDefaultExternals, + getTranspilePackages, + makeIsInstalled, +} from '../diagnosticsChannelInjection'; import type { NextConfigObject, SentryBuildOptions } from '../types'; import { getNextjsVersion } from '../util'; import { setUpBuildTimeVariables } from './buildTime'; @@ -97,7 +102,7 @@ export function getFinalConfigObject( new Set([ ...(incomingUserNextConfigObject.transpilePackages ?? []), ...getTranspilePackages({ - instrumented: INSTRUMENTED_MODULE_NAMES, + instrumented: getBundleableInstrumented(INSTRUMENTED_MODULE_NAMES), nextDefaultExternals: getNextDefaultExternals(process.cwd()), isInstalled: makeIsInstalled(process.cwd()), }), diff --git a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectBundlerUtils.ts b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectBundlerUtils.ts index 3894140d9a8f..4b98d9bf65fc 100644 --- a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectBundlerUtils.ts +++ b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectBundlerUtils.ts @@ -1,5 +1,9 @@ import { INSTRUMENTED_MODULE_NAMES } from '@sentry/server-utils/orchestrion/config'; -import { filterInstrumentedExternals } from '../diagnosticsChannelInjection'; +import { + filterInstrumentedExternals, + getBundleableInstrumented, + ORCHESTRION_RUNTIME_EXTERNAL_PACKAGES, +} from '../diagnosticsChannelInjection'; import { handleRunAfterProductionCompile } from '../handleRunAfterProductionCompile'; import type { RouteManifest } from '../manifest/types'; import { constructTurbopackConfig } from '../turbopack'; @@ -232,10 +236,16 @@ export function getServerExternalPackagesPatch( useDiagnosticsChannelInjection = false, ): Partial { // Diagnostics-channel injection needs the instrumented packages bundled (so the code transform - // reaches them), so drop them from the external list — the rest stay external for the OTel path. - const instrumented = useDiagnosticsChannelInjection ? INSTRUMENTED_MODULE_NAMES : []; + // reaches them), so drop them from the external list — except the bundle-unsafe ones, which stay + // external and get instrumented by the runtime module hook instead. That hook only works if the + // orchestrion machinery itself is external too (bundled, its parser breaks), so add those. + const instrumented = useDiagnosticsChannelInjection ? getBundleableInstrumented(INSTRUMENTED_MODULE_NAMES) : []; const mergeExternals = (userProvided: string[] | undefined): string[] => { - const merged = [...(userProvided || []), ...DEFAULT_SERVER_EXTERNAL_PACKAGES]; + const merged = [ + ...(userProvided || []), + ...DEFAULT_SERVER_EXTERNAL_PACKAGES, + ...(useDiagnosticsChannelInjection ? ORCHESTRION_RUNTIME_EXTERNAL_PACKAGES : []), + ]; return useDiagnosticsChannelInjection ? filterInstrumentedExternals(merged, instrumented) : merged; }; diff --git a/packages/nextjs/test/config/diagnosticsChannelInjection.test.ts b/packages/nextjs/test/config/diagnosticsChannelInjection.test.ts index 0142a2a32cae..bd0f9210f82d 100644 --- a/packages/nextjs/test/config/diagnosticsChannelInjection.test.ts +++ b/packages/nextjs/test/config/diagnosticsChannelInjection.test.ts @@ -1,5 +1,15 @@ import { describe, expect, it } from 'vitest'; -import { filterInstrumentedExternals, getTranspilePackages } from '../../src/config/diagnosticsChannelInjection'; +import { + filterInstrumentedExternals, + getBundleableInstrumented, + getTranspilePackages, +} from '../../src/config/diagnosticsChannelInjection'; + +describe('getBundleableInstrumented', () => { + it('excludes bundle-unsafe packages (mysql stays external / runtime-hook instrumented)', () => { + expect(getBundleableInstrumented(['pg', 'mysql', 'ioredis'])).toEqual(['pg', 'ioredis']); + }); +}); describe('filterInstrumentedExternals', () => { it('removes orchestrion-instrumented packages, keeps the rest', () => { diff --git a/packages/server-utils/src/orchestrion/bundler/webpack.ts b/packages/server-utils/src/orchestrion/bundler/webpack.ts index ca1a0fee2e4e..43e7fb1356a2 100644 --- a/packages/server-utils/src/orchestrion/bundler/webpack.ts +++ b/packages/server-utils/src/orchestrion/bundler/webpack.ts @@ -11,14 +11,6 @@ import { createRequire } from 'node:module'; import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; import { SENTRY_INSTRUMENTATIONS } from '../config'; -// Sets `globalThis.__SENTRY_ORCHESTRION__.bundler = true` at app boot so the runtime detector can -// confirm the bundler transform ran. Only the webpack plugin can inject this; the bare loader can't. -const BUNDLER_MARKER = [ - 'globalThis.__SENTRY_ORCHESTRION__ = (globalThis.__SENTRY_ORCHESTRION__ || {});', - 'globalThis.__SENTRY_ORCHESTRION__.bundler = true;', - '', -].join('\n'); - // Resolve the bundler-plugins package from this module regardless of ESM/CJS output. function getOrchestrionRequire(): ReturnType { let nodeRequire: ReturnType; @@ -41,14 +33,18 @@ export function getSentryInstrumentations(): InstrumentationConfig[] { return SENTRY_INSTRUMENTATIONS; } -/** The code-transform webpack plugin (for `next build --webpack`), pre-fed the instrumentation config. */ +/** + * The code-transform webpack plugin (for `next build --webpack`), pre-fed the instrumentation config. + * + * Intentionally does NOT inject the `__SENTRY_ORCHESTRION__.bundler` marker (unlike the Vite plugin): + * in a Next.js build, bundle-unsafe packages (e.g. `mysql`) stay externalized and rely on the runtime + * module hook, which `registerDiagnosticsChannelInjection()` skips when the bundler marker is set. The + * hybrid setup (bundler transform for bundled deps + runtime hook for external ones) needs both active. + */ export function sentryOrchestrionWebpackPlugin(): unknown { const mod = getOrchestrionRequire()('@apm-js-collab/code-transformer-bundler-plugins/webpack') as { - default?: (options: { instrumentations: InstrumentationConfig[]; injectDiagnostics?: () => string }) => unknown; + default?: (options: { instrumentations: InstrumentationConfig[] }) => unknown; }; const codeTransformerWebpack = mod.default ?? (mod as unknown as NonNullable); - return codeTransformerWebpack({ - instrumentations: SENTRY_INSTRUMENTATIONS, - injectDiagnostics: () => BUNDLER_MARKER, - }); + return codeTransformerWebpack({ instrumentations: SENTRY_INSTRUMENTATIONS }); } From f068a038526fe51ecb541dda8c03e03a6e36f090 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 8 Jul 2026 13:40:37 +0200 Subject: [PATCH 4/9] ref(nextjs): Replace transpile-based bundling with bundle-safe allowlist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of un-externalizing every instrumented package and forcing the Next-default-external ones through transpilePackages, only packages on an explicit bundle-safe allowlist (currently ioredis) are removed from Sentry's own serverExternalPackages defaults. Everything else — Next's defaults, the user's externals, the rest of Sentry's defaults — stays external and is instrumented by the runtime module hook on require, which works since the orchestrion machinery is externalized. This is safer: bundling a server package changes real behavior (mysql 2.x corrupts its wire protocol when bundled by Turbopack), and new upstream instrumentations (e.g. hapi) now default to the external/runtime-hook path instead of silently becoming bundled. It also removes the Next server-external-packages list parsing and the pg-native webpack workaround, both only needed to support force-bundling. Verified in the nextjs-16-orchestrion e2e: ioredis via the build-time loader, pg and mysql via the runtime hook. Co-Authored-By: Claude Fable 5 --- .../src/config/diagnosticsChannelInjection.ts | 88 ++++--------------- packages/nextjs/src/config/types.ts | 11 ++- packages/nextjs/src/config/webpack.ts | 2 - .../withSentryConfig/getFinalConfigObject.ts | 25 ------ .../getFinalConfigObjectBundlerUtils.ts | 21 ++--- .../diagnosticsChannelInjection.test.ts | 58 ++++++------ 6 files changed, 62 insertions(+), 143 deletions(-) diff --git a/packages/nextjs/src/config/diagnosticsChannelInjection.ts b/packages/nextjs/src/config/diagnosticsChannelInjection.ts index 68a641e611a2..b876df0cb545 100644 --- a/packages/nextjs/src/config/diagnosticsChannelInjection.ts +++ b/packages/nextjs/src/config/diagnosticsChannelInjection.ts @@ -1,88 +1,30 @@ -import * as fs from 'node:fs'; -import { createRequire } from 'node:module'; -import * as path from 'node:path'; - /** - * Instrumented packages that MUST stay externalized (and thus get instrumented by the runtime - * module hook instead of the build-time loader): Turbopack cannot bundle them correctly. - * `mysql` (2.x) corrupts its wire protocol when bundled ("Received packet in the wrong sequence" - * during the handshake) — even completely untransformed, so this is a bundling incompatibility, - * not a transform issue. + * Instrumented packages that are verified to bundle correctly under Turbopack. Only these are + * removed from Sentry's own `serverExternalPackages` defaults, so the build-time loader can + * transform them. Everything else instrumented stays externalized — Next's own defaults, the + * user's config, and the rest of Sentry's defaults are never overridden — and is instrumented by + * the runtime module hook on `require` instead. + * + * Deliberately an allowlist: bundling a server package changes real behavior (e.g. `mysql` 2.x + * corrupts its wire protocol when bundled by Turbopack, even untransformed), so packages are only + * added here once bundling them is e2e-verified. */ -export const BUNDLE_UNSAFE_INSTRUMENTED_PACKAGES = ['mysql']; +export const BUNDLE_SAFE_INSTRUMENTED_PACKAGES = ['ioredis']; /** * The orchestrion runtime machinery, which must NOT be bundled: the code transformer's parser * breaks when bundled ("a.parse is not a function"), making the runtime module hook silently * return untransformed sources. Externalizing these keeps the hook running from real - * `node_modules`, so externalized instrumented packages (e.g. `mysql`) get transformed on require. + * `node_modules`, so externalized instrumented packages (e.g. `pg`, `mysql`) get transformed on + * require. */ export const ORCHESTRION_RUNTIME_EXTERNAL_PACKAGES = [ '@apm-js-collab/tracing-hooks', '@apm-js-collab/code-transformer', ]; -/** Remove orchestrion-instrumented packages from a `serverExternalPackages` list. */ -export function filterInstrumentedExternals(externals: string[], instrumented: string[]): string[] { - const set = new Set(instrumented); +/** Remove the given packages from a `serverExternalPackages` list. */ +export function filterInstrumentedExternals(externals: string[], packagesToBundle: string[]): string[] { + const set = new Set(packagesToBundle); return externals.filter(name => !set.has(name)); } - -/** The instrumented packages that should be force-bundled (i.e. reached by the build-time loader). */ -export function getBundleableInstrumented(instrumented: string[]): string[] { - const unsafe = new Set(BUNDLE_UNSAFE_INSTRUMENTED_PACKAGES); - return instrumented.filter(name => !unsafe.has(name)); -} - -/** - * Next's own default-external package list. Resolved from the project (Next isn't a dep of - * `@sentry/nextjs`), reading the file relative to `next/package.json` since the deep path isn't an - * exports subpath. The file is `.json` or `.jsonc` (with comments) depending on the Next version. - */ -export function getNextDefaultExternals(projectDir: string): string[] { - try { - const nextDir = path.dirname(createRequire(path.join(projectDir, 'package.json')).resolve('next/package.json')); - for (const ext of ['json', 'jsonc']) { - const p = path.join(nextDir, 'dist', 'lib', `server-external-packages.${ext}`); - if (fs.existsSync(p)) { - const jsonc = fs - .readFileSync(p, 'utf8') - .replace(/\/\*[\s\S]*?\*\//g, '') // block comments - .replace(/^\s*\/\/.*$/gm, '') // line comments - .replace(/,(\s*[\]}])/g, '$1'); // trailing commas - return JSON.parse(jsonc) as string[]; - } - } - return []; - } catch { - return []; - } -} - -// Only packages Next externalizes by default need `transpilePackages` to be bundled; the rest are -// bundled just by being absent from `serverExternalPackages`. -export function getTranspilePackages({ - instrumented, - nextDefaultExternals, - isInstalled, -}: { - instrumented: string[]; - nextDefaultExternals: string[]; - isInstalled: (name: string) => boolean; -}): string[] { - const defaults = new Set(nextDefaultExternals); - return instrumented.filter(name => defaults.has(name) && isInstalled(name)); -} - -/** Whether a package is resolvable from the project directory. */ -export function makeIsInstalled(projectDir: string): (name: string) => boolean { - const req = createRequire(path.join(projectDir, 'package.json')); - return (name: string) => { - try { - req.resolve(`${name}/package.json`); - return true; - } catch { - return false; - } - }; -} diff --git a/packages/nextjs/src/config/types.ts b/packages/nextjs/src/config/types.ts index 50c0988c5ee6..d37e04dd11dd 100644 --- a/packages/nextjs/src/config/types.ts +++ b/packages/nextjs/src/config/types.ts @@ -53,7 +53,6 @@ export type NextConfigObject = { // https://nextjs.org/docs/pages/api-reference/next-config-js/env env?: Record; serverExternalPackages?: string[]; // next >= v15.0.0 - transpilePackages?: string[]; turbopack?: TurbopackOptions; compiler?: { runAfterProductionCompile?: (context: { distDir: string; projectDir: string }) => Promise | void; @@ -769,10 +768,11 @@ export type SentryBuildOptions = { /** * EXPERIMENTAL: Wire up orchestrion diagnostics-channel instrumentation at build time. * - * When enabled, `withSentryConfig` injects the orchestrion code-transform loader and bundles - * the instrumented server packages (e.g. `pg`, `ioredis`, `mysql`) so that - * `Sentry.experimentalUseDiagnosticsChannelInjection()` (which you must still call in your - * server config) can produce spans for them. + * When enabled, `withSentryConfig` injects the orchestrion code-transform loader (which + * instruments bundled server packages at build time) and externalizes the orchestrion runtime + * machinery so that externalized packages (e.g. `pg`, `mysql`) get instrumented by the runtime + * module hook on require. You must still call + * `Sentry.experimentalUseDiagnosticsChannelInjection()` in your server config to record spans. * * Turbopack support requires Next.js 16+; the webpack path works on earlier versions. * @@ -849,7 +849,6 @@ export type BuildContext = { version: string; DefinePlugin: new (values: Record) => WebpackPluginInstance; ProvidePlugin: new (values: Record) => WebpackPluginInstance; - IgnorePlugin: new (options: { resourceRegExp: RegExp; contextRegExp?: RegExp }) => WebpackPluginInstance; }; // eslint-disable-next-line @typescript-eslint/no-explicit-any defaultLoaders: any; // needed for type tests (test:types) diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts index 8c97390425fd..443497fa2000 100644 --- a/packages/nextjs/src/config/webpack.ts +++ b/packages/nextjs/src/config/webpack.ts @@ -433,8 +433,6 @@ export function constructWebpackConfigFunction({ // Orchestrion code-transform loader if (isServer && userSentryOptions._experimental?.useDiagnosticsChannelInjection) { - // stops the webpack build from failing on pg's optional (uninstalled) pg-native native binding now that we bundle pg. - newConfig.plugins.push(new buildContext.webpack.IgnorePlugin({ resourceRegExp: /^pg-native$/ })); newConfig.plugins.push(sentryOrchestrionWebpackPlugin() as WebpackPluginInstance); } diff --git a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObject.ts b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObject.ts index e2b33dd902c2..e96ecbf1719f 100644 --- a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObject.ts +++ b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObject.ts @@ -1,10 +1,3 @@ -import { INSTRUMENTED_MODULE_NAMES } from '@sentry/server-utils/orchestrion/config'; -import { - getBundleableInstrumented, - getNextDefaultExternals, - getTranspilePackages, - makeIsInstalled, -} from '../diagnosticsChannelInjection'; import type { NextConfigObject, SentryBuildOptions } from '../types'; import { getNextjsVersion } from '../util'; import { setUpBuildTimeVariables } from './buildTime'; @@ -94,26 +87,8 @@ export function getFinalConfigObject( const useDiagnosticsChannelInjection = userSentryOptions._experimental?.useDiagnosticsChannelInjection ?? false; - // Force-bundle the instrumented packages Next externalizes by default, so the code transform - // reaches them (removing them from `serverExternalPackages` isn't enough for Next's own defaults). - const transpilePackagesPatch = useDiagnosticsChannelInjection - ? { - transpilePackages: Array.from( - new Set([ - ...(incomingUserNextConfigObject.transpilePackages ?? []), - ...getTranspilePackages({ - instrumented: getBundleableInstrumented(INSTRUMENTED_MODULE_NAMES), - nextDefaultExternals: getNextDefaultExternals(process.cwd()), - isInstalled: makeIsInstalled(process.cwd()), - }), - ]), - ), - } - : {}; - return { ...incomingUserNextConfigObject, - ...transpilePackagesPatch, ...getServerExternalPackagesPatch(incomingUserNextConfigObject, nextMajor, useDiagnosticsChannelInjection), ...getWebpackPatch({ incomingUserNextConfigObject, diff --git a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectBundlerUtils.ts b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectBundlerUtils.ts index 4b98d9bf65fc..9237570ead3f 100644 --- a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectBundlerUtils.ts +++ b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectBundlerUtils.ts @@ -1,7 +1,6 @@ -import { INSTRUMENTED_MODULE_NAMES } from '@sentry/server-utils/orchestrion/config'; import { + BUNDLE_SAFE_INSTRUMENTED_PACKAGES, filterInstrumentedExternals, - getBundleableInstrumented, ORCHESTRION_RUNTIME_EXTERNAL_PACKAGES, } from '../diagnosticsChannelInjection'; import { handleRunAfterProductionCompile } from '../handleRunAfterProductionCompile'; @@ -235,18 +234,20 @@ export function getServerExternalPackagesPatch( nextMajor: number | undefined, useDiagnosticsChannelInjection = false, ): Partial { - // Diagnostics-channel injection needs the instrumented packages bundled (so the code transform - // reaches them), so drop them from the external list — except the bundle-unsafe ones, which stay - // external and get instrumented by the runtime module hook instead. That hook only works if the - // orchestrion machinery itself is external too (bundled, its parser breaks), so add those. - const instrumented = useDiagnosticsChannelInjection ? getBundleableInstrumented(INSTRUMENTED_MODULE_NAMES) : []; + // Diagnostics-channel injection: drop the verified bundle-safe instrumented packages from OUR + // defaults so the build-time loader transforms them. Everything else — the user's own externals, + // Next's defaults, the rest of our defaults — stays external and is instrumented by the runtime + // module hook on require. That hook only works if the orchestrion machinery itself is external + // too (bundled, its parser breaks), so add those packages to the external list. const mergeExternals = (userProvided: string[] | undefined): string[] => { - const merged = [ + const defaults = useDiagnosticsChannelInjection + ? filterInstrumentedExternals(DEFAULT_SERVER_EXTERNAL_PACKAGES, BUNDLE_SAFE_INSTRUMENTED_PACKAGES) + : DEFAULT_SERVER_EXTERNAL_PACKAGES; + return [ ...(userProvided || []), - ...DEFAULT_SERVER_EXTERNAL_PACKAGES, + ...defaults, ...(useDiagnosticsChannelInjection ? ORCHESTRION_RUNTIME_EXTERNAL_PACKAGES : []), ]; - return useDiagnosticsChannelInjection ? filterInstrumentedExternals(merged, instrumented) : merged; }; if (nextMajor && nextMajor >= 15) { diff --git a/packages/nextjs/test/config/diagnosticsChannelInjection.test.ts b/packages/nextjs/test/config/diagnosticsChannelInjection.test.ts index bd0f9210f82d..4cd24c80bbd0 100644 --- a/packages/nextjs/test/config/diagnosticsChannelInjection.test.ts +++ b/packages/nextjs/test/config/diagnosticsChannelInjection.test.ts @@ -1,46 +1,50 @@ import { describe, expect, it } from 'vitest'; import { + BUNDLE_SAFE_INSTRUMENTED_PACKAGES, filterInstrumentedExternals, - getBundleableInstrumented, - getTranspilePackages, } from '../../src/config/diagnosticsChannelInjection'; - -describe('getBundleableInstrumented', () => { - it('excludes bundle-unsafe packages (mysql stays external / runtime-hook instrumented)', () => { - expect(getBundleableInstrumented(['pg', 'mysql', 'ioredis'])).toEqual(['pg', 'ioredis']); - }); -}); +import { getServerExternalPackagesPatch } from '../../src/config/withSentryConfig/getFinalConfigObjectBundlerUtils'; describe('filterInstrumentedExternals', () => { - it('removes orchestrion-instrumented packages, keeps the rest', () => { + it('removes the given packages, keeps the rest', () => { expect( filterInstrumentedExternals(['express', 'pg', 'pg-pool', 'ioredis', 'mongodb'], ['pg', 'pg-pool', 'ioredis']), ).toEqual(['express', 'mongodb']); }); - it('is a no-op with an empty instrumented list', () => { + it('is a no-op with an empty bundle list', () => { expect(filterInstrumentedExternals(['express', 'pg'], [])).toEqual(['express', 'pg']); }); }); -describe('getTranspilePackages', () => { - it('returns installed instrumented packages that Next externalizes by default', () => { - const result = getTranspilePackages({ - instrumented: ['pg', 'pg-pool', 'ioredis', 'mysql', 'openai'], - nextDefaultExternals: ['pg', 'pg-pool', 'mysql', 'mysql2'], - isInstalled: name => name !== 'mysql', // pretend mysql is not installed - }); - // ioredis/openai aren't Next-default-external → not needed; mysql not installed → excluded - expect(result.sort()).toEqual(['pg', 'pg-pool']); +describe('getServerExternalPackagesPatch (diagnostics-channel injection)', () => { + it('keeps everything external except the bundle-safe allowlist, and adds the runtime machinery', () => { + const patch = getServerExternalPackagesPatch({}, 16, true); + const externals = patch.serverExternalPackages ?? []; + + // Only the verified bundle-safe packages leave the external list (→ build-time loader). + for (const name of BUNDLE_SAFE_INSTRUMENTED_PACKAGES) { + expect(externals).not.toContain(name); + } + // Other instrumented packages stay external (→ runtime module hook). + expect(externals).toContain('mysql'); + expect(externals).toContain('pg'); + expect(externals).toContain('pg-pool'); + // The orchestrion machinery must be external for the runtime hook to work. + expect(externals).toContain('@apm-js-collab/tracing-hooks'); + expect(externals).toContain('@apm-js-collab/code-transformer'); }); - it('returns nothing when none of the instrumented packages are Next-default-external', () => { - expect( - getTranspilePackages({ - instrumented: ['ioredis', 'openai'], - nextDefaultExternals: ['pg', 'mysql'], - isInstalled: () => true, - }), - ).toEqual([]); + it('respects user-provided externals even for bundle-safe packages', () => { + const patch = getServerExternalPackagesPatch({ serverExternalPackages: ['ioredis'] }, 16, true); + expect(patch.serverExternalPackages).toContain('ioredis'); + }); + + it('is unchanged with the flag off', () => { + const patch = getServerExternalPackagesPatch({}, 16, false); + const externals = patch.serverExternalPackages ?? []; + expect(externals).toContain('ioredis'); + expect(externals).toContain('mysql'); + expect(externals).not.toContain('@apm-js-collab/tracing-hooks'); }); }); From dcf5b0ba315000002460bfcbd19da977f7b2f5ff Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 8 Jul 2026 15:04:08 +0200 Subject: [PATCH 5/9] fix(nextjs): Resolve orchestrion CI failures on exports check and next@13 Use createRequire(__filename) instead of aliasing the CJS require in the orchestrion webpack bundler module. Next.js 13 bundles @sentry/nextjs into the server build, and webpack flags the aliased require with 'Critical dependency: require function is used in a way in which dependencies cannot be statically extracted', which the nextjs-app-dir e2e treats as a failure. Add isDiagnosticsChannelInjectionEnabled to the consistent-exports ignore list: like its companions experimentalUseDiagnosticsChannelInjection and diagnosticsChannelInjectionIntegrations, the Node-runtime-only opt-in is not surfaced through the framework / serverless SDKs. Co-Authored-By: Claude Fable 5 --- .../node-exports-test-app/scripts/consistentExports.ts | 4 ++++ packages/server-utils/src/orchestrion/bundler/webpack.ts | 8 ++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts index dd6f7f4613cb..c4bb9d908330 100644 --- a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts +++ b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts @@ -29,6 +29,10 @@ const NODE_EXPORTS_IGNORE = [ // factories for that same Node-runtime-only opt-in, so it isn't surfaced // through the framework / serverless SDKs either. 'diagnosticsChannelInjectionIntegrations', + // Companion to the above: reports whether that same Node-runtime-only opt-in + // was enabled, so it isn't surfaced through the framework / serverless SDKs + // either. (The Next.js SDK consumes it via its own `export * from '@sentry/node'`.) + 'isDiagnosticsChannelInjectionEnabled', // Internal helper only needed within integrations (e.g. bunRuntimeMetricsIntegration) '_INTERNAL_normalizeCollectionInterval', ]; diff --git a/packages/server-utils/src/orchestrion/bundler/webpack.ts b/packages/server-utils/src/orchestrion/bundler/webpack.ts index 43e7fb1356a2..18a7c11b53d2 100644 --- a/packages/server-utils/src/orchestrion/bundler/webpack.ts +++ b/packages/server-utils/src/orchestrion/bundler/webpack.ts @@ -11,11 +11,15 @@ import { createRequire } from 'node:module'; import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; import { SENTRY_INSTRUMENTATIONS } from '../config'; -// Resolve the bundler-plugins package from this module regardless of ESM/CJS output. +// Resolve the bundler-plugins package from this module regardless of ESM/CJS output. Both branches +// go through `createRequire` (rather than aliasing the CJS `require`) so that a bundler consuming +// this module (e.g. Next.js 13 bundling `@sentry/nextjs` into the server build) doesn't emit a +// "Critical dependency: require function is used in a way in which dependencies cannot be +// statically extracted" warning. function getOrchestrionRequire(): ReturnType { let nodeRequire: ReturnType; /*! rollup-include-cjs-only */ - nodeRequire = require; + nodeRequire = createRequire(__filename); /*! rollup-include-cjs-only-end */ /*! rollup-include-esm-only */ nodeRequire = createRequire(import.meta.url); From a3a359566529d557c921eddeb646279073084738 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 8 Jul 2026 17:15:36 +0200 Subject: [PATCH 6/9] fix(nextjs): Gate orchestrion webpack plugin to the Node server runtime The plugin previously ran for all server compilations, including the edge runtime, which diagnostics-channel injection does not target. Gate it on the already-derived runtime instead of isServer and add unit tests covering the server/edge/client/flag-off cases. Also trim down the orchestrion-related comments. Co-Authored-By: Claude Fable 5 --- .../scripts/consistentExports.ts | 4 +- .../src/config/diagnosticsChannelInjection.ts | 20 +++---- packages/nextjs/src/config/types.ts | 9 ++-- packages/nextjs/src/config/webpack.ts | 4 +- .../getFinalConfigObjectBundlerUtils.ts | 8 ++- .../webpack/constructWebpackConfig.test.ts | 53 +++++++++++++++++++ .../src/orchestrion/bundler/webpack.ts | 26 +++------ 7 files changed, 76 insertions(+), 48 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts index c4bb9d908330..14dedc28363d 100644 --- a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts +++ b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts @@ -29,9 +29,7 @@ const NODE_EXPORTS_IGNORE = [ // factories for that same Node-runtime-only opt-in, so it isn't surfaced // through the framework / serverless SDKs either. 'diagnosticsChannelInjectionIntegrations', - // Companion to the above: reports whether that same Node-runtime-only opt-in - // was enabled, so it isn't surfaced through the framework / serverless SDKs - // either. (The Next.js SDK consumes it via its own `export * from '@sentry/node'`.) + // Companion to the above two, same reasoning (Next.js re-exports it via `export * from '@sentry/node'`) 'isDiagnosticsChannelInjectionEnabled', // Internal helper only needed within integrations (e.g. bunRuntimeMetricsIntegration) '_INTERNAL_normalizeCollectionInterval', diff --git a/packages/nextjs/src/config/diagnosticsChannelInjection.ts b/packages/nextjs/src/config/diagnosticsChannelInjection.ts index b876df0cb545..cf8b26f31771 100644 --- a/packages/nextjs/src/config/diagnosticsChannelInjection.ts +++ b/packages/nextjs/src/config/diagnosticsChannelInjection.ts @@ -1,22 +1,14 @@ /** - * Instrumented packages that are verified to bundle correctly under Turbopack. Only these are - * removed from Sentry's own `serverExternalPackages` defaults, so the build-time loader can - * transform them. Everything else instrumented stays externalized — Next's own defaults, the - * user's config, and the rest of Sentry's defaults are never overridden — and is instrumented by - * the runtime module hook on `require` instead. - * - * Deliberately an allowlist: bundling a server package changes real behavior (e.g. `mysql` 2.x - * corrupts its wire protocol when bundled by Turbopack, even untransformed), so packages are only - * added here once bundling them is e2e-verified. + * Instrumented packages verified (via e2e) to bundle correctly, removed from Sentry's own + * `serverExternalPackages` defaults so the build-time loader can transform them. Everything else + * stays external and is instrumented by the runtime module hook instead. Deliberately an + * allowlist — bundling can break packages outright (e.g. `mysql` 2.x under Turbopack). */ export const BUNDLE_SAFE_INSTRUMENTED_PACKAGES = ['ioredis']; /** - * The orchestrion runtime machinery, which must NOT be bundled: the code transformer's parser - * breaks when bundled ("a.parse is not a function"), making the runtime module hook silently - * return untransformed sources. Externalizing these keeps the hook running from real - * `node_modules`, so externalized instrumented packages (e.g. `pg`, `mysql`) get transformed on - * require. + * The orchestrion runtime machinery must stay external — its parser breaks when bundled, which + * silently disables the runtime module hook. */ export const ORCHESTRION_RUNTIME_EXTERNAL_PACKAGES = [ '@apm-js-collab/tracing-hooks', diff --git a/packages/nextjs/src/config/types.ts b/packages/nextjs/src/config/types.ts index d37e04dd11dd..1ca30de85804 100644 --- a/packages/nextjs/src/config/types.ts +++ b/packages/nextjs/src/config/types.ts @@ -768,11 +768,10 @@ export type SentryBuildOptions = { /** * EXPERIMENTAL: Wire up orchestrion diagnostics-channel instrumentation at build time. * - * When enabled, `withSentryConfig` injects the orchestrion code-transform loader (which - * instruments bundled server packages at build time) and externalizes the orchestrion runtime - * machinery so that externalized packages (e.g. `pg`, `mysql`) get instrumented by the runtime - * module hook on require. You must still call - * `Sentry.experimentalUseDiagnosticsChannelInjection()` in your server config to record spans. + * When enabled, `withSentryConfig` injects the orchestrion code-transform loader for bundled + * server packages and keeps the remaining instrumented packages external so the runtime module + * hook picks them up. You must still call `Sentry.experimentalUseDiagnosticsChannelInjection()` + * in your server config to record spans. * * Turbopack support requires Next.js 16+; the webpack path works on earlier versions. * diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts index 443497fa2000..94db1cca621d 100644 --- a/packages/nextjs/src/config/webpack.ts +++ b/packages/nextjs/src/config/webpack.ts @@ -431,8 +431,8 @@ export function constructWebpackConfigFunction({ }), ); - // Orchestrion code-transform loader - if (isServer && userSentryOptions._experimental?.useDiagnosticsChannelInjection) { + // Orchestrion code-transform loader — Node server runtime only, never the edge compilation + if (runtime === 'server' && userSentryOptions._experimental?.useDiagnosticsChannelInjection) { newConfig.plugins.push(sentryOrchestrionWebpackPlugin() as WebpackPluginInstance); } diff --git a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectBundlerUtils.ts b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectBundlerUtils.ts index 9237570ead3f..18460dda5ec2 100644 --- a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectBundlerUtils.ts +++ b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectBundlerUtils.ts @@ -234,11 +234,9 @@ export function getServerExternalPackagesPatch( nextMajor: number | undefined, useDiagnosticsChannelInjection = false, ): Partial { - // Diagnostics-channel injection: drop the verified bundle-safe instrumented packages from OUR - // defaults so the build-time loader transforms them. Everything else — the user's own externals, - // Next's defaults, the rest of our defaults — stays external and is instrumented by the runtime - // module hook on require. That hook only works if the orchestrion machinery itself is external - // too (bundled, its parser breaks), so add those packages to the external list. + // Diagnostics-channel injection: only bundle-safe packages leave OUR defaults (→ build-time + // loader); everything else stays external (→ runtime module hook), including the orchestrion + // machinery itself, which breaks when bundled. const mergeExternals = (userProvided: string[] | undefined): string[] => { const defaults = useDiagnosticsChannelInjection ? filterInstrumentedExternals(DEFAULT_SERVER_EXTERNAL_PACKAGES, BUNDLE_SAFE_INSTRUMENTED_PACKAGES) diff --git a/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts b/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts index debc0ed77ae4..b822495d1a67 100644 --- a/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts +++ b/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts @@ -16,6 +16,10 @@ import { } from '../fixtures'; import { materializeFinalNextConfig, materializeFinalWebpackConfig } from '../testUtils'; +vi.mock('@sentry/server-utils/orchestrion/webpack', () => ({ + sentryOrchestrionWebpackPlugin: () => ({ _name: 'sentry-orchestrion-webpack-plugin' }), +})); + describe('constructWebpackConfigFunction()', () => { it('includes expected properties', async () => { vi.spyOn(core, 'loadModule').mockImplementation(() => ({ @@ -789,4 +793,53 @@ describe('constructWebpackConfigFunction()', () => { expect(treeshakePlugin.definitions).not.toHaveProperty('__RRWEB_EXCLUDE_SHADOW_DOM__'); }); }); + + describe('orchestrion webpack plugin', () => { + const findOrchestrionPlugin = (config: { plugins?: unknown[] }): unknown => + config.plugins?.find(plugin => (plugin as { _name?: string })._name === 'sentry-orchestrion-webpack-plugin'); + + it('adds the plugin to the node server build when diagnostics-channel injection is enabled', async () => { + const finalWebpackConfig = await materializeFinalWebpackConfig({ + exportedNextConfig, + incomingWebpackConfig: serverWebpackConfig, + incomingWebpackBuildContext: serverBuildContext, + sentryBuildTimeOptions: { _experimental: { useDiagnosticsChannelInjection: true } }, + }); + + expect(findOrchestrionPlugin(finalWebpackConfig)).toBeDefined(); + }); + + it('does not add the plugin to the edge build', async () => { + const finalWebpackConfig = await materializeFinalWebpackConfig({ + exportedNextConfig, + incomingWebpackConfig: serverWebpackConfig, + incomingWebpackBuildContext: edgeBuildContext, + sentryBuildTimeOptions: { _experimental: { useDiagnosticsChannelInjection: true } }, + }); + + expect(findOrchestrionPlugin(finalWebpackConfig)).toBeUndefined(); + }); + + it('does not add the plugin to the client build', async () => { + const finalWebpackConfig = await materializeFinalWebpackConfig({ + exportedNextConfig, + incomingWebpackConfig: clientWebpackConfig, + incomingWebpackBuildContext: clientBuildContext, + sentryBuildTimeOptions: { _experimental: { useDiagnosticsChannelInjection: true } }, + }); + + expect(findOrchestrionPlugin(finalWebpackConfig)).toBeUndefined(); + }); + + it('does not add the plugin when diagnostics-channel injection is not enabled', async () => { + const finalWebpackConfig = await materializeFinalWebpackConfig({ + exportedNextConfig, + incomingWebpackConfig: serverWebpackConfig, + incomingWebpackBuildContext: serverBuildContext, + sentryBuildTimeOptions: {}, + }); + + expect(findOrchestrionPlugin(finalWebpackConfig)).toBeUndefined(); + }); + }); }); diff --git a/packages/server-utils/src/orchestrion/bundler/webpack.ts b/packages/server-utils/src/orchestrion/bundler/webpack.ts index 18a7c11b53d2..7ef8e5d8b61d 100644 --- a/packages/server-utils/src/orchestrion/bundler/webpack.ts +++ b/packages/server-utils/src/orchestrion/bundler/webpack.ts @@ -1,21 +1,12 @@ -// EXPERIMENTAL — orchestrion code-transform loader + webpack plugin. Consumed by SDKs whose bundler -// takes a webpack-compatible loader: webpack directly, and Turbopack via `turbopack.rules` (Turbopack -// can't load webpack *plugins*, only loaders, so the loader path is exposed for that path). -// -// Published dual (ESM + CJS) via `@sentry/server-utils/orchestrion/webpack` since consumers (e.g. a -// Next.js `next.config`) may load either format. The `@apm-js-collab/code-transformer-bundler-plugins` -// deps are resolved from THIS package's own graph (they're direct deps here), so consumers don't have -// to reach into a transitive dependency. +// EXPERIMENTAL — 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 type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; import { SENTRY_INSTRUMENTATIONS } from '../config'; -// Resolve the bundler-plugins package from this module regardless of ESM/CJS output. Both branches -// go through `createRequire` (rather than aliasing the CJS `require`) so that a bundler consuming -// this module (e.g. Next.js 13 bundling `@sentry/nextjs` into the server build) doesn't emit a -// "Critical dependency: require function is used in a way in which dependencies cannot be -// statically extracted" warning. +// 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 */ @@ -38,12 +29,9 @@ export function getSentryInstrumentations(): InstrumentationConfig[] { } /** - * The code-transform webpack plugin (for `next build --webpack`), pre-fed the instrumentation config. - * - * Intentionally does NOT inject the `__SENTRY_ORCHESTRION__.bundler` marker (unlike the Vite plugin): - * in a Next.js build, bundle-unsafe packages (e.g. `mysql`) stay externalized and rely on the runtime - * module hook, which `registerDiagnosticsChannelInjection()` skips when the bundler marker is set. The - * hybrid setup (bundler transform for bundled deps + runtime hook for external ones) needs both active. + * The code-transform webpack plugin, pre-fed the instrumentation config. Unlike the Vite plugin it + * does NOT inject the `__SENTRY_ORCHESTRION__.bundler` marker — that would disable the runtime + * module hook, which externalized packages still need (hybrid setup). */ export function sentryOrchestrionWebpackPlugin(): unknown { const mod = getOrchestrionRequire()('@apm-js-collab/code-transformer-bundler-plugins/webpack') as { From 5820b35af905779b9010108dc6d2c1f2160c9b7e Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 9 Jul 2026 11:56:51 +0200 Subject: [PATCH 7/9] fix(nextjs): Resolve orchestrion tracing-hooks from bundled server code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the SDK is bundled into a Next.js server build, the runtime module hook's bare require of @apm-js-collab/tracing-hooks resolves relative to the emitted chunk, which fails under isolated installs (pnpm) where the package is not linked at the app root — the hook silently no-ops and externalized packages (pg, mysql) lose their spans. Resolve the package location in withSentryConfig, where the SDK is a real on-disk package, and inline it as a build-time env value that the runtime prefers over the bare specifier. Also construct nodeRequire via createRequire in both build flavors so bundlers don't statically trace the call (Turbopack otherwise tries to resolve the injected absolute path at build time). Co-Authored-By: Claude Fable 5 --- .../src/config/withSentryConfig/buildTime.ts | 4 +++ .../diagnosticsChannelInjection.test.ts | 23 +++++++++++++ .../src/orchestrion/bundler/webpack.ts | 13 ++++++++ .../src/orchestrion/runtime/register.ts | 32 ++++++++++++++++--- .../test/orchestrion/bundler.test.ts | 16 ++++++++++ 5 files changed, 84 insertions(+), 4 deletions(-) create mode 100644 packages/server-utils/test/orchestrion/bundler.test.ts diff --git a/packages/nextjs/src/config/withSentryConfig/buildTime.ts b/packages/nextjs/src/config/withSentryConfig/buildTime.ts index b799c9becfaa..93ec6e42e243 100644 --- a/packages/nextjs/src/config/withSentryConfig/buildTime.ts +++ b/packages/nextjs/src/config/withSentryConfig/buildTime.ts @@ -1,6 +1,7 @@ import * as childProcess from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; +import { getTracingHooksDirectory } from '@sentry/server-utils/orchestrion/webpack'; import type { NextConfigObject, SentryBuildOptions } from '../types'; /** @@ -53,6 +54,9 @@ export function setUpBuildTimeVariables( // Marker read by the server SDK to warn if the runtime opt-in call is missing. if (userSentryOptions._experimental?.useDiagnosticsChannelInjection) { buildTimeVariables._sentryUseDiagnosticsChannelInjection = 'true'; + // Resolved here (where the SDK is a real on-disk package) and inlined, because the runtime + // module hook can't resolve the bare specifier from a bundled server chunk under pnpm. + buildTimeVariables._sentryOrchestrionTracingHooksDir = getTracingHooksDirectory(); } if (basePath) { diff --git a/packages/nextjs/test/config/diagnosticsChannelInjection.test.ts b/packages/nextjs/test/config/diagnosticsChannelInjection.test.ts index 4cd24c80bbd0..881da6a3caf6 100644 --- a/packages/nextjs/test/config/diagnosticsChannelInjection.test.ts +++ b/packages/nextjs/test/config/diagnosticsChannelInjection.test.ts @@ -3,7 +3,9 @@ import { BUNDLE_SAFE_INSTRUMENTED_PACKAGES, filterInstrumentedExternals, } from '../../src/config/diagnosticsChannelInjection'; +import { setUpBuildTimeVariables } from '../../src/config/withSentryConfig/buildTime'; import { getServerExternalPackagesPatch } from '../../src/config/withSentryConfig/getFinalConfigObjectBundlerUtils'; +import type { NextConfigObject } from '../../src/config/types'; describe('filterInstrumentedExternals', () => { it('removes the given packages, keeps the rest', () => { @@ -48,3 +50,24 @@ describe('getServerExternalPackagesPatch (diagnostics-channel injection)', () => expect(externals).not.toContain('@apm-js-collab/tracing-hooks'); }); }); + +describe('setUpBuildTimeVariables (diagnostics-channel injection)', () => { + it('injects the flag marker and the tracing-hooks location', () => { + const nextConfig: NextConfigObject = {}; + setUpBuildTimeVariables(nextConfig, { _experimental: { useDiagnosticsChannelInjection: true } }, undefined); + + expect(nextConfig.env).toMatchObject({ + _sentryUseDiagnosticsChannelInjection: 'true', + // The runtime module hook joins subpaths onto this, so it must be an absolute directory. + _sentryOrchestrionTracingHooksDir: expect.stringMatching(/@apm-js-collab[/+]tracing-hooks/), + }); + }); + + it('injects neither with the flag off', () => { + const nextConfig: NextConfigObject = {}; + setUpBuildTimeVariables(nextConfig, {}, undefined); + + expect(nextConfig.env).not.toHaveProperty('_sentryUseDiagnosticsChannelInjection'); + expect(nextConfig.env).not.toHaveProperty('_sentryOrchestrionTracingHooksDir'); + }); +}); diff --git a/packages/server-utils/src/orchestrion/bundler/webpack.ts b/packages/server-utils/src/orchestrion/bundler/webpack.ts index 7ef8e5d8b61d..04eb56a2c6b9 100644 --- a/packages/server-utils/src/orchestrion/bundler/webpack.ts +++ b/packages/server-utils/src/orchestrion/bundler/webpack.ts @@ -2,6 +2,7 @@ // separately because Turbopack can only take webpack loaders (via `turbopack.rules`), not plugins. import { createRequire } from 'node:module'; +import { dirname } from 'node:path'; import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; import { SENTRY_INSTRUMENTATIONS } from '../config'; @@ -23,6 +24,18 @@ export function getOrchestrionLoaderPath(): string { return getOrchestrionRequire().resolve('@apm-js-collab/code-transformer-bundler-plugins/webpack-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 + * load the package even where the bare specifier doesn't resolve (bundled SDK code under + * isolated installs, e.g. pnpm). + */ +export function getTracingHooksDirectory(): string { + const packageJsonPath = getOrchestrionRequire().resolve('@apm-js-collab/tracing-hooks/package.json'); + // This avoids any backslash-escaping concerns on Windows + return dirname(packageJsonPath).replace(/\\/g, '/'); +} + /** The central instrumentation config, to pass as the loader's `instrumentations` option. */ export function getSentryInstrumentations(): InstrumentationConfig[] { return SENTRY_INSTRUMENTATIONS; diff --git a/packages/server-utils/src/orchestrion/runtime/register.ts b/packages/server-utils/src/orchestrion/runtime/register.ts index 195464f7375f..ce48b9fde16b 100644 --- a/packages/server-utils/src/orchestrion/runtime/register.ts +++ b/packages/server-utils/src/orchestrion/runtime/register.ts @@ -1,10 +1,27 @@ import { debug } from '@sentry/core'; import { createRequire } from 'node:module'; import * as Module from 'node:module'; +import { isAbsolute } from 'node:path'; import { pathToFileURL } from 'node:url'; import { DEBUG_BUILD } from '../../debug-build'; import { SENTRY_INSTRUMENTATIONS } from '../config'; +/** + * Where to load `@apm-js-collab/tracing-hooks` from. When this module is bundled into an app's + * server build, the bare specifier resolves relative to the emitted chunk — which fails under + * isolated installs (pnpm) where the package isn't linked at the app root. Build tooling (e.g. + * `withSentryConfig`) therefore inlines the package's real location as a build-time env value, + * which takes precedence; unbundled SDK code resolves the bare specifier from its real + * `node_modules` location just fine. + */ +function getTracingHooksSpecifier(subpath?: string): string { + const dir = process.env._sentryOrchestrionTracingHooksDir; + if (dir) { + return subpath ? `${dir}/${subpath}` : dir; + } + return subpath ? `@apm-js-collab/tracing-hooks/${subpath}` : '@apm-js-collab/tracing-hooks'; +} + declare global { // eslint-disable-next-line no-var var __SENTRY_ORCHESTRION__: { runtime?: boolean; bundler?: boolean } | undefined; @@ -45,9 +62,12 @@ export function registerDiagnosticsChannelInjection(): void { (denoVersion[0] ?? 0) > 2 || (denoVersion[0] === 2 && (denoVersion[1] ?? 0) >= 8); + // Both branches use `createRequire` (never alias the CJS `require`): bundlers statically trace + // aliased `require` calls, and e.g. Turbopack would try to resolve the injected absolute + // tracing-hooks path at build time. `createRequire` stays a true runtime require. let nodeRequire: (specifier: string) => unknown; /*! rollup-include-cjs-only */ - nodeRequire = require; + nodeRequire = createRequire(__filename); /*! rollup-include-cjs-only-end */ /*! rollup-include-esm-only */ nodeRequire = createRequire(import.meta.url); @@ -71,7 +91,7 @@ export function registerDiagnosticsChannelInjection(): void { // We require() the module here so that we can synchronously load it, // including from a CommonJS Sentry build, without bundlers pulling in. // All versions in stableSyncHooks support this. - const { initialize, resolve, load } = nodeRequire('@apm-js-collab/tracing-hooks/hook-sync.mjs') as { + const { initialize, resolve, load } = nodeRequire(getTracingHooksSpecifier('hook-sync.mjs')) as { initialize: (opts: { instrumentations: unknown }) => void; resolve: unknown; load: unknown; @@ -91,7 +111,11 @@ export function registerDiagnosticsChannelInjection(): void { parentURL = import.meta.url; /*! rollup-include-esm-only-end */ - mod.register('@apm-js-collab/tracing-hooks/hook.mjs', { + // `Module.register` resolves ESM-style: a bare package specifier is resolved against + // `parentURL`, but a filesystem path (the injected override) is not a valid ESM specifier + // and must be passed as a file:// URL. + const hookSpecifier = getTracingHooksSpecifier('hook.mjs'); + mod.register(isAbsolute(hookSpecifier) ? pathToFileURL(hookSpecifier).href : hookSpecifier, { parentURL, data: { instrumentations: SENTRY_INSTRUMENTATIONS }, }); @@ -101,7 +125,7 @@ export function registerDiagnosticsChannelInjection(): void { // are resolved through the CJS machinery and never reach the ESM // register hook, so without this patch the file we want to instrument // loads untransformed. - const ModulePatch = nodeRequire('@apm-js-collab/tracing-hooks') as new (opts: { instrumentations: unknown }) => { + const ModulePatch = nodeRequire(getTracingHooksSpecifier()) as new (opts: { instrumentations: unknown }) => { patch: () => void; }; new ModulePatch({ instrumentations: SENTRY_INSTRUMENTATIONS }).patch(); diff --git a/packages/server-utils/test/orchestrion/bundler.test.ts b/packages/server-utils/test/orchestrion/bundler.test.ts new file mode 100644 index 000000000000..b4c309b9152c --- /dev/null +++ b/packages/server-utils/test/orchestrion/bundler.test.ts @@ -0,0 +1,16 @@ +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { getTracingHooksDirectory } from '../../src/orchestrion/bundler/webpack'; + +describe('getTracingHooksDirectory', () => { + it('returns the tracing-hooks package directory with the runtime hook entry points', () => { + const dir = getTracingHooksDirectory(); + + expect(dir).not.toContain('\\'); + // The runtime module hook loads these files by joining them onto the directory. + expect(existsSync(join(dir, 'hook-sync.mjs'))).toBe(true); + expect(existsSync(join(dir, 'hook.mjs'))).toBe(true); + expect(existsSync(join(dir, 'package.json'))).toBe(true); + }); +}); From 027bac6b642c577d1233ff2fcf3dd3cbe64871a6 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 9 Jul 2026 14:30:54 +0200 Subject: [PATCH 8/9] ref(nextjs): Scope orchestrion require indirection to the Next.js SDK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit registerDiagnosticsChannelInjection() now takes an optional tracingHooksDir and by default loads @apm-js-collab/tracing-hooks via its bare specifier again, keeping the require statically analyzable for bundlers. Only the Next.js SDK — whose server builds bundle the SDK and can't resolve the bare specifier under pnpm — passes the build-time-resolved package location (via a Next.js-specific experimentalUseDiagnosticsChannelInjection wrapper), where loading switches to an opaque createRequire. webpack ignore-comments were evaluated as an alternative: turbopackIgnore works on require(), but webpack only honors webpackIgnore on import() and compiles the call to a broken module stub, so createRequire it is. Co-Authored-By: Claude Fable 5 --- packages/nextjs/src/server/index.ts | 18 +++ ...erimentalUseDiagnosticsChannelInjection.ts | 10 +- .../src/orchestrion/runtime/register.ts | 108 ++++++++++-------- 3 files changed, 89 insertions(+), 47 deletions(-) diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index 32ab5917c6fc..ee0d2346c4f2 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -13,6 +13,7 @@ import { } from '@sentry/core'; import type { NodeClient, NodeOptions } from '@sentry/node'; import { + experimentalUseDiagnosticsChannelInjection as nodeExperimentalUseDiagnosticsChannelInjection, getDefaultIntegrations, httpIntegration, init as nodeInit, @@ -47,8 +48,25 @@ const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryRewriteFramesDistDir?: string; _sentryRelease?: string; _sentryUseDiagnosticsChannelInjection?: string; + _sentryOrchestrionTracingHooksDir?: string; }; +/** + * EXPERIMENTAL: Next.js-aware variant of `Sentry.experimentalUseDiagnosticsChannelInjection()` + * from `@sentry/node` (see its docs for behavior and caveats). + * + * Next.js bundles the SDK into the server build, from where the runtime module hook can't resolve + * the `@apm-js-collab/tracing-hooks` bare specifier under isolated installs (pnpm). This variant + * points the hook at the package location that `withSentryConfig` resolved at build time. + * + * @experimental May change or be removed in any release. + */ +export function experimentalUseDiagnosticsChannelInjection(): void { + const tracingHooksDir = + process.env._sentryOrchestrionTracingHooksDir || globalWithInjectedValues._sentryOrchestrionTracingHooksDir; + nodeExperimentalUseDiagnosticsChannelInjection(tracingHooksDir ? { tracingHooksDir } : undefined); +} + // Call at module level so `next build` prerender workers still register the runner without `init` prepareSafeIdGeneratorContext(); diff --git a/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts b/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts index f9506731993b..091566b05963 100644 --- a/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts +++ b/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts @@ -3,6 +3,7 @@ import { ioredisChannelIntegration, detectOrchestrionSetup, } from '@sentry/server-utils/orchestrion'; +import type { RegisterDiagnosticsChannelInjectionOptions } from '@sentry/server-utils/orchestrion/register'; import { registerDiagnosticsChannelInjection } from '@sentry/server-utils/orchestrion/register'; import { cacheResponseHook } from '../integrations/tracing/redis/cache'; import type { DiagnosticsChannelInjection } from './diagnosticsChannelInjection'; @@ -44,7 +45,12 @@ export function diagnosticsChannelInjectionIntegrations(): typeof channelIntegra * * @experimental May change or be removed in any release. */ -export function experimentalUseDiagnosticsChannelInjection(): void { +export function experimentalUseDiagnosticsChannelInjection( + // Forwarded to `registerDiagnosticsChannelInjection()`; framework SDKs whose bundlers compile + // the SDK into the app (e.g. `@sentry/nextjs`) use it to point the runtime module hook at the + // tracing-hooks package location resolved at build time. Plain Node apps don't need it. + options?: RegisterDiagnosticsChannelInjectionOptions, +): void { setDiagnosticsChannelInjectionLoader((): DiagnosticsChannelInjection => { // The registry integrations 1:1 replace the OTel integration of the same name. const integrations = Object.values(channelIntegrations).map(createIntegration => createIntegration()); @@ -58,7 +64,7 @@ export function experimentalUseDiagnosticsChannelInjection(): void { // must stay; its ioredis <5.11 monkey-patch is gated off in `redisIntegration` instead. integrations: [...integrations, ioredisChannelIntegration({ responseHook: cacheResponseHook })], replacedOtelIntegrationNames, - register: registerDiagnosticsChannelInjection, + register: () => registerDiagnosticsChannelInjection(options), detect: detectOrchestrionSetup, }; }); diff --git a/packages/server-utils/src/orchestrion/runtime/register.ts b/packages/server-utils/src/orchestrion/runtime/register.ts index ce48b9fde16b..77259831a3e5 100644 --- a/packages/server-utils/src/orchestrion/runtime/register.ts +++ b/packages/server-utils/src/orchestrion/runtime/register.ts @@ -1,25 +1,22 @@ import { debug } from '@sentry/core'; import { createRequire } from 'node:module'; import * as Module from 'node:module'; -import { isAbsolute } from 'node:path'; import { pathToFileURL } from 'node:url'; import { DEBUG_BUILD } from '../../debug-build'; import { SENTRY_INSTRUMENTATIONS } from '../config'; -/** - * Where to load `@apm-js-collab/tracing-hooks` from. When this module is bundled into an app's - * server build, the bare specifier resolves relative to the emitted chunk — which fails under - * isolated installs (pnpm) where the package isn't linked at the app root. Build tooling (e.g. - * `withSentryConfig`) therefore inlines the package's real location as a build-time env value, - * which takes precedence; unbundled SDK code resolves the bare specifier from its real - * `node_modules` location just fine. - */ -function getTracingHooksSpecifier(subpath?: string): string { - const dir = process.env._sentryOrchestrionTracingHooksDir; - if (dir) { - return subpath ? `${dir}/${subpath}` : dir; - } - return subpath ? `@apm-js-collab/tracing-hooks/${subpath}` : '@apm-js-collab/tracing-hooks'; +export interface RegisterDiagnosticsChannelInjectionOptions { + /** + * Absolute directory of the `@apm-js-collab/tracing-hooks` package (forward slashes). + * + * By default the package is loaded via its bare specifier, which bundlers and Node resolve + * normally. When SDK code is bundled into an app's server build, that resolution starts at the + * emitted chunk and fails under isolated installs (pnpm) — framework SDKs (e.g. + * `@sentry/nextjs`) resolve the package at build time and pass its location here instead. + * Loading then goes through an opaque `createRequire`, keeping the absolute path out of the + * bundler's static analysis. + */ + tracingHooksDir?: string; } declare global { @@ -27,6 +24,20 @@ declare global { var __SENTRY_ORCHESTRION__: { runtime?: boolean; bundler?: boolean } | undefined; } +/** `Module.registerHooks` only became stable in Node 24.13 / 25.1 and Deno 2.8. */ +function hasStableSyncModuleHooks(denoVersionString: string | undefined): boolean { + const parseVersion = (v: string): number[] => v.split('.').map(n => parseInt(n, 10)); + const nodeVersion = parseVersion(process.versions.node ?? '0.0.0'); + const denoVersion = parseVersion(denoVersionString ?? '0.0.0'); + return ( + (nodeVersion[0] ?? 0) > 25 || + (nodeVersion[0] === 25 && (nodeVersion[1] ?? 0) >= 1) || + (nodeVersion[0] === 24 && (nodeVersion[1] ?? 0) >= 13) || + (denoVersion[0] ?? 0) > 2 || + (denoVersion[0] === 2 && (denoVersion[1] ?? 0) >= 8) + ); +} + /** * Synchronously register the diagnostics-channel injection module hooks. * @@ -42,7 +53,7 @@ declare global { * Idempotent via `globalThis.__SENTRY_ORCHESTRION__` — a no-op if the runtime * `--import` hook or a bundler plugin already injected the channels. */ -export function registerDiagnosticsChannelInjection(): void { +export function registerDiagnosticsChannelInjection(options?: RegisterDiagnosticsChannelInjectionOptions): void { const g = (globalThis.__SENTRY_ORCHESTRION__ ??= {}); // Already injected (runtime --import hook or bundler plugin) — nothing to do. @@ -51,28 +62,33 @@ export function registerDiagnosticsChannelInjection(): void { } const globalAny = globalThis as { Bun?: unknown; Deno?: { version?: { deno?: string } } }; - const parseVersion = (v: string): number[] => v.split('.').map(n => parseInt(n, 10)); - const nodeVersion = parseVersion(process.versions.node ?? '0.0.0'); - const denoVersion = parseVersion(globalAny.Deno?.version?.deno ?? '0.0.0'); - // `Module.registerHooks` only became stable in Node 24.13 / 25.1 and Deno 2.8. - const stableSyncHooks = - (nodeVersion[0] ?? 0) > 25 || - (nodeVersion[0] === 25 && (nodeVersion[1] ?? 0) >= 1) || - (nodeVersion[0] === 24 && (nodeVersion[1] ?? 0) >= 13) || - (denoVersion[0] ?? 0) > 2 || - (denoVersion[0] === 2 && (denoVersion[1] ?? 0) >= 8); + const stableSyncHooks = hasStableSyncModuleHooks(globalAny.Deno?.version?.deno); + + let thisModuleUrl: string; + /*! rollup-include-cjs-only */ + thisModuleUrl = pathToFileURL(__filename).href; + /*! rollup-include-cjs-only-end */ + /*! rollup-include-esm-only */ + thisModuleUrl = import.meta.url; + /*! rollup-include-esm-only-end */ - // Both branches use `createRequire` (never alias the CJS `require`): bundlers statically trace - // aliased `require` calls, and e.g. Turbopack would try to resolve the injected absolute - // tracing-hooks path at build time. `createRequire` stays a true runtime require. + // Default: bare specifiers via a plain (aliased) `require`, so bundlers see and resolve them + // like any other dependency. Override: with `tracingHooksDir`, absolute paths are loaded through + // `createRequire`, which bundlers leave as a true runtime require — they must not statically + // resolve these (Turbopack fails the build on an absolute request, and the machinery breaks when + // bundled anyway). `createRequire` rather than ignore-comments because webpack only honors + // `webpackIgnore` on `import()`, not `require()` (it compiles the call to a broken module stub). let nodeRequire: (specifier: string) => unknown; /*! rollup-include-cjs-only */ - nodeRequire = createRequire(__filename); + nodeRequire = require; /*! rollup-include-cjs-only-end */ /*! rollup-include-esm-only */ nodeRequire = createRequire(import.meta.url); /*! rollup-include-esm-only-end */ + const tracingHooksDir = options?.tracingHooksDir; + const requireFromHooksDir = tracingHooksDir ? createRequire(thisModuleUrl) : undefined; + // `Module.registerHooks` / `Module.register` are newer than the @types/node // we build against, hence the cast. const mod = Module as unknown as { @@ -91,7 +107,11 @@ export function registerDiagnosticsChannelInjection(): void { // We require() the module here so that we can synchronously load it, // including from a CommonJS Sentry build, without bundlers pulling in. // All versions in stableSyncHooks support this. - const { initialize, resolve, load } = nodeRequire(getTracingHooksSpecifier('hook-sync.mjs')) as { + const { initialize, resolve, load } = ( + requireFromHooksDir + ? requireFromHooksDir(`${tracingHooksDir}/hook-sync.mjs`) + : nodeRequire('@apm-js-collab/tracing-hooks/hook-sync.mjs') + ) as { initialize: (opts: { instrumentations: unknown }) => void; resolve: unknown; load: unknown; @@ -103,20 +123,14 @@ export function registerDiagnosticsChannelInjection(): void { // `Module.register` + the `_compile` patch is Node 18.19–24.12 / 25.0 // path. Bun/Deno are excluded: they don't support this combination and // must use the stable `registerHooks` path above (or none at all). - let parentURL: string; - /*! rollup-include-cjs-only */ - parentURL = pathToFileURL(__filename).href; - /*! rollup-include-cjs-only-end */ - /*! rollup-include-esm-only */ - parentURL = import.meta.url; - /*! rollup-include-esm-only-end */ - // `Module.register` resolves ESM-style: a bare package specifier is resolved against - // `parentURL`, but a filesystem path (the injected override) is not a valid ESM specifier - // and must be passed as a file:// URL. - const hookSpecifier = getTracingHooksSpecifier('hook.mjs'); - mod.register(isAbsolute(hookSpecifier) ? pathToFileURL(hookSpecifier).href : hookSpecifier, { - parentURL, + // `parentURL`, but a filesystem path (the `tracingHooksDir` override) is not a valid ESM + // specifier and must be passed as a file:// URL. + const hookSpecifier = tracingHooksDir + ? pathToFileURL(`${tracingHooksDir}/hook.mjs`).href + : '@apm-js-collab/tracing-hooks/hook.mjs'; + mod.register(hookSpecifier, { + parentURL: thisModuleUrl, data: { instrumentations: SENTRY_INSTRUMENTATIONS }, }); @@ -125,7 +139,11 @@ export function registerDiagnosticsChannelInjection(): void { // are resolved through the CJS machinery and never reach the ESM // register hook, so without this patch the file we want to instrument // loads untransformed. - const ModulePatch = nodeRequire(getTracingHooksSpecifier()) as new (opts: { instrumentations: unknown }) => { + const ModulePatch = ( + requireFromHooksDir && tracingHooksDir + ? requireFromHooksDir(tracingHooksDir) + : nodeRequire('@apm-js-collab/tracing-hooks') + ) as new (opts: { instrumentations: unknown }) => { patch: () => void; }; new ModulePatch({ instrumentations: SENTRY_INSTRUMENTATIONS }).patch(); From 0a6439dbf28d20f5136212c586727bb92032d754 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 9 Jul 2026 14:58:00 +0200 Subject: [PATCH 9/9] ref(server-utils): Tighten tracingHooksDir option docs Co-Authored-By: Claude Fable 5 --- .../server-utils/src/orchestrion/runtime/register.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/server-utils/src/orchestrion/runtime/register.ts b/packages/server-utils/src/orchestrion/runtime/register.ts index 77259831a3e5..157672c8c3cd 100644 --- a/packages/server-utils/src/orchestrion/runtime/register.ts +++ b/packages/server-utils/src/orchestrion/runtime/register.ts @@ -9,12 +9,10 @@ export interface RegisterDiagnosticsChannelInjectionOptions { /** * Absolute directory of the `@apm-js-collab/tracing-hooks` package (forward slashes). * - * By default the package is loaded via its bare specifier, which bundlers and Node resolve - * normally. When SDK code is bundled into an app's server build, that resolution starts at the - * emitted chunk and fails under isolated installs (pnpm) — framework SDKs (e.g. - * `@sentry/nextjs`) resolve the package at build time and pass its location here instead. - * Loading then goes through an opaque `createRequire`, keeping the absolute path out of the - * bundler's static analysis. + * Needed when SDK code is bundled into an app's server build: the default bare-specifier + * require then resolves from the emitted chunk, which fails under isolated installs (pnpm). + * Framework SDKs (e.g. `@sentry/nextjs`) resolve the package at build time and pass its + * location here; it is loaded through an opaque `createRequire` that bundlers can't trace. */ tracingHooksDir?: string; }