Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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);
});
8 changes: 4 additions & 4 deletions packages/nextjs/src/config/diagnosticsChannelInjection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
44 changes: 40 additions & 4 deletions packages/server-runtime-injection/src/register.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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, {
Comment thread
andreiborza marked this conversation as resolved.
parentURL,
data: { instrumentations: SENTRY_INSTRUMENTATIONS, diagnosticsPort },
transferList: [diagnosticsPort],
Expand Down
Loading