From 15d0a3a4b16c87389b4bfbb5171c2f709e3b9b6a Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:31:20 +0200 Subject: [PATCH] feat(nuxt): Start the server SDK on the Nuxt Vite server (experimental) --- CHANGELOG.md | 8 + .../nuxt-4/app/pages/db-ssr.vue | 33 +++ .../test-applications/nuxt-4/nuxt.config.ts | 1 + .../test-applications/nuxt-4/package.json | 5 + .../nuxt-4/tests/build-injection.test.ts | 11 + .../nuxt-4/tests/cache.test.ts | 3 + .../nuxt-4/tests/database-multi.test.ts | 3 + .../nuxt-4/tests/database.test.ts | 3 + .../nuxt-4/tests/db-drivers.ssr.test.ts | 33 +++ .../nuxt-4/tests/db-drivers.test.ts | 3 + .../nuxt-4/tests/environment.test.ts | 3 + .../nuxt-4/tests/errors.server.test.ts | 3 + .../nuxt-4/tests/import-compat.test.ts | 3 + .../nuxt-4/tests/isViteServer.ts | 7 + .../nuxt-4/tests/middleware.test.ts | 3 + .../nuxt-4/tests/startup-error.test.ts | 3 + .../nuxt-4/tests/storage-aliases.test.ts | 3 + .../nuxt-4/tests/storage.test.ts | 3 + .../nuxt-4/tests/tracing.cached-html.test.ts | 6 + .../nuxt-4/tests/tracing.server.test.ts | 3 + .../nuxt-4/tests/tracing.test.ts | 6 + packages/nuxt/src/module.ts | 7 +- packages/nuxt/src/vite/addServerConfig.ts | 23 +- packages/nuxt/src/vite/nitroServer.ts | 9 +- packages/nuxt/src/vite/viteServer.ts | 108 ++++++++++ packages/nuxt/test/vite/nitroServer.test.ts | 15 +- packages/nuxt/test/vite/viteServer.test.ts | 198 ++++++++++++++++++ 27 files changed, 474 insertions(+), 32 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4/app/pages/db-ssr.vue create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4/tests/db-drivers.ssr.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4/tests/isViteServer.ts create mode 100644 packages/nuxt/src/vite/viteServer.ts create mode 100644 packages/nuxt/test/vite/viteServer.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 21394e7db5b2..872a6c628ef5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +### Important Changes + +- **feat(nuxt): Start the server SDK on the Nuxt Vite server (experimental)** + + Nuxt 4.6 added the experimental `@nuxt/vite-server` (`server: { builder: 'vite' }`), which renders pages without Nitro. The SDK now bundles `sentry.server.config` into the `ssr` build of this server and instruments database drivers such as `mysql` at build time. Support is experimental, like the Vite server itself, and can change in a minor release. + + What works: the server SDK in production builds that run on Node.js, request spans and the `db` spans of instrumented libraries. Not supported yet: `nuxt dev`, Cloudflare, trace meta tags in the HTML, and the capture of errors during server-side rendering. + ## 11.6.0 ### Important Changes diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/app/pages/db-ssr.vue b/dev-packages/e2e-tests/test-applications/nuxt-4/app/pages/db-ssr.vue new file mode 100644 index 000000000000..de0125832e06 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/app/pages/db-ssr.vue @@ -0,0 +1,33 @@ + + + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts index c7acd2b12328..89b8ac452b55 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts @@ -2,6 +2,7 @@ export default defineNuxtConfig({ compatibilityDate: '2025-06-06', imports: { autoImport: false }, + ...(process.env.E2E_TEST_VITE_SERVER && { server: { builder: 'vite' } }), routeRules: { '/rendering-modes/client-side-only-page': { ssr: false }, diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json index 5f84fb085b6d..c764eded96d5 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json @@ -45,6 +45,11 @@ "build-command": "pnpm test:build", "assert-command": "pnpm test:prod:import", "label": "nuxt-4 (--import compat)" + }, + { + "build-command": "E2E_TEST_VITE_SERVER=true pnpm test:build", + "assert-command": "E2E_TEST_VITE_SERVER=true pnpm test:prod", + "label": "nuxt-4 (Vite server)" } ], "optionalVariants": [ diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/build-injection.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/build-injection.test.ts index cd609b2321a4..e00258723c71 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/build-injection.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/build-injection.test.ts @@ -1,6 +1,7 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs'; import path from 'node:path'; import { expect, test } from '@playwright/test'; +import { isViteServer, NITRO_ONLY, NO_IMPORT_CONFIG } from './isViteServer'; function readServerBundle(): string { const serverDir = path.join(process.cwd(), '.output/server'); @@ -34,6 +35,12 @@ test.describe('Orchestrion build-time injection', () => { // on the global marker (and registers its subscriber factory) when evaluated. expect(serverBundle).toContain('orchestrionModuleInjected'); expect(serverBundle).toMatch(/tracingChannel\(["']orchestrion:mysql:query["']\)/); + }); + + test('injects diagnostics-channel publishers into ioredis', () => { + // Only a server route imports ioredis. + test.skip(isViteServer, NITRO_ONLY); + expect(serverBundle).toMatch(/tracingChannel\(["']orchestrion:ioredis:command["']\)/); expect(serverBundle).toMatch(/tracingChannel\(["']orchestrion:ioredis:connect["']\)/); }); @@ -46,6 +53,8 @@ test.describe('Orchestrion build-time injection', () => { test.describe('Sentry server config injection', () => { test('evaluates Sentry.init before nitro runs its plugins', () => { + test.skip(isViteServer, NITRO_ONLY); + const nitroChunk = readFileSync(path.join(process.cwd(), '.output/server/chunks/nitro/nitro.mjs'), 'utf8'); // The app DSN only appears in the transpiled `Sentry.init` options object, so it marks where @@ -59,6 +68,8 @@ test.describe('Sentry server config injection', () => { }); test('emits the `--import` compatibility shim at the former config path', () => { + test.skip(isViteServer, NO_IMPORT_CONFIG); + const shimPath = path.join(process.cwd(), '.output/server/sentry.server.config.mjs'); expect(existsSync(shimPath)).toBe(true); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/cache.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/cache.test.ts index 0917da52d140..812ae2641ca0 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/cache.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/cache.test.ts @@ -1,5 +1,8 @@ import { expect, test } from '@playwright/test'; import { collectStreamedSpansUntilSegment } from '@sentry-internal/test-utils'; +import { isViteServer, NITRO_ONLY } from './isViteServer'; + +test.skip(isViteServer, NITRO_ONLY); test.describe('Cache Instrumentation', () => { const SEMANTIC_ATTRIBUTE_CACHE_KEY = 'cache.key'; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/database-multi.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/database-multi.test.ts index bec2b7c3b269..50a0f6ae09d6 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/database-multi.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/database-multi.test.ts @@ -1,5 +1,8 @@ import { expect, test } from '@playwright/test'; import { collectStreamedSpansUntilSegment, getSpanOp } from '@sentry-internal/test-utils'; +import { isViteServer, NITRO_ONLY } from './isViteServer'; + +test.skip(isViteServer, NITRO_ONLY); async function collectDbSpans() { const spans = await collectStreamedSpansUntilSegment( diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/database.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/database.test.ts index b73c4ee990f4..ab6071727d6f 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/database.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/database.test.ts @@ -1,5 +1,8 @@ import { expect, test } from '@playwright/test'; import { collectStreamedSpansUntilSegment, getSpanOp, waitForError } from '@sentry-internal/test-utils'; +import { isViteServer, NITRO_ONLY } from './isViteServer'; + +test.skip(isViteServer, NITRO_ONLY); async function collectDbSpans() { const spans = await collectStreamedSpansUntilSegment( diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/db-drivers.ssr.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/db-drivers.ssr.test.ts new file mode 100644 index 000000000000..dd0aba69fe24 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/db-drivers.ssr.test.ts @@ -0,0 +1,33 @@ +import { expect, test } from '@playwright/test'; +import { collectStreamedSpansUntilSegment } from '@sentry-internal/test-utils'; + +test('Instruments mysql in a page that queries the database while it renders', async ({ baseURL }) => { + const spansPromise = collectStreamedSpansUntilSegment( + 'nuxt-4', + span => span.attributes['url.path']?.value === '/db-ssr', + ); + + const response = await fetch(`${baseURL}/db-ssr`); + expect(response.status).toBe(200); + expect(await response.text()).toContain('

2

'); + + const spans = await spansPromise; + const rootSpan = spans.find(span => span.is_segment && span.attributes['url.path']?.value === '/db-ssr'); + const mysqlSpans = spans.filter( + span => span.trace_id === rootSpan?.trace_id && span.attributes['sentry.origin']?.value === 'auto.db.mysql', + ); + + expect(mysqlSpans).toHaveLength(1); + expect(mysqlSpans[0]).toEqual( + expect.objectContaining({ + name: 'SELECT', + status: 'ok', + is_segment: false, + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'db' }, + 'db.system.name': { type: 'string', value: 'mysql' }, + 'db.query.text': { type: 'string', value: 'SELECT ? + ? AS solution' }, + }), + }), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/db-drivers.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/db-drivers.test.ts index 5b1bccedab4d..db3b67f5f7e7 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/db-drivers.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/db-drivers.test.ts @@ -1,5 +1,8 @@ import { expect, test } from '@playwright/test'; import { collectStreamedSpansUntilSegment, getSpanOp } from '@sentry-internal/test-utils'; +import { isViteServer, NITRO_ONLY } from './isViteServer'; + +test.skip(isViteServer, NITRO_ONLY); // The Nuxt module auto-wires the orchestrion build-time transform, which injects // `diagnostics_channel` publishers into these drivers as Nitro bundles them. `nuxt dev` diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/environment.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/environment.test.ts index 35e626110382..005e694e0b96 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/environment.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/environment.test.ts @@ -1,6 +1,7 @@ import { expect, test } from '@playwright/test'; import { getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; import { isDevMode } from './isDevMode'; +import { isViteServer, NITRO_ONLY } from './isViteServer'; test.describe('environment detection', async () => { test('sets correct environment for client-side errors', async ({ page }) => { @@ -38,6 +39,8 @@ test.describe('environment detection', async () => { }); test('sets correct environment for server-side errors', async ({ page }) => { + test.skip(isViteServer, NITRO_ONLY); + const errorPromise = waitForError('nuxt-4', async errorEvent => { return errorEvent?.exception?.values?.[0]?.value === 'Nuxt 4 Server error'; }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/errors.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/errors.server.test.ts index 418c09f16c57..48802a997129 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/errors.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/errors.server.test.ts @@ -1,6 +1,9 @@ import { expect, test } from '@playwright/test'; import { waitForError } from '@sentry-internal/test-utils'; import { IMPORT_SURFACES } from './importSurfaces'; +import { isViteServer, NITRO_ONLY } from './isViteServer'; + +test.skip(isViteServer, NITRO_ONLY); IMPORT_SURFACES.forEach(({ name, apiPrefix }) => { test.describe(`server-side errors (${name})`, () => { diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/import-compat.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/import-compat.test.ts index a2eac8bbbf54..be1316fb61a2 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/import-compat.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/import-compat.test.ts @@ -2,6 +2,7 @@ import type { ChildProcess } from 'node:child_process'; import { spawn } from 'node:child_process'; import { expect, test } from '@playwright/test'; import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; +import { isViteServer, NO_IMPORT_CONFIG } from './isViteServer'; // `node --import` start commands must keep working now that the config is bundled: the emitted // config file is a shim that only prints a removal hint, and a preload that really initializes @@ -34,6 +35,8 @@ async function startServerWithPreload(preloadPath: string, port: string): Promis } test('serves traced requests with the shim preloaded and prints the removal hint', async () => { + test.skip(isViteServer, NO_IMPORT_CONFIG); + const server = await startServerWithPreload('./.output/server/sentry.server.config.mjs', '3081'); try { diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/isViteServer.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/isViteServer.ts new file mode 100644 index 000000000000..d0c9404a88e9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/isViteServer.ts @@ -0,0 +1,7 @@ +export const isViteServer = !!process.env.E2E_TEST_VITE_SERVER; + +export const NITRO_ONLY = 'Nitro only: the Vite server builds no `server/` directory'; + +export const NO_IMPORT_CONFIG = 'Nitro only: the Vite server never had an `--import` config to keep working'; + +export const NO_META_TAGS = 'Vite server: the SDK adds no trace meta tags yet'; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/middleware.test.ts index cc1dd5b14d2d..cc12ce192ebd 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/middleware.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/middleware.test.ts @@ -1,6 +1,9 @@ import { expect, test } from '@playwright/test'; import { collectStreamedSpansUntilSegment, getSpanOp, waitForError } from '@sentry-internal/test-utils'; import { IMPORT_SURFACES } from './importSurfaces'; +import { isViteServer, NITRO_ONLY } from './isViteServer'; + +test.skip(isViteServer, NITRO_ONLY); // Nitro runs every file in `server/middleware/` on every request, so each surface tells its spans apart by // middleware name. `nuxt/server` has no object syntax (`onRequest`, `onBeforeResponse`) and no `eventHandler` diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/startup-error.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/startup-error.test.ts index fd6a98a0f714..b51a0719c206 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/startup-error.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/startup-error.test.ts @@ -1,6 +1,9 @@ import { spawn } from 'node:child_process'; import { expect, test } from '@playwright/test'; import { waitForError, waitForSession } from '@sentry-internal/test-utils'; +import { isViteServer, NITRO_ONLY } from './isViteServer'; + +test.skip(isViteServer, NITRO_ONLY); // Errors thrown between server start and `listen` (module evaluation, nitro plugin runs) must be // captured and flushed before the process exits. Each test spawns its own server process because diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/storage-aliases.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/storage-aliases.test.ts index 8482fea9fe79..9152c4d82905 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/storage-aliases.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/storage-aliases.test.ts @@ -1,5 +1,8 @@ import { expect, test } from '@playwright/test'; import { collectStreamedSpansUntilSegment } from '@sentry-internal/test-utils'; +import { isViteServer, NITRO_ONLY } from './isViteServer'; + +test.skip(isViteServer, NITRO_ONLY); async function collectStorageSpans(route: string) { const spans = await collectStreamedSpansUntilSegment('nuxt-4', span => span.attributes['url.path']?.value === route); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/storage.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/storage.test.ts index ecee3f1146db..1c4ea12af2db 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/storage.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/storage.test.ts @@ -1,5 +1,8 @@ import { expect, test } from '@playwright/test'; import { collectStreamedSpansUntilSegment } from '@sentry-internal/test-utils'; +import { isViteServer, NITRO_ONLY } from './isViteServer'; + +test.skip(isViteServer, NITRO_ONLY); async function collectStorageSpans(route: string) { const spans = await collectStreamedSpansUntilSegment('nuxt-4', span => span.attributes['url.path']?.value === route); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.cached-html.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.cached-html.test.ts index 45afb6475b20..42ed5115020c 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.cached-html.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.cached-html.test.ts @@ -1,16 +1,20 @@ import { expect, test, type Page } from '@playwright/test'; import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; +import { isViteServer, NO_META_TAGS } from './isViteServer'; test.describe('Rendering Modes with Cached HTML', () => { test('changes tracing meta tags with multiple requests on Client-Side only page', async ({ page }) => { + test.fail(isViteServer, NO_META_TAGS); await testChangingTracingMetaTagsOnISRPage(page, '/rendering-modes/client-side-only-page', 'Client Side Only Page'); }); test('changes tracing meta tags with multiple requests on ISR-cached page', async ({ page }) => { + test.fail(isViteServer, NO_META_TAGS); await testChangingTracingMetaTagsOnISRPage(page, '/rendering-modes/isr-cached-page', 'ISR Cached Page'); }); test('changes tracing meta tags with multiple requests on 1h ISR-cached page', async ({ page }) => { + test.fail(isViteServer, NO_META_TAGS); await testChangingTracingMetaTagsOnISRPage(page, '/rendering-modes/isr-1h-cached-page', 'ISR 1h Cached Page'); }); @@ -69,6 +73,7 @@ export async function testChangingTracingMetaTagsOnISRPage( expect(page.getByText(expectedPageText, { exact: true })).toBeVisible(), ]); + await expect(page.locator('meta[name="baggage"]')).toHaveCount(1); const baggageMetaTagContent1 = await page.locator('meta[name="baggage"]').getAttribute('content'); const sentryTraceMetaTagContent1 = await page.locator('meta[name="sentry-trace"]').getAttribute('content'); const [htmlMetaTraceId1] = sentryTraceMetaTagContent1?.split('-') || []; @@ -85,6 +90,7 @@ export async function testChangingTracingMetaTagsOnISRPage( expect(page.getByText(expectedPageText, { exact: true })).toBeVisible(), ]); + await expect(page.locator('meta[name="baggage"]')).toHaveCount(1); const baggageMetaTagContent2 = await page.locator('meta[name="baggage"]').getAttribute('content'); const sentryTraceMetaTagContent2 = await page.locator('meta[name="sentry-trace"]').getAttribute('content'); const [htmlMetaTraceId2] = sentryTraceMetaTagContent2?.split('-') || []; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.server.test.ts index 4ef6d7c039e0..a0c8630bd730 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.server.test.ts @@ -1,6 +1,7 @@ import { expect, test } from '@playwright/test'; import { collectStreamedSpans, getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; import { IMPORT_SURFACES } from './importSurfaces'; +import { isViteServer, NITRO_ONLY } from './isViteServer'; test('sends a server root span on pageload', async ({ page }) => { const serverSpanPromise = waitForStreamedSpan('nuxt-4', span => { @@ -41,6 +42,8 @@ test('does not send spans for build asset folder "_nuxt"', async ({ page }) => { IMPORT_SURFACES.forEach(({ name, apiPrefix }) => { test(`captures server API calls made with Nitro $fetch (${name})`, async ({ page }) => { + test.skip(isViteServer, NITRO_ONLY); + // The server-side http.client span can flush before its segment. Accumulate until both arrived. const spansPromise = collectStreamedSpans('nuxt-4', spans => { return ( diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.test.ts index 7ee91e3eeb9c..b67402a6ae23 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.test.ts @@ -1,11 +1,14 @@ import { expect, test } from '@playwright/test'; import { collectStreamedSpans, getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; import { IMPORT_SURFACES } from './importSurfaces'; +import { isViteServer, NITRO_ONLY, NO_META_TAGS } from './isViteServer'; test.describe('distributed tracing', () => { const PARAM = 's0me-param'; test('capture a distributed pageload trace', async ({ page }) => { + test.fail(isViteServer, NO_META_TAGS); + const clientSpanPromise = waitForStreamedSpan('nuxt-4', span => { return getSpanOp(span) === 'pageload' && span.is_segment; }); @@ -21,6 +24,7 @@ test.describe('distributed tracing', () => { expect(page.getByText(`Param: ${PARAM}`)).toBeVisible(), ]); + await expect(page.locator('meta[name="baggage"]')).toHaveCount(1); const baggageMetaTagContent = await page.locator('meta[name="baggage"]').getAttribute('content'); // URL-encoded for parametrized 'GET /test-param/s0me-param' -> `GET /test-param/:param` @@ -66,6 +70,8 @@ test.describe('distributed tracing', () => { test(`capture a distributed trace from a client-side API request with parametrized routes (${name})`, async ({ page, }) => { + test.skip(isViteServer, NITRO_ONLY); + const API_PATH = `${apiPrefix}/user/${PARAM}`; // The `http.client` span ends after the pageload segment, so it can be flushed in a later diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index 10f22891ac2d..e56db92a3f2a 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -8,6 +8,7 @@ import { setupNitroServer } from './vite/nitroServer'; import { setupOrchestrion } from './vite/orchestrion'; import { setupSourceMaps } from './vite/sourceMaps'; import { findDefaultSdkInitFile, resolveServerApi } from './vite/utils'; +import { setupViteServer } from './vite/viteServer'; export type ModuleOptions = SentryNuxtModuleOptions; type NuxtPageSubset = { file?: string; path: string }; @@ -84,7 +85,11 @@ export default defineNuxtModule({ setupOrchestrion(nuxt, !!serverConfigFile, moduleOptions.buildTimeInstrumentation); if (serverConfigFile) { - setupNitroServer(nuxt, serverApi, serverConfigFile, moduleOptions); + if (serverApi === 'nuxt') { + setupViteServer(nuxt, serverConfigFile, moduleOptions); + } else { + setupNitroServer(nuxt, serverApi, serverConfigFile, moduleOptions); + } if (isMinNuxtV4) { addPlugin({ src: moduleDirResolver.resolve('./runtime/plugins/route-detector.server'), mode: 'server' }); diff --git a/packages/nuxt/src/vite/addServerConfig.ts b/packages/nuxt/src/vite/addServerConfig.ts index 7b0970ef3f6d..9b62b66ba64b 100644 --- a/packages/nuxt/src/vite/addServerConfig.ts +++ b/packages/nuxt/src/vite/addServerConfig.ts @@ -2,7 +2,7 @@ import { existsSync } from 'node:fs'; import { basename } from 'node:path'; import { pathToFileURL } from 'node:url'; import { addNitroPlugin, addTemplate, createResolver } from '@nuxt/kit'; -import type { Nuxt } from '@nuxt/schema'; +import type { Nuxt, ResolvedNuxtTemplate } from '@nuxt/schema'; import { consoleSandbox, debug } from '@sentry/core'; import * as fs from 'fs'; import type { Nitro } from 'nitropack'; @@ -105,15 +105,11 @@ export function addSentryTopImport(moduleOptions: SentryNuxtModuleOptions, nitro } /** - * Registers a Nitro plugin that statically imports the Sentry server config, so the SDK initializes - * at server startup without a `node --import` preload. + * Generates the module that sets the runtime flags `Sentry.init` reads. Import it before the + * Sentry server config: a statement above the config import would not survive import hoisting. */ -export function addServerConfigPlugin(nuxt: Nuxt, serverConfigFile: string, isLegacyNitro: boolean): void { - const configPath = createResolver(nuxt.options.rootDir).resolve(serverConfigFile); - - // `Sentry.init` reads these flags, and a statement above the config import would not survive - // import hoisting — so they live in their own module which is imported first. - const runtimeFlagsTemplate = addTemplate({ +export function addRuntimeFlagsTemplate(): ResolvedNuxtTemplate { + return addTemplate({ filename: 'sentry-runtime-flags.mjs', write: true, getContents: () => @@ -124,6 +120,15 @@ export function addServerConfigPlugin(nuxt: Nuxt, serverConfigFile: string, isLe '', ].join('\n'), }); +} + +/** + * Registers a Nitro plugin that statically imports the Sentry server config, so the SDK initializes + * at server startup without a `node --import` preload. + */ +export function addServerConfigPlugin(nuxt: Nuxt, serverConfigFile: string, isLegacyNitro: boolean): void { + const configPath = createResolver(nuxt.options.rootDir).resolve(serverConfigFile); + const runtimeFlagsTemplate = addRuntimeFlagsTemplate(); const configPluginTemplate = addTemplate({ filename: 'sentry-server-config-plugin.mjs', diff --git a/packages/nuxt/src/vite/nitroServer.ts b/packages/nuxt/src/vite/nitroServer.ts index c999b29cd186..54618b43b387 100644 --- a/packages/nuxt/src/vite/nitroServer.ts +++ b/packages/nuxt/src/vite/nitroServer.ts @@ -21,20 +21,13 @@ type NitroModuleOptions = Omit, serverConfigFile: string, moduleOptions: NitroModuleOptions, ): void { - if (serverApi === 'nuxt') { - return; - } - const isNitroV3 = serverApi === 'nitro3'; const moduleDirResolver = createResolver(import.meta.url); // oxlint-disable-next-line typescript/no-deprecated -- supported until removal diff --git a/packages/nuxt/src/vite/viteServer.ts b/packages/nuxt/src/vite/viteServer.ts new file mode 100644 index 000000000000..2fa44d49d9ef --- /dev/null +++ b/packages/nuxt/src/vite/viteServer.ts @@ -0,0 +1,108 @@ +import { addVitePlugin, createResolver } from '@nuxt/kit'; +import type { Nuxt } from '@nuxt/schema'; +import { consoleSandbox } from '@sentry/core'; +import { sentryOrchestrionPlugin } from '@sentry/server-utils/orchestrion/vite'; +import type { Plugin } from 'vite'; +import type { SentryNuxtModuleOptions } from '../common/types'; +import { addRuntimeFlagsTemplate } from './addServerConfig'; + +const SERVER_ENTRY_ALIAS = '#server-entry'; + +/** + * Sets up the server SDK on the Vite server (`server.builder: 'vite'`), which runs no Nitro. The + * `ssr` build imports the Sentry server config at the top of the server entry, and orchestrion + * instruments the dependencies in that build. + */ +export function setupViteServer(nuxt: Nuxt, serverConfigFile: string, moduleOptions: SentryNuxtModuleOptions): void { + if (nuxt.options._prepare) { + return; + } + + warn( + "Support for the Nuxt Vite server (`server.builder: 'vite'`) is experimental and can change in a minor release. The server SDK starts only in production builds that run on Node.js.", + ); + warnOnDeprecatedInjectMode(moduleOptions); + + if (nuxt.options.dev) { + return; + } + + const configPath = createResolver(nuxt.options.rootDir).resolve(serverConfigFile); + const importPaths = [addRuntimeFlagsTemplate().dst, configPath]; + let isConfigBundled = false; + + addVitePlugin( + sentryServerEntryPlugin(importPaths, () => { + isConfigBundled = true; + }), + { client: false }, + ); + + if (moduleOptions.buildTimeInstrumentation !== false) { + addVitePlugin({ ...sentryOrchestrionPlugin(), applyToEnvironment: isSsrEnvironment }, { client: false }); + } + + // The Vite server API is experimental. If Nuxt renames the alias or the environment, the plugin + // silently matches nothing, so check the result once all environments are built. + nuxt.hook('build:done', () => { + if (!isConfigBundled) { + warn( + `Could not add the Sentry server config to the server build: no \`ssr\` module resolved from \`${SERVER_ENTRY_ALIAS}\`. The server SDK does not start. Load the config with \`node --import\` instead.`, + ); + } + }); +} + +/** + * Prepends side-effect imports to the module behind `#server-entry`, the documented seam through + * which every deploy target reaches the Nuxt renderer. The imports evaluate once, before the + * server handles its first request. + */ +function sentryServerEntryPlugin(importPaths: string[], onBundled: () => void): Plugin { + let serverEntryId: string | undefined; + + return { + name: 'sentry-nuxt-server-entry', + applyToEnvironment: isSsrEnvironment, + async buildStart() { + // The id is virtual and contains the build directory, which changes between builds. + serverEntryId = (await this.resolve(SERVER_ENTRY_ALIAS))?.id; + }, + transform: { + order: 'pre', + handler(code, id) { + if (id !== serverEntryId) { + return null; + } + + onBundled(); + const imports = importPaths.map(path => `import ${JSON.stringify(path)};`).join(''); + return { code: `${imports}${code}`, map: null }; + }, + }, + }; +} + +// Deploy targets add their own server environments. The Cloudflare worker environment, for +// example, cannot run the Node SDK. +function isSsrEnvironment(environment: { name: string }): boolean { + return environment.name === 'ssr'; +} + +function warnOnDeprecatedInjectMode(moduleOptions: SentryNuxtModuleOptions): void { + // oxlint-disable-next-line typescript/no-deprecated -- supported until removal + const injectMode = moduleOptions.autoInjectServerSentry; + + if (injectMode) { + warn( + `\`autoInjectServerSentry: '${injectMode}'\` is deprecated and has no effect on the Vite server. The Sentry server config is bundled into the server build. Remove the option.`, + ); + } +} + +function warn(message: string): void { + consoleSandbox(() => { + // eslint-disable-next-line no-console + console.warn(`[Sentry] ${message}`); + }); +} diff --git a/packages/nuxt/test/vite/nitroServer.test.ts b/packages/nuxt/test/vite/nitroServer.test.ts index a1ab7e7d40e2..4a684b25bfe5 100644 --- a/packages/nuxt/test/vite/nitroServer.test.ts +++ b/packages/nuxt/test/vite/nitroServer.test.ts @@ -26,7 +26,6 @@ type HookCallback = (arg: unknown) => void; function createFakeNuxt(): { nuxt: Nuxt; - hook: ReturnType; callHook: (name: string, arg: unknown) => void; } { const callbacks = new Map(); @@ -39,7 +38,7 @@ function createFakeNuxt(): { hooks: { hook }, } as unknown as Nuxt; - return { nuxt, hook, callHook: (name, arg) => callbacks.get(name)?.forEach(callback => callback(arg)) }; + return { nuxt, callHook: (name, arg) => callbacks.get(name)?.forEach(callback => callback(arg)) }; } describe('setupNitroServer', () => { @@ -48,18 +47,6 @@ describe('setupNitroServer', () => { addTemplateMock.mockImplementation((opts: { filename: string }) => ({ dst: `${APP_ROOT}/.nuxt/${opts.filename}` })); }); - it('registers nothing on a host without Nitro', () => { - const { nuxt, hook } = createFakeNuxt(); - - setupNitroServer(nuxt, 'nuxt', SERVER_CONFIG, MODULE_OPTIONS); - - expect(addNitroPluginMock).not.toHaveBeenCalled(); - expect(addServerImportsMock).not.toHaveBeenCalled(); - expect(addTemplateMock).not.toHaveBeenCalled(); - expect(addServerTemplateMock).not.toHaveBeenCalled(); - expect(hook).not.toHaveBeenCalled(); - }); - it.each([ { serverApi: 'nitro2', diff --git a/packages/nuxt/test/vite/viteServer.test.ts b/packages/nuxt/test/vite/viteServer.test.ts new file mode 100644 index 000000000000..1954dc6d5779 --- /dev/null +++ b/packages/nuxt/test/vite/viteServer.test.ts @@ -0,0 +1,198 @@ +import type { Nuxt } from '@nuxt/schema'; +import type { Plugin } from 'vite'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { setupViteServer } from '../../src/vite/viteServer'; + +const addVitePluginMock = vi.hoisted(() => vi.fn()); +const addTemplateMock = vi.hoisted(() => vi.fn()); + +vi.mock('@nuxt/kit', () => ({ + addVitePlugin: addVitePluginMock, + addTemplate: addTemplateMock, + createResolver: (base: string) => ({ resolve: (input: string) => `${base}/${input}` }), +})); + +vi.mock('@sentry/server-utils/orchestrion/vite', () => ({ + sentryOrchestrionPlugin: () => ({ name: 'sentry-orchestrion', applyToEnvironment: () => true }), +})); + +const APP_ROOT = '/my/app'; +const FLAGS_PATH = `${APP_ROOT}/.nuxt/sentry-runtime-flags.mjs`; +const CONFIG_PATH = `${APP_ROOT}/sentry.server.config.ts`; +const SERVER_ENTRY_ID = 'virtual:nuxt:.nuxt%2Fvite-server%2Fserver-handler.mjs'; +const EXPERIMENTAL_WARNING = + "[Sentry] Support for the Nuxt Vite server (`server.builder: 'vite'`) is experimental and can change in a minor release. The server SDK starts only in production builds that run on Node.js."; +const NOT_BUNDLED_WARNING = + '[Sentry] Could not add the Sentry server config to the server build: no `ssr` module resolved from `#server-entry`. The server SDK does not start. Load the config with `node --import` instead.'; + +type ApplyToEnvironment = (environment: { name: string }) => boolean; +type TransformHandler = (code: string, id: string) => unknown; + +function createFakeNuxt(options: { dev?: boolean; _prepare?: boolean } = {}): { + nuxt: Nuxt; + callHook: (name: string) => void; +} { + const callbacks = new Map void>(); + const nuxt = { + options: { rootDir: APP_ROOT, dev: false, ...options }, + hook: (name: string, callback: () => void) => callbacks.set(name, callback), + } as unknown as Nuxt; + + return { nuxt, callHook: name => callbacks.get(name)?.() }; +} + +function addedPlugins(): Plugin[] { + return addVitePluginMock.mock.calls.map(([plugin]) => plugin as Plugin); +} + +async function buildServerEntryPlugin(resolvedId: string | undefined): Promise { + const plugin = addedPlugins()[0] as Plugin; + const resolve = vi.fn().mockResolvedValue(resolvedId ? { id: resolvedId } : null); + await (plugin.buildStart as (this: unknown) => Promise).call({ resolve }); + + const { handler } = plugin.transform as { order: string; handler: TransformHandler }; + return (code, id) => handler.call({}, code, id); +} + +describe('setupViteServer', () => { + beforeEach(() => { + addTemplateMock.mockImplementation((opts: { filename: string }) => ({ dst: `${APP_ROOT}/.nuxt/${opts.filename}` })); + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); + }); + + it('adds the server entry and orchestrion plugins to the server build', () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + + setupViteServer(createFakeNuxt().nuxt, 'sentry.server.config.ts', {}); + + expect(addVitePluginMock).toHaveBeenCalledTimes(2); + expect(addVitePluginMock).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ name: 'sentry-nuxt-server-entry' }), + { + client: false, + }, + ); + expect(addVitePluginMock).toHaveBeenNthCalledWith(2, expect.objectContaining({ name: 'sentry-orchestrion' }), { + client: false, + }); + }); + + it('imports the runtime flags and the config from the project root into the server entry', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + setupViteServer(createFakeNuxt().nuxt, 'sentry.server.config.ts', {}); + const transform = await buildServerEntryPlugin(SERVER_ENTRY_ID); + + const result = transform('export default {};', SERVER_ENTRY_ID); + + expect(result).toEqual({ + code: `import "${FLAGS_PATH}";import "${CONFIG_PATH}";export default {};`, + map: null, + }); + }); + + it.each([ + { environment: 'ssr', applies: true }, + { environment: 'client', applies: false }, + { environment: 'nuxt_vite_server_cloudflare', applies: false }, + ])('applies both plugins to the $environment environment: $applies', ({ environment, applies }) => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + setupViteServer(createFakeNuxt().nuxt, 'sentry.server.config.ts', {}); + + const results = addedPlugins().map(plugin => + (plugin.applyToEnvironment as ApplyToEnvironment)({ name: environment }), + ); + + expect(results).toEqual([applies, applies]); + }); + + it('adds no orchestrion plugin when build-time instrumentation is off', () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + + setupViteServer(createFakeNuxt().nuxt, 'sentry.server.config.ts', { buildTimeInstrumentation: false }); + + expect(addedPlugins().map(plugin => plugin.name)).toEqual(['sentry-nuxt-server-entry']); + }); + + it('warns once that the Vite server support is experimental', () => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + setupViteServer(createFakeNuxt().nuxt, 'sentry.server.config.ts', {}); + + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + expect(consoleWarnSpy).toHaveBeenCalledWith(EXPERIMENTAL_WARNING); + }); + + it('only warns in dev mode, where the server SDK does not start', () => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + setupViteServer(createFakeNuxt({ dev: true }).nuxt, 'sentry.server.config.ts', {}); + + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + expect(consoleWarnSpy).toHaveBeenCalledWith(EXPERIMENTAL_WARNING); + expect(addVitePluginMock).not.toHaveBeenCalled(); + }); + + it('does nothing while Nuxt prepares types', () => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + setupViteServer(createFakeNuxt({ _prepare: true }).nuxt, 'sentry.server.config.ts', {}); + + expect(consoleWarnSpy).not.toHaveBeenCalled(); + expect(addVitePluginMock).not.toHaveBeenCalled(); + }); + + it.each(['top-level-import', 'experimental_dynamic-import'] as const)( + 'warns about the deprecated `%s` mode and still bundles the config', + injectMode => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + setupViteServer(createFakeNuxt().nuxt, 'sentry.server.config.ts', { autoInjectServerSentry: injectMode }); + + expect(consoleWarnSpy).toHaveBeenCalledTimes(2); + expect(consoleWarnSpy).toHaveBeenCalledWith( + `[Sentry] \`autoInjectServerSentry: '${injectMode}'\` is deprecated and has no effect on the Vite server. The Sentry server config is bundled into the server build. Remove the option.`, + ); + expect(addedPlugins().map(plugin => plugin.name)).toEqual(['sentry-nuxt-server-entry', 'sentry-orchestrion']); + }, + ); + + it('warns after the build when no module received the config import', async () => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const { nuxt, callHook } = createFakeNuxt(); + setupViteServer(nuxt, 'sentry.server.config.ts', {}); + await buildServerEntryPlugin(undefined); + + callHook('build:done'); + + expect(consoleWarnSpy).toHaveBeenCalledTimes(2); + expect(consoleWarnSpy).toHaveBeenLastCalledWith(NOT_BUNDLED_WARNING); + }); + + it('does not warn after the build when the server entry received the config import', async () => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const { nuxt, callHook } = createFakeNuxt(); + setupViteServer(nuxt, 'sentry.server.config.ts', {}); + const transform = await buildServerEntryPlugin(SERVER_ENTRY_ID); + transform('export default {};', SERVER_ENTRY_ID); + + callHook('build:done'); + + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + expect(consoleWarnSpy).toHaveBeenCalledWith(EXPERIMENTAL_WARNING); + }); + + it('leaves every other module unchanged', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + setupViteServer(createFakeNuxt().nuxt, 'sentry.server.config.ts', {}); + const transform = await buildServerEntryPlugin(SERVER_ENTRY_ID); + + const result = transform('export default {};', 'virtual:nuxt:.nuxt%2Fvite-server%2Fserver-entry.mjs'); + + expect(result).toBeNull(); + }); +});