From d1543c588eb1001962c9b27a1d042d49854ac866 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Wed, 30 Sep 2026 16:57:15 +0200 Subject: [PATCH] fix(nuxt): Keep `@sentry/server-runtime-injection` external on Nitro v3 Nitro v3 bundles every dependency, including `@sentry/server-runtime-injection`. On Node versions without `Module.registerHooks` (< 24.13), it registers its ESM loader hook through `Module.register`, which needs the hook file on disk. The bundled copy has none, and the fallback package specifier cannot resolve without `node_modules` in the output, so registration failed with a warning. Add the package to Nitro's `traceDeps` so it ships in `.output/server/node_modules`. Also include the underlying error in the registration failure warning. Co-Authored-By: Claude Opus 5.5 --- .../nuxt-5/tests/runtime-injection.test.ts | 20 +++++++++++++++++++ packages/nuxt/src/vite/nitroServer.ts | 3 ++- packages/nuxt/src/vite/utils.ts | 16 +++++++++++++++ packages/nuxt/test/vite/utils.test.ts | 19 ++++++++++++++++++ .../server-runtime-injection/src/register.ts | 3 ++- .../test/register.test.ts | 19 ++++++++++++++++++ 6 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/tests/runtime-injection.test.ts diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/runtime-injection.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/runtime-injection.test.ts new file mode 100644 index 000000000000..c16c103126d6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/runtime-injection.test.ts @@ -0,0 +1,20 @@ +import { execFileSync } from 'node:child_process'; +import { expect, test } from '@playwright/test'; +import { isDevMode } from './isDevMode'; + +// Nitro v3 bundles every dependency, including the SDK's copy of `@sentry/server-runtime-injection`. +// On Node versions without `Module.registerHooks` (< 24.13), that bundled copy registers its ESM +// loader hook through the `@sentry/server-runtime-injection/hook` specifier, which has to resolve +// from the server output. The runner's Node takes the `registerHooks` path, so assert the +// resolution directly instead of the missing warning. +test('keeps the runtime injection loader hook resolvable from the server output', () => { + test.skip(isDevMode, 'nuxt dev has no server output'); + + const resolved = execFileSync( + 'node', + ['--input-type=module', '-e', "console.log(import.meta.resolve('@sentry/server-runtime-injection/hook'))"], + { cwd: '.output/server', encoding: 'utf8' }, + ); + + expect(resolved.trim()).toMatch(/\/\.output\/server\/node_modules\/@sentry\/server-runtime-injection\/.*hook\.js$/); +}); diff --git a/packages/nuxt/src/vite/nitroServer.ts b/packages/nuxt/src/vite/nitroServer.ts index c999b29cd186..500e94f1f8f6 100644 --- a/packages/nuxt/src/vite/nitroServer.ts +++ b/packages/nuxt/src/vite/nitroServer.ts @@ -13,7 +13,7 @@ import { import { addDatabaseInstrumentation } from './databaseConfig'; import { addMiddlewareImports, addMiddlewareInstrumentation } from './middlewareConfig'; import { addStorageInstrumentation } from './storageConfig'; -import type { ServerApi } from './utils'; +import { addRuntimeInjectionTraceDep, type ServerApi } from './utils'; type NitroModuleOptions = Omit & Required>; @@ -61,6 +61,7 @@ export function setupNitroServer( addMiddlewareImports(); addStorageInstrumentation(nuxt, !isNitroV3); addDatabaseInstrumentation(nuxt.options.nitro, !isNitroV3, moduleOptions); + addRuntimeInjectionTraceDep(nuxt, isNitroV3); nuxt.hooks.hook('nitro:init', nitro => { if (nuxt.options?._prepare) { diff --git a/packages/nuxt/src/vite/utils.ts b/packages/nuxt/src/vite/utils.ts index fc393242b682..ca0e5a761b61 100644 --- a/packages/nuxt/src/vite/utils.ts +++ b/packages/nuxt/src/vite/utils.ts @@ -224,3 +224,19 @@ export function toResolvablePath(source: string): { path: string; wasFileUrl: bo return undefined; } } + +/** + * Nitro v3 bundles every dependency by default, but `@sentry/server-runtime-injection` must load + * from `node_modules`: on Node versions without `Module.registerHooks` it registers its ESM loader + * hook by file, and that file does not exist next to a bundled copy. Nitro v2 already keeps + * dependencies external. + */ +export function addRuntimeInjectionTraceDep(nuxt: Nuxt, isNitroV3: boolean): void { + if (!isNitroV3) { + return; + } + + // Nuxt types `nitro` against Nitro v2, which has no `traceDeps`. + const nitroOptions = nuxt.options.nitro as { traceDeps?: (string | RegExp)[] }; + nitroOptions.traceDeps = [...(nitroOptions.traceDeps ?? []), '@sentry/server-runtime-injection']; +} diff --git a/packages/nuxt/test/vite/utils.test.ts b/packages/nuxt/test/vite/utils.test.ts index 7d1213fdc959..fa1673c4dcd3 100644 --- a/packages/nuxt/test/vite/utils.test.ts +++ b/packages/nuxt/test/vite/utils.test.ts @@ -3,6 +3,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { + addRuntimeInjectionTraceDep, constructFunctionReExport, constructWrappedFunctionExportQuery, extractFunctionReexportQueryParameters, @@ -427,3 +428,21 @@ export { foo_sentryWrapped as foo }; expect(result).toBe(''); }); }); + +describe('addRuntimeInjectionTraceDep', () => { + it('keeps `@sentry/server-runtime-injection` out of the Nitro v3 bundle', () => { + const nuxtMock = { options: { nitro: { traceDeps: ['sharp'] } } } as unknown as Nuxt; + + addRuntimeInjectionTraceDep(nuxtMock, true); + + expect(nuxtMock.options.nitro).toEqual({ traceDeps: ['sharp', '@sentry/server-runtime-injection'] }); + }); + + it('does not touch the Nitro v2 config', () => { + const nuxtMock = { options: { nitro: {} } } as unknown as Nuxt; + + addRuntimeInjectionTraceDep(nuxtMock, false); + + expect(nuxtMock.options.nitro).toEqual({}); + }); +}); diff --git a/packages/server-runtime-injection/src/register.ts b/packages/server-runtime-injection/src/register.ts index d84c2d76ec97..fd9289aeb26d 100644 --- a/packages/server-runtime-injection/src/register.ts +++ b/packages/server-runtime-injection/src/register.ts @@ -300,8 +300,9 @@ export function registerDiagnosticsChannelInjection(): void { } } catch (error) { marker.runtimeUnavailable = true; + const reason = error instanceof Error ? error.message : String(error); warnRuntimeUnavailable( - 'Failed to register diagnostics-channel injection hooks, so channel-based integrations will not record spans.', + `Failed to register diagnostics-channel injection hooks (${reason}), so channel-based integrations will not record spans.`, ); debug.warn('Diagnostics-channel injection registration error:', error); return; diff --git a/packages/server-runtime-injection/test/register.test.ts b/packages/server-runtime-injection/test/register.test.ts index bab7e6c56c8a..f34408f98009 100644 --- a/packages/server-runtime-injection/test/register.test.ts +++ b/packages/server-runtime-injection/test/register.test.ts @@ -96,6 +96,25 @@ describe('registerDiagnosticsChannelInjection - bundled/tree-shaken detection', warnSpy.mockRestore(); }); + it('includes the underlying error when registering the hooks fails', () => { + // Deno always takes the `Module.registerHooks` path, independent of the Node version running the tests. + (globalThis as { Deno?: unknown }).Deno = { version: { deno: '2.8.3' } }; + registerHooksMock.mockImplementationOnce(() => { + throw new Error('boom'); + }); + + try { + registerDiagnosticsChannelInjection(); + } finally { + delete (globalThis as { Deno?: unknown }).Deno; + } + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('Failed to register diagnostics-channel injection hooks (boom)'), + ); + expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.runtimeUnavailable).toBe(true); + }); + it('warns once when a module fails to transform because the transformer was tree-shaken', () => { registerDiagnosticsChannelInjection(); const onDiagnostics = diagnosticsCallback();