diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e119a79e315..3cf431202285 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ Work in this release was contributed by @faizansaiyed123, @itisrmk, @kunal649, @ ### Important Changes +- **feat(nuxt): Start the server SDK on the Nuxt Vite server (experimental) ([#25524](https://github.com/getsentry/sentry-javascript/pull/25524))** + + Sentry now supports Node.js production builds using Nuxt 4.6's experimental Vite server (`server: { builder: 'vite' }`). Your `sentry.server.config` loads automatically, enabling request tracing and automatic database spans for supported libraries. + + Development mode, Cloudflare, automatic capture of server rendering errors, and connecting server and browser traces via HTML meta tags are not yet supported. This experimental support may change in minor releases. + - **feat(cloudflare): Add incoming request span options to `httpServerIntegration`** Cloudflare's `httpServerIntegration` now accepts `ignoreIncomingRequests`, `ignoreStaticAssets`, `spans`, `onSpanCreated` and `onSpanEnd`, with the same meaning as in the Node, Deno and Bun integrations. The callbacks receive the web `Request`. 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 bf2941ed1992..fb9cd0ed7f19 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json @@ -46,6 +46,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 38000686a79b..4702225dda1b 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 928434c44b27..b9c8f5f8c5d1 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,8 +1,11 @@ import { expect, test } from '@playwright/test'; import { waitForError } from '@sentry-internal/test-utils'; import { IMPORT_SURFACES } from './importSurfaces'; +import { isViteServer, NITRO_ONLY } from './isViteServer'; import { waitForHydration } from './waitForHydration'; +test.skip(isViteServer, NITRO_ONLY); + IMPORT_SURFACES.forEach(({ name, apiPrefix }) => { test.describe(`server-side errors (${name})`, () => { test('captures api fetch error (fetched on click)', async ({ page }) => { 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..a6d22cbc48a9 100644 --- a/packages/nuxt/src/vite/addServerConfig.ts +++ b/packages/nuxt/src/vite/addServerConfig.ts @@ -7,9 +7,9 @@ import { consoleSandbox, debug } from '@sentry/core'; import * as fs from 'fs'; import type { Nitro } from 'nitropack'; import type { InputPluginOption } from 'rollup'; -import { NUXT_DEV_MODE_FLAG, NUXT_PRERENDER_FLAG } from '../common/devMode'; import type { SentryNuxtModuleOptions } from '../common/types'; import { + addRuntimeFlagsTemplate, constructFunctionReExport, constructWrappedFunctionExportQuery, getFilenameFromNodeStartCommand, @@ -110,20 +110,7 @@ export function addSentryTopImport(moduleOptions: SentryNuxtModuleOptions, nitro */ 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({ - filename: 'sentry-runtime-flags.mjs', - write: true, - getContents: () => - [ - '// Generated by @sentry/nuxt. Sets runtime flags before the Sentry server config evaluates.', - `globalThis.${NUXT_DEV_MODE_FLAG} = import.meta.dev === true;`, - `globalThis.${NUXT_PRERENDER_FLAG} = import.meta.prerender === true;`, - '', - ].join('\n'), - }); + 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 500e94f1f8f6..306579c1ef6e 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/utils.ts b/packages/nuxt/src/vite/utils.ts index ca0e5a761b61..e5b81e2802ac 100644 --- a/packages/nuxt/src/vite/utils.ts +++ b/packages/nuxt/src/vite/utils.ts @@ -1,10 +1,11 @@ -import type { Nuxt } from '@nuxt/schema'; +import type { Nuxt, ResolvedNuxtTemplate } from '@nuxt/schema'; import { consoleSandbox } from '@sentry/core'; import * as fs from 'fs'; import * as path from 'path'; import { fileURLToPath } from 'node:url'; import type { SentryNuxtModuleOptions } from '../common/types'; -import { resolvePath, resolveServerVariant } from '@nuxt/kit'; +import { addTemplate, resolvePath, resolveServerVariant } from '@nuxt/kit'; +import { NUXT_DEV_MODE_FLAG, NUXT_PRERENDER_FLAG } from '../common/devMode'; const SERVER_API_VARIANTS = { nitro2: 'nitro2', nitro3: 'nitro3', nuxt: 'nuxt' } as const; @@ -240,3 +241,21 @@ export function addRuntimeInjectionTraceDep(nuxt: Nuxt, isNitroV3: boolean): voi const nitroOptions = nuxt.options.nitro as { traceDeps?: (string | RegExp)[] }; nitroOptions.traceDeps = [...(nitroOptions.traceDeps ?? []), '@sentry/server-runtime-injection']; } + +/** + * 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 addRuntimeFlagsTemplate(): ResolvedNuxtTemplate { + return addTemplate({ + filename: 'sentry-runtime-flags.mjs', + write: true, + getContents: () => + [ + '// Generated by @sentry/nuxt. Sets runtime flags before the Sentry server config evaluates.', + `globalThis.${NUXT_DEV_MODE_FLAG} = import.meta.dev === true;`, + `globalThis.${NUXT_PRERENDER_FLAG} = import.meta.prerender === true;`, + '', + ].join('\n'), + }); +} diff --git a/packages/nuxt/src/vite/viteServer.ts b/packages/nuxt/src/vite/viteServer.ts new file mode 100644 index 000000000000..033dcdc99ce4 --- /dev/null +++ b/packages/nuxt/src/vite/viteServer.ts @@ -0,0 +1,109 @@ +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 './utils'; + +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 { + // `nuxt prepare` (often a `postinstall` script) only generates types, so it needs no plugins and no warnings. + 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(); + }); +});