From d1cac54c5349cc84639112fb63854ab2068f224e Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Tue, 21 Jul 2026 16:15:24 +0100 Subject: [PATCH 01/15] chore: Cleanup hook registration --- .../runtime/apm-js-collab-tracing-hooks.d.ts | 37 +++++ .../src/orchestrion/runtime/register.ts | 148 +++++------------- 2 files changed, 76 insertions(+), 109 deletions(-) create mode 100644 packages/server-utils/src/orchestrion/runtime/apm-js-collab-tracing-hooks.d.ts diff --git a/packages/server-utils/src/orchestrion/runtime/apm-js-collab-tracing-hooks.d.ts b/packages/server-utils/src/orchestrion/runtime/apm-js-collab-tracing-hooks.d.ts new file mode 100644 index 000000000000..79b4edfb7f39 --- /dev/null +++ b/packages/server-utils/src/orchestrion/runtime/apm-js-collab-tracing-hooks.d.ts @@ -0,0 +1,37 @@ +// Ambient declarations for `@apm-js-collab/tracing-hooks`, which ships no types of its own. +// `register.ts` loads these modules through `require`/`createRequire`, but the static +// `import` of the package entry still needs the module to resolve at type-check time. + +declare module '@apm-js-collab/tracing-hooks' { + import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; + + type PatchConfig = { instrumentations: InstrumentationConfig[] }; + + /** Patches `Module.prototype._compile` to transform CJS modules as they load. */ + export default class ModulePatch { + public constructor(config?: PatchConfig); + public patch(): void; + public unpatch(): void; + } +} + +declare module '@apm-js-collab/tracing-hooks/lib/diagnostics.js' { + type DiagnosticsEvent = { url: string; moduleName: string; error?: Error }; + + export function setDiagnosticsHook(callback: (event: DiagnosticsEvent) => void): void; + export function emitDiagnostics(event: DiagnosticsEvent): void; +} + +declare module '@apm-js-collab/tracing-hooks/hook-sync.mjs' { + import type { MessagePort } from 'node:worker_threads'; + import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; + + type DiagnosticsEvent = { url: string; moduleName: string; error?: Error }; + type InitializeData = { instrumentations?: InstrumentationConfig[]; diagnosticsPort?: MessagePort }; + + export function initialize(data?: InitializeData): void; + export function resolve(specifier: string, context: unknown, nextResolve: Function): unknown; + export function load(url: string, context: unknown, nextLoad: Function): unknown; + export function setDiagnosticsHook(callback: (event: DiagnosticsEvent) => void): void; + export function createDiagnosticsPort(): MessagePort; +} diff --git a/packages/server-utils/src/orchestrion/runtime/register.ts b/packages/server-utils/src/orchestrion/runtime/register.ts index bd559b2a6e48..5c0e4f13c903 100644 --- a/packages/server-utils/src/orchestrion/runtime/register.ts +++ b/packages/server-utils/src/orchestrion/runtime/register.ts @@ -1,26 +1,15 @@ -import { debug, GLOBAL_OBJ } from '@sentry/core'; -import { createRequire } from 'node:module'; +import { debug, GLOBAL_OBJ, parseSemver } from '@sentry/core'; import * as Module from 'node:module'; import { pathToFileURL } from 'node:url'; -import { isMainThread, MessageChannel, parentPort } from 'node:worker_threads'; +import { isMainThread, parentPort } from 'node:worker_threads'; import { SENTRY_INSTRUMENTATIONS } from '../config'; import type { register } from 'node:module'; -import type { InstrumentationConfig } from '..'; - -type DiagnosticsEvent = { url: string; moduleName: string; error?: Error }; - -type TracingHooksSync = { - initialize: (opts: { instrumentations: InstrumentationConfig[] }) => void; - resolve: Function; - load: Function; -}; - -type TracingHooksDiagnostics = { - setDiagnosticsHook: (callback: (event: DiagnosticsEvent) => void) => void; -}; +import ModulePatch from '@apm-js-collab/tracing-hooks'; +import { initialize, load, resolve, createDiagnosticsPort } from '@apm-js-collab/tracing-hooks/hook-sync.mjs'; +import { setDiagnosticsHook } from '@apm-js-collab/tracing-hooks/lib/diagnostics.js'; type NodeModule = { - registerHooks?: (options: unknown) => { deregister: () => void }; + registerHooks?: (options: { load: Function; resolve: Function }) => { deregister: () => void }; register?: typeof register; }; @@ -38,16 +27,14 @@ export interface RegisterDiagnosticsChannelInjectionOptions { /** `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) - ); + const { major: nodeMajor = 0, minor: nodeMinor = 0 } = parseSemver(process.versions.node ?? '0.0.0'); + + if (nodeMajor > 25 || (nodeMajor === 25 && nodeMinor >= 1) || (nodeMajor === 24 && nodeMinor >= 13)) { + return true; + } + + const { major: denoMajor = 0, minor: denoMinor = 0 } = parseSemver(denoVersionString ?? '0.0.0'); + return denoMajor > 2 || (denoMajor === 2 && denoMinor >= 8); } /** @@ -62,7 +49,7 @@ function hasStableSyncModuleHooks(denoVersionString: string | undefined): boolea * Libraries imported *after* this call publish the `tracingChannel` events that * the channel-based integrations subscribe to. */ -export function registerDiagnosticsChannelInjection(options?: RegisterDiagnosticsChannelInjectionOptions): void { +export function registerDiagnosticsChannelInjection(_options?: RegisterDiagnosticsChannelInjectionOptions): void { // Skip Node's internal loader (hooks) threads, recognizable as the only threads without a // `parentPort`. Node re-runs `--require` preloads (though not `--import` ones) on the loader // thread it spawns for `Module.register()`, so this function runs there too — but that thread @@ -82,74 +69,27 @@ export function registerDiagnosticsChannelInjection(options?: RegisterDiagnostic const globalAny = globalThis as { Bun?: unknown; Deno?: { version?: { deno?: string } } }; 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 */ - - // 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 = 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 NodeModule; + setDiagnosticsHook(({ moduleName, error }): void => { + if (error) { + debug.warn(`[orchestrion] failed to inject diagnostics-channel into ${moduleName}:`, error); + } else { + GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = GLOBAL_OBJ.__SENTRY_ORCHESTRION__ || {}; + GLOBAL_OBJ.__SENTRY_ORCHESTRION__.runtime = GLOBAL_OBJ.__SENTRY_ORCHESTRION__.runtime || []; + GLOBAL_OBJ.__SENTRY_ORCHESTRION__.runtime.push(moduleName); + } + }); + // runs both at `--import` time and (synchronously) inside `Sentry.init()`, // so an unguarded throw would either abort startup or make `init()` throw. // On any failure (e.g. dep resolution, `require(esm)` / Node-compat // incompatibility) we warn (DEBUG only) and continue without channel // injection try { - // `lib/diagnostics.js` is plain CJS, so unlike the ESM hook entry points it can be - // require()d on every supported Node version. It holds the hook state shared by - // everything that can transform a module on this thread (the sync ESM hooks and the - // `_compile` patch), so setting the hook once here covers both branches below. - const { setDiagnosticsHook } = ( - requireFromHooksDir - ? requireFromHooksDir(`${tracingHooksDir}/lib/diagnostics.js`) - : nodeRequire('@apm-js-collab/tracing-hooks/lib/diagnostics.js') - ) as TracingHooksDiagnostics; - - const onDiagnostics = ({ moduleName, error }: DiagnosticsEvent): void => { - if (error) { - debug.warn(`[orchestrion] failed to inject diagnostics-channel into ${moduleName}:`, error); - } else { - GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = GLOBAL_OBJ.__SENTRY_ORCHESTRION__ || {}; - GLOBAL_OBJ.__SENTRY_ORCHESTRION__.runtime = GLOBAL_OBJ.__SENTRY_ORCHESTRION__.runtime || []; - GLOBAL_OBJ.__SENTRY_ORCHESTRION__.runtime.push(moduleName); - } - }; - - setDiagnosticsHook(onDiagnostics); - if (typeof mod.registerHooks === 'function' && stableSyncHooks) { - // Sync hooks cover CJS and ESM, no separate `_compile` patch needed. - // We require() this ESM module so that we can synchronously load it, - // including from a CommonJS Sentry build; all versions in - // stableSyncHooks support require(esm). - const { initialize, resolve, load } = ( - requireFromHooksDir - ? requireFromHooksDir(`${tracingHooksDir}/hook-sync.mjs`) - : nodeRequire('@apm-js-collab/tracing-hooks/hook-sync.mjs') - ) as TracingHooksSync; - initialize({ instrumentations: SENTRY_INSTRUMENTATIONS }); mod.registerHooks({ resolve, load }); debug.log('Registered diagnostics-channel injection via Module.registerHooks()'); @@ -160,23 +100,20 @@ export function registerDiagnosticsChannelInjection(options?: RegisterDiagnostic // `Module.register` resolves ESM-style: a bare package specifier is resolved against // `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'; - - // The `Module.register` hooks run on a loader thread with its own copy of - // `lib/diagnostics.js`, so the hook set above never fires there; the loader thread - // posts diagnostics back over a MessagePort instead. This replicates - // `createDiagnosticsPort` from hook.mjs, which is ESM and therefore not - // synchronously loadable on all Node versions that take this branch. - const { port1, port2 } = new MessageChannel(); - port1.on('message', onDiagnostics); - // The diagnostics channel must not keep the process alive. - port1.unref(); - mod.register(hookSpecifier, { - parentURL: thisModuleUrl, - data: { instrumentations: SENTRY_INSTRUMENTATIONS, diagnosticsPort: port2 }, - transferList: [port2], + const diagnosticsPort = createDiagnosticsPort(); + + 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 */ + + mod.register('@apm-js-collab/tracing-hooks/hook.mjs', { + parentURL, + data: { instrumentations: SENTRY_INSTRUMENTATIONS, diagnosticsPort }, + transferList: [diagnosticsPort], }); // ALSO patch `Module.prototype._compile` for the CJS side: when an ESM @@ -184,13 +121,6 @@ export function registerDiagnosticsChannelInjection(options?: RegisterDiagnostic // 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 = ( - requireFromHooksDir && tracingHooksDir - ? requireFromHooksDir(tracingHooksDir) - : nodeRequire('@apm-js-collab/tracing-hooks') - ) as new (opts: { instrumentations: unknown }) => { - patch: () => void; - }; new ModulePatch({ instrumentations: SENTRY_INSTRUMENTATIONS }).patch(); debug.log('Registered diagnostics-channel injection via Module.register()'); } else { From a0092cdc2903618f250d9fed583a1274c607341e Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Tue, 21 Jul 2026 16:42:34 +0100 Subject: [PATCH 02/15] shortcut version detection --- .../server-utils/src/orchestrion/runtime/register.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/server-utils/src/orchestrion/runtime/register.ts b/packages/server-utils/src/orchestrion/runtime/register.ts index 5c0e4f13c903..038cd91a9412 100644 --- a/packages/server-utils/src/orchestrion/runtime/register.ts +++ b/packages/server-utils/src/orchestrion/runtime/register.ts @@ -27,14 +27,13 @@ export interface RegisterDiagnosticsChannelInjectionOptions { /** `Module.registerHooks` only became stable in Node 24.13 / 25.1 and Deno 2.8. */ function hasStableSyncModuleHooks(denoVersionString: string | undefined): boolean { - const { major: nodeMajor = 0, minor: nodeMinor = 0 } = parseSemver(process.versions.node ?? '0.0.0'); - - if (nodeMajor > 25 || (nodeMajor === 25 && nodeMinor >= 1) || (nodeMajor === 24 && nodeMinor >= 13)) { - return true; + if (denoVersionString) { + const { major = 0, minor = 0 } = parseSemver(denoVersionString); + return major > 2 || (major === 2 && minor >= 8); } - const { major: denoMajor = 0, minor: denoMinor = 0 } = parseSemver(denoVersionString ?? '0.0.0'); - return denoMajor > 2 || (denoMajor === 2 && denoMinor >= 8); + const { major = 0, minor = 0 } = parseSemver(process.versions.node ?? '0.0.0'); + return major > 25 || (major === 25 && minor >= 1) || (major === 24 && minor >= 13); } /** From f0b2eec257b454ccc90bdc4b3078c6bc82537b74 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Tue, 21 Jul 2026 19:00:33 +0100 Subject: [PATCH 03/15] Fix a couple of issues --- packages/server-utils/rollup.npm.config.mjs | 20 +++++++++++-------- .../runtime/apm-js-collab-tracing-hooks.d.ts | 2 +- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/packages/server-utils/rollup.npm.config.mjs b/packages/server-utils/rollup.npm.config.mjs index f1b3a19655a7..531080e840c7 100644 --- a/packages/server-utils/rollup.npm.config.mjs +++ b/packages/server-utils/rollup.npm.config.mjs @@ -44,14 +44,18 @@ export default [ exports: 'named', // set preserveModules to true because we don't want to bundle everything into one file. preserveModules: true, - // `@apm-js-collab/code-transformer-bundler-plugins` ships CJS entries as bare - // `module.exports = fn` with no `__esModule`/`.default`. The repo default - // `interop: 'esModule'` assumes ESM-shaped externals and would dereference a nonexistent - // `.default`, so a default import compiles to `codeTransformer.default(...)` → "not a - // function". Use 'auto' for just these so Rollup emits its interop helper. Scoped here (not - // repo-wide) because 'auto' also turns `import * as x` into a copy, which breaks in-place - // monkey-patching that other packages (e.g. the OTel fs instrumentation) depend on. - interop: id => (id?.startsWith('@apm-js-collab/code-transformer-bundler-plugins') ? 'auto' : 'esModule'), + // `@apm-js-collab/code-transformer-bundler-plugins` and `@apm-js-collab/tracing-hooks` + // ship CJS entries as bare `module.exports = fn`/`= class` with no `__esModule`/`.default`. + // The repo default `interop: 'esModule'` assumes ESM-shaped externals and would dereference + // a nonexistent `.default`, so a default import compiles to `codeTransformer.default(...)` / + // `ModulePatch.default(...)` → "not a function"/"not a constructor". Use 'auto' for just + // these so Rollup emits its interop helper. Scoped here (not repo-wide) because 'auto' also + // turns `import * as x` into a copy, which breaks in-place monkey-patching that other + // packages (e.g. the OTel fs instrumentation) depend on. + interop: id => + id?.startsWith('@apm-js-collab/code-transformer-bundler-plugins') || id?.startsWith('@apm-js-collab/tracing-hooks') + ? 'auto' + : 'esModule', }, }, }), diff --git a/packages/server-utils/src/orchestrion/runtime/apm-js-collab-tracing-hooks.d.ts b/packages/server-utils/src/orchestrion/runtime/apm-js-collab-tracing-hooks.d.ts index 79b4edfb7f39..175e9045ef25 100644 --- a/packages/server-utils/src/orchestrion/runtime/apm-js-collab-tracing-hooks.d.ts +++ b/packages/server-utils/src/orchestrion/runtime/apm-js-collab-tracing-hooks.d.ts @@ -3,7 +3,7 @@ // `import` of the package entry still needs the module to resolve at type-check time. declare module '@apm-js-collab/tracing-hooks' { - import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; + import type { InstrumentationConfig } from '@apm-js-collab/code-transformer-bundler-plugins/core'; type PatchConfig = { instrumentations: InstrumentationConfig[] }; From a4928dfc8adbae0a502f2c1af521e3fcade55e41 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Tue, 21 Jul 2026 19:27:00 +0100 Subject: [PATCH 04/15] Fix more --- .../nextjs/src/config/diagnosticsChannelInjection.ts | 9 +++++++++ packages/server-utils/rollup.npm.config.mjs | 3 ++- .../orchestrion/runtime/apm-js-collab-tracing-hooks.d.ts | 2 +- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/nextjs/src/config/diagnosticsChannelInjection.ts b/packages/nextjs/src/config/diagnosticsChannelInjection.ts index cf8b26f31771..946b9bd988af 100644 --- a/packages/nextjs/src/config/diagnosticsChannelInjection.ts +++ b/packages/nextjs/src/config/diagnosticsChannelInjection.ts @@ -9,10 +9,19 @@ export const BUNDLE_SAFE_INSTRUMENTED_PACKAGES = ['ioredis']; /** * The orchestrion runtime machinery must stay external — its parser breaks when bundled, which * silently disables the runtime module hook. + * + * `@sentry/server-utils` (the package `register.ts` — the code that actually calls into + * `@apm-js-collab/tracing-hooks` — ships in) is included too: if it stays external, its own + * `__filename`/`import.meta.url` keep pointing at their real `node_modules` location, so its + * bare-specifier `require`/`import` of the (also-external) tracing-hooks packages resolve + * correctly. If `@sentry/server-utils` were bundled into an app server chunk instead, its code + * would be relocated away from `node_modules`, and those same specifiers would fail to resolve + * under isolated installs (pnpm) — exactly the problem `tracingHooksDir` used to paper over. */ export const ORCHESTRION_RUNTIME_EXTERNAL_PACKAGES = [ '@apm-js-collab/tracing-hooks', '@apm-js-collab/code-transformer', + '@sentry/server-utils', ]; /** Remove the given packages from a `serverExternalPackages` list. */ diff --git a/packages/server-utils/rollup.npm.config.mjs b/packages/server-utils/rollup.npm.config.mjs index 531080e840c7..d45af68b35f1 100644 --- a/packages/server-utils/rollup.npm.config.mjs +++ b/packages/server-utils/rollup.npm.config.mjs @@ -53,7 +53,8 @@ export default [ // turns `import * as x` into a copy, which breaks in-place monkey-patching that other // packages (e.g. the OTel fs instrumentation) depend on. interop: id => - id?.startsWith('@apm-js-collab/code-transformer-bundler-plugins') || id?.startsWith('@apm-js-collab/tracing-hooks') + id?.startsWith('@apm-js-collab/code-transformer-bundler-plugins') || + id?.startsWith('@apm-js-collab/tracing-hooks') ? 'auto' : 'esModule', }, diff --git a/packages/server-utils/src/orchestrion/runtime/apm-js-collab-tracing-hooks.d.ts b/packages/server-utils/src/orchestrion/runtime/apm-js-collab-tracing-hooks.d.ts index 175e9045ef25..1d08aa67439a 100644 --- a/packages/server-utils/src/orchestrion/runtime/apm-js-collab-tracing-hooks.d.ts +++ b/packages/server-utils/src/orchestrion/runtime/apm-js-collab-tracing-hooks.d.ts @@ -3,7 +3,7 @@ // `import` of the package entry still needs the module to resolve at type-check time. declare module '@apm-js-collab/tracing-hooks' { - import type { InstrumentationConfig } from '@apm-js-collab/code-transformer-bundler-plugins/core'; + type InstrumentationConfig = unknown; type PatchConfig = { instrumentations: InstrumentationConfig[] }; From cc1f4eeddc140b2f4fa9631cbcbce1ed17a2c963 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Tue, 21 Jul 2026 19:38:56 +0100 Subject: [PATCH 05/15] More fixes --- .../src/config/diagnosticsChannelInjection.ts | 2 +- .../src/config/withSentryConfig/buildTime.ts | 4 ---- packages/nextjs/src/server/index.ts | 10 +--------- .../config/diagnosticsChannelInjection.test.ts | 3 --- ...xperimentalUseDiagnosticsChannelInjection.ts | 9 ++------- .../src/orchestrion/bundler/webpack.ts | 13 ------------- .../runtime/apm-js-collab-tracing-hooks.d.ts | 2 -- .../src/orchestrion/runtime/register.ts | 17 +---------------- .../test/orchestrion/bundler.test.ts | 14 +------------- 9 files changed, 6 insertions(+), 68 deletions(-) diff --git a/packages/nextjs/src/config/diagnosticsChannelInjection.ts b/packages/nextjs/src/config/diagnosticsChannelInjection.ts index 946b9bd988af..075c42b2c86a 100644 --- a/packages/nextjs/src/config/diagnosticsChannelInjection.ts +++ b/packages/nextjs/src/config/diagnosticsChannelInjection.ts @@ -16,7 +16,7 @@ export const BUNDLE_SAFE_INSTRUMENTED_PACKAGES = ['ioredis']; * bare-specifier `require`/`import` of the (also-external) tracing-hooks packages resolve * correctly. If `@sentry/server-utils` were bundled into an app server chunk instead, its code * would be relocated away from `node_modules`, and those same specifiers would fail to resolve - * under isolated installs (pnpm) — exactly the problem `tracingHooksDir` used to paper over. + * under isolated installs (pnpm). */ export const ORCHESTRION_RUNTIME_EXTERNAL_PACKAGES = [ '@apm-js-collab/tracing-hooks', diff --git a/packages/nextjs/src/config/withSentryConfig/buildTime.ts b/packages/nextjs/src/config/withSentryConfig/buildTime.ts index 93ec6e42e243..b799c9becfaa 100644 --- a/packages/nextjs/src/config/withSentryConfig/buildTime.ts +++ b/packages/nextjs/src/config/withSentryConfig/buildTime.ts @@ -1,7 +1,6 @@ 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'; /** @@ -54,9 +53,6 @@ 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/src/server/index.ts b/packages/nextjs/src/server/index.ts index ee0d2346c4f2..203b0f348a97 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -48,23 +48,15 @@ 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); + nodeExperimentalUseDiagnosticsChannelInjection(); } // Call at module level so `next build` prerender workers still register the runner without `init` diff --git a/packages/nextjs/test/config/diagnosticsChannelInjection.test.ts b/packages/nextjs/test/config/diagnosticsChannelInjection.test.ts index 881da6a3caf6..f721ea16be7c 100644 --- a/packages/nextjs/test/config/diagnosticsChannelInjection.test.ts +++ b/packages/nextjs/test/config/diagnosticsChannelInjection.test.ts @@ -58,8 +58,6 @@ describe('setUpBuildTimeVariables (diagnostics-channel injection)', () => { 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/), }); }); @@ -68,6 +66,5 @@ describe('setUpBuildTimeVariables (diagnostics-channel injection)', () => { setUpBuildTimeVariables(nextConfig, {}, undefined); expect(nextConfig.env).not.toHaveProperty('_sentryUseDiagnosticsChannelInjection'); - expect(nextConfig.env).not.toHaveProperty('_sentryOrchestrionTracingHooksDir'); }); }); diff --git a/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts b/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts index 1fce9b35372d..14eed50aa7ae 100644 --- a/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts +++ b/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts @@ -46,12 +46,7 @@ export function diagnosticsChannelInjectionIntegrations(): typeof channelIntegra * * @experimental May change or be removed in any release. */ -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 { +export function experimentalUseDiagnosticsChannelInjection(): void { setDiagnosticsChannelInjectionLoader((): DiagnosticsChannelInjection => { // These channel integrations 1:1 replace the OTel integration of the // same name. Framework SDKs that own their own channel listener @@ -71,7 +66,7 @@ export function experimentalUseDiagnosticsChannelInjection( redisChannelIntegration({ responseHook: cacheResponseHook }), ], replacedOtelIntegrationNames, - register: () => registerDiagnosticsChannelInjection(options), + register: () => registerDiagnosticsChannelInjection(), detect: detectOrchestrionSetup, }; }); diff --git a/packages/server-utils/src/orchestrion/bundler/webpack.ts b/packages/server-utils/src/orchestrion/bundler/webpack.ts index 74c63f29633e..56d35f731cde 100644 --- a/packages/server-utils/src/orchestrion/bundler/webpack.ts +++ b/packages/server-utils/src/orchestrion/bundler/webpack.ts @@ -2,7 +2,6 @@ // 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 { Compiler } from 'webpack'; import type { InstrumentationConfig } from '..'; import { instrumentedModuleNames, SENTRY_INSTRUMENTATIONS } from '../config'; @@ -31,18 +30,6 @@ 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/apm-js-collab-tracing-hooks.d.ts b/packages/server-utils/src/orchestrion/runtime/apm-js-collab-tracing-hooks.d.ts index 1d08aa67439a..24a6b6a58a0f 100644 --- a/packages/server-utils/src/orchestrion/runtime/apm-js-collab-tracing-hooks.d.ts +++ b/packages/server-utils/src/orchestrion/runtime/apm-js-collab-tracing-hooks.d.ts @@ -1,6 +1,4 @@ // Ambient declarations for `@apm-js-collab/tracing-hooks`, which ships no types of its own. -// `register.ts` loads these modules through `require`/`createRequire`, but the static -// `import` of the package entry still needs the module to resolve at type-check time. declare module '@apm-js-collab/tracing-hooks' { type InstrumentationConfig = unknown; diff --git a/packages/server-utils/src/orchestrion/runtime/register.ts b/packages/server-utils/src/orchestrion/runtime/register.ts index 038cd91a9412..ff12a7deb108 100644 --- a/packages/server-utils/src/orchestrion/runtime/register.ts +++ b/packages/server-utils/src/orchestrion/runtime/register.ts @@ -13,18 +13,6 @@ type NodeModule = { register?: typeof register; }; -export interface RegisterDiagnosticsChannelInjectionOptions { - /** - * Absolute directory of the `@apm-js-collab/tracing-hooks` package (forward slashes). - * - * 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; -} - /** `Module.registerHooks` only became stable in Node 24.13 / 25.1 and Deno 2.8. */ function hasStableSyncModuleHooks(denoVersionString: string | undefined): boolean { if (denoVersionString) { @@ -48,7 +36,7 @@ function hasStableSyncModuleHooks(denoVersionString: string | undefined): boolea * Libraries imported *after* this call publish the `tracingChannel` events that * the channel-based integrations subscribe to. */ -export function registerDiagnosticsChannelInjection(_options?: RegisterDiagnosticsChannelInjectionOptions): void { +export function registerDiagnosticsChannelInjection(): void { // Skip Node's internal loader (hooks) threads, recognizable as the only threads without a // `parentPort`. Node re-runs `--require` preloads (though not `--import` ones) on the loader // thread it spawns for `Module.register()`, so this function runs there too — but that thread @@ -96,9 +84,6 @@ export function registerDiagnosticsChannelInjection(_options?: RegisterDiagnosti // `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). - // `Module.register` resolves ESM-style: a bare package specifier is resolved against - // `parentURL`, but a filesystem path (the `tracingHooksDir` override) is not a valid ESM - // specifier and must be passed as a file:// URL. const diagnosticsPort = createDiagnosticsPort(); let parentURL: string; diff --git a/packages/server-utils/test/orchestrion/bundler.test.ts b/packages/server-utils/test/orchestrion/bundler.test.ts index f13209fba323..82e86bbacf94 100644 --- a/packages/server-utils/test/orchestrion/bundler.test.ts +++ b/packages/server-utils/test/orchestrion/bundler.test.ts @@ -8,7 +8,7 @@ import { describe, expect, it, vi } from 'vitest'; import { sentryOrchestrionPlugin as esbuildPlugin } from '../../src/orchestrion/bundler/esbuild'; import { sentryOrchestrionPlugin as rollupPlugin } from '../../src/orchestrion/bundler/rollup'; import { sentryOrchestrionPlugin as vitePlugin } from '../../src/orchestrion/bundler/vite'; -import { getTracingHooksDirectory, sentryOrchestrionWebpackPlugin } from '../../src/orchestrion/bundler/webpack'; +import { sentryOrchestrionWebpackPlugin } from '../../src/orchestrion/bundler/webpack'; // The upstream transform plugins are mocked so tests exercise only the hooks // added on top of them (the externalized-modules warnings). @@ -135,15 +135,3 @@ describe('sentryOrchestrionPlugin (vite)', () => { expect(runConfigResolved(undefined)).not.toHaveBeenCalled(); }); }); - -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 0ecdeeca828cd709f48449469653331840dbbd20 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Tue, 21 Jul 2026 19:50:58 +0100 Subject: [PATCH 06/15] Ooops --- .size-limit.js | 2 +- .../node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts | 1 - packages/server-utils/test/orchestrion/bundler.test.ts | 2 -- 3 files changed, 1 insertion(+), 4 deletions(-) diff --git a/.size-limit.js b/.size-limit.js index 4ff4c7d05f7f..2bf1fc905e94 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -400,7 +400,7 @@ module.exports = [ import: createImport('init', 'experimentalUseDiagnosticsChannelInjection'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '154 KB', + limit: '230 KB', disablePlugins: ['@size-limit/esbuild'], }, { diff --git a/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts b/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts index 14eed50aa7ae..2472fba61c92 100644 --- a/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts +++ b/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts @@ -4,7 +4,6 @@ import { redisChannelIntegration, 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'; diff --git a/packages/server-utils/test/orchestrion/bundler.test.ts b/packages/server-utils/test/orchestrion/bundler.test.ts index 82e86bbacf94..ddb909f085ea 100644 --- a/packages/server-utils/test/orchestrion/bundler.test.ts +++ b/packages/server-utils/test/orchestrion/bundler.test.ts @@ -1,5 +1,3 @@ -import { existsSync } from 'node:fs'; -import { join } from 'node:path'; import type { OnStartResult, PluginBuild } from 'esbuild'; import type { NormalizedInputOptions, PluginContext } from 'rollup'; import type { ResolvedConfig } from 'vite'; From 2b9180d393a3be90ce94e036e00480cf8c1e65de Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Wed, 22 Jul 2026 02:30:42 +0100 Subject: [PATCH 07/15] Fix nextjs --- .../src/config/diagnosticsChannelInjection.ts | 30 ++++++++++++++ packages/nextjs/src/config/webpack.ts | 19 +++++++++ .../diagnosticsChannelInjection.test.ts | 14 +++++++ .../webpack/constructWebpackConfig.test.ts | 41 +++++++++++++++++++ 4 files changed, 104 insertions(+) diff --git a/packages/nextjs/src/config/diagnosticsChannelInjection.ts b/packages/nextjs/src/config/diagnosticsChannelInjection.ts index 075c42b2c86a..9dc1a24992fa 100644 --- a/packages/nextjs/src/config/diagnosticsChannelInjection.ts +++ b/packages/nextjs/src/config/diagnosticsChannelInjection.ts @@ -29,3 +29,33 @@ export function filterInstrumentedExternals(externals: string[], packagesToBundl const set = new Set(packagesToBundle); return externals.filter(name => !set.has(name)); } + +/** + * `@apm-js-collab/tracing-hooks/hook-sync.mjs` is ESM-only with no CJS equivalent, but + * `@sentry/server-utils`'s `register.ts` must `require()` it synchronously at runtime (see that + * file). Next.js's webpack config refuses to compile any bare `require()` of an ESM-only package + * (`ESM packages (...) need to be imported`, thrown by `handleExternals` in `next/dist/build/handle-externals.js`) + * unless the app-wide `experimental.esmExternals: 'loose'` flag is set — which we don't want to force + * on every user, and which routes through a separate Next.js ESM-interop codepath that has its own + * `require(esm)` parent-URL misattribution bug at runtime. + * + * Being in `serverExternalPackages`/`ORCHESTRION_RUNTIME_EXTERNAL_PACKAGES` above doesn't help here: + * Next's ESM guard throws before it even considers whether a package is externalized. + */ +export const ESM_ONLY_ORCHESTRION_SPECIFIERS = ['@apm-js-collab/tracing-hooks/hook-sync.mjs']; + +/** + * A webpack `externals` array entry that externalizes {@link ESM_ONLY_ORCHESTRION_SPECIFIERS} as + * plain `commonjs` requires — the same declaration Next.js's own `handleExternals` would produce for + * a genuinely CJS package — so its ESM guard never sees (and never throws on) these specifiers. + * + * Must be placed *before* Next's own externals handler in the `externals` array: webpack calls array + * entries in order and stops at the first one that returns a result. + */ +export async function externalizeEsmOnlyOrchestrionSpecifiers({ + request, +}: { + request?: string; +}): Promise { + return request && ESM_ONLY_ORCHESTRION_SPECIFIERS.includes(request) ? `commonjs ${request}` : undefined; +} diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts index 08c874250d96..5e99c18d4e5b 100644 --- a/packages/nextjs/src/config/webpack.ts +++ b/packages/nextjs/src/config/webpack.ts @@ -6,6 +6,7 @@ import * as fs from 'fs'; import { createRequire } from 'module'; import * as path from 'path'; import type { VercelCronsConfig } from '../common/types'; +import { externalizeEsmOnlyOrchestrionSpecifiers } from './diagnosticsChannelInjection'; import { getBuildPluginOptions, normalizePathForGlob } from './getBuildPluginOptions'; import type { RouteManifest } from './manifest/types'; // Note: If you need to import a type from Webpack, do it in `types.ts` and export it from there. Otherwise, our @@ -435,6 +436,7 @@ export function constructWebpackConfigFunction({ // Orchestrion code-transform loader — Node server runtime only, never the edge compilation if (runtime === 'server' && userSentryOptions._experimental?.useDiagnosticsChannelInjection) { newConfig.plugins.push(sentryOrchestrionWebpackPlugin() as unknown as WebpackPluginInstance); + prependEsmOnlyOrchestrionExternalsShim(newConfig); } return newConfig; @@ -873,6 +875,23 @@ function addOtelWarningIgnoreRule(newConfig: WebpackConfigObjectWithModuleRules) } } +/** + * Prepends {@link externalizeEsmOnlyOrchestrionSpecifiers} to `newConfig.externals`, ahead of + * Next.js's own externals handler, so its ESM guard never sees the orchestrion runtime's ESM-only + * specifiers. See that function's docs for why this is necessary. + */ +function prependEsmOnlyOrchestrionExternalsShim(newConfig: WebpackConfigObjectWithModuleRules): void { + const existingExternals = newConfig.externals; + + if (Array.isArray(existingExternals)) { + existingExternals.unshift(externalizeEsmOnlyOrchestrionSpecifiers); + } else if (existingExternals === undefined) { + newConfig.externals = [externalizeEsmOnlyOrchestrionSpecifiers]; + } else { + newConfig.externals = [externalizeEsmOnlyOrchestrionSpecifiers, existingExternals]; + } +} + function addEdgeRuntimePolyfills(newConfig: WebpackConfigObjectWithModuleRules, buildContext: BuildContext): void { // Use ProvidePlugin to inject performance global only when accessed newConfig.plugins = newConfig.plugins || []; diff --git a/packages/nextjs/test/config/diagnosticsChannelInjection.test.ts b/packages/nextjs/test/config/diagnosticsChannelInjection.test.ts index f721ea16be7c..1382d7f21b55 100644 --- a/packages/nextjs/test/config/diagnosticsChannelInjection.test.ts +++ b/packages/nextjs/test/config/diagnosticsChannelInjection.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { BUNDLE_SAFE_INSTRUMENTED_PACKAGES, + externalizeEsmOnlyOrchestrionSpecifiers, filterInstrumentedExternals, } from '../../src/config/diagnosticsChannelInjection'; import { setUpBuildTimeVariables } from '../../src/config/withSentryConfig/buildTime'; @@ -51,6 +52,19 @@ describe('getServerExternalPackagesPatch (diagnostics-channel injection)', () => }); }); +describe('externalizeEsmOnlyOrchestrionSpecifiers', () => { + it('externalizes the ESM-only orchestrion specifier as a plain commonjs require', async () => { + await expect( + externalizeEsmOnlyOrchestrionSpecifiers({ request: '@apm-js-collab/tracing-hooks/hook-sync.mjs' }), + ).resolves.toBe('commonjs @apm-js-collab/tracing-hooks/hook-sync.mjs'); + }); + + it('ignores unrelated requests so later externals handlers still run', async () => { + await expect(externalizeEsmOnlyOrchestrionSpecifiers({ request: 'some-other-package' })).resolves.toBeUndefined(); + await expect(externalizeEsmOnlyOrchestrionSpecifiers({})).resolves.toBeUndefined(); + }); +}); + describe('setUpBuildTimeVariables (diagnostics-channel injection)', () => { it('injects the flag marker and the tracing-hooks location', () => { const nextConfig: NextConfigObject = {}; diff --git a/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts b/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts index b822495d1a67..1fc90a3b10a2 100644 --- a/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts +++ b/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts @@ -842,4 +842,45 @@ describe('constructWebpackConfigFunction()', () => { expect(findOrchestrionPlugin(finalWebpackConfig)).toBeUndefined(); }); }); + + describe('esm-only orchestrion externals shim', () => { + it('prepends the shim to `externals` when diagnostics-channel injection is enabled', async () => { + const finalWebpackConfig = await materializeFinalWebpackConfig({ + exportedNextConfig, + incomingWebpackConfig: serverWebpackConfig, + incomingWebpackBuildContext: serverBuildContext, + sentryBuildTimeOptions: { _experimental: { useDiagnosticsChannelInjection: true } }, + }); + + const externals = finalWebpackConfig.externals as ((data: { request?: string }) => Promise)[]; + + expect(Array.isArray(externals)).toBe(true); + await expect(externals[0]({ request: '@apm-js-collab/tracing-hooks/hook-sync.mjs' })).resolves.toBe( + 'commonjs @apm-js-collab/tracing-hooks/hook-sync.mjs', + ); + await expect(externals[0]({ request: 'some-other-package' })).resolves.toBeUndefined(); + }); + + it('does not touch `externals` when diagnostics-channel injection is not enabled', async () => { + const finalWebpackConfig = await materializeFinalWebpackConfig({ + exportedNextConfig, + incomingWebpackConfig: serverWebpackConfig, + incomingWebpackBuildContext: serverBuildContext, + sentryBuildTimeOptions: {}, + }); + + expect(finalWebpackConfig.externals).toBeUndefined(); + }); + + it('does not touch `externals` on the edge build', async () => { + const finalWebpackConfig = await materializeFinalWebpackConfig({ + exportedNextConfig, + incomingWebpackConfig: serverWebpackConfig, + incomingWebpackBuildContext: edgeBuildContext, + sentryBuildTimeOptions: { _experimental: { useDiagnosticsChannelInjection: true } }, + }); + + expect(finalWebpackConfig.externals).toBeUndefined(); + }); + }); }); From ea7c5d803073e04da67896a63991f92b901f3d06 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Wed, 22 Jul 2026 14:20:35 +0100 Subject: [PATCH 08/15] More NextJs fixes --- .../src/config/diagnosticsChannelInjection.ts | 47 +++++++++++-------- packages/nextjs/src/config/webpack.ts | 18 +++---- .../diagnosticsChannelInjection.test.ts | 41 ++++++++++++---- .../webpack/constructWebpackConfig.test.ts | 13 +++-- .../src/orchestrion/bundler/webpack.ts | 18 +++++++ .../test/orchestrion/bundler.test.ts | 36 +++++++++++++- 6 files changed, 130 insertions(+), 43 deletions(-) diff --git a/packages/nextjs/src/config/diagnosticsChannelInjection.ts b/packages/nextjs/src/config/diagnosticsChannelInjection.ts index 9dc1a24992fa..a3acef88fe41 100644 --- a/packages/nextjs/src/config/diagnosticsChannelInjection.ts +++ b/packages/nextjs/src/config/diagnosticsChannelInjection.ts @@ -1,3 +1,5 @@ +import { resolveOrchestrionRuntimeRequest } from '@sentry/server-utils/orchestrion/webpack'; + /** * 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 @@ -31,31 +33,36 @@ export function filterInstrumentedExternals(externals: string[], packagesToBundl } /** - * `@apm-js-collab/tracing-hooks/hook-sync.mjs` is ESM-only with no CJS equivalent, but - * `@sentry/server-utils`'s `register.ts` must `require()` it synchronously at runtime (see that - * file). Next.js's webpack config refuses to compile any bare `require()` of an ESM-only package - * (`ESM packages (...) need to be imported`, thrown by `handleExternals` in `next/dist/build/handle-externals.js`) - * unless the app-wide `experimental.esmExternals: 'loose'` flag is set — which we don't want to force - * on every user, and which routes through a separate Next.js ESM-interop codepath that has its own - * `require(esm)` parent-URL misattribution bug at runtime. + * A webpack `externals` array entry that keeps {@link ORCHESTRION_RUNTIME_EXTERNAL_PACKAGES} truly + * external by resolving each request to an absolute path at build time and emitting a + * `commonjs ` external. * - * Being in `serverExternalPackages`/`ORCHESTRION_RUNTIME_EXTERNAL_PACKAGES` above doesn't help here: - * Next's ESM guard throws before it even considers whether a package is externalized. - */ -export const ESM_ONLY_ORCHESTRION_SPECIFIERS = ['@apm-js-collab/tracing-hooks/hook-sync.mjs']; - -/** - * A webpack `externals` array entry that externalizes {@link ESM_ONLY_ORCHESTRION_SPECIFIERS} as - * plain `commonjs` requires — the same declaration Next.js's own `handleExternals` would produce for - * a genuinely CJS package — so its ESM guard never sees (and never throws on) these specifiers. + * Listing the packages in `serverExternalPackages` is not enough: Next.js only externalizes a + * package when its bare specifier also resolves from the project root (`resolveExternal`'s + * base-resolve check in `next/dist/build/handle-externals.js`) — otherwise the + * `require('')` it emits into the chunk would dangle at runtime, so Next silently + * bundles the package instead. Under isolated installs (pnpm) these packages are transitive + * dependencies that never resolve from the project root, so the whole orchestrion runtime ended up + * compiled into the server chunk, which breaks it twice over: the code-transformer parser doesn't + * survive bundling, and the runtime hook's own bare specifiers can't resolve from the chunk's + * output location. Absolute paths sidestep all of this — webpack emits `require('/abs/path/…')`, + * which loads the real files from `node_modules` no matter where the chunk lives. * - * Must be placed *before* Next's own externals handler in the `externals` array: webpack calls array - * entries in order and stops at the first one that returns a result. + * Must be placed *before* Next's own externals handler in the `externals` array: webpack calls + * array entries in order and stops at the first one that returns a result. */ -export async function externalizeEsmOnlyOrchestrionSpecifiers({ +export async function externalizeOrchestrionRuntimePackages({ request, }: { request?: string; }): Promise { - return request && ESM_ONLY_ORCHESTRION_SPECIFIERS.includes(request) ? `commonjs ${request}` : undefined; + if ( + !request || + !ORCHESTRION_RUNTIME_EXTERNAL_PACKAGES.some(pkg => request === pkg || request.startsWith(`${pkg}/`)) + ) { + return undefined; + } + + const resolved = resolveOrchestrionRuntimeRequest(request); + return resolved ? `commonjs ${resolved}` : undefined; } diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts index 5e99c18d4e5b..d98472c5896d 100644 --- a/packages/nextjs/src/config/webpack.ts +++ b/packages/nextjs/src/config/webpack.ts @@ -6,7 +6,7 @@ import * as fs from 'fs'; import { createRequire } from 'module'; import * as path from 'path'; import type { VercelCronsConfig } from '../common/types'; -import { externalizeEsmOnlyOrchestrionSpecifiers } from './diagnosticsChannelInjection'; +import { externalizeOrchestrionRuntimePackages } from './diagnosticsChannelInjection'; import { getBuildPluginOptions, normalizePathForGlob } from './getBuildPluginOptions'; import type { RouteManifest } from './manifest/types'; // Note: If you need to import a type from Webpack, do it in `types.ts` and export it from there. Otherwise, our @@ -436,7 +436,7 @@ export function constructWebpackConfigFunction({ // Orchestrion code-transform loader — Node server runtime only, never the edge compilation if (runtime === 'server' && userSentryOptions._experimental?.useDiagnosticsChannelInjection) { newConfig.plugins.push(sentryOrchestrionWebpackPlugin() as unknown as WebpackPluginInstance); - prependEsmOnlyOrchestrionExternalsShim(newConfig); + prependOrchestrionRuntimeExternals(newConfig); } return newConfig; @@ -876,19 +876,19 @@ function addOtelWarningIgnoreRule(newConfig: WebpackConfigObjectWithModuleRules) } /** - * Prepends {@link externalizeEsmOnlyOrchestrionSpecifiers} to `newConfig.externals`, ahead of - * Next.js's own externals handler, so its ESM guard never sees the orchestrion runtime's ESM-only - * specifiers. See that function's docs for why this is necessary. + * Prepends {@link externalizeOrchestrionRuntimePackages} to `newConfig.externals`, ahead of + * Next.js's own externals handler, so the orchestrion runtime packages stay external even where + * `serverExternalPackages` can't keep them so. See that function's docs for why this is necessary. */ -function prependEsmOnlyOrchestrionExternalsShim(newConfig: WebpackConfigObjectWithModuleRules): void { +function prependOrchestrionRuntimeExternals(newConfig: WebpackConfigObjectWithModuleRules): void { const existingExternals = newConfig.externals; if (Array.isArray(existingExternals)) { - existingExternals.unshift(externalizeEsmOnlyOrchestrionSpecifiers); + existingExternals.unshift(externalizeOrchestrionRuntimePackages); } else if (existingExternals === undefined) { - newConfig.externals = [externalizeEsmOnlyOrchestrionSpecifiers]; + newConfig.externals = [externalizeOrchestrionRuntimePackages]; } else { - newConfig.externals = [externalizeEsmOnlyOrchestrionSpecifiers, existingExternals]; + newConfig.externals = [externalizeOrchestrionRuntimePackages, existingExternals]; } } diff --git a/packages/nextjs/test/config/diagnosticsChannelInjection.test.ts b/packages/nextjs/test/config/diagnosticsChannelInjection.test.ts index 1382d7f21b55..d5b592607fe4 100644 --- a/packages/nextjs/test/config/diagnosticsChannelInjection.test.ts +++ b/packages/nextjs/test/config/diagnosticsChannelInjection.test.ts @@ -1,7 +1,9 @@ +import { existsSync } from 'node:fs'; +import { isAbsolute } from 'node:path'; import { describe, expect, it } from 'vitest'; import { BUNDLE_SAFE_INSTRUMENTED_PACKAGES, - externalizeEsmOnlyOrchestrionSpecifiers, + externalizeOrchestrionRuntimePackages, filterInstrumentedExternals, } from '../../src/config/diagnosticsChannelInjection'; import { setUpBuildTimeVariables } from '../../src/config/withSentryConfig/buildTime'; @@ -52,16 +54,39 @@ describe('getServerExternalPackagesPatch (diagnostics-channel injection)', () => }); }); -describe('externalizeEsmOnlyOrchestrionSpecifiers', () => { - it('externalizes the ESM-only orchestrion specifier as a plain commonjs require', async () => { - await expect( - externalizeEsmOnlyOrchestrionSpecifiers({ request: '@apm-js-collab/tracing-hooks/hook-sync.mjs' }), - ).resolves.toBe('commonjs @apm-js-collab/tracing-hooks/hook-sync.mjs'); +describe('externalizeOrchestrionRuntimePackages', () => { + it.each([ + '@sentry/server-utils', + '@sentry/server-utils/orchestrion', + '@sentry/server-utils/orchestrion/register', + '@apm-js-collab/tracing-hooks', + '@apm-js-collab/tracing-hooks/hook-sync.mjs', + '@apm-js-collab/tracing-hooks/lib/diagnostics.js', + '@apm-js-collab/code-transformer', + ])('externalizes %s as an absolute-path commonjs require', async request => { + const external = await externalizeOrchestrionRuntimePackages({ request }); + + expect(external).toMatch(/^commonjs /); + const resolvedPath = external!.slice('commonjs '.length); + expect(isAbsolute(resolvedPath)).toBe(true); + expect(existsSync(resolvedPath)).toBe(true); + }); + + it('resolves @sentry/server-utils subpaths to the CJS build, since the emitted external is a require()', async () => { + const external = await externalizeOrchestrionRuntimePackages({ + request: '@sentry/server-utils/orchestrion/register', + }); + + expect(external).toMatch(/[/\\]cjs[/\\]/); }); it('ignores unrelated requests so later externals handlers still run', async () => { - await expect(externalizeEsmOnlyOrchestrionSpecifiers({ request: 'some-other-package' })).resolves.toBeUndefined(); - await expect(externalizeEsmOnlyOrchestrionSpecifiers({})).resolves.toBeUndefined(); + await expect(externalizeOrchestrionRuntimePackages({ request: 'some-other-package' })).resolves.toBeUndefined(); + // Prefix matching must not leak beyond a package-name boundary. + await expect( + externalizeOrchestrionRuntimePackages({ request: '@sentry/server-utils-extras' }), + ).resolves.toBeUndefined(); + await expect(externalizeOrchestrionRuntimePackages({})).resolves.toBeUndefined(); }); }); diff --git a/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts b/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts index 1fc90a3b10a2..d046d1a23bfe 100644 --- a/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts +++ b/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts @@ -16,7 +16,10 @@ import { } from '../fixtures'; import { materializeFinalNextConfig, materializeFinalWebpackConfig } from '../testUtils'; -vi.mock('@sentry/server-utils/orchestrion/webpack', () => ({ +// Only the plugin factory is stubbed — `resolveOrchestrionRuntimeRequest` must stay real because +// the externals handler under test uses it. +vi.mock('@sentry/server-utils/orchestrion/webpack', async importOriginal => ({ + ...(await importOriginal>()), sentryOrchestrionWebpackPlugin: () => ({ _name: 'sentry-orchestrion-webpack-plugin' }), })); @@ -843,8 +846,8 @@ describe('constructWebpackConfigFunction()', () => { }); }); - describe('esm-only orchestrion externals shim', () => { - it('prepends the shim to `externals` when diagnostics-channel injection is enabled', async () => { + describe('orchestrion runtime externals', () => { + it('prepends an externals handler that resolves runtime packages to absolute paths when diagnostics-channel injection is enabled', async () => { const finalWebpackConfig = await materializeFinalWebpackConfig({ exportedNextConfig, incomingWebpackConfig: serverWebpackConfig, @@ -855,8 +858,8 @@ describe('constructWebpackConfigFunction()', () => { const externals = finalWebpackConfig.externals as ((data: { request?: string }) => Promise)[]; expect(Array.isArray(externals)).toBe(true); - await expect(externals[0]({ request: '@apm-js-collab/tracing-hooks/hook-sync.mjs' })).resolves.toBe( - 'commonjs @apm-js-collab/tracing-hooks/hook-sync.mjs', + await expect(externals[0]({ request: '@sentry/server-utils/orchestrion/register' })).resolves.toMatch( + /^commonjs ([/\\]|[A-Za-z]:).*register\.js$/, ); await expect(externals[0]({ request: 'some-other-package' })).resolves.toBeUndefined(); }); diff --git a/packages/server-utils/src/orchestrion/bundler/webpack.ts b/packages/server-utils/src/orchestrion/bundler/webpack.ts index 56d35f731cde..bbffbf5b323f 100644 --- a/packages/server-utils/src/orchestrion/bundler/webpack.ts +++ b/packages/server-utils/src/orchestrion/bundler/webpack.ts @@ -30,6 +30,24 @@ export function getOrchestrionLoaderPath(): string { return getOrchestrionRequire().resolve('@apm-js-collab/code-transformer-bundler-plugins/webpack-loader'); } +/** + * Resolves a request for one of the orchestrion runtime packages (`@sentry/server-utils` itself, via + * self-reference, or its `@apm-js-collab/*` dependencies) to an absolute path, from this package's + * own on-disk location — where the whole dependency graph always resolves, regardless of the + * consuming app's install layout. Returns `undefined` when the request can't be resolved. + * + * Bundler configs use this to emit absolute-path `commonjs` externals: a bare-specifier external + * emitted into a bundled chunk resolves from the chunk's output location at runtime, which fails + * under isolated installs (pnpm) where these packages are transitive dependencies. + */ +export function resolveOrchestrionRuntimeRequest(request: string): string | undefined { + try { + return getOrchestrionRequire().resolve(request); + } catch { + return undefined; + } +} + /** 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/test/orchestrion/bundler.test.ts b/packages/server-utils/test/orchestrion/bundler.test.ts index ddb909f085ea..ee1c746fc6de 100644 --- a/packages/server-utils/test/orchestrion/bundler.test.ts +++ b/packages/server-utils/test/orchestrion/bundler.test.ts @@ -1,3 +1,5 @@ +import { existsSync } from 'node:fs'; +import { isAbsolute } from 'node:path'; import type { OnStartResult, PluginBuild } from 'esbuild'; import type { NormalizedInputOptions, PluginContext } from 'rollup'; import type { ResolvedConfig } from 'vite'; @@ -6,7 +8,10 @@ import { describe, expect, it, vi } from 'vitest'; import { sentryOrchestrionPlugin as esbuildPlugin } from '../../src/orchestrion/bundler/esbuild'; import { sentryOrchestrionPlugin as rollupPlugin } from '../../src/orchestrion/bundler/rollup'; import { sentryOrchestrionPlugin as vitePlugin } from '../../src/orchestrion/bundler/vite'; -import { sentryOrchestrionWebpackPlugin } from '../../src/orchestrion/bundler/webpack'; +import { + resolveOrchestrionRuntimeRequest, + sentryOrchestrionWebpackPlugin, +} from '../../src/orchestrion/bundler/webpack'; // The upstream transform plugins are mocked so tests exercise only the hooks // added on top of them (the externalized-modules warnings). @@ -133,3 +138,32 @@ describe('sentryOrchestrionPlugin (vite)', () => { expect(runConfigResolved(undefined)).not.toHaveBeenCalled(); }); }); + +describe('resolveOrchestrionRuntimeRequest', () => { + it.each([ + // Self-references — resolve through this package's own exports map to the CJS build. + '@sentry/server-utils/orchestrion/register', + '@sentry/server-utils/orchestrion', + // Dependencies of this package, including subpaths only reachable from its location. + '@apm-js-collab/tracing-hooks', + '@apm-js-collab/tracing-hooks/hook.mjs', + '@apm-js-collab/tracing-hooks/hook-sync.mjs', + '@apm-js-collab/tracing-hooks/lib/diagnostics.js', + '@apm-js-collab/code-transformer', + ])('resolves %s to an existing absolute path', request => { + const resolved = resolveOrchestrionRuntimeRequest(request); + + expect(resolved).toBeDefined(); + expect(isAbsolute(resolved!)).toBe(true); + expect(existsSync(resolved!)).toBe(true); + }); + + it('resolves self-references with require conditions, so the paths are loadable via require()', () => { + expect(resolveOrchestrionRuntimeRequest('@sentry/server-utils/orchestrion/register')).toMatch(/[/\\]cjs[/\\]/); + }); + + it('returns undefined for unresolvable requests', () => { + expect(resolveOrchestrionRuntimeRequest('@sentry/server-utils/no-such-subpath')).toBeUndefined(); + expect(resolveOrchestrionRuntimeRequest('some-package-that-does-not-exist')).toBeUndefined(); + }); +}); From a2149dbdd4a96f82c337cab091add04ef6140a63 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Thu, 23 Jul 2026 02:19:15 +0100 Subject: [PATCH 09/15] Workaround Nitro issues --- .../test-applications/nuxt-3-min/package.json | 3 +- packages/nuxt/src/module.ts | 5 ++ packages/nuxt/src/vite/moduleSyncTracing.ts | 43 +++++++++ .../nuxt/test/vite/moduleSyncTracing.test.ts | 90 +++++++++++++++++++ packages/solidstart/src/config/withSentry.ts | 15 ++++ .../solidstart/test/config/withSentry.test.ts | 26 ++++++ .../src/vite/moduleSyncTracing.ts | 41 +++++++++ .../src/vite/sentryTanstackStart.ts | 3 +- .../test/vite/moduleSyncTracing.test.ts | 26 ++++++ .../test/vite/sentryTanstackStart.test.ts | 39 ++++++-- 10 files changed, 283 insertions(+), 8 deletions(-) create mode 100644 packages/nuxt/src/vite/moduleSyncTracing.ts create mode 100644 packages/nuxt/test/vite/moduleSyncTracing.test.ts create mode 100644 packages/tanstackstart-react/src/vite/moduleSyncTracing.ts create mode 100644 packages/tanstackstart-react/test/vite/moduleSyncTracing.test.ts diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-min/package.json b/dev-packages/e2e-tests/test-applications/nuxt-3-min/package.json index 73b0c59e8a24..3ac4f2494e97 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3-min/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-min/package.json @@ -27,8 +27,7 @@ }, "pnpm": { "overrides": { - "ofetch": "1.4.0", - "@vercel/nft": "0.29.4" + "ofetch": "1.4.0" } }, "volta": { diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index 4cf39d16cdbc..86b7c245a3fa 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -13,6 +13,7 @@ import type { SentryNuxtModuleOptions } from './common/types'; import { addDynamicImportEntryFileWrapper, addSentryTopImport, addServerConfigToBuild } from './vite/addServerConfig'; import { addDatabaseInstrumentation } from './vite/databaseConfig'; import { addMiddlewareImports, addMiddlewareInstrumentation } from './vite/middlewareConfig'; +import { setupModuleSyncTracing } from './vite/moduleSyncTracing'; import { setupOrchestrion } from './vite/orchestrion'; import { setupSourceMaps } from './vite/sourceMaps'; import { addStorageInstrumentation } from './vite/storageConfig'; @@ -85,6 +86,10 @@ export default defineNuxtModule({ const isMinNuxtV4 = nuxtMajor >= 4; if (serverConfigFile) { + // Deliberately not gated on the orchestrion opt-in: the server SDK's dependency graph reaches + // `module-sync` packages either way, so every traced server build needs this. + setupModuleSyncTracing(nuxt, isNitroV3); + if (moduleOptions._experimental?.useDiagnosticsChannelInjection) { setupOrchestrion(nuxt); } diff --git a/packages/nuxt/src/vite/moduleSyncTracing.ts b/packages/nuxt/src/vite/moduleSyncTracing.ts new file mode 100644 index 000000000000..8a4326c5342b --- /dev/null +++ b/packages/nuxt/src/vite/moduleSyncTracing.ts @@ -0,0 +1,43 @@ +import type { Nuxt } from '@nuxt/schema'; +import type { NitroConfig } from 'nitropack'; + +// `@vercel/nft` only traces the `module-sync` target of a package's `exports` map when the Node +// process *running the build* is >= 22, but Node's CJS loader matches `module-sync` at runtime from +// 20.19. A Nitro server built on Node 20 is therefore missing files its own runtime resolves (e.g. +// `meriyah/dist/meriyah.mjs`, reached through `@apm-js-collab/code-transformer`) and crashes with +// `MODULE_NOT_FOUND`. nft's `moduleSyncCatchall` option (>= 1.10.0, ignored by older versions) +// makes it emit both targets regardless of the build Node version. +// +// Remove once https://github.com/vercel/nft/issues/603 is fixed and picked up by Nitro. + +type TraceOptionsWithModuleSyncCatchall = { moduleSyncCatchall?: boolean } & Record; + +/** + * Configures Nitro's externals tracing to emit all `module-sync` exports targets, independent of + * the Node.js version running the build. See the comment above for background. + */ +export function setupModuleSyncTracing(nuxt: Nuxt, isNitroV3: boolean): void { + nuxt.hook('nitro:config', (nitroConfig: NitroConfig) => { + if (nuxt.options?._prepare) { + return; + } + + const externals = (nitroConfig.externals ||= {}); + + if (isNitroV3) { + // Nitro v3: options are forwarded to nft via `externals.trace.nft`. `trace: false` disables + // tracing entirely and must be respected. + const externalsV3 = externals as { trace?: false | { nft?: TraceOptionsWithModuleSyncCatchall } }; + const trace = externalsV3.trace ?? {}; + if (trace === false) { + return; + } + trace.nft = { moduleSyncCatchall: true, ...trace.nft }; + externalsV3.trace = trace; + } else { + // Nitro v2: `externals.traceOptions` is spread directly into `nodeFileTrace()`. + const externalsV2 = externals as { traceOptions?: TraceOptionsWithModuleSyncCatchall }; + externalsV2.traceOptions = { moduleSyncCatchall: true, ...externalsV2.traceOptions }; + } + }); +} diff --git a/packages/nuxt/test/vite/moduleSyncTracing.test.ts b/packages/nuxt/test/vite/moduleSyncTracing.test.ts new file mode 100644 index 000000000000..64db5a63e366 --- /dev/null +++ b/packages/nuxt/test/vite/moduleSyncTracing.test.ts @@ -0,0 +1,90 @@ +import type { Nuxt } from '@nuxt/schema'; +import { describe, expect, it } from 'vitest'; +import { setupModuleSyncTracing } from '../../src/vite/moduleSyncTracing'; + +function createMockNuxt(options: { _prepare?: boolean } = {}) { + const hooks: Record void | Promise>> = {}; + + return { + options: { _prepare: options._prepare ?? false }, + hook: (name: string, callback: (...args: any[]) => void | Promise) => { + hooks[name] = hooks[name] || []; + hooks[name].push(callback); + }, + triggerHook: async (name: string, ...args: any[]) => { + for (const callback of hooks[name] || []) { + await callback(...args); + } + }, + }; +} + +describe('setupModuleSyncTracing', () => { + describe('nitro v2', () => { + it('sets moduleSyncCatchall in externals.traceOptions', async () => { + const mockNuxt = createMockNuxt(); + const nitroConfig: Record = {}; + + setupModuleSyncTracing(mockNuxt as unknown as Nuxt, false); + await mockNuxt.triggerHook('nitro:config', nitroConfig); + + expect(nitroConfig.externals.traceOptions).toEqual({ moduleSyncCatchall: true }); + }); + + it('preserves existing traceOptions and lets an explicit user value win', async () => { + const mockNuxt = createMockNuxt(); + const nitroConfig: Record = { + externals: { inline: ['some-pkg'], traceOptions: { base: '/', moduleSyncCatchall: false } }, + }; + + setupModuleSyncTracing(mockNuxt as unknown as Nuxt, false); + await mockNuxt.triggerHook('nitro:config', nitroConfig); + + expect(nitroConfig.externals).toEqual({ + inline: ['some-pkg'], + traceOptions: { base: '/', moduleSyncCatchall: false }, + }); + }); + }); + + describe('nitro v3', () => { + it('sets moduleSyncCatchall in externals.trace.nft', async () => { + const mockNuxt = createMockNuxt(); + const nitroConfig: Record = {}; + + setupModuleSyncTracing(mockNuxt as unknown as Nuxt, true); + await mockNuxt.triggerHook('nitro:config', nitroConfig); + + expect(nitroConfig.externals.trace).toEqual({ nft: { moduleSyncCatchall: true } }); + }); + + it('respects trace: false and preserves existing nft options', async () => { + const mockNuxt = createMockNuxt(); + const disabledConfig: Record = { externals: { trace: false } }; + + setupModuleSyncTracing(mockNuxt as unknown as Nuxt, true); + await mockNuxt.triggerHook('nitro:config', disabledConfig); + + expect(disabledConfig.externals.trace).toBe(false); + + const existingConfig: Record = { + externals: { trace: { nft: { base: '/', moduleSyncCatchall: false } } }, + }; + + setupModuleSyncTracing(mockNuxt as unknown as Nuxt, true); + await mockNuxt.triggerHook('nitro:config', existingConfig); + + expect(existingConfig.externals.trace.nft).toEqual({ base: '/', moduleSyncCatchall: false }); + }); + }); + + it('does nothing during nuxt prepare', async () => { + const mockNuxt = createMockNuxt({ _prepare: true }); + const nitroConfig: Record = {}; + + setupModuleSyncTracing(mockNuxt as unknown as Nuxt, false); + await mockNuxt.triggerHook('nitro:config', nitroConfig); + + expect(nitroConfig.externals).toBeUndefined(); + }); +}); diff --git a/packages/solidstart/src/config/withSentry.ts b/packages/solidstart/src/config/withSentry.ts index 31d8574b2a40..33ae9a552d66 100644 --- a/packages/solidstart/src/config/withSentry.ts +++ b/packages/solidstart/src/config/withSentry.ts @@ -68,11 +68,26 @@ export function withSentry( const existingModules = (server as SolidStartInlineServerConfig & { modules?: unknown[] }).modules || []; + // `@vercel/nft` only traces the `module-sync` target of a package's `exports` map when the Node + // process *running the build* is >= 22, but Node's CJS loader matches `module-sync` at runtime + // from 20.19 — so a server built on Node 20 is missing files its own runtime resolves and crashes + // with `MODULE_NOT_FOUND`. Nitro spreads `externals.traceOptions` into `nodeFileTrace()`, and + // nft's `moduleSyncCatchall` option (>= 1.10.0, ignored by older versions) makes it emit both + // targets. Remove once https://github.com/vercel/nft/issues/603 is fixed and picked up by Nitro. + const existingExternals = (server as { externals?: { traceOptions?: Record } }).externals; + return { ...solidStartConfig, vite, server: { ...server, + externals: { + ...existingExternals, + traceOptions: { + moduleSyncCatchall: true, + ...existingExternals?.traceOptions, + }, + }, modules: [...existingModules, sentryNitroModule], }, }; diff --git a/packages/solidstart/test/config/withSentry.test.ts b/packages/solidstart/test/config/withSentry.test.ts index 6a15013a524c..6c3b2a103241 100644 --- a/packages/solidstart/test/config/withSentry.test.ts +++ b/packages/solidstart/test/config/withSentry.test.ts @@ -173,4 +173,30 @@ describe('withSentry()', () => { expect(modules[0]).toBe(existingModule); expect(typeof modules[1]).toBe('function'); }); + + it('sets moduleSyncCatchall in externals.traceOptions', () => { + const config = withSentry(solidStartConfig, {}); + + expect((config?.server as { externals?: unknown })?.externals).toEqual({ + traceOptions: { moduleSyncCatchall: true }, + }); + }); + + it('preserves existing externals and lets an explicit user traceOptions value win', () => { + const config = withSentry( + { + ...solidStartConfig, + server: { + ...solidStartConfig.server, + externals: { inline: ['some-pkg'], traceOptions: { base: '/', moduleSyncCatchall: false } }, + } as typeof solidStartConfig.server, + }, + {}, + ); + + expect((config?.server as { externals?: unknown })?.externals).toEqual({ + inline: ['some-pkg'], + traceOptions: { base: '/', moduleSyncCatchall: false }, + }); + }); }); diff --git a/packages/tanstackstart-react/src/vite/moduleSyncTracing.ts b/packages/tanstackstart-react/src/vite/moduleSyncTracing.ts new file mode 100644 index 000000000000..24c58af9c5c9 --- /dev/null +++ b/packages/tanstackstart-react/src/vite/moduleSyncTracing.ts @@ -0,0 +1,41 @@ +import type { Plugin, UserConfig } from 'vite'; + +// `@vercel/nft` only traces the `module-sync` target of a package's `exports` map when the Node +// process *running the build* is >= 22, but Node's CJS loader matches `module-sync` at runtime from +// 20.19. A Nitro server built on Node 20 is therefore missing files its own runtime resolves (e.g. +// `meriyah/dist/meriyah.mjs`, reached through `@apm-js-collab/code-transformer`) and crashes with +// `MODULE_NOT_FOUND`. nft's `moduleSyncCatchall` option (>= 1.10.0, ignored by older versions) +// makes it emit both targets regardless of the build Node version. +// +// The Nitro v3 Vite plugin merges the `nitro` key of the resolved Vite config into its options with +// the lowest precedence (options passed to `nitro()` win), and forwards `externals.trace.nft` into +// `nodeFileTrace()`. +// +// Remove once https://github.com/vercel/nft/issues/603 is fixed and picked up by Nitro. + +/** + * Configures Nitro's externals tracing to emit all `module-sync` exports targets, independent of + * the Node.js version running the build. See the comment above for background. + */ +export function makeModuleSyncTracingPlugin(): Plugin { + return { + name: 'sentry-tanstackstart-react-module-sync-tracing', + config: { + // The Nitro plugin reads the config's `nitro` key inside its own plain-ordered `config` hook, + // and users place `nitro()` before the Sentry plugins — `pre` makes this run first anyway. + order: 'pre', + handler: () => + ({ + nitro: { + externals: { + trace: { + nft: { + moduleSyncCatchall: true, + }, + }, + }, + }, + }) as UserConfig, + }, + }; +} diff --git a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts index a440e791e242..db27039600dd 100644 --- a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts +++ b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts @@ -1,6 +1,7 @@ import type { BuildTimeOptionsBase } from '@sentry/core'; import type { Plugin } from 'vite'; import { makeAutoInstrumentMiddlewarePlugin } from './autoInstrumentMiddleware'; +import { makeModuleSyncTracingPlugin } from './moduleSyncTracing'; import { makeRoutePatternPlugin } from './routePatterns'; import { makeAddSentryVitePlugin, makeEnableSourceMapsVitePlugin } from './sourceMaps'; import type { TunnelRouteOptions } from './tunnelRoute'; @@ -85,7 +86,7 @@ export interface SentryTanstackStartOptions extends BuildTimeOptionsBase { * @returns An array of Vite plugins */ export function sentryTanstackStart(options: SentryTanstackStartOptions = {}): Plugin[] { - const plugins: Plugin[] = [makeRoutePatternPlugin()]; + const plugins: Plugin[] = [makeRoutePatternPlugin(), makeModuleSyncTracingPlugin()]; if (options.tunnelRoute) { plugins.push(makeTunnelRoutePlugin(options.tunnelRoute, options.debug)); diff --git a/packages/tanstackstart-react/test/vite/moduleSyncTracing.test.ts b/packages/tanstackstart-react/test/vite/moduleSyncTracing.test.ts new file mode 100644 index 000000000000..5c696c7bd23f --- /dev/null +++ b/packages/tanstackstart-react/test/vite/moduleSyncTracing.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { makeModuleSyncTracingPlugin } from '../../src/vite/moduleSyncTracing'; + +describe('makeModuleSyncTracingPlugin', () => { + it('injects moduleSyncCatchall for the Nitro vite plugin via a pre-ordered config hook', () => { + const plugin = makeModuleSyncTracingPlugin(); + + expect(plugin.name).toBe('sentry-tanstackstart-react-module-sync-tracing'); + + // Must run before the Nitro plugin's plain-ordered `config` hook, which reads the `nitro` key. + const configHook = plugin.config as { order?: string; handler: () => unknown }; + expect(configHook.order).toBe('pre'); + + expect(configHook.handler()).toEqual({ + nitro: { + externals: { + trace: { + nft: { + moduleSyncCatchall: true, + }, + }, + }, + }, + }); + }); +}); diff --git a/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts b/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts index cba4508de1a1..b06dfa416f38 100644 --- a/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts +++ b/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts @@ -41,10 +41,19 @@ const mockRoutePatternPlugin: Plugin = { config: vi.fn(), }; +const mockModuleSyncTracingPlugin: Plugin = { + name: 'sentry-tanstackstart-react-module-sync-tracing', + config: vi.fn(), +}; + vi.mock('../../src/vite/routePatterns', () => ({ makeRoutePatternPlugin: vi.fn(() => mockRoutePatternPlugin), })); +vi.mock('../../src/vite/moduleSyncTracing', () => ({ + makeModuleSyncTracingPlugin: vi.fn(() => mockModuleSyncTracingPlugin), +})); + vi.mock('../../src/vite/sourceMaps', () => ({ makeAddSentryVitePlugin: vi.fn(() => [mockSourceMapsConfigPlugin, mockSentryVitePlugin]), makeEnableSourceMapsVitePlugin: vi.fn(() => [mockEnableSourceMapsPlugin]), @@ -74,6 +83,7 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockModuleSyncTracingPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, mockEnableSourceMapsPlugin, @@ -85,7 +95,7 @@ describe('sentryTanstackStart()', () => { const plugins = sentryTanstackStart({ autoInstrumentMiddleware: false }); - expect(plugins).toEqual([mockRoutePatternPlugin]); + expect(plugins).toEqual([mockRoutePatternPlugin, mockModuleSyncTracingPlugin]); }); it('returns only the tunnel route plugin in development mode when tunnelRoute is configured', () => { @@ -96,7 +106,7 @@ describe('sentryTanstackStart()', () => { tunnelRoute: { allowedDsns: ['https://public@o0.ingest.sentry.io/0'] }, }); - expect(plugins).toEqual([mockRoutePatternPlugin, mockTunnelRoutePlugin]); + expect(plugins).toEqual([mockRoutePatternPlugin, mockModuleSyncTracingPlugin, mockTunnelRoutePlugin]); }); it('returns Sentry Vite plugins but not enable source maps plugin when sourcemaps.disable is true', () => { @@ -105,7 +115,12 @@ describe('sentryTanstackStart()', () => { sourcemaps: { disable: true }, }); - expect(plugins).toEqual([mockRoutePatternPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin]); + expect(plugins).toEqual([ + mockRoutePatternPlugin, + mockModuleSyncTracingPlugin, + mockSourceMapsConfigPlugin, + mockSentryVitePlugin, + ]); }); it('returns Sentry Vite plugins but not enable source maps plugin when sourcemaps.disable is "disable-upload"', () => { @@ -114,7 +129,12 @@ describe('sentryTanstackStart()', () => { sourcemaps: { disable: 'disable-upload' }, }); - expect(plugins).toEqual([mockRoutePatternPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin]); + expect(plugins).toEqual([ + mockRoutePatternPlugin, + mockModuleSyncTracingPlugin, + mockSourceMapsConfigPlugin, + mockSentryVitePlugin, + ]); }); it('returns Sentry Vite plugins and enable source maps plugin when sourcemaps.disable is false', () => { @@ -125,6 +145,7 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockModuleSyncTracingPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, mockEnableSourceMapsPlugin, @@ -138,6 +159,7 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockModuleSyncTracingPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, mockMiddlewarePlugin, @@ -152,6 +174,7 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockModuleSyncTracingPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, mockMiddlewarePlugin, @@ -164,7 +187,12 @@ describe('sentryTanstackStart()', () => { sourcemaps: { disable: true }, }); - expect(plugins).toEqual([mockRoutePatternPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin]); + expect(plugins).toEqual([ + mockRoutePatternPlugin, + mockModuleSyncTracingPlugin, + mockSourceMapsConfigPlugin, + mockSentryVitePlugin, + ]); }); it('passes correct options to makeAutoInstrumentMiddlewarePlugin', () => { @@ -192,6 +220,7 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockModuleSyncTracingPlugin, mockTunnelRoutePlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, From be7546a5eab0659ad8fc11cf58a70bb2da9bce06 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Thu, 23 Jul 2026 13:20:28 +0100 Subject: [PATCH 10/15] Just bundle `@sentry/server-utils` --- .../src/config/diagnosticsChannelInjection.ts | 35 ++++---- .../diagnosticsChannelInjection.test.ts | 34 +++---- packages/nuxt/src/module.ts | 5 -- packages/nuxt/src/vite/moduleSyncTracing.ts | 43 --------- .../nuxt/test/vite/moduleSyncTracing.test.ts | 90 ------------------- packages/server-utils/package.json | 15 +++- packages/server-utils/rollup.npm.config.mjs | 75 ++++++++++++---- .../src/orchestrion/bundler/webpack-loader.ts | 7 ++ .../src/orchestrion/bundler/webpack.ts | 8 +- .../src/orchestrion/runtime/hook.mjs | 10 +++ .../src/orchestrion/runtime/register.ts | 5 +- packages/solidstart/src/config/withSentry.ts | 15 ---- .../solidstart/test/config/withSentry.test.ts | 26 ------ .../src/vite/moduleSyncTracing.ts | 41 --------- .../src/vite/sentryTanstackStart.ts | 3 +- .../test/vite/moduleSyncTracing.test.ts | 26 ------ .../test/vite/sentryTanstackStart.test.ts | 39 ++------ 17 files changed, 132 insertions(+), 345 deletions(-) delete mode 100644 packages/nuxt/src/vite/moduleSyncTracing.ts delete mode 100644 packages/nuxt/test/vite/moduleSyncTracing.test.ts create mode 100644 packages/server-utils/src/orchestrion/bundler/webpack-loader.ts create mode 100644 packages/server-utils/src/orchestrion/runtime/hook.mjs delete mode 100644 packages/tanstackstart-react/src/vite/moduleSyncTracing.ts delete mode 100644 packages/tanstackstart-react/test/vite/moduleSyncTracing.test.ts diff --git a/packages/nextjs/src/config/diagnosticsChannelInjection.ts b/packages/nextjs/src/config/diagnosticsChannelInjection.ts index a3acef88fe41..74233c87c923 100644 --- a/packages/nextjs/src/config/diagnosticsChannelInjection.ts +++ b/packages/nextjs/src/config/diagnosticsChannelInjection.ts @@ -9,22 +9,17 @@ import { resolveOrchestrionRuntimeRequest } from '@sentry/server-utils/orchestri export const BUNDLE_SAFE_INSTRUMENTED_PACKAGES = ['ioredis']; /** - * The orchestrion runtime machinery must stay external — its parser breaks when bundled, which - * silently disables the runtime module hook. + * `@sentry/server-utils` (where `register.ts` and the bundled orchestrion runtime ship) must stay + * external: `register.ts` passes its own `__filename`/`import.meta.url` as the `parentURL` for + * `Module.register('@sentry/server-utils/orchestrion/hook.mjs', …)`, so that self-reference only + * resolves while the code still lives at its real `node_modules` location. Bundled into an app + * server chunk instead, the specifier would have to resolve from the chunk's output location, + * which fails under isolated installs (pnpm) where the package is a transitive dependency. * - * `@sentry/server-utils` (the package `register.ts` — the code that actually calls into - * `@apm-js-collab/tracing-hooks` — ships in) is included too: if it stays external, its own - * `__filename`/`import.meta.url` keep pointing at their real `node_modules` location, so its - * bare-specifier `require`/`import` of the (also-external) tracing-hooks packages resolve - * correctly. If `@sentry/server-utils` were bundled into an app server chunk instead, its code - * would be relocated away from `node_modules`, and those same specifiers would fail to resolve - * under isolated installs (pnpm). + * (The `@apm-js-collab/*` packages no longer appear here: they are bundled into + * `@sentry/server-utils`' build, so no import of them exists at runtime.) */ -export const ORCHESTRION_RUNTIME_EXTERNAL_PACKAGES = [ - '@apm-js-collab/tracing-hooks', - '@apm-js-collab/code-transformer', - '@sentry/server-utils', -]; +export const ORCHESTRION_RUNTIME_EXTERNAL_PACKAGES = ['@sentry/server-utils']; /** Remove the given packages from a `serverExternalPackages` list. */ export function filterInstrumentedExternals(externals: string[], packagesToBundle: string[]): string[] { @@ -41,12 +36,12 @@ export function filterInstrumentedExternals(externals: string[], packagesToBundl * package when its bare specifier also resolves from the project root (`resolveExternal`'s * base-resolve check in `next/dist/build/handle-externals.js`) — otherwise the * `require('')` it emits into the chunk would dangle at runtime, so Next silently - * bundles the package instead. Under isolated installs (pnpm) these packages are transitive - * dependencies that never resolve from the project root, so the whole orchestrion runtime ended up - * compiled into the server chunk, which breaks it twice over: the code-transformer parser doesn't - * survive bundling, and the runtime hook's own bare specifiers can't resolve from the chunk's - * output location. Absolute paths sidestep all of this — webpack emits `require('/abs/path/…')`, - * which loads the real files from `node_modules` no matter where the chunk lives. + * bundles the package instead. Under isolated installs (pnpm) the package is a transitive + * dependency that never resolves from the project root, so the orchestrion runtime ended up + * compiled into the server chunk — breaking the `Module.register` self-reference described on + * {@link ORCHESTRION_RUNTIME_EXTERNAL_PACKAGES}. Absolute paths sidestep all of this — webpack + * emits `require('/abs/path/…')`, which loads the real files from `node_modules` no matter where + * the chunk lives. * * Must be placed *before* Next's own externals handler in the `externals` array: webpack calls * array entries in order and stops at the first one that returns a result. diff --git a/packages/nextjs/test/config/diagnosticsChannelInjection.test.ts b/packages/nextjs/test/config/diagnosticsChannelInjection.test.ts index d5b592607fe4..50e4e371e85d 100644 --- a/packages/nextjs/test/config/diagnosticsChannelInjection.test.ts +++ b/packages/nextjs/test/config/diagnosticsChannelInjection.test.ts @@ -36,8 +36,7 @@ describe('getServerExternalPackagesPatch (diagnostics-channel injection)', () => 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'); + expect(externals).toContain('@sentry/server-utils'); }); it('respects user-provided externals even for bundle-safe packages', () => { @@ -55,21 +54,22 @@ describe('getServerExternalPackagesPatch (diagnostics-channel injection)', () => }); describe('externalizeOrchestrionRuntimePackages', () => { - it.each([ - '@sentry/server-utils', - '@sentry/server-utils/orchestrion', - '@sentry/server-utils/orchestrion/register', - '@apm-js-collab/tracing-hooks', - '@apm-js-collab/tracing-hooks/hook-sync.mjs', - '@apm-js-collab/tracing-hooks/lib/diagnostics.js', - '@apm-js-collab/code-transformer', - ])('externalizes %s as an absolute-path commonjs require', async request => { - const external = await externalizeOrchestrionRuntimePackages({ request }); - - expect(external).toMatch(/^commonjs /); - const resolvedPath = external!.slice('commonjs '.length); - expect(isAbsolute(resolvedPath)).toBe(true); - expect(existsSync(resolvedPath)).toBe(true); + it.each(['@sentry/server-utils', '@sentry/server-utils/orchestrion', '@sentry/server-utils/orchestrion/register'])( + 'externalizes %s as an absolute-path commonjs require', + async request => { + const external = await externalizeOrchestrionRuntimePackages({ request }); + + expect(external).toMatch(/^commonjs /); + const resolvedPath = external!.slice('commonjs '.length); + expect(isAbsolute(resolvedPath)).toBe(true); + expect(existsSync(resolvedPath)).toBe(true); + }, + ); + + it('ignores the bundled @apm-js-collab packages — no import of them exists in the dist anymore', async () => { + await expect( + externalizeOrchestrionRuntimePackages({ request: '@apm-js-collab/tracing-hooks' }), + ).resolves.toBeUndefined(); }); it('resolves @sentry/server-utils subpaths to the CJS build, since the emitted external is a require()', async () => { diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index 86b7c245a3fa..4cf39d16cdbc 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -13,7 +13,6 @@ import type { SentryNuxtModuleOptions } from './common/types'; import { addDynamicImportEntryFileWrapper, addSentryTopImport, addServerConfigToBuild } from './vite/addServerConfig'; import { addDatabaseInstrumentation } from './vite/databaseConfig'; import { addMiddlewareImports, addMiddlewareInstrumentation } from './vite/middlewareConfig'; -import { setupModuleSyncTracing } from './vite/moduleSyncTracing'; import { setupOrchestrion } from './vite/orchestrion'; import { setupSourceMaps } from './vite/sourceMaps'; import { addStorageInstrumentation } from './vite/storageConfig'; @@ -86,10 +85,6 @@ export default defineNuxtModule({ const isMinNuxtV4 = nuxtMajor >= 4; if (serverConfigFile) { - // Deliberately not gated on the orchestrion opt-in: the server SDK's dependency graph reaches - // `module-sync` packages either way, so every traced server build needs this. - setupModuleSyncTracing(nuxt, isNitroV3); - if (moduleOptions._experimental?.useDiagnosticsChannelInjection) { setupOrchestrion(nuxt); } diff --git a/packages/nuxt/src/vite/moduleSyncTracing.ts b/packages/nuxt/src/vite/moduleSyncTracing.ts deleted file mode 100644 index 8a4326c5342b..000000000000 --- a/packages/nuxt/src/vite/moduleSyncTracing.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Nuxt } from '@nuxt/schema'; -import type { NitroConfig } from 'nitropack'; - -// `@vercel/nft` only traces the `module-sync` target of a package's `exports` map when the Node -// process *running the build* is >= 22, but Node's CJS loader matches `module-sync` at runtime from -// 20.19. A Nitro server built on Node 20 is therefore missing files its own runtime resolves (e.g. -// `meriyah/dist/meriyah.mjs`, reached through `@apm-js-collab/code-transformer`) and crashes with -// `MODULE_NOT_FOUND`. nft's `moduleSyncCatchall` option (>= 1.10.0, ignored by older versions) -// makes it emit both targets regardless of the build Node version. -// -// Remove once https://github.com/vercel/nft/issues/603 is fixed and picked up by Nitro. - -type TraceOptionsWithModuleSyncCatchall = { moduleSyncCatchall?: boolean } & Record; - -/** - * Configures Nitro's externals tracing to emit all `module-sync` exports targets, independent of - * the Node.js version running the build. See the comment above for background. - */ -export function setupModuleSyncTracing(nuxt: Nuxt, isNitroV3: boolean): void { - nuxt.hook('nitro:config', (nitroConfig: NitroConfig) => { - if (nuxt.options?._prepare) { - return; - } - - const externals = (nitroConfig.externals ||= {}); - - if (isNitroV3) { - // Nitro v3: options are forwarded to nft via `externals.trace.nft`. `trace: false` disables - // tracing entirely and must be respected. - const externalsV3 = externals as { trace?: false | { nft?: TraceOptionsWithModuleSyncCatchall } }; - const trace = externalsV3.trace ?? {}; - if (trace === false) { - return; - } - trace.nft = { moduleSyncCatchall: true, ...trace.nft }; - externalsV3.trace = trace; - } else { - // Nitro v2: `externals.traceOptions` is spread directly into `nodeFileTrace()`. - const externalsV2 = externals as { traceOptions?: TraceOptionsWithModuleSyncCatchall }; - externalsV2.traceOptions = { moduleSyncCatchall: true, ...externalsV2.traceOptions }; - } - }); -} diff --git a/packages/nuxt/test/vite/moduleSyncTracing.test.ts b/packages/nuxt/test/vite/moduleSyncTracing.test.ts deleted file mode 100644 index 64db5a63e366..000000000000 --- a/packages/nuxt/test/vite/moduleSyncTracing.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import type { Nuxt } from '@nuxt/schema'; -import { describe, expect, it } from 'vitest'; -import { setupModuleSyncTracing } from '../../src/vite/moduleSyncTracing'; - -function createMockNuxt(options: { _prepare?: boolean } = {}) { - const hooks: Record void | Promise>> = {}; - - return { - options: { _prepare: options._prepare ?? false }, - hook: (name: string, callback: (...args: any[]) => void | Promise) => { - hooks[name] = hooks[name] || []; - hooks[name].push(callback); - }, - triggerHook: async (name: string, ...args: any[]) => { - for (const callback of hooks[name] || []) { - await callback(...args); - } - }, - }; -} - -describe('setupModuleSyncTracing', () => { - describe('nitro v2', () => { - it('sets moduleSyncCatchall in externals.traceOptions', async () => { - const mockNuxt = createMockNuxt(); - const nitroConfig: Record = {}; - - setupModuleSyncTracing(mockNuxt as unknown as Nuxt, false); - await mockNuxt.triggerHook('nitro:config', nitroConfig); - - expect(nitroConfig.externals.traceOptions).toEqual({ moduleSyncCatchall: true }); - }); - - it('preserves existing traceOptions and lets an explicit user value win', async () => { - const mockNuxt = createMockNuxt(); - const nitroConfig: Record = { - externals: { inline: ['some-pkg'], traceOptions: { base: '/', moduleSyncCatchall: false } }, - }; - - setupModuleSyncTracing(mockNuxt as unknown as Nuxt, false); - await mockNuxt.triggerHook('nitro:config', nitroConfig); - - expect(nitroConfig.externals).toEqual({ - inline: ['some-pkg'], - traceOptions: { base: '/', moduleSyncCatchall: false }, - }); - }); - }); - - describe('nitro v3', () => { - it('sets moduleSyncCatchall in externals.trace.nft', async () => { - const mockNuxt = createMockNuxt(); - const nitroConfig: Record = {}; - - setupModuleSyncTracing(mockNuxt as unknown as Nuxt, true); - await mockNuxt.triggerHook('nitro:config', nitroConfig); - - expect(nitroConfig.externals.trace).toEqual({ nft: { moduleSyncCatchall: true } }); - }); - - it('respects trace: false and preserves existing nft options', async () => { - const mockNuxt = createMockNuxt(); - const disabledConfig: Record = { externals: { trace: false } }; - - setupModuleSyncTracing(mockNuxt as unknown as Nuxt, true); - await mockNuxt.triggerHook('nitro:config', disabledConfig); - - expect(disabledConfig.externals.trace).toBe(false); - - const existingConfig: Record = { - externals: { trace: { nft: { base: '/', moduleSyncCatchall: false } } }, - }; - - setupModuleSyncTracing(mockNuxt as unknown as Nuxt, true); - await mockNuxt.triggerHook('nitro:config', existingConfig); - - expect(existingConfig.externals.trace.nft).toEqual({ base: '/', moduleSyncCatchall: false }); - }); - }); - - it('does nothing during nuxt prepare', async () => { - const mockNuxt = createMockNuxt({ _prepare: true }); - const nitroConfig: Record = {}; - - setupModuleSyncTracing(mockNuxt as unknown as Nuxt, false); - await mockNuxt.triggerHook('nitro:config', nitroConfig); - - expect(nitroConfig.externals).toBeUndefined(); - }); -}); diff --git a/packages/server-utils/package.json b/packages/server-utils/package.json index 4b7c302aa376..14484e37fdc8 100644 --- a/packages/server-utils/package.json +++ b/packages/server-utils/package.json @@ -52,6 +52,10 @@ "import": "./build/esm/orchestrion/bundler/webpack.js", "require": "./build/cjs/orchestrion/bundler/webpack.js" }, + "./orchestrion/webpack-loader": { + "import": "./build/esm/orchestrion/bundler/webpack-loader.js", + "require": "./build/cjs/orchestrion/bundler/webpack-loader.js" + }, "./orchestrion/esbuild": { "types": "./build/types/orchestrion/bundler/esbuild.d.ts", "import": "./build/esm/orchestrion/bundler/esbuild.js", @@ -59,6 +63,9 @@ }, "./orchestrion/import-hook": { "import": "./build/orchestrion/import-hook.mjs" + }, + "./orchestrion/hook": { + "import": "./build/esm/orchestrion/runtime/hook.js" } }, "typesVersions": { @@ -90,14 +97,14 @@ "access": "public" }, "dependencies": { - "@apm-js-collab/code-transformer-bundler-plugins": "^0.7.1", - "@apm-js-collab/tracing-hooks": "^0.13.0", "@sentry/conventions": "^0.16.0", - "@sentry/core": "10.67.0", - "meriyah": "^6.1.4" + "@sentry/core": "10.67.0" }, "devDependencies": { + "@apm-js-collab/code-transformer-bundler-plugins": "^0.7.1", + "@apm-js-collab/tracing-hooks": "^0.13.0", "@types/node": "^18.19.1", + "meriyah": "^6.1.4", "vite": "^6.4.3" }, "scripts": { diff --git a/packages/server-utils/rollup.npm.config.mjs b/packages/server-utils/rollup.npm.config.mjs index d45af68b35f1..abf0eee571e6 100644 --- a/packages/server-utils/rollup.npm.config.mjs +++ b/packages/server-utils/rollup.npm.config.mjs @@ -1,13 +1,48 @@ +import { builtinModules } from 'node:module'; +import commonjs from '@rollup/plugin-commonjs'; import { defineConfig } from 'rollup'; import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollup-utils'; -// EXPERIMENTAL — orchestrion.js runtime hook. A hand-written `.mjs` shim that -// SDKs reference via a `--import .../orchestrion/import-hook` flag. We pass it -// through rollup only to copy it into `build/orchestrion/` at the path the -// package.json `exports` map expects; `external: /.*/` keeps every import (e.g. -// `@sentry/server-utils/orchestrion/config`) as a runtime resolution -// against the installed package. +// The orchestrion runtime dependency chain (`@apm-js-collab/tracing-hooks` → +// `@apm-js-collab/code-transformer` → meriyah/esquery/astring/…) is bundled into this package's +// build instead of installed as runtime dependencies. Everything in the chain is plain JS, and +// bundling removes two whole classes of downstream breakage: +// +// 1. `require(esm)`: the chain's only sync entry (`hook-sync.mjs`) is ESM-only, so an installed +// dependency forces our CJS build through Node's `require(esm)` bridge — unavailable on the AWS +// Lambda runtime (`--no-experimental-require-module`) and broken on `Module.register()` loader +// threads on Node 22.15–24.12 (`The resolveSync() method is not implemented`). Compiled into our +// own dual build, the CJS variant is genuine CJS. +// 2. Tracer/runtime exports-map mismatches: meriyah 6.1's `module-sync`-first exports map is +// resolved differently by build-time tracers (`@vercel/nft`, nf3, Nitro externals) than by the +// runtime CJS loader, producing pruned server bundles that crash with `MODULE_NOT_FOUND` +// (https://github.com/vercel/nft/issues/603, https://github.com/nitrojs/nitro/issues/4456). +// Bundled, there is no runtime package resolution left to get wrong. +// +// `@apm-js-collab/code-transformer-bundler-plugins` (build-time only) is bundled as well so the +// build-time and runtime transforms always ship the same `code-transformer` version, and so this +// package has no `@apm-js-collab/*` install footprint at all. +// +// `requireReturnsDefault: 'auto'`: node-resolve prefers a dependency's ESM build even for CJS +// `require()`s inside the vendored graph. Default-export-only ESM (e.g. esquery) must then resolve +// to the default itself, not a `{ default }` namespace — CJS callers use it as +// `require('esquery').parse(...)`. +const commonJSOptions = { transformMixedEsModules: true, requireReturnsDefault: 'auto' }; +const commonJSPlugin = commonjs(commonJSOptions); + +// Bundling files from the repo-root `node_modules` moves rollup's common source ancestor up to the +// repo root, so `preserveModules` names our own files `packages/server-utils/src/...` — strip that +// prefix to keep the `build/cjs/index.js` layout the `exports` map points at. And npm never packs +// `node_modules` directories, so the vendored dependencies must not be emitted under that name. +const sanitizedFileNames = info => + `${info.name.replace(/^packages\/server-utils\/src\//, '').replace(/node_modules/g, 'vendored')}.js`; + const orchestrionRuntimeHooks = [ + // EXPERIMENTAL — orchestrion.js runtime hook. A hand-written `.mjs` shim that SDKs reference via + // a `--import .../orchestrion/import-hook` flag. We pass it through rollup only to copy it into + // `build/orchestrion/` at the path the package.json `exports` map expects; `external: /.*/` keeps + // every import (e.g. `@sentry/server-utils/orchestrion/config`) as a runtime resolution against + // the installed package. defineConfig({ input: 'src/orchestrion/runtime/import-hook.mjs', external: /.*/, @@ -33,30 +68,32 @@ export default [ // subpath export; the Node SDK `require`s it synchronously from // `Sentry.init()` to install the channel-injection hooks. 'src/orchestrion/runtime/register.ts', + // The async module hooks passed to `Module.register()`. They load on Node's ESM loader + // thread, which cannot resolve bare specifiers into our bundled dependency graph — but + // relative imports of on-disk files work, and `build/esm` is a `"type": "module"` scope, so + // this entrypoint shares the vendored chunks with the rest of the build. The `./orchestrion/ + // hook` export only maps its `import` condition (nothing ever `require()`s it), so the copy + // in `build/cjs` is unused. + 'src/orchestrion/runtime/hook.mjs', 'src/orchestrion/bundler/vite.ts', 'src/orchestrion/bundler/rollup.ts', 'src/orchestrion/bundler/webpack.ts', + 'src/orchestrion/bundler/webpack-loader.ts', 'src/orchestrion/bundler/esbuild.ts', ], packageSpecificConfig: { + plugins: [commonJSPlugin], output: { // set exports to 'named' or 'auto' so that rollup doesn't warn exports: 'named', // set preserveModules to true because we don't want to bundle everything into one file. preserveModules: true, - // `@apm-js-collab/code-transformer-bundler-plugins` and `@apm-js-collab/tracing-hooks` - // ship CJS entries as bare `module.exports = fn`/`= class` with no `__esModule`/`.default`. - // The repo default `interop: 'esModule'` assumes ESM-shaped externals and would dereference - // a nonexistent `.default`, so a default import compiles to `codeTransformer.default(...)` / - // `ModulePatch.default(...)` → "not a function"/"not a constructor". Use 'auto' for just - // these so Rollup emits its interop helper. Scoped here (not repo-wide) because 'auto' also - // turns `import * as x` into a copy, which breaks in-place monkey-patching that other - // packages (e.g. the OTel fs instrumentation) depend on. - interop: id => - id?.startsWith('@apm-js-collab/code-transformer-bundler-plugins') || - id?.startsWith('@apm-js-collab/tracing-hooks') - ? 'auto' - : 'esModule', + entryFileNames: sanitizedFileNames, + // The repo default `interop: 'esModule'` dereferences `.default` on default imports of + // externals. The commonjs-converted vendored dependencies import Node builtins that way + // (e.g. `require('path')` → default import of `path`), and builtins have no `.default` in + // CJS — so builtins need `'default'` interop (the module itself is the default export). + interop: id => (id && (id.startsWith('node:') || builtinModules.includes(id)) ? 'default' : 'esModule'), }, }, }), diff --git a/packages/server-utils/src/orchestrion/bundler/webpack-loader.ts b/packages/server-utils/src/orchestrion/bundler/webpack-loader.ts new file mode 100644 index 000000000000..ec58b50fe0d6 --- /dev/null +++ b/packages/server-utils/src/orchestrion/bundler/webpack-loader.ts @@ -0,0 +1,7 @@ +// EXPERIMENTAL — the webpack/Turbopack code-transform loader, re-exported so it compiles into this +// package's build (the `@apm-js-collab` packages are bundled devDependencies and not resolvable on +// user installs). Bundlers reference it by on-disk path via `getOrchestrionLoaderPath()`, so it +// needs its own entrypoint/subpath rather than being reachable from another module. +import codeTransformerLoader from '@apm-js-collab/code-transformer-bundler-plugins/webpack-loader'; + +export default codeTransformerLoader; diff --git a/packages/server-utils/src/orchestrion/bundler/webpack.ts b/packages/server-utils/src/orchestrion/bundler/webpack.ts index bbffbf5b323f..28f50d38df6a 100644 --- a/packages/server-utils/src/orchestrion/bundler/webpack.ts +++ b/packages/server-utils/src/orchestrion/bundler/webpack.ts @@ -25,9 +25,13 @@ function getOrchestrionRequire(): ReturnType { return nodeRequire; } -/** Absolute path to the code-transform loader (a webpack loader; also usable as a Turbopack loader). */ +/** + * Absolute path to the code-transform loader (a webpack loader; also usable as a Turbopack loader). + * Resolved via self-reference to this package's own bundled copy — the `@apm-js-collab` packages + * are bundled devDependencies and not resolvable on user installs. + */ export function getOrchestrionLoaderPath(): string { - return getOrchestrionRequire().resolve('@apm-js-collab/code-transformer-bundler-plugins/webpack-loader'); + return getOrchestrionRequire().resolve('@sentry/server-utils/orchestrion/webpack-loader'); } /** diff --git a/packages/server-utils/src/orchestrion/runtime/hook.mjs b/packages/server-utils/src/orchestrion/runtime/hook.mjs new file mode 100644 index 000000000000..776fed04537d --- /dev/null +++ b/packages/server-utils/src/orchestrion/runtime/hook.mjs @@ -0,0 +1,10 @@ +// EXPERIMENTAL — the async module hooks handed to `Module.register()` by +// `registerDiagnosticsChannelInjection()` (Node 18.19–24.12, where the stable sync +// `Module.registerHooks` API isn't available). +// +// `Module.register()` loads its target on Node's ESM loader thread, so the target must be a real, +// on-disk ES module graph — the loader thread cannot resolve bare specifiers into the dependency +// graph this package bundles away, but it can follow relative imports. This shim is therefore an +// entrypoint of the regular ESM build (sharing the vendored dependency chunks) and exposed via the +// `@sentry/server-utils/orchestrion/hook` subpath. +export * from '@apm-js-collab/tracing-hooks/hook.mjs'; diff --git a/packages/server-utils/src/orchestrion/runtime/register.ts b/packages/server-utils/src/orchestrion/runtime/register.ts index 562a11553081..0cdf73ac9b5a 100644 --- a/packages/server-utils/src/orchestrion/runtime/register.ts +++ b/packages/server-utils/src/orchestrion/runtime/register.ts @@ -81,7 +81,10 @@ export function registerDiagnosticsChannelInjection(): void { parentURL = import.meta.url; /*! rollup-include-esm-only-end */ - mod.register('@apm-js-collab/tracing-hooks/hook.mjs', { + // Our own bundled copy of the tracing-hooks async hooks (see + // `src/orchestrion/runtime/hook.mjs`) — the dependency itself is bundled into this package's + // build and no longer resolvable as a bare specifier at runtime. + mod.register('@sentry/server-utils/orchestrion/hook', { parentURL, data: { instrumentations: SENTRY_INSTRUMENTATIONS, diagnosticsPort }, transferList: [diagnosticsPort], diff --git a/packages/solidstart/src/config/withSentry.ts b/packages/solidstart/src/config/withSentry.ts index 33ae9a552d66..31d8574b2a40 100644 --- a/packages/solidstart/src/config/withSentry.ts +++ b/packages/solidstart/src/config/withSentry.ts @@ -68,26 +68,11 @@ export function withSentry( const existingModules = (server as SolidStartInlineServerConfig & { modules?: unknown[] }).modules || []; - // `@vercel/nft` only traces the `module-sync` target of a package's `exports` map when the Node - // process *running the build* is >= 22, but Node's CJS loader matches `module-sync` at runtime - // from 20.19 — so a server built on Node 20 is missing files its own runtime resolves and crashes - // with `MODULE_NOT_FOUND`. Nitro spreads `externals.traceOptions` into `nodeFileTrace()`, and - // nft's `moduleSyncCatchall` option (>= 1.10.0, ignored by older versions) makes it emit both - // targets. Remove once https://github.com/vercel/nft/issues/603 is fixed and picked up by Nitro. - const existingExternals = (server as { externals?: { traceOptions?: Record } }).externals; - return { ...solidStartConfig, vite, server: { ...server, - externals: { - ...existingExternals, - traceOptions: { - moduleSyncCatchall: true, - ...existingExternals?.traceOptions, - }, - }, modules: [...existingModules, sentryNitroModule], }, }; diff --git a/packages/solidstart/test/config/withSentry.test.ts b/packages/solidstart/test/config/withSentry.test.ts index 6c3b2a103241..6a15013a524c 100644 --- a/packages/solidstart/test/config/withSentry.test.ts +++ b/packages/solidstart/test/config/withSentry.test.ts @@ -173,30 +173,4 @@ describe('withSentry()', () => { expect(modules[0]).toBe(existingModule); expect(typeof modules[1]).toBe('function'); }); - - it('sets moduleSyncCatchall in externals.traceOptions', () => { - const config = withSentry(solidStartConfig, {}); - - expect((config?.server as { externals?: unknown })?.externals).toEqual({ - traceOptions: { moduleSyncCatchall: true }, - }); - }); - - it('preserves existing externals and lets an explicit user traceOptions value win', () => { - const config = withSentry( - { - ...solidStartConfig, - server: { - ...solidStartConfig.server, - externals: { inline: ['some-pkg'], traceOptions: { base: '/', moduleSyncCatchall: false } }, - } as typeof solidStartConfig.server, - }, - {}, - ); - - expect((config?.server as { externals?: unknown })?.externals).toEqual({ - inline: ['some-pkg'], - traceOptions: { base: '/', moduleSyncCatchall: false }, - }); - }); }); diff --git a/packages/tanstackstart-react/src/vite/moduleSyncTracing.ts b/packages/tanstackstart-react/src/vite/moduleSyncTracing.ts deleted file mode 100644 index 24c58af9c5c9..000000000000 --- a/packages/tanstackstart-react/src/vite/moduleSyncTracing.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { Plugin, UserConfig } from 'vite'; - -// `@vercel/nft` only traces the `module-sync` target of a package's `exports` map when the Node -// process *running the build* is >= 22, but Node's CJS loader matches `module-sync` at runtime from -// 20.19. A Nitro server built on Node 20 is therefore missing files its own runtime resolves (e.g. -// `meriyah/dist/meriyah.mjs`, reached through `@apm-js-collab/code-transformer`) and crashes with -// `MODULE_NOT_FOUND`. nft's `moduleSyncCatchall` option (>= 1.10.0, ignored by older versions) -// makes it emit both targets regardless of the build Node version. -// -// The Nitro v3 Vite plugin merges the `nitro` key of the resolved Vite config into its options with -// the lowest precedence (options passed to `nitro()` win), and forwards `externals.trace.nft` into -// `nodeFileTrace()`. -// -// Remove once https://github.com/vercel/nft/issues/603 is fixed and picked up by Nitro. - -/** - * Configures Nitro's externals tracing to emit all `module-sync` exports targets, independent of - * the Node.js version running the build. See the comment above for background. - */ -export function makeModuleSyncTracingPlugin(): Plugin { - return { - name: 'sentry-tanstackstart-react-module-sync-tracing', - config: { - // The Nitro plugin reads the config's `nitro` key inside its own plain-ordered `config` hook, - // and users place `nitro()` before the Sentry plugins — `pre` makes this run first anyway. - order: 'pre', - handler: () => - ({ - nitro: { - externals: { - trace: { - nft: { - moduleSyncCatchall: true, - }, - }, - }, - }, - }) as UserConfig, - }, - }; -} diff --git a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts index db27039600dd..a440e791e242 100644 --- a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts +++ b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts @@ -1,7 +1,6 @@ import type { BuildTimeOptionsBase } from '@sentry/core'; import type { Plugin } from 'vite'; import { makeAutoInstrumentMiddlewarePlugin } from './autoInstrumentMiddleware'; -import { makeModuleSyncTracingPlugin } from './moduleSyncTracing'; import { makeRoutePatternPlugin } from './routePatterns'; import { makeAddSentryVitePlugin, makeEnableSourceMapsVitePlugin } from './sourceMaps'; import type { TunnelRouteOptions } from './tunnelRoute'; @@ -86,7 +85,7 @@ export interface SentryTanstackStartOptions extends BuildTimeOptionsBase { * @returns An array of Vite plugins */ export function sentryTanstackStart(options: SentryTanstackStartOptions = {}): Plugin[] { - const plugins: Plugin[] = [makeRoutePatternPlugin(), makeModuleSyncTracingPlugin()]; + const plugins: Plugin[] = [makeRoutePatternPlugin()]; if (options.tunnelRoute) { plugins.push(makeTunnelRoutePlugin(options.tunnelRoute, options.debug)); diff --git a/packages/tanstackstart-react/test/vite/moduleSyncTracing.test.ts b/packages/tanstackstart-react/test/vite/moduleSyncTracing.test.ts deleted file mode 100644 index 5c696c7bd23f..000000000000 --- a/packages/tanstackstart-react/test/vite/moduleSyncTracing.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { makeModuleSyncTracingPlugin } from '../../src/vite/moduleSyncTracing'; - -describe('makeModuleSyncTracingPlugin', () => { - it('injects moduleSyncCatchall for the Nitro vite plugin via a pre-ordered config hook', () => { - const plugin = makeModuleSyncTracingPlugin(); - - expect(plugin.name).toBe('sentry-tanstackstart-react-module-sync-tracing'); - - // Must run before the Nitro plugin's plain-ordered `config` hook, which reads the `nitro` key. - const configHook = plugin.config as { order?: string; handler: () => unknown }; - expect(configHook.order).toBe('pre'); - - expect(configHook.handler()).toEqual({ - nitro: { - externals: { - trace: { - nft: { - moduleSyncCatchall: true, - }, - }, - }, - }, - }); - }); -}); diff --git a/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts b/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts index b06dfa416f38..cba4508de1a1 100644 --- a/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts +++ b/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts @@ -41,19 +41,10 @@ const mockRoutePatternPlugin: Plugin = { config: vi.fn(), }; -const mockModuleSyncTracingPlugin: Plugin = { - name: 'sentry-tanstackstart-react-module-sync-tracing', - config: vi.fn(), -}; - vi.mock('../../src/vite/routePatterns', () => ({ makeRoutePatternPlugin: vi.fn(() => mockRoutePatternPlugin), })); -vi.mock('../../src/vite/moduleSyncTracing', () => ({ - makeModuleSyncTracingPlugin: vi.fn(() => mockModuleSyncTracingPlugin), -})); - vi.mock('../../src/vite/sourceMaps', () => ({ makeAddSentryVitePlugin: vi.fn(() => [mockSourceMapsConfigPlugin, mockSentryVitePlugin]), makeEnableSourceMapsVitePlugin: vi.fn(() => [mockEnableSourceMapsPlugin]), @@ -83,7 +74,6 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, - mockModuleSyncTracingPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, mockEnableSourceMapsPlugin, @@ -95,7 +85,7 @@ describe('sentryTanstackStart()', () => { const plugins = sentryTanstackStart({ autoInstrumentMiddleware: false }); - expect(plugins).toEqual([mockRoutePatternPlugin, mockModuleSyncTracingPlugin]); + expect(plugins).toEqual([mockRoutePatternPlugin]); }); it('returns only the tunnel route plugin in development mode when tunnelRoute is configured', () => { @@ -106,7 +96,7 @@ describe('sentryTanstackStart()', () => { tunnelRoute: { allowedDsns: ['https://public@o0.ingest.sentry.io/0'] }, }); - expect(plugins).toEqual([mockRoutePatternPlugin, mockModuleSyncTracingPlugin, mockTunnelRoutePlugin]); + expect(plugins).toEqual([mockRoutePatternPlugin, mockTunnelRoutePlugin]); }); it('returns Sentry Vite plugins but not enable source maps plugin when sourcemaps.disable is true', () => { @@ -115,12 +105,7 @@ describe('sentryTanstackStart()', () => { sourcemaps: { disable: true }, }); - expect(plugins).toEqual([ - mockRoutePatternPlugin, - mockModuleSyncTracingPlugin, - mockSourceMapsConfigPlugin, - mockSentryVitePlugin, - ]); + expect(plugins).toEqual([mockRoutePatternPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin]); }); it('returns Sentry Vite plugins but not enable source maps plugin when sourcemaps.disable is "disable-upload"', () => { @@ -129,12 +114,7 @@ describe('sentryTanstackStart()', () => { sourcemaps: { disable: 'disable-upload' }, }); - expect(plugins).toEqual([ - mockRoutePatternPlugin, - mockModuleSyncTracingPlugin, - mockSourceMapsConfigPlugin, - mockSentryVitePlugin, - ]); + expect(plugins).toEqual([mockRoutePatternPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin]); }); it('returns Sentry Vite plugins and enable source maps plugin when sourcemaps.disable is false', () => { @@ -145,7 +125,6 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, - mockModuleSyncTracingPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, mockEnableSourceMapsPlugin, @@ -159,7 +138,6 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, - mockModuleSyncTracingPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, mockMiddlewarePlugin, @@ -174,7 +152,6 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, - mockModuleSyncTracingPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, mockMiddlewarePlugin, @@ -187,12 +164,7 @@ describe('sentryTanstackStart()', () => { sourcemaps: { disable: true }, }); - expect(plugins).toEqual([ - mockRoutePatternPlugin, - mockModuleSyncTracingPlugin, - mockSourceMapsConfigPlugin, - mockSentryVitePlugin, - ]); + expect(plugins).toEqual([mockRoutePatternPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin]); }); it('passes correct options to makeAutoInstrumentMiddlewarePlugin', () => { @@ -220,7 +192,6 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, - mockModuleSyncTracingPlugin, mockTunnelRoutePlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, From 3c772e4466f2b209970e1e304f59e357e7f635dc Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Thu, 23 Jul 2026 14:07:16 +0100 Subject: [PATCH 11/15] Fix bundled type output --- packages/server-utils/rollup.npm.config.mjs | 4 + .../server-utils/src/orchestrion/apmTypes.ts | 165 ++++++++++++++++++ .../src/orchestrion/bundler/esbuild.ts | 3 +- .../src/orchestrion/bundler/options.ts | 2 +- .../src/orchestrion/bundler/rollup.ts | 4 +- .../orchestrion/bundler/subscribeInjection.ts | 2 +- .../src/orchestrion/bundler/vite.ts | 4 +- .../src/orchestrion/bundler/webpack-loader.ts | 8 +- .../src/orchestrion/bundler/webpack.ts | 34 +++- .../src/orchestrion/config/aws-sdk.ts | 2 +- .../src/orchestrion/config/koa.ts | 2 +- .../orchestrion/config/subscribe-injection.ts | 2 +- .../server-utils/src/orchestrion/index.ts | 2 +- 13 files changed, 219 insertions(+), 15 deletions(-) create mode 100644 packages/server-utils/src/orchestrion/apmTypes.ts diff --git a/packages/server-utils/rollup.npm.config.mjs b/packages/server-utils/rollup.npm.config.mjs index abf0eee571e6..d10946a5c783 100644 --- a/packages/server-utils/rollup.npm.config.mjs +++ b/packages/server-utils/rollup.npm.config.mjs @@ -94,6 +94,10 @@ export default [ // (e.g. `require('path')` → default import of `path`), and builtins have no `.default` in // CJS — so builtins need `'default'` interop (the module itself is the default export). interop: id => (id && (id.startsWith('node:') || builtinModules.includes(id)) ? 'default' : 'esModule'), + // The vendored dependencies import builtins unprefixed (`import … from 'tty'`), which + // Deno rejects outright and vite-node (Node 26) misresolves as a relative path. Emit them + // `node:`-prefixed. + paths: Object.fromEntries(builtinModules.map(m => [m, `node:${m}`])), }, }, }), diff --git a/packages/server-utils/src/orchestrion/apmTypes.ts b/packages/server-utils/src/orchestrion/apmTypes.ts new file mode 100644 index 000000000000..0df0a57d9069 --- /dev/null +++ b/packages/server-utils/src/orchestrion/apmTypes.ts @@ -0,0 +1,165 @@ +// Vendored copies of the `@apm-js-collab/code-transformer` / +// `@apm-js-collab/code-transformer-bundler-plugins` types that appear in this package's public API. +// Those packages are bundled devDependencies, so the emitted `build/types` declarations must not +// reference them — consumers don't have them installed and their `tsc` would fail with TS2307. + +/** The kind of function */ +export type FunctionKind = 'Sync' | 'Async' | 'Callback' | 'Auto'; + +/** Describes which function to instrument */ +export type FunctionQuery = + | { className: string; methodName: string; kind: FunctionKind; index?: number | null; isExportAlias?: boolean } + | { className: string; privateMethodName: string; kind: FunctionKind; index?: number | null } + | { className: string; index?: number | null; isExportAlias?: boolean } + | { methodName: string; kind: FunctionKind; index?: number | null } + | { functionName: string; kind: FunctionKind; index?: number | null; isExportAlias?: boolean } + | { expressionName: string; kind: FunctionKind; index?: number | null; isExportAlias?: boolean }; + +/** + * A custom transform function registered via `addTransform`. Receives the instrumentation state + * and the matched AST node. + * + * Upstream types the node parameters with estree's `Node`; here they are `unknown` so the shipped + * declarations don't depend on `@types/estree` being installed. + */ +export type CustomTransform = (state: unknown, node: unknown, parent: unknown, ancestry: unknown[]) => void; + +/** + * The behaviour-only fields of a `FunctionQuery`. Used together with `astQuery`, where the raw + * selector chooses the node and these fields drive how it is wrapped (the name-based matching + * fields are ignored). + */ +export interface FunctionBehavior { + kind?: FunctionKind; + index?: number | null; + callbackIndex?: number; + mutableResult?: boolean; +} + +/** Describes the module and file path you would like to match */ +export interface ModuleMatcher { + /** The name of the module you want to match */ + name: string; + /** The semver range that you want to match */ + versionRange: string; + /** The path of the file you want to match from the module root */ + filePath: string | RegExp; +} + +/** + * Configuration for injecting instrumentation code. + * + * Either `functionQuery` (name-based matching) or `astQuery` (a raw esquery selector) must + * identify the node(s) to instrument. When `astQuery` is set it takes precedence over + * `functionQuery`'s matching fields, and `functionQuery` becomes an optional bag of behaviour + * options ({@link FunctionBehavior}). + */ +export type InstrumentationConfig = + | { + /** The name of the diagnostics channel to publish to */ + channelName: string; + /** The module matcher to identify the module and file to instrument */ + module: ModuleMatcher; + /** The function query to identify the function to instrument */ + functionQuery: FunctionQuery; + /** + * A raw esquery selector that chooses the node(s) to instrument. When set, it takes + * precedence over `functionQuery`'s matching fields. + */ + astQuery?: string; + /** + * The name of a custom transform registered via `addTransform`. When set, takes precedence + * over `functionQuery.kind`. + */ + transform?: string; + } + | { + channelName: string; + module: ModuleMatcher; + /** + * A raw esquery selector that chooses the node(s) to instrument. This is the escape hatch + * for shapes the name-based `functionQuery` can't express, e.g. an anonymous arrow returned + * by a factory function. + */ + astQuery: string; + /** Behaviour options for the matched node(s); matching fields are ignored. */ + functionQuery?: FunctionBehavior; + transform?: string; + }; + +/** + * A plain-object encoding of a `RegExp` that survives JSON serialization. Revive it with + * `new RegExp(source, flags)`. + */ +export interface SerializedRegExp { + type: 'RegExp'; + source: string; + flags: string; +} + +/** + * An `InstrumentationConfig` whose `module.filePath` is never a `RegExp` instance — regexes are + * encoded as {@link SerializedRegExp} — making the whole config a POJO that can cross + * serialization boundaries such as Turbopack's loader options. + */ +export type SerializableInstrumentationConfig = InstrumentationConfig extends infer T + ? T extends { module: InstrumentationConfig['module'] } + ? Omit & { module: Omit & { filePath: string | SerializedRegExp } } + : never + : never; + +/** Either the native config shape or its JSON-safe counterpart. */ +export type AnyInstrumentationConfig = InstrumentationConfig | SerializableInstrumentationConfig; + +/** Diagnostics passed to the `injectDiagnostics` callback. */ +export interface TransformDiagnostics { + transformedModules: string[]; + failedModules: string[]; +} + +/** + * A matcher for module ids, mirroring the shape accepted by the bundler transform hook filter + * (Rollup >= 4.38, Rolldown, Vite). A single string/RegExp (or array) is treated as an `include`; + * the object form allows both. + */ +export type TransformIdFilter = + | string + | RegExp + | Array + | { + include?: string | RegExp | Array; + exclude?: string | RegExp | Array; + }; + +/** Options accepted by the code-transformer bundler plugins. */ +export interface CodeTransformerPluginOptions { + /** Array of instrumentation configurations */ + instrumentations: InstrumentationConfig[]; + /** Optional path to a polyfill module for diagnostics_channel */ + dcModule?: string; + /** Optional callback that that injects the code returned */ + injectDiagnostics?: (diagnostics: TransformDiagnostics) => string | undefined; + /** + * Custom transforms registered on the matcher via orchestrion's `addTransform`. An + * `InstrumentationConfig` opts in by naming one of these in its `transform` field; the function + * is then called for every AST node matched by that config's `functionQuery`/`astQuery` with + * `(state, node, parent, ancestry)`, where `state` is the matched config spread together with + * `{ dcModule, moduleType, moduleVersion }`. + * + * A single transform can serve many configs — each invocation can branch on + * `state.module.name` or `state.channelName` to tell the sites apart. + */ + customTransforms?: Record; + /** + * Restricts which modules the transform hook runs on, via the bundler's hook filter + * (Rollup >= 4.38, Rolldown, Vite). All built-in instrumentations live within `node_modules`, + * which is the default. Provide your own matcher to broaden or narrow this — e.g. to also + * transform your own source — or pass `false` to disable filtering entirely. + * + * Bundlers without hook-filter support (esbuild, webpack) ignore this; the transformer skips + * non-matching modules regardless. + * + * @default /node_modules/ + */ + transformFilter?: TransformIdFilter | false; +} diff --git a/packages/server-utils/src/orchestrion/bundler/esbuild.ts b/packages/server-utils/src/orchestrion/bundler/esbuild.ts index 307e5e78a033..7215c48c7f3c 100644 --- a/packages/server-utils/src/orchestrion/bundler/esbuild.ts +++ b/packages/server-utils/src/orchestrion/bundler/esbuild.ts @@ -1,4 +1,5 @@ import codeTransformer from '@apm-js-collab/code-transformer-bundler-plugins/esbuild'; +import type { Plugin } from 'esbuild'; import { escapeStringForRegex } from '@sentry/core'; import { instrumentedModuleNames } from '../config'; import type { PluginOptions } from './options'; @@ -28,7 +29,7 @@ function matchesEsbuildExternal(entry: string, moduleName: string): boolean { * await esbuild.build({ plugins: [sentryOrchestrionPlugin()] }); * ``` */ -export function sentryOrchestrionPlugin(options: PluginOptions = {}): ReturnType { +export function sentryOrchestrionPlugin(options: PluginOptions = {}): Plugin { const plugin = codeTransformer(orchestrionTransformOptions(options)); const moduleNames = instrumentedModuleNames(options.instrumentations); const setup = plugin.setup; diff --git a/packages/server-utils/src/orchestrion/bundler/options.ts b/packages/server-utils/src/orchestrion/bundler/options.ts index 7776c878bbf3..1d8efcc61532 100644 --- a/packages/server-utils/src/orchestrion/bundler/options.ts +++ b/packages/server-utils/src/orchestrion/bundler/options.ts @@ -1,7 +1,7 @@ import type { InstrumentationConfig, CustomTransform } from '..'; import { SENTRY_INSTRUMENTATIONS } from '../config'; import { subscribeInjectionOptions } from './subscribeInjection'; -import type { CodeTransformerPluginOptions } from '@apm-js-collab/code-transformer-bundler-plugins/core'; +import type { CodeTransformerPluginOptions } from '../apmTypes'; export type PluginOptions = { /** diff --git a/packages/server-utils/src/orchestrion/bundler/rollup.ts b/packages/server-utils/src/orchestrion/bundler/rollup.ts index d42abede972c..f9262d9bd46e 100644 --- a/packages/server-utils/src/orchestrion/bundler/rollup.ts +++ b/packages/server-utils/src/orchestrion/bundler/rollup.ts @@ -1,5 +1,5 @@ import codeTransformer from '@apm-js-collab/code-transformer-bundler-plugins/rollup'; -import type { NormalizedInputOptions, PluginContext } from 'rollup'; +import type { NormalizedInputOptions, Plugin, PluginContext } from 'rollup'; import { instrumentedModuleNames } from '../config'; import type { PluginOptions } from './options'; import { externalizedModulesWarning, orchestrionTransformOptions } from './options'; @@ -17,7 +17,7 @@ import { externalizedModulesWarning, orchestrionTransformOptions } from './optio * export default { plugins: [sentryOrchestrionPlugin()] }; * ``` */ -export function sentryOrchestrionPlugin(options: PluginOptions = {}): ReturnType { +export function sentryOrchestrionPlugin(options: PluginOptions = {}): Plugin { const moduleNames = instrumentedModuleNames(options.instrumentations); return { diff --git a/packages/server-utils/src/orchestrion/bundler/subscribeInjection.ts b/packages/server-utils/src/orchestrion/bundler/subscribeInjection.ts index 9bb89257ab89..b60562575915 100644 --- a/packages/server-utils/src/orchestrion/bundler/subscribeInjection.ts +++ b/packages/server-utils/src/orchestrion/bundler/subscribeInjection.ts @@ -1,4 +1,4 @@ -import type { CustomTransform } from '@apm-js-collab/code-transformer'; +import type { CustomTransform } from '../apmTypes'; import { parse } from 'meriyah'; import { SUBSCRIBE_INJECTIONS } from '../config'; import { subscriberExportForModule } from '../config/channel-integration-definitions'; diff --git a/packages/server-utils/src/orchestrion/bundler/vite.ts b/packages/server-utils/src/orchestrion/bundler/vite.ts index 0b586f0b682c..90274bb52212 100644 --- a/packages/server-utils/src/orchestrion/bundler/vite.ts +++ b/packages/server-utils/src/orchestrion/bundler/vite.ts @@ -1,5 +1,5 @@ import codeTransformer from '@apm-js-collab/code-transformer-bundler-plugins/vite'; -import type { ResolvedConfig } from 'vite'; +import type { Plugin, ResolvedConfig } from 'vite'; import { instrumentedModuleNames } from '../config'; import type { PluginOptions } from './options'; import { externalEntryMatchesModule, externalizedModulesWarning, orchestrionTransformOptions } from './options'; @@ -18,7 +18,7 @@ import { externalEntryMatchesModule, externalizedModulesWarning, orchestrionTran * export default { plugins: [sentryOrchestrionPlugin()] }; * ``` */ -export function sentryOrchestrionPlugin(options: PluginOptions = {}): ReturnType { +export function sentryOrchestrionPlugin(options: PluginOptions = {}): Plugin { return { ...codeTransformer(orchestrionTransformOptions(options)), config(): { ssr: { noExternal: string[] } } { diff --git a/packages/server-utils/src/orchestrion/bundler/webpack-loader.ts b/packages/server-utils/src/orchestrion/bundler/webpack-loader.ts index ec58b50fe0d6..26de2b44d5c6 100644 --- a/packages/server-utils/src/orchestrion/bundler/webpack-loader.ts +++ b/packages/server-utils/src/orchestrion/bundler/webpack-loader.ts @@ -2,6 +2,12 @@ // package's build (the `@apm-js-collab` packages are bundled devDependencies and not resolvable on // user installs). Bundlers reference it by on-disk path via `getOrchestrionLoaderPath()`, so it // needs its own entrypoint/subpath rather than being reachable from another module. -import codeTransformerLoader from '@apm-js-collab/code-transformer-bundler-plugins/webpack-loader'; +import codeTransformerLoaderImpl from '@apm-js-collab/code-transformer-bundler-plugins/webpack-loader'; + +// Explicitly typed so the emitted declaration doesn't reference the bundled devDependency. +// (Nothing imports this subpath from TS — bundlers load it by file path — so the loose +// signature is never consumed.) +const codeTransformerLoader: (this: unknown, code: string, inputSourceMap?: unknown) => void = + codeTransformerLoaderImpl; export default codeTransformerLoader; diff --git a/packages/server-utils/src/orchestrion/bundler/webpack.ts b/packages/server-utils/src/orchestrion/bundler/webpack.ts index 28f50d38df6a..cb608b55b2e6 100644 --- a/packages/server-utils/src/orchestrion/bundler/webpack.ts +++ b/packages/server-utils/src/orchestrion/bundler/webpack.ts @@ -8,10 +8,16 @@ import { instrumentedModuleNames, SENTRY_INSTRUMENTATIONS } from '../config'; import codeTransformerWebpack from '@apm-js-collab/code-transformer-bundler-plugins/webpack'; import type { PluginOptions } from './options'; -export { serializeInstrumentations } from '@apm-js-collab/code-transformer-bundler-plugins/core'; -export type { SerializableInstrumentationConfig } from '@apm-js-collab/code-transformer-bundler-plugins/core'; +import { serializeInstrumentations as serializeInstrumentationsImpl } from '@apm-js-collab/code-transformer-bundler-plugins/core'; +import type { AnyInstrumentationConfig, SerializableInstrumentationConfig } from '../apmTypes'; import { externalEntryMatchesModule, externalizedModulesWarning, orchestrionTransformOptions } from './options'; +// Explicitly annotated with the vendored types so the emitted declaration doesn't reference +// `@apm-js-collab/code-transformer-bundler-plugins` — a bundled devDependency consumers don't have. +export const serializeInstrumentations: (configs: AnyInstrumentationConfig[]) => SerializableInstrumentationConfig[] = + serializeInstrumentationsImpl; +export type { SerializableInstrumentationConfig } from '../apmTypes'; + // Both branches use `createRequire` (never alias the CJS `require`) so bundlers consuming this // module don't emit a "Critical dependency" warning. function getOrchestrionRequire(): ReturnType { @@ -78,13 +84,34 @@ function externalizedWebpackModules(externals: unknown, moduleNames: string[]): ); } +// The upstream plugin computes its loader path relative to its own file location, which after +// bundling points into our `vendored/` tree at a file rollup never emitted. Replace it in the +// rule the plugin just unshifted with our own bundled loader entrypoint. +function fixupLoaderPath(compiler: Compiler): void { + for (const rule of compiler.options.module?.rules ?? []) { + if (!rule || typeof rule !== 'object' || !('use' in rule) || !Array.isArray(rule.use)) { + continue; + } + for (const use of rule.use) { + if ( + use && + typeof use === 'object' && + typeof use.loader === 'string' && + use.loader.endsWith('webpack-loader.cjs') + ) { + use.loader = getOrchestrionLoaderPath(); + } + } + } +} + /** * The code-transform webpack plugin, pre-fed the instrumentation config. * * Instrumented packages marked as `externals` never pass through the code * transform, so a compilation warning is emitted for them. */ -export function sentryOrchestrionWebpackPlugin(options: PluginOptions = {}): ReturnType { +export function sentryOrchestrionWebpackPlugin(options: PluginOptions = {}): { apply(compiler: Compiler): void } { const plugin = codeTransformerWebpack(orchestrionTransformOptions(options)); const moduleNames = instrumentedModuleNames(options.instrumentations); // The upstream plugin is a class instance, so `apply` is overridden in place @@ -98,6 +125,7 @@ export function sentryOrchestrionWebpackPlugin(options: PluginOptions = {}): Ret }); } apply(compiler); + fixupLoaderPath(compiler); }; return plugin; } diff --git a/packages/server-utils/src/orchestrion/config/aws-sdk.ts b/packages/server-utils/src/orchestrion/config/aws-sdk.ts index d9e6bf35726d..a6d1d73fe5dd 100644 --- a/packages/server-utils/src/orchestrion/config/aws-sdk.ts +++ b/packages/server-utils/src/orchestrion/config/aws-sdk.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '../apmTypes'; import { toSubscribeInjections } from './subscribe-injection'; // The AWS SDK (v3) routes every command through the smithy `Client.prototype.send` method. Which diff --git a/packages/server-utils/src/orchestrion/config/koa.ts b/packages/server-utils/src/orchestrion/config/koa.ts index 8e4ddfab0fff..5699f2bab8b0 100644 --- a/packages/server-utils/src/orchestrion/config/koa.ts +++ b/packages/server-utils/src/orchestrion/config/koa.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '../apmTypes'; import { toSubscribeInjections } from './subscribe-injection'; export const koaConfig = [ diff --git a/packages/server-utils/src/orchestrion/config/subscribe-injection.ts b/packages/server-utils/src/orchestrion/config/subscribe-injection.ts index 129fc637feaf..43faff7313e1 100644 --- a/packages/server-utils/src/orchestrion/config/subscribe-injection.ts +++ b/packages/server-utils/src/orchestrion/config/subscribe-injection.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '../apmTypes'; /** * Name shared by the `Program` injection configs (their `transform` field) and diff --git a/packages/server-utils/src/orchestrion/index.ts b/packages/server-utils/src/orchestrion/index.ts index b27f74b9b28d..941fd474a7b5 100644 --- a/packages/server-utils/src/orchestrion/index.ts +++ b/packages/server-utils/src/orchestrion/index.ts @@ -72,7 +72,7 @@ export type { IORedisChannelIntegrationOptions, IORedisResponseHook } from '../i export type { PostgresJsChannelIntegrationOptions } from '../integrations/tracing-channel/postgres-js'; export { redisChannelIntegration } from '../integrations/tracing-channel/redis'; export type { RedisChannelIntegrationOptions, RedisResponseHook } from '../integrations/tracing-channel/redis'; -export type { InstrumentationConfig, CustomTransform } from '@apm-js-collab/code-transformer-bundler-plugins/core'; +export type { InstrumentationConfig, CustomTransform } from './apmTypes'; // The structural `graphql` package types are the single source of truth shared with `@sentry/node`'s // vendored OTel graphql instrumentation (re-exported from here so the two can't drift). From 6384b1a89e9fc3a10d0a8a73eedf65ab50e47051 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Thu, 23 Jul 2026 15:54:10 +0100 Subject: [PATCH 12/15] Fix cloudflare --- .size-limit.js | 2 +- packages/server-utils/rollup.npm.config.mjs | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.size-limit.js b/.size-limit.js index 2f57478eee4b..223865fd897f 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -390,7 +390,7 @@ module.exports = [ import: createImport('init', 'experimentalUseDiagnosticsChannelInjection'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '230 KB', + limit: '190 KB', disablePlugins: ['@size-limit/esbuild'], }, { diff --git a/packages/server-utils/rollup.npm.config.mjs b/packages/server-utils/rollup.npm.config.mjs index d10946a5c783..3bc2f1b1e8a4 100644 --- a/packages/server-utils/rollup.npm.config.mjs +++ b/packages/server-utils/rollup.npm.config.mjs @@ -27,7 +27,13 @@ import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollu // `require()`s inside the vendored graph. Default-export-only ESM (e.g. esquery) must then resolve // to the default itself, not a `{ default }` namespace — CJS callers use it as // `require('esquery').parse(...)`. -const commonJSOptions = { transformMixedEsModules: true, requireReturnsDefault: 'auto' }; +// +// `strictRequires: false`: the default `'auto'` wraps conditionally-required modules (e.g. +// `debug`'s browser/node split) in lazy initializers exported as `__require` — an export name that +// downstream re-bundlers mishandle (Turbopack renames it, producing `.require is not a function` +// crashes in Next.js on Cloudflare). Hoisting is safe here: the vendored graph is closed (nothing +// optional/missing) and has no require cycles that depend on lazy evaluation. +const commonJSOptions = { transformMixedEsModules: true, requireReturnsDefault: 'auto', strictRequires: false }; const commonJSPlugin = commonjs(commonJSOptions); // Bundling files from the repo-root `node_modules` moves rollup's common source ancestor up to the From 45ce0741e7eebbe82b287beae1469d293d5dc5d9 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Thu, 23 Jul 2026 16:36:35 +0100 Subject: [PATCH 13/15] Another fix --- packages/server-utils/rollup.npm.config.mjs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/server-utils/rollup.npm.config.mjs b/packages/server-utils/rollup.npm.config.mjs index 3bc2f1b1e8a4..40a89de631f0 100644 --- a/packages/server-utils/rollup.npm.config.mjs +++ b/packages/server-utils/rollup.npm.config.mjs @@ -36,6 +36,22 @@ import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollu const commonJSOptions = { transformMixedEsModules: true, requireReturnsDefault: 'auto', strictRequires: false }; const commonJSPlugin = commonjs(commonJSOptions); +// Always vendor `debug`'s Node build. Its default entry picks browser vs node at require time, +// which drags the browser build into this server-only bundle — and, hoisted by +// `strictRequires: false`, the browser build's storage detection probes `localStorage` at import +// time, which on Node >= 26 emits an ExperimentalWarning that pollutes stderr and console +// breadcrumbs in every user app. `order: 'pre'` because the base config's node-resolve plugin +// sorts ahead of package-specific plugins and would otherwise resolve `debug` first. +const debugNodeAlias = { + name: 'debug-node-alias', + resolveId: { + order: 'pre', + handler(source, importer) { + return source === 'debug' ? this.resolve('debug/src/node.js', importer, { skipSelf: true }) : null; + }, + }, +}; + // Bundling files from the repo-root `node_modules` moves rollup's common source ancestor up to the // repo root, so `preserveModules` names our own files `packages/server-utils/src/...` — strip that // prefix to keep the `build/cjs/index.js` layout the `exports` map points at. And npm never packs @@ -88,7 +104,7 @@ export default [ 'src/orchestrion/bundler/esbuild.ts', ], packageSpecificConfig: { - plugins: [commonJSPlugin], + plugins: [debugNodeAlias, commonJSPlugin], output: { // set exports to 'named' or 'auto' so that rollup doesn't warn exports: 'named', From 5d670de294a1989e20efe2835f4e39661a9c88da Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Thu, 23 Jul 2026 17:20:02 +0100 Subject: [PATCH 14/15] Fix size limit --- .size-limit.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.size-limit.js b/.size-limit.js index 223865fd897f..b83cffdd2cd7 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -395,7 +395,7 @@ module.exports = [ }, { name: '@sentry/node/import (ESM hook with diagnostics-channel injection)', - path: ['node_modules/@apm-js-collab/tracing-hooks/hook.mjs', 'packages/node/build/import-hook.mjs'], + path: ['packages/server-utils/build/esm/orchestrion/runtime/hook.js', 'packages/node/build/import-hook.mjs'], ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, limit: '76 KB', From 6382892349650d1f225b1dd0733adcb3ee6b2ac0 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Thu, 23 Jul 2026 21:22:51 +0100 Subject: [PATCH 15/15] thank you @isaacs --- packages/server-utils/rollup.npm.config.mjs | 22 ++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/server-utils/rollup.npm.config.mjs b/packages/server-utils/rollup.npm.config.mjs index 40a89de631f0..1a7bf99b5d12 100644 --- a/packages/server-utils/rollup.npm.config.mjs +++ b/packages/server-utils/rollup.npm.config.mjs @@ -1,5 +1,6 @@ import { builtinModules } from 'node:module'; import commonjs from '@rollup/plugin-commonjs'; +import license from 'rollup-plugin-license'; import { defineConfig } from 'rollup'; import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollup-utils'; @@ -59,6 +60,25 @@ const debugNodeAlias = { const sanitizedFileNames = info => `${info.name.replace(/^packages\/server-utils\/src\//, '').replace(/node_modules/g, 'vendored')}.js`; +// The vendored dependencies (see above) are third-party code redistributed inside this package's +// published `build/`, so their licenses require us to carry each one's copyright/permission notice +// (and, for Apache-2.0 deps like `@apm-js-collab/*`, the upstream NOTICE). Rollup strips per-file +// banners, so instead we aggregate them into a single `build/THIRD-PARTY-LICENSES.txt`. The default +// template emits each dependency's license text AND its NOTICE text, which covers the MIT/ISC/BSD +// notice requirement and the Apache-2.0 §4(d) NOTICE requirement. Only bundled (non-external) +// packages are collected — our own `@sentry/*` deps stay external and are excluded. +// +// Both the CJS and ESM build variants run this and bundle the same dependency set, so each writes +// the same file; the last write wins and the content is identical. +const thirdPartyLicensePlugin = license({ + thirdParty: { + includePrivate: false, + output: { + file: 'build/THIRD-PARTY-LICENSES.txt', + }, + }, +}); + const orchestrionRuntimeHooks = [ // EXPERIMENTAL — orchestrion.js runtime hook. A hand-written `.mjs` shim that SDKs reference via // a `--import .../orchestrion/import-hook` flag. We pass it through rollup only to copy it into @@ -104,7 +124,7 @@ export default [ 'src/orchestrion/bundler/esbuild.ts', ], packageSpecificConfig: { - plugins: [debugNodeAlias, commonJSPlugin], + plugins: [debugNodeAlias, commonJSPlugin, thirdPartyLicensePlugin], output: { // set exports to 'named' or 'auto' so that rollup doesn't warn exports: 'named',