From e4fca316f4e185ed5df01c08bcee0fe3991880a0 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 11 Nov 2026 08:14:52 +0000 Subject: [PATCH 1/6] fix(server-runtime-injection): Bundle the ESM loader hook into one self-contained file `register.ts` hands `build/esm/hook.js` to `Module.register()` by runtime path. Turbopack ships that file into the Next.js server trace but does not follow its imports, so on Node < 24.13 the loader thread failed to find the vendored chunks (and `@sentry/core`'s ESM build, imported by the `debug` shim) and registration warned on every cold start. The hook is now built as a single file that imports only Node builtins. Its `debug` shim is a no-op: the loader thread has its own `globalThis`, so the Sentry logger there is never enabled. Fixes #26121 Co-Authored-By: Francesco Gringl-Novy --- .../tests/build-output.test.ts | 35 ++++++-- .../server-runtime-injection/package.json | 2 +- .../rollup.npm.config.mjs | 67 +++++++++++---- .../server-runtime-injection/src/hook.mjs | 7 +- .../server-runtime-injection/src/register.ts | 5 +- .../test/hook.test.ts | 82 +++++++++++++++++++ .../test/nftTrace.test.ts | 5 +- 7 files changed, 170 insertions(+), 33 deletions(-) create mode 100644 packages/server-runtime-injection/test/hook.test.ts diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/tests/build-output.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/tests/build-output.test.ts index 936ff77b6494..a8af527107f1 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/tests/build-output.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/tests/build-output.test.ts @@ -1,8 +1,20 @@ import { expect, test } from '@playwright/test'; import { findAbsolutePathImports } from '@sentry-internal/test-utils'; +import { spawnSync } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; +const runtimeInjectionBuildDir = path.join('@sentry', 'server-runtime-injection', 'build'); + +function findStandaloneFile(suffix: string): string | undefined { + const standaloneDir = path.join(process.cwd(), '.next', 'standalone'); + return fs + .readdirSync(standaloneDir, { recursive: true, withFileTypes: true }) + .filter(entry => entry.isFile()) + .map(entry => path.join(entry.parentPath, entry.name)) + .find(file => file.endsWith(suffix)); +} + // `output: 'standalone'` is the mode where a baked-in absolute specifier actually bites: the server // chunks are copied to `.next/standalone` and run from there, so anything resolving against the // build machine's layout becomes MODULE_NOT_FOUND on the first request that reaches the chunk. @@ -21,12 +33,23 @@ test('emits no absolute-path imports into the relocated standalone output', () = // telemetry, because Next.js records its server spans through its own wrapping either way, so the // tests in `standalone.test.ts` stay green even when channel injection is dead. test('traces the orchestrion ESM loader hook into the standalone output', () => { - const standaloneDir = path.join(process.cwd(), '.next', 'standalone'); - const hookSuffix = path.join('@sentry', 'server-runtime-injection', 'build', 'esm', 'hook.js'); + expect(findStandaloneFile(path.join(runtimeInjectionBuildDir, 'esm', 'hook.js'))).toBeDefined(); +}); - const found = fs - .readdirSync(standaloneDir, { recursive: true, withFileTypes: true }) - .some(entry => entry.isFile() && path.join(entry.parentPath, entry.name).endsWith(hookSuffix)); +// Regression test for JS-4067: Turbopack ships the hook without following its imports, so the +// standalone output must hold everything the hook needs. The runner's Node takes the +// `Module.registerHooks` path, which never loads the hook, so force the `Module.register()` path +// that Node < 24.13 takes. +test('registers the orchestrion ESM loader hook from the standalone output', () => { + const registerPath = findStandaloneFile(path.join(runtimeInjectionBuildDir, 'cjs', 'register.js')); + expect(registerPath).toBeDefined(); + + const script = ` + require('node:module').registerHooks = undefined; + require(${JSON.stringify(registerPath)}).registerDiagnosticsChannelInjection(); + `; + const { stderr, status } = spawnSync(process.execPath, ['-e', script], { encoding: 'utf8' }); - expect(found).toBe(true); + expect(stderr).not.toContain('Failed to register diagnostics-channel injection hooks'); + expect(status).toBe(0); }); diff --git a/packages/server-runtime-injection/package.json b/packages/server-runtime-injection/package.json index cf44f9bacc4d..0479485a2383 100644 --- a/packages/server-runtime-injection/package.json +++ b/packages/server-runtime-injection/package.json @@ -70,7 +70,7 @@ "lint:fix": "oxlint . --fix --type-aware", "lint": "oxlint . --type-aware", "lint:types": "oxlint src --type-aware --type-check", - "lint:es-compatibility": "es-check es2020 ./build/cjs/*.js && es-check es2020 ./build/esm/*.js --module", + "lint:es-compatibility": "es-check es2020 ./build/cjs/*.js && es-check es2020 ./build/esm/*.js --module --not=./build/esm/hook.js && es-check es2022 ./build/esm/hook.js --module", "test:unit": "vitest run", "test": "vitest run", "test:watch": "vitest --watch", diff --git a/packages/server-runtime-injection/rollup.npm.config.mjs b/packages/server-runtime-injection/rollup.npm.config.mjs index 49f2ddd2ba89..b70eb4dfea33 100644 --- a/packages/server-runtime-injection/rollup.npm.config.mjs +++ b/packages/server-runtime-injection/rollup.npm.config.mjs @@ -35,7 +35,6 @@ import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollu // (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); // Replace `debug` with `src/debug.ts`, which logs through the Sentry logger. `debug` and the // `supports-color` it loads read `process.env` at import time, which throws on Deno without env @@ -51,6 +50,24 @@ const debugShim = { }, }; +// The ESM loader hook runs on Node's loader thread, which has its own `globalThis`, so the Sentry +// logger there is never enabled by `debug: true` and `src/debug.ts` would never print. Importing it +// would only pull `@sentry/core`'s ESM build into the hook, and build tracers that only see the CJS +// build in use (Turbopack on Next.js) don't ship that. +const HOOK_DEBUG_ID = '\0hook-debug'; +const hookDebugShim = { + name: 'hook-debug-shim', + resolveId: { + order: 'pre', + handler(source) { + return source === 'debug' ? HOOK_DEBUG_ID : null; + }, + }, + load(id) { + return id === HOOK_DEBUG_ID ? 'export default function createDebug() { return function () {}; }' : null; + }, +}; + // `ModulePatch` reads `process.env.TRACING_DUMP` after each module it transforms. On Deno without // env permission that read throws, and the module counts as failed. Treat the throw as unset. const guardTracingDump = { @@ -71,6 +88,15 @@ const guardTracingDump = { }, }; +// The commonjs-converted vendored dependencies import Node builtins as default imports +// (`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). +const builtinInterop = id => (id && (id.startsWith('node:') || builtinModules.includes(id)) ? 'default' : 'esModule'); + +// The vendored dependencies import builtins unprefixed (`import … from 'tty'`), which Deno rejects +// and vite-node (Node 26) misresolves as a relative path. Emit them `node:`-prefixed. +const builtinPaths = Object.fromEntries(builtinModules.map(m => [m, `node:${m}`])); + // 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-runtime-injection/src/...` — // strip that prefix to keep the `build/cjs/register.js` layout the `exports` map points at. A vendored @@ -107,29 +133,40 @@ const orchestrionRuntimeHooks = [ export default [ ...orchestrionRuntimeHooks, + // `register.ts` backs `./register` (the Node SDK `require`s it synchronously from `Sentry.init()`). ...makeNPMConfigVariants( makeBaseNPMConfig({ - // `register.ts` backs `./register` (the Node SDK `require`s it synchronously from - // `Sentry.init()`); `hook.mjs` backs `./hook` (the async `Module.register()` target, loaded on - // Node's ESM loader thread, which cannot resolve bare specifiers into the vendored chunks — but - // relative imports work, so it shares the ESM build's vendored chunks). `./hook` only maps its - // `import` condition, so the `build/cjs` copy is unused. - entrypoints: ['src/register.ts', 'src/hook.mjs'], + entrypoints: ['src/register.ts'], packageSpecificConfig: { - plugins: [debugShim, guardTracingDump, commonJSPlugin, thirdPartyLicensePlugin], + plugins: [debugShim, guardTracingDump, commonjs(commonJSOptions), thirdPartyLicensePlugin], output: { exports: 'named', preserveModules: true, entryFileNames: sanitizedFileNames, - // The commonjs-converted vendored dependencies import Node builtins as default imports - // (`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 and vite-node (Node 26) misresolves as a relative path. Emit them `node:`-prefixed. - paths: Object.fromEntries(builtinModules.map(m => [m, `node:${m}`])), + interop: builtinInterop, + paths: builtinPaths, + }, + }, + }), + ), + // `hook.mjs` backs `./hook`, the async `Module.register()` target. `register.ts` hands it over by + // runtime path, so build tracers ship `build/esm/hook.js` but may treat it as an opaque asset + // and skip its imports (Turbopack does). It is therefore one file that imports only Node builtins. + // Its vendored dependencies duplicate the ones in the preserved-modules build above. + ...makeNPMConfigVariants( + makeBaseNPMConfig({ + entrypoints: ['src/hook.mjs'], + packageSpecificConfig: { + plugins: [hookDebugShim, guardTracingDump, commonjs(commonJSOptions)], + output: { + exports: 'named', + preserveModules: false, + entryFileNames: 'hook.js', + interop: builtinInterop, + paths: builtinPaths, }, }, }), + { emitCjs: false }, ), ]; diff --git a/packages/server-runtime-injection/src/hook.mjs b/packages/server-runtime-injection/src/hook.mjs index e0f187804615..1a6eeacbedb2 100644 --- a/packages/server-runtime-injection/src/hook.mjs +++ b/packages/server-runtime-injection/src/hook.mjs @@ -3,8 +3,7 @@ // `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-runtime-injection/hook` subpath. +// on-disk ES module. It is built into a single file that imports only Node builtins (see +// `rollup.npm.config.mjs`), because build tracers ship it without following its imports, and is +// exposed via the `@sentry/server-runtime-injection/hook` subpath. export * from '@apm-js-collab/tracing-hooks/hook.mjs'; diff --git a/packages/server-runtime-injection/src/register.ts b/packages/server-runtime-injection/src/register.ts index b5e73dcfbc05..95c2fa8996e5 100644 --- a/packages/server-runtime-injection/src/register.ts +++ b/packages/server-runtime-injection/src/register.ts @@ -301,8 +301,7 @@ export function registerDiagnosticsChannelInjection(): void { // own imports are missing. let hookPath: string; /*! rollup-include-cjs-only */ - // This file is `build/cjs/register.js`; the loader thread needs the ESM build, which shares - // the vendored dependency chunks. + // This file is `build/cjs/register.js`; the loader thread needs the ESM build. hookPath = join(__dirname, '../esm/hook.js'); /*! rollup-include-cjs-only-end */ /*! rollup-include-esm-only */ @@ -329,7 +328,7 @@ export function registerDiagnosticsChannelInjection(): void { // This registers a small loader hook before the real one. Its `load` sets // `format: 'module'` for every URL inside our ESM build directory and passes all other URLs // through unchanged. Hooks registered earlier also apply when Node loads hook modules - // registered later, so the real hook and its chunks load as ESM even without the marker. + // registered later, so the real hook loads as ESM even without the marker. // The loader is inlined as a `data:` URL because Node always loads those as ESM, so it // cannot fail the same way and needs no file of its own. const esmDirUrl = `${pathToFileURL(dirname(hookPath)).href}/`; diff --git a/packages/server-runtime-injection/test/hook.test.ts b/packages/server-runtime-injection/test/hook.test.ts new file mode 100644 index 000000000000..34ab2202b3d7 --- /dev/null +++ b/packages/server-runtime-injection/test/hook.test.ts @@ -0,0 +1,82 @@ +import { spawnSync } from 'node:child_process'; +import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { builtinModules } from 'node:module'; +import { join } from 'node:path'; +import { parseModule } from 'meriyah'; +import { afterAll, describe, expect, it } from 'vitest'; + +/** + * `register.ts` hands the ESM loader hook to `Module.register()` by runtime path, so build tracers + * ship `build/esm/hook.js` without following its imports (Turbopack on Next.js does this). Anything + * the hook imports beyond Node builtins is then missing on the loader thread. Runs against the + * built files, so it needs the package built. + */ +const buildDir = join(__dirname, '..', 'build'); + +function collectSpecifiers(node: unknown, specifiers: string[]): void { + if (!node || typeof node !== 'object') { + return; + } + if (Array.isArray(node)) { + node.forEach(child => collectSpecifiers(child, specifiers)); + return; + } + const { type, source } = node as { type?: string; source?: { type?: string; value?: unknown } }; + if ( + (type === 'ImportDeclaration' || + type === 'ExportAllDeclaration' || + type === 'ExportNamedDeclaration' || + type === 'ImportExpression') && + source + ) { + specifiers.push(source.type === 'Literal' ? String(source.value) : ''); + } + Object.values(node).forEach(child => collectSpecifiers(child, specifiers)); +} + +describe('the ESM loader hook', () => { + it('imports nothing but Node builtins', () => { + const specifiers: string[] = []; + collectSpecifiers(parseModule(readFileSync(join(buildDir, 'esm', 'hook.js'), 'utf8')), specifiers); + + const builtins = new Set(builtinModules); + const nonBuiltins = specifiers.filter(s => !(s.startsWith('node:') && builtins.has(s.slice('node:'.length)))); + + expect(specifiers.length).toBeGreaterThan(0); + expect(nonBuiltins).toEqual([]); + }); + + describe('when a deployment ships only the hook from the ESM build', () => { + // The copy sits inside this package so `register.js` still resolves `@sentry/*` from its + // `node_modules`. + const copyDir = mkdtempSync(join(__dirname, '.hook-only-')); + cpSync(join(buildDir, 'cjs'), join(copyDir, 'build', 'cjs'), { recursive: true }); + mkdirSync(join(copyDir, 'build', 'esm')); + cpSync(join(buildDir, 'esm', 'hook.js'), join(copyDir, 'build', 'esm', 'hook.js')); + + afterAll(() => { + rmSync(copyDir, { recursive: true, force: true }); + }); + + it('registers it', { timeout: 30_000 }, () => { + // Removing `registerHooks` forces the `Module.register()` path, the one that loads the hook on + // Node < 24.13, whatever Node version runs the test. + const script = ` + require('node:module').registerHooks = undefined; + const { registerDiagnosticsChannelInjection } = require(${JSON.stringify(join(copyDir, 'build', 'cjs', 'register.js'))}); + registerDiagnosticsChannelInjection(); + import('data:text/javascript,export default 1').then(() => { + const marker = globalThis.__SENTRY__?.__SENTRY_ORCHESTRION__ ?? globalThis.__SENTRY_ORCHESTRION__; + process.stdout.write(JSON.stringify(marker)); + }); + `; + + const { stdout, stderr } = spawnSync(process.execPath, ['--no-experimental-detect-module', '-e', script], { + encoding: 'utf8', + }); + + expect(stderr).not.toContain('Failed to register diagnostics-channel injection hooks'); + expect(JSON.parse(stdout)).toEqual({ runtime: [] }); + }); + }); +}); diff --git a/packages/server-runtime-injection/test/nftTrace.test.ts b/packages/server-runtime-injection/test/nftTrace.test.ts index 2aa8ff749dbe..1d7f38d1ed2c 100644 --- a/packages/server-runtime-injection/test/nftTrace.test.ts +++ b/packages/server-runtime-injection/test/nftTrace.test.ts @@ -21,14 +21,11 @@ async function traceFrom(entry: string): Promise> { } describe.each(['cjs', 'esm'])('the %s build', variant => { - it('traces the ESM loader hook and the chunks it imports', { timeout: 60_000 }, async () => { + it('traces the ESM loader hook', { timeout: 60_000 }, async () => { const traced = await traceFrom(`${buildDir}/${variant}/register.js`); expect(traced).toContain(`${buildDir}/esm/hook.js`); // The hook is ESM. Without this marker the loader thread parses it as CommonJS and fails. expect(traced).toContain(`${buildDir}/esm/package.json`); - // The hook re-exports the vendored chain, so its presence alone would not prove the tracer - // followed the hook's own imports rather than copying it as an opaque asset. - expect(traced).toContain(`${buildDir}/esm/vendored/@apm-js-collab/tracing-hooks/hook.js`); }); }); From a37375e1bdfe5179f5691b42eac2b7e98773c299 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 11 Nov 2026 08:36:20 +0000 Subject: [PATCH 2/6] ref(server-runtime-injection): Name what imports `debug` in the hook build --- .../server-runtime-injection/rollup.npm.config.mjs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/server-runtime-injection/rollup.npm.config.mjs b/packages/server-runtime-injection/rollup.npm.config.mjs index b70eb4dfea33..fa2d05a9c7c2 100644 --- a/packages/server-runtime-injection/rollup.npm.config.mjs +++ b/packages/server-runtime-injection/rollup.npm.config.mjs @@ -50,10 +50,12 @@ const debugShim = { }, }; -// The ESM loader hook runs on Node's loader thread, which has its own `globalThis`, so the Sentry -// logger there is never enabled by `debug: true` and `src/debug.ts` would never print. Importing it -// would only pull `@sentry/core`'s ESM build into the hook, and build tracers that only see the CJS -// build in use (Turbopack on Next.js) don't ship that. +// The hook build's stand-in for `debug`, which `@apm-js-collab/tracing-hooks/hook.mjs` imports: a +// no-op virtual module (the `\0` prefix keeps other plugins from resolving or loading the ID). The +// hook runs on Node's loader thread, which has its own `globalThis`, so the Sentry logger there is +// never enabled by `debug: true` and `src/debug.ts` would never print. Importing it would only pull +// `@sentry/core`'s ESM build into the hook, and build tracers that only see the CJS build in use +// (Turbopack on Next.js) don't ship that. const HOOK_DEBUG_ID = '\0hook-debug'; const hookDebugShim = { name: 'hook-debug-shim', From c18a94a888bb51fa0ebf3c30140c064850fce7e5 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 11 Nov 2026 08:42:24 +0000 Subject: [PATCH 3/6] ref(server-runtime-injection): Keep the real `debug` in the hook build The no-op shim only existed to avoid `src/debug.ts`. The hook never runs on Deno, so the original reason for `debugShim` doesn't apply, and bundling `debug` keeps `DEBUG=@apm-js-collab/*` working on the loader thread. The hook build now feeds the shared license plugin so debug, ms, supports-color and has-flag are listed in THIRD-PARTY-LICENSES.txt. --- .../rollup.npm.config.mjs | 26 +++---------------- 1 file changed, 4 insertions(+), 22 deletions(-) diff --git a/packages/server-runtime-injection/rollup.npm.config.mjs b/packages/server-runtime-injection/rollup.npm.config.mjs index fa2d05a9c7c2..f51c1256c341 100644 --- a/packages/server-runtime-injection/rollup.npm.config.mjs +++ b/packages/server-runtime-injection/rollup.npm.config.mjs @@ -50,26 +50,6 @@ const debugShim = { }, }; -// The hook build's stand-in for `debug`, which `@apm-js-collab/tracing-hooks/hook.mjs` imports: a -// no-op virtual module (the `\0` prefix keeps other plugins from resolving or loading the ID). The -// hook runs on Node's loader thread, which has its own `globalThis`, so the Sentry logger there is -// never enabled by `debug: true` and `src/debug.ts` would never print. Importing it would only pull -// `@sentry/core`'s ESM build into the hook, and build tracers that only see the CJS build in use -// (Turbopack on Next.js) don't ship that. -const HOOK_DEBUG_ID = '\0hook-debug'; -const hookDebugShim = { - name: 'hook-debug-shim', - resolveId: { - order: 'pre', - handler(source) { - return source === 'debug' ? HOOK_DEBUG_ID : null; - }, - }, - load(id) { - return id === HOOK_DEBUG_ID ? 'export default function createDebug() { return function () {}; }' : null; - }, -}; - // `ModulePatch` reads `process.env.TRACING_DUMP` after each module it transforms. On Deno without // env permission that read throws, and the module counts as failed. Treat the throw as unset. const guardTracingDump = { @@ -154,12 +134,14 @@ export default [ // `hook.mjs` backs `./hook`, the async `Module.register()` target. `register.ts` hands it over by // runtime path, so build tracers ship `build/esm/hook.js` but may treat it as an opaque asset // and skip its imports (Turbopack does). It is therefore one file that imports only Node builtins. - // Its vendored dependencies duplicate the ones in the preserved-modules build above. + // Its vendored dependencies duplicate the ones in the preserved-modules build above. It keeps the + // real `debug` rather than `debugShim`: `Module.register()` never runs on Deno, and `src/debug.ts` + // would pull in `@sentry/core`'s ESM build. ...makeNPMConfigVariants( makeBaseNPMConfig({ entrypoints: ['src/hook.mjs'], packageSpecificConfig: { - plugins: [hookDebugShim, guardTracingDump, commonjs(commonJSOptions)], + plugins: [guardTracingDump, commonjs(commonJSOptions), thirdPartyLicensePlugin], output: { exports: 'named', preserveModules: false, From 59917f81e78c240ba610f64987689c700bd6ffde Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 11 Nov 2026 08:48:28 +0000 Subject: [PATCH 4/6] test(size-limit): Measure only the main-thread entry for @sentry/node/import Co-Authored-By: Francesco Gringl-Novy --- .size-limit.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.size-limit.js b/.size-limit.js index b841667d9d89..dbd955857337 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -379,7 +379,9 @@ module.exports = [ }, { name: '@sentry/node/import (ESM hook with diagnostics-channel injection)', - path: ['packages/server-runtime-injection/build/esm/hook.js', 'packages/node/build/import-hook.mjs'], + // `@sentry/server-runtime-injection/hook` is left out: it runs on the separate `Module.register()` + // loader thread (Node < 24.13 only), not in the app's thread, so it isn't part of what this loads. + path: 'packages/node/build/import-hook.mjs', ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, disablePlugins: ['@size-limit/esbuild'], From 36d574a1164bf62ab0ddb9aff6551133d9a3d9ce Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 11 Nov 2026 08:53:31 +0000 Subject: [PATCH 5/6] test(size-limit): Drop the comment on the @sentry/node/import entry --- .size-limit.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/.size-limit.js b/.size-limit.js index dbd955857337..35f5ad2e9d5b 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -379,8 +379,6 @@ module.exports = [ }, { name: '@sentry/node/import (ESM hook with diagnostics-channel injection)', - // `@sentry/server-runtime-injection/hook` is left out: it runs on the separate `Module.register()` - // loader thread (Node < 24.13 only), not in the app's thread, so it isn't part of what this loads. path: 'packages/node/build/import-hook.mjs', ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, From 1a39557badd80613de451a4c0ba6233e16a46961 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 11 Nov 2026 08:58:03 +0000 Subject: [PATCH 6/6] test(nextjs): Fold the hook-trace check into the registration test The registration test fails whenever the hook or anything it loads is missing, so the file-exists check adds nothing. Assert on the registration marker instead of the warning text, which #26272 rewords. --- .../tests/build-output.test.ts | 44 +++++++------------ 1 file changed, 16 insertions(+), 28 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/tests/build-output.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/tests/build-output.test.ts index a8af527107f1..3bd1c5956c6a 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/tests/build-output.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/tests/build-output.test.ts @@ -4,17 +4,6 @@ import { spawnSync } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; -const runtimeInjectionBuildDir = path.join('@sentry', 'server-runtime-injection', 'build'); - -function findStandaloneFile(suffix: string): string | undefined { - const standaloneDir = path.join(process.cwd(), '.next', 'standalone'); - return fs - .readdirSync(standaloneDir, { recursive: true, withFileTypes: true }) - .filter(entry => entry.isFile()) - .map(entry => path.join(entry.parentPath, entry.name)) - .find(file => file.endsWith(suffix)); -} - // `output: 'standalone'` is the mode where a baked-in absolute specifier actually bites: the server // chunks are copied to `.next/standalone` and run from there, so anything resolving against the // build machine's layout becomes MODULE_NOT_FOUND on the first request that reaches the chunk. @@ -26,30 +15,29 @@ test('emits no absolute-path imports into the relocated standalone output', () = expect(leaks).toEqual([]); }); -// Regression test for the bug this app surfaced (JS-3451): `register.ts` reaches the ESM loader -// hook through `Module.register(...)`, a runtime call `@vercel/nft` cannot trace. The hook was -// therefore never copied into the standalone output, and channel-based instrumentation failed to -// register, silently, behind `debug: true`. Asserting on the traced output rather than on emitted -// telemetry, because Next.js records its server spans through its own wrapping either way, so the -// tests in `standalone.test.ts` stay green even when channel injection is dead. -test('traces the orchestrion ESM loader hook into the standalone output', () => { - expect(findStandaloneFile(path.join(runtimeInjectionBuildDir, 'esm', 'hook.js'))).toBeDefined(); -}); - -// Regression test for JS-4067: Turbopack ships the hook without following its imports, so the -// standalone output must hold everything the hook needs. The runner's Node takes the -// `Module.registerHooks` path, which never loads the hook, so force the `Module.register()` path -// that Node < 24.13 takes. +// Regression test for JS-3451 and JS-4067. `register.ts` hands the ESM loader hook to +// `Module.register()` by runtime path, so the standalone output must ship the hook and everything it +// loads: first `@vercel/nft` didn't trace the hook at all, then Turbopack shipped it without its +// imports. Either way channel injection failed to register, silently, behind `debug: true`. This +// checks the registration marker rather than emitted telemetry, because Next.js records its server +// spans through its own wrapping either way. The runner's Node takes the `Module.registerHooks` path, +// which never loads the hook, so force the `Module.register()` path that Node < 24.13 takes. test('registers the orchestrion ESM loader hook from the standalone output', () => { - const registerPath = findStandaloneFile(path.join(runtimeInjectionBuildDir, 'cjs', 'register.js')); + const registerSuffix = path.join('@sentry', 'server-runtime-injection', 'build', 'cjs', 'register.js'); + const registerPath = fs + .readdirSync(path.join(process.cwd(), '.next', 'standalone'), { recursive: true, withFileTypes: true }) + .map(entry => path.join(entry.parentPath, entry.name)) + .find(file => file.endsWith(registerSuffix)); expect(registerPath).toBeDefined(); const script = ` require('node:module').registerHooks = undefined; require(${JSON.stringify(registerPath)}).registerDiagnosticsChannelInjection(); + const marker = globalThis.__SENTRY__?.__SENTRY_ORCHESTRION__ ?? globalThis.__SENTRY_ORCHESTRION__; + process.stdout.write(JSON.stringify(marker)); `; - const { stderr, status } = spawnSync(process.execPath, ['-e', script], { encoding: 'utf8' }); + const { stdout, status } = spawnSync(process.execPath, ['-e', script], { encoding: 'utf8' }); - expect(stderr).not.toContain('Failed to register diagnostics-channel injection hooks'); expect(status).toBe(0); + expect(JSON.parse(stdout)).toEqual({ runtime: [] }); });