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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<script setup lang="ts">
import { useAsyncData } from '#imports';

// Server routes do not exist on the Vite server, so the page queries the database while it renders.
const { data: solution } = await useAsyncData('db-ssr', async () => {
// `mysql` only runs on Node, so it must stay out of the client bundle.
if (!import.meta.server) {
return null;
}

const { default: mysql } = await import('mysql');
const connection = mysql.createConnection({ user: 'root', password: 'docker' });

try {
return await new Promise<number>((resolve, reject) => {
connection.query('SELECT 1 + 1 AS solution', (error, results) => {
if (error) {
reject(error);
return;
}

resolve(results[0].solution);
});
});
} finally {
connection.end();
}
});
</script>

<template>
<p id="solution">{{ solution }}</p>
</template>
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
5 changes: 5 additions & 0 deletions dev-packages/e2e-tests/test-applications/nuxt-4/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
Original file line number Diff line number Diff line change
@@ -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');
Expand Down Expand Up @@ -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["']\)/);
});
Expand All @@ -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
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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('<p id="solution">2</p>');

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' },
}),
}),
);
});
Original file line number Diff line number Diff line change
@@ -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`
Expand Down
Original file line number Diff line number Diff line change
@@ -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 }) => {
Expand Down Expand Up @@ -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';
});
Expand Down
Original file line number Diff line number Diff line change
@@ -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})`, () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Original file line number Diff line number Diff line change
@@ -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`
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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');
});

Expand Down Expand Up @@ -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('-') || [];
Expand All @@ -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('-') || [];
Expand Down
Original file line number Diff line number Diff line change
@@ -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 => {
Expand Down Expand Up @@ -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 (
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
});
Expand All @@ -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`
Expand Down Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion packages/nuxt/src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -84,7 +85,11 @@ export default defineNuxtModule<ModuleOptions>({
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' });
Expand Down
Loading