diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/modules/sentry-server-init.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/modules/sentry-server-init.ts deleted file mode 100644 index 4cd518b88974..000000000000 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/modules/sentry-server-init.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { existsSync, readFileSync, writeFileSync } from 'node:fs'; -import { createResolver, defineNuxtModule } from '@nuxt/kit'; - -const SERVER_CONFIG_FILENAME = 'sentry.server.config'; - -/** - * Local demo module — NOT part of `@sentry/nuxt`. - * - * The orchestrion bundler approach needs the Sentry init to run inside the - * server build (so the diagnostics-channel subscribers are registered before - * the first request) WITHOUT relying on `node --import`. - */ -export default defineNuxtModule({ - meta: { name: 'sentry-server-init' }, - setup(_options, nuxt) { - nuxt.hooks.hook('nitro:init', nitro => { - nitro.hooks.hook('close', () => { - const entryFilePath = createResolver(nitro.options.output.serverDir).resolve('index.mjs'); - - if (!existsSync(entryFilePath)) { - return; - } - - const topImport = `import './${SERVER_CONFIG_FILENAME}.mjs';\n`; - const data = readFileSync(entryFilePath, 'utf8'); - - if (data.startsWith(topImport)) { - return; - } - - writeFileSync(entryFilePath, topImport + data, 'utf8'); - }); - }); - }, -}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/nuxt.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/nuxt.config.ts index 4f663643f739..8e7b7a15eedb 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/nuxt.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/nuxt.config.ts @@ -1,21 +1,16 @@ -import codeTransformerRollup from '@apm-js-collab/code-transformer-bundler-plugins/rollup'; -import { INSTRUMENTED_MODULE_NAMES, SENTRY_INSTRUMENTATIONS } from '@sentry/server-utils/orchestrion/config'; - -// Tells the SDK the orchestrion bundler transform ran, so `detectOrchestrionSetup()` -// no-ops the runtime diagnostics-channel hook. Injected via `codeTransformerRollup`'s -// `injectDiagnostics` option (sourcemap-safe) instead of a hand-rolled plugin. -const orchestrionBundlerMarker = [ - 'globalThis.__SENTRY_ORCHESTRION__ = (globalThis.__SENTRY_ORCHESTRION__ || {});', - 'globalThis.__SENTRY_ORCHESTRION__.bundler = true;', - '', -].join('\n'); - // https://nuxt.com/docs/api/configuration/nuxt-config export default defineNuxtConfig({ compatibilityDate: '2025-07-15', devtools: { enabled: true }, - modules: ['@sentry/nuxt/module', './modules/sentry-server-init'], + modules: ['@sentry/nuxt/module'], + + sentry: { + _experimental: { + useDiagnosticsChannelInjection: true, + }, + autoInjectServerSentry: 'top-level-import', + }, runtimeConfig: { public: { @@ -24,29 +19,4 @@ export default defineNuxtConfig({ }, }, }, - - nitro: { - // Nuxt's server is built by Nitro (Rollup), not Vite — so the orchestrion - // code transform has to run as a Nitro Rollup plugin to reach `server/api/*` - // routes. Force-bundle the instrumented deps via `externals.inline`; - // externalized deps are `require()`d from `node_modules` at runtime and never - // pass through the transform. - // - // `standard-as-callback` is ioredis' CJS `export default` helper used by - // `connect()`. Left external, Rollup's interop resolves its `.default` to a - // non-function in the bundle; inlining it alongside ioredis links the - // interop consistently. - externals: { - inline: [...INSTRUMENTED_MODULE_NAMES, 'standard-as-callback'], - }, - rollupConfig: { - plugins: [ - codeTransformerRollup({ - instrumentations: SENTRY_INSTRUMENTATIONS, - injectDiagnostics: () => orchestrionBundlerMarker, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - }) as any, - ], - }, - }, }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/package.json b/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/package.json index 3f96e7bc8f6d..ca4c3a887d9b 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/package.json @@ -8,7 +8,6 @@ "generate": "nuxt generate", "preview": "nuxt preview", "start": "node .output/server/index.mjs", - "start:import": "node --import ./.output/server/sentry.server.config.mjs .output/server/index.mjs", "clean": "npx nuxi cleanup", "test": "playwright test", "test:prod": "TEST_ENV=production playwright test", @@ -19,7 +18,6 @@ "//": "Need to use ioredis 5.10.1 because that's the last version before they support tracing channels", "dependencies": { "@sentry/nuxt": "file:../../packed/sentry-nuxt-packed.tgz", - "@sentry/server-utils": "file:../../packed/sentry-server-utils-packed.tgz", "ioredis": "5.10.1", "mysql": "^2.18.1", "nuxt": "^4.4.8", @@ -27,7 +25,6 @@ "vue-router": "^5.1.0" }, "devDependencies": { - "@apm-js-collab/code-transformer-bundler-plugins": "^0.7.1", "@playwright/test": "~1.56.0", "@sentry-internal/test-utils": "link:../../../test-utils" }, diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/sentry.server.config.ts index 712b60d404b0..b58f42b3aec1 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/sentry.server.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/sentry.server.config.ts @@ -1,9 +1,8 @@ import * as Sentry from '@sentry/nuxt'; -// Opt into diagnostics-channel-based auto-instrumentation. This registers the -// channel subscribers (e.g. for `mysql`) that turn the diagnostics-channel -// events — injected at build time by the orchestrion Rollup plugin (see -// `nuxt.config.ts`) — into Sentry spans. Must run before `Sentry.init()`. +// The Nuxt module transforms supported Nitro dependencies when enabled in +// `nuxt.config.ts`. In v10, register Node's matching channel subscribers before +// initializing the SDK so those events become spans. Sentry.experimentalUseDiagnosticsChannelInjection(); Sentry.init({ diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/server/api/db-mysql.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/server/api/db-mysql.ts index 5fbe4ba8f934..5c05d2062105 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/server/api/db-mysql.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/server/api/db-mysql.ts @@ -7,9 +7,19 @@ const connection = mysql.createConnection({ }); export default defineEventHandler(() => { - return new Promise(resolve => { - connection.query('SELECT 1 + 1 AS solution', () => { - connection.query('SELECT NOW()', ['1', '2'], () => { + return new Promise((resolve, reject) => { + connection.query('SELECT 1 + 1 AS solution', error => { + if (error) { + reject(error); + return; + } + + connection.query('SELECT NOW()', ['1', '2'], nestedError => { + if (nestedError) { + reject(nestedError); + return; + } + resolve({ status: 'ok' }); }); }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/tests/build-injection.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/tests/build-injection.test.ts new file mode 100644 index 000000000000..3cc7b960feee --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/tests/build-injection.test.ts @@ -0,0 +1,42 @@ +import { readFileSync, readdirSync } from 'node:fs'; +import path from 'node:path'; +import { expect, test } from '@playwright/test'; + +function readServerBundle(): string { + const serverDir = path.join(process.cwd(), '.output/server'); + return readdirSync(serverDir, { recursive: true }) + .filter(file => typeof file === 'string' && file.endsWith('.mjs')) + .map(file => readFileSync(path.join(serverDir, file), 'utf8')) + .join('\n'); +} + +function readClientBundle(): string { + const serverDir = path.join(process.cwd(), '.output/public'); + return readdirSync(serverDir, { recursive: true }) + .filter(file => typeof file === 'string' && file.endsWith('.js')) + .map(file => readFileSync(path.join(serverDir, file), 'utf8')) + .join('\n'); +} + +test.describe('Orchestrion build-time injection', () => { + const serverBundle = readServerBundle(); + const clientBundle = readClientBundle(); + + test('force-bundles instrumented dependencies', () => { + expect(serverBundle).not.toMatch(/(from\s*["']mysql["']|require\(["']mysql["']\))/); + expect(serverBundle).not.toMatch(/(from\s*["']ioredis["']|require\(["']ioredis["']\))/); + expect(serverBundle).not.toMatch(/(from\s*["']standard-as-callback["']|require\(["']standard-as-callback["']\))/); + }); + + test('injects diagnostics-channel publishers into the server build', () => { + expect(serverBundle).toContain('__SENTRY_ORCHESTRION__'); + expect(serverBundle).toMatch(/tracingChannel\(["']orchestrion:mysql:query["']\)/); + expect(serverBundle).toMatch(/tracingChannel\(["']orchestrion:ioredis:command["']\)/); + expect(serverBundle).toMatch(/tracingChannel\(["']orchestrion:ioredis:connect["']\)/); + }); + + test('does not inject diagnostics-channel publishers into the client build', () => { + expect(clientBundle).not.toContain('__SENTRY_ORCHESTRION__'); + expect(clientBundle).not.toMatch(/orchestrion:/); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/tests/db.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/tests/db.test.ts index 4aefc07f7c59..2ac8601b0303 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/tests/db.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/tests/db.test.ts @@ -8,7 +8,9 @@ test('Instruments ioredis automatically', async ({ baseURL }) => { ); }); - await fetch(`${baseURL}/api/db-ioredis`); + const response = await fetch(`${baseURL}/api/db-ioredis`); + expect(response.status).toBe(200); + expect(await response.text()).toBe('test-value'); const transactionEvent = await transactionEventPromise; @@ -50,7 +52,9 @@ test('Instruments mysql automatically', async ({ baseURL }) => { ); }); - await fetch(`${baseURL}/api/db-mysql`); + const response = await fetch(`${baseURL}/api/db-mysql`); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ status: 'ok' }); const transactionEvent = await transactionEventPromise; diff --git a/packages/nuxt/package.json b/packages/nuxt/package.json index afad87708909..9f855cc0cb13 100644 --- a/packages/nuxt/package.json +++ b/packages/nuxt/package.json @@ -60,6 +60,7 @@ "@sentry/node": "10.66.0", "@sentry/node-core": "10.66.0", "@sentry/rollup-plugin": "^5.3.0", + "@sentry/server-utils": "10.66.0", "@sentry/vite-plugin": "^5.3.0", "@sentry/vue": "10.66.0", "local-pkg": "^1.1.2" diff --git a/packages/nuxt/src/common/types.ts b/packages/nuxt/src/common/types.ts index 7c84775e6820..fbc9604cb310 100644 --- a/packages/nuxt/src/common/types.ts +++ b/packages/nuxt/src/common/types.ts @@ -183,6 +183,24 @@ export type SentryNuxtModuleOptions = BuildTimeOptionsBase & { */ enabled?: boolean; + // todo(v11): Update this JSDoc (and remove from experimental) + /** + * Experimental build-time options that may change or be removed without notice. + */ + _experimental?: { + /** + * Enables build-time diagnostics-channel instrumentation for supported dependencies bundled into the Nitro server. + * + * 1. Call `Sentry.experimentalUseDiagnosticsChannelInjection()` just before `Sentry.init()` + * 2. Remove `--import ./.output/server/sentry.server.config.mjs` from your `start`script + * 3. Add `sentry.autoInjectServerSentry: 'top-level-import'` in `nuxt.config.ts` so Sentry's server configuration is automatically imported + * + * @default false + * @experimental May change or be removed in any release. + */ + useDiagnosticsChannelInjection?: boolean; + }; + /** * Options for the Sentry Vite plugin to customize the source maps upload process. * diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index 72ab409ba384..4cf39d16cdbc 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 { setupOrchestrion } from './vite/orchestrion'; import { setupSourceMaps } from './vite/sourceMaps'; import { addStorageInstrumentation } from './vite/storageConfig'; import { addOTelCommonJSImportAlias, findDefaultSdkInitFile, getNitroMajorVersion } from './vite/utils'; @@ -84,6 +85,10 @@ export default defineNuxtModule({ const isMinNuxtV4 = nuxtMajor >= 4; if (serverConfigFile) { + if (moduleOptions._experimental?.useDiagnosticsChannelInjection) { + setupOrchestrion(nuxt); + } + if (isNitroV3) { addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/handler.server')); addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/update-route-name.server')); diff --git a/packages/nuxt/src/vite/orchestrion.ts b/packages/nuxt/src/vite/orchestrion.ts new file mode 100644 index 000000000000..5bc3d3be1a12 --- /dev/null +++ b/packages/nuxt/src/vite/orchestrion.ts @@ -0,0 +1,35 @@ +import type { Nuxt } from '@nuxt/schema'; +import { INSTRUMENTED_MODULE_NAMES } from '@sentry/server-utils/orchestrion/config'; +import { sentryOrchestrionPlugin } from '@sentry/server-utils/orchestrion/rollup'; +import type { NitroConfig } from 'nitropack'; + +// ioredis requires this CommonJS helper to be bundled with it. Leaving it +// external makes Nitro resolve the default export as a namespace object. +const IORedisDependencies = ['standard-as-callback']; + +/** + * Configures Nitro to bundle and transform dependencies that publish tracing + * events through diagnostics channels. + */ +export function setupOrchestrion(nuxt: Nuxt): void { + nuxt.hook('nitro:config', (nitroConfig: NitroConfig) => { + if (nuxt.options?._prepare) { + return; + } + + nitroConfig.rollupConfig ??= {}; + + if (nitroConfig.rollupConfig.plugins === null || nitroConfig.rollupConfig.plugins === undefined) { + nitroConfig.rollupConfig.plugins = []; + } else if (!Array.isArray(nitroConfig.rollupConfig.plugins)) { + nitroConfig.rollupConfig.plugins = [nitroConfig.rollupConfig.plugins]; + } + + nitroConfig.rollupConfig.plugins.push(sentryOrchestrionPlugin()); + + const externals = (nitroConfig.externals ||= {}); + const inline = externals.inline; + const existingInline = Array.isArray(inline) ? inline : inline ? [inline] : []; + externals.inline = [...new Set([...existingInline, ...INSTRUMENTED_MODULE_NAMES, ...IORedisDependencies])]; + }); +} diff --git a/packages/nuxt/src/vite/sourceMaps.ts b/packages/nuxt/src/vite/sourceMaps.ts index b249b29f4cd3..bba2e6440c46 100644 --- a/packages/nuxt/src/vite/sourceMaps.ts +++ b/packages/nuxt/src/vite/sourceMaps.ts @@ -94,9 +94,7 @@ export function setupSourceMaps( nuxt.hook('nitro:config', (nitroConfig: NitroConfig) => { if (sourceMapsEnabled && !nitroConfig.dev && !nuxt.options?._prepare) { - if (!nitroConfig.rollupConfig) { - nitroConfig.rollupConfig = {}; - } + nitroConfig.rollupConfig ??= {}; if (nitroConfig.rollupConfig.plugins === null || nitroConfig.rollupConfig.plugins === undefined) { nitroConfig.rollupConfig.plugins = []; diff --git a/packages/nuxt/test/vite/buildOptions.test-d.ts b/packages/nuxt/test/vite/buildOptions.test-d.ts index 1ad1560e54ec..0eca417d46f0 100644 --- a/packages/nuxt/test/vite/buildOptions.test-d.ts +++ b/packages/nuxt/test/vite/buildOptions.test-d.ts @@ -54,6 +54,9 @@ describe('Sentry Nuxt build-time options type', () => { // --- SentryNuxtModuleOptions specific options --- enabled: true, + _experimental: { + useDiagnosticsChannelInjection: true, + }, autoInjectServerSentry: 'experimental_dynamic-import', configDir: '~/custom-config', experimental_entrypointWrappedFunctions: ['default', 'handler', 'server', 'customExport'], diff --git a/packages/nuxt/test/vite/orchestrion.test.ts b/packages/nuxt/test/vite/orchestrion.test.ts new file mode 100644 index 000000000000..1d0f8d105627 --- /dev/null +++ b/packages/nuxt/test/vite/orchestrion.test.ts @@ -0,0 +1,84 @@ +import type { Nuxt } from '@nuxt/schema'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockSentryOrchestrionPlugin = vi.fn(() => ({ name: 'sentry-orchestrion-plugin' })); + +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('setupOrchestrion', () => { + beforeAll(() => { + vi.doMock('@sentry/server-utils/orchestrion/config', () => ({ + INSTRUMENTED_MODULE_NAMES: ['mysql', 'ioredis'], + })); + vi.doMock('@sentry/server-utils/orchestrion/rollup', () => ({ + sentryOrchestrionPlugin: mockSentryOrchestrionPlugin, + })); + }); + + afterAll(() => { + vi.doUnmock('@sentry/server-utils/orchestrion/config'); + vi.doUnmock('@sentry/server-utils/orchestrion/rollup'); + }); + + beforeEach(() => { + mockSentryOrchestrionPlugin.mockClear(); + }); + + it('adds the transformer and preserves existing inline dependencies', async () => { + const { setupOrchestrion } = await import('../../src/vite/orchestrion'); + const mockNuxt = createMockNuxt(); + const existingPlugin = { name: 'existing-plugin' }; + const nitroConfig = { + rollupConfig: { plugins: existingPlugin }, + externals: { inline: ['ioredis', 'custom-dependency'] }, + }; + + setupOrchestrion(mockNuxt as unknown as Nuxt); + await mockNuxt.triggerHook('nitro:config', nitroConfig); + + expect(mockSentryOrchestrionPlugin).toHaveBeenCalledOnce(); + expect(nitroConfig.rollupConfig.plugins).toEqual([existingPlugin, { name: 'sentry-orchestrion-plugin' }]); + expect(nitroConfig.externals.inline).toEqual(['ioredis', 'custom-dependency', 'mysql', 'standard-as-callback']); + }); + + it('initializes absent Nitro configuration', async () => { + const { setupOrchestrion } = await import('../../src/vite/orchestrion'); + const mockNuxt = createMockNuxt(); + const nitroConfig = {}; + + setupOrchestrion(mockNuxt as unknown as Nuxt); + await mockNuxt.triggerHook('nitro:config', nitroConfig); + + expect(nitroConfig).toEqual({ + rollupConfig: { plugins: [{ name: 'sentry-orchestrion-plugin' }] }, + externals: { inline: ['mysql', 'ioredis', 'standard-as-callback'] }, + }); + }); + + it('does not change Nitro configuration in prepare mode', async () => { + const { setupOrchestrion } = await import('../../src/vite/orchestrion'); + const mockNuxt = createMockNuxt({ _prepare: true }); + const nitroConfig = { rollupConfig: { plugins: [] } }; + + setupOrchestrion(mockNuxt as unknown as Nuxt); + await mockNuxt.triggerHook('nitro:config', nitroConfig); + + expect(mockSentryOrchestrionPlugin).not.toHaveBeenCalled(); + expect(nitroConfig).toEqual({ rollupConfig: { plugins: [] } }); + }); +});