From f91a8301a7e83bceb252128a828e09206f5d035c Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Thu, 27 Aug 2026 15:00:39 +0200 Subject: [PATCH] fix(server-runtime-injection): Resolve the orchestrion ESM hook by path so build tracers follow it Co-Authored-By: Claude Opus 5 --- .../tests/build-output.test.ts | 18 ++++++++ .../src/config/diagnosticsChannelInjection.ts | 8 ++-- .../server-runtime-injection/src/register.ts | 44 +++++++++++++++++-- 3 files changed, 62 insertions(+), 8 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 5384a96579c6..936ff77b6494 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,5 +1,6 @@ import { expect, test } from '@playwright/test'; import { findAbsolutePathImports } from '@sentry-internal/test-utils'; +import * as fs from 'fs'; import * as path from 'path'; // `output: 'standalone'` is the mode where a baked-in absolute specifier actually bites: the server @@ -12,3 +13,20 @@ 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', () => { + const standaloneDir = path.join(process.cwd(), '.next', 'standalone'); + const hookSuffix = path.join('@sentry', 'server-runtime-injection', 'build', 'esm', 'hook.js'); + + const found = fs + .readdirSync(standaloneDir, { recursive: true, withFileTypes: true }) + .some(entry => entry.isFile() && path.join(entry.parentPath, entry.name).endsWith(hookSuffix)); + + expect(found).toBe(true); +}); diff --git a/packages/nextjs/src/config/diagnosticsChannelInjection.ts b/packages/nextjs/src/config/diagnosticsChannelInjection.ts index 36939c446f2b..ffc215061eda 100644 --- a/packages/nextjs/src/config/diagnosticsChannelInjection.ts +++ b/packages/nextjs/src/config/diagnosticsChannelInjection.ts @@ -11,10 +11,10 @@ export const BUNDLE_SAFE_INSTRUMENTED_PACKAGES = ['ioredis']; /** * `@sentry/server-runtime-injection` (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-runtime-injection/hook', …)`, 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. + * for `Module.register()` and resolves the ESM loader hook relative to that same location, so both + * only work while the code still lives at its real `node_modules` location. Bundled into an app + * server chunk instead, they would resolve from the chunk's output location, where neither the hook + * nor the vendored transformer it loads exists. * * `@sentry/server-utils` (the barrel + bundler plugins) is NOT here — it is meant to be bundled; the * build-time snippet's `@sentry/server-utils` import is handled separately by the code-transform. diff --git a/packages/server-runtime-injection/src/register.ts b/packages/server-runtime-injection/src/register.ts index 288bc571a6f5..4b56de89ac29 100644 --- a/packages/server-runtime-injection/src/register.ts +++ b/packages/server-runtime-injection/src/register.ts @@ -1,6 +1,8 @@ import { consoleSandbox, debug, getClient, GLOBAL_OBJ, parseSemver } from '@sentry/core'; +import { existsSync } from 'node:fs'; import * as Module from 'node:module'; -import { pathToFileURL } from 'node:url'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { SENTRY_INSTRUMENTATIONS } from '@sentry/server-utils/orchestrion/config'; import type { register } from 'node:module'; import ModulePatch from '@apm-js-collab/tracing-hooks'; @@ -140,9 +142,43 @@ export function registerDiagnosticsChannelInjection(): void { // Our own bundled copy of the tracing-hooks async hooks (see `src/hook.mjs`) — the dependency // itself is bundled into this package's build and no longer resolvable as a bare specifier at - // runtime. This self-referential specifier only resolves while this package lives at its real - // `node_modules` location, which is why it must stay external (never bundled into an app). - mod.register('@sentry/server-runtime-injection/hook', { + // runtime. + // + // Registered by path rather than through the `@sentry/server-runtime-injection/hook` + // self-reference, because `Module.register` resolves its specifier at RUNTIME: build-time + // tracers (`@vercel/nft`) never learn the hook is needed and leave it out of traced output. + // `output: 'standalone'`, Docker and Vercel builds then lose channel instrumentation + // entirely, and only say so behind `debug: true`. A literal relative path is static, so the + // tracer follows it like any other dependency, and it is still computed at runtime from + // `__filename`/`import.meta.url`, so nothing absolute is baked into the build. + // + // Built from `join()` rather than `new URL('./hook.js', import.meta.url)` because webpack + // reads that second form as an asset reference: it copies the hook next to the app bundle + // without its vendored chunks, which would leave the loader thread importing a file whose + // 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. + hookPath = join(__dirname, '../esm/hook.js'); + /*! rollup-include-cjs-only-end */ + /*! rollup-include-esm-only */ + hookPath = join(dirname(fileURLToPath(import.meta.url)), 'hook.js'); + /*! rollup-include-esm-only-end */ + + // The path only points at the shipped hook while this package runs from `node_modules`. A + // copy bundled into an app sits somewhere else entirely, so it keeps the self-reference, + // which at least resolves against the app's own install. The same fallback catches a broken + // path after a build layout change, hence the log: tracers would quietly stop following the + // hook again, and this line is the only thing that says so. + const hookFound = existsSync(hookPath); + if (!hookFound) { + debug.warn(`No orchestrion ESM hook at ${hookPath}; falling back to the package specifier.`); + } + + const hookSpecifier = hookFound ? pathToFileURL(hookPath).href : '@sentry/server-runtime-injection/hook'; + + mod.register(hookSpecifier, { parentURL, data: { instrumentations: SENTRY_INSTRUMENTATIONS, diagnosticsPort }, transferList: [diagnosticsPort],