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 @@
+
+
+
+ {{ solution }}
+
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();
+ });
+});