diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/instrument.mjs b/dev-packages/e2e-tests/test-applications/nitro-3/instrument.mjs index 53b80d309a5b..ff6290e461a4 100644 --- a/dev-packages/e2e-tests/test-applications/nitro-3/instrument.mjs +++ b/dev-packages/e2e-tests/test-applications/nitro-3/instrument.mjs @@ -2,7 +2,7 @@ import * as Sentry from '@sentry/nitro'; Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions - dsn: process.env.E2E_TEST_DSN, - tunnel: `http://localhost:3031/`, // proxy server + dsn: process.env.E2E_TEST_TUNNEL ? 'http://public@localhost:3031/1337' : process.env.E2E_TEST_DSN, + tunnel: process.env.E2E_TEST_TUNNEL ? true : `http://localhost:3031/`, // proxy server tracesSampleRate: 1, }); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/package.json b/dev-packages/e2e-tests/test-applications/nitro-3/package.json index eb77a39f32fb..9df73a3af556 100644 --- a/dev-packages/e2e-tests/test-applications/nitro-3/package.json +++ b/dev-packages/e2e-tests/test-applications/nitro-3/package.json @@ -9,7 +9,13 @@ "clean": "npx rimraf node_modules pnpm-lock.yaml .output", "test": "playwright test", "test:build": "pnpm install && pnpm build", - "test:assert": "pnpm test" + "test:assert": "pnpm test", + "test:build:tunnel": "pnpm install && E2E_TEST_TUNNEL=1 pnpm build", + "test:build:tunnel-basepath": "pnpm install && E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app pnpm build", + "test:assert:tunnel": "E2E_TEST_TUNNEL=1 playwright test tunnel", + "test:assert:tunnel-basepath": "E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app playwright test tunnel", + "test:assert:tunnel-dev": "TEST_ENV=development E2E_TEST_TUNNEL=1 playwright test tunnel", + "test:assert:tunnel-basepath-dev": "TEST_ENV=development E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app playwright test tunnel" }, "dependencies": { "@sentry/browser": "latest || *", @@ -25,5 +31,29 @@ }, "volta": { "extends": "../../package.json" + }, + "sentryTest": { + "variants": [ + { + "build-command": "pnpm test:build:tunnel", + "assert-command": "pnpm test:assert:tunnel", + "label": "nitro-3 (tunnel)" + }, + { + "build-command": "pnpm test:build:tunnel-basepath", + "assert-command": "pnpm test:assert:tunnel-basepath", + "label": "nitro-3 (tunnel-basepath)" + }, + { + "build-command": "pnpm install", + "assert-command": "pnpm test:assert:tunnel-dev", + "label": "nitro-3 (tunnel-dev)" + }, + { + "build-command": "pnpm install", + "assert-command": "pnpm test:assert:tunnel-basepath-dev", + "label": "nitro-3 (tunnel-basepath-dev)" + } + ] } } diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/nitro-3/playwright.config.mjs index 31f2b913b58b..62bc93244137 100644 --- a/dev-packages/e2e-tests/test-applications/nitro-3/playwright.config.mjs +++ b/dev-packages/e2e-tests/test-applications/nitro-3/playwright.config.mjs @@ -1,7 +1,10 @@ import { getPlaywrightConfig } from '@sentry-internal/test-utils'; const config = getPlaywrightConfig({ - startCommand: `pnpm start`, + startCommand: + process.env.TEST_ENV === 'development' + ? `PORT=3030 NODE_OPTIONS='--import ./instrument.mjs' pnpm exec vite dev --port 3030` + : `pnpm start`, }); export default config; diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/server/middleware/test.ts b/dev-packages/e2e-tests/test-applications/nitro-3/server/middleware/test.ts index 92d8f80c3756..0120ae6ce24e 100644 --- a/dev-packages/e2e-tests/test-applications/nitro-3/server/middleware/test.ts +++ b/dev-packages/e2e-tests/test-applications/nitro-3/server/middleware/test.ts @@ -1,6 +1,11 @@ -import { defineHandler, getQuery, setResponseHeader } from 'nitro/h3'; +import { defineHandler, getQuery, HTTPError, setResponseHeader } from 'nitro/h3'; export default defineHandler(event => { + // The tunnel variant checks that the managed tunnel answers before an auth middleware like this one. + if (process.env.E2E_TEST_TUNNEL && event.req.method === 'POST' && event.req.headers.get('x-auth') !== 'ok') { + throw new HTTPError({ status: 401, statusText: 'blocked by user auth' }); + } + setResponseHeader(event, 'x-sentry-test-middleware', 'executed'); const query = getQuery(event); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/src/main.ts b/dev-packages/e2e-tests/test-applications/nitro-3/src/main.ts index d27d0ba1763a..d09a43e411ed 100644 --- a/dev-packages/e2e-tests/test-applications/nitro-3/src/main.ts +++ b/dev-packages/e2e-tests/test-applications/nitro-3/src/main.ts @@ -1,10 +1,18 @@ import * as Sentry from '@sentry/browser'; +declare const __E2E_TEST_TUNNEL__: boolean; +declare const __E2E_TEST_BASE_PATH__: string; + // Let's us test trace propagation Sentry.init({ environment: 'qa', - dsn: 'https://public@dsn.ingest.sentry.io/1337', - tunnel: 'http://localhost:3031/', // proxy server + dsn: __E2E_TEST_TUNNEL__ ? 'http://public@localhost:3031/1337' : 'https://public@dsn.ingest.sentry.io/1337', + // Plain Nitro has no client SDK to pass `baseURL` on, so the browser names it. The server picks it up itself. + tunnel: __E2E_TEST_TUNNEL__ + ? __E2E_TEST_BASE_PATH__ + ? { basePath: __E2E_TEST_BASE_PATH__ } + : true + : 'http://localhost:3031/', // proxy server integrations: [Sentry.browserTracingIntegration()], tracesSampleRate: 1.0, }); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/nitro-3/tests/tunnel.test.ts new file mode 100644 index 000000000000..f32d0563ff3a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/tests/tunnel.test.ts @@ -0,0 +1,42 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +test.skip(!process.env.E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); + +const BASE_PATH = process.env.E2E_TEST_BASE_PATH ?? ''; +const TUNNEL_PATH = new RegExp(`^${BASE_PATH}/[a-z0-9]{1,7}$`); + +test('answers the managed tunnel before the app auth middleware', async ({ page }) => { + // The variant's auth middleware rejects every other POST. + expect((await page.request.post(`${BASE_PATH}/api/anything`, { data: 'x' })).status()).toBe(401); + + const tunnelServerSpanPromise = waitForStreamedSpan( + 'nitro-3', + span => getSpanOp(span) === 'http.server' && TUNNEL_PATH.test(String(span.attributes['url.path']?.value ?? '')), + ); + const tunnelResponsePromise = page.waitForResponse( + response => response.request().method() === 'POST' && TUNNEL_PATH.test(new URL(response.url()).pathname), + ); + + await page.goto(`${BASE_PATH}/`); + const tunnelResponse = await tunnelResponsePromise; + + expect(tunnelResponse.status()).toBe(200); + expect(new URL(tunnelResponse.url()).origin).toBe(new URL(page.url()).origin); + + // Anchor on a regular server span requested after the tunnel traffic: spans flush in order, so a tunnel span + // would arrive first. + const anchorSpanPromise = waitForStreamedSpan( + 'nitro-3', + span => + getSpanOp(span) === 'http.server' && + String(span.attributes['url.path']?.value ?? '') === `${BASE_PATH}/api/test-transaction`, + ); + await page.request.get(`${BASE_PATH}/api/test-transaction`); + + const winner = await Promise.race([ + tunnelServerSpanPromise.then(() => 'tunnel' as const), + anchorSpanPromise.then(() => 'anchor' as const), + ]); + expect(winner).toBe('anchor'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/vite.config.ts b/dev-packages/e2e-tests/test-applications/nitro-3/vite.config.ts index d488f8298777..b7c6de66d47c 100644 --- a/dev-packages/e2e-tests/test-applications/nitro-3/vite.config.ts +++ b/dev-packages/e2e-tests/test-applications/nitro-3/vite.config.ts @@ -3,12 +3,17 @@ import { nitro } from 'nitro/vite'; import { defineConfig } from 'vite'; export default defineConfig({ + define: { + __E2E_TEST_TUNNEL__: JSON.stringify(!!process.env.E2E_TEST_TUNNEL), + __E2E_TEST_BASE_PATH__: JSON.stringify(process.env.E2E_TEST_BASE_PATH ?? ''), + }, plugins: [ nitro( // FIXME: Nitro plugin has a type issue // @ts-expect-error withSentryConfig({ serverDir: './server', + ...(process.env.E2E_TEST_BASE_PATH && { baseURL: `${process.env.E2E_TEST_BASE_PATH}/` }), }), ), ], 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..b137ec2b0e52 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 @@ -1,4 +1,6 @@ // https://nuxt.com/docs/api/configuration/nuxt-config +const tunnelVariant = !!process.env.E2E_TEST_TUNNEL; + export default defineNuxtConfig({ compatibilityDate: '2025-06-06', imports: { autoImport: false }, @@ -12,12 +14,17 @@ export default defineNuxtConfig({ '/rendering-modes/pre-rendered-page': { prerender: true }, }, + // The base-path variant serves the app, and so the tunnel, under `/app`. + ...(process.env.E2E_TEST_BASE_PATH && { app: { baseURL: `${process.env.E2E_TEST_BASE_PATH}/` } }), + modules: ['@pinia/nuxt', '@sentry/nuxt/module'], runtimeConfig: { public: { sentry: { - dsn: 'https://public@dsn.ingest.sentry.io/1337', + // The tunnel variant forwards to the test proxy, so the DSN has to point there. + dsn: tunnelVariant ? 'http://public@localhost:3031/1337' : 'https://public@dsn.ingest.sentry.io/1337', }, + e2eTunnel: tunnelVariant, }, }, nitro: { 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..6a5c249f7aef 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json @@ -16,7 +16,13 @@ "test:build": "pnpm install && node ./scripts/build-with-prerender-event-sink.mjs", "test:build-canary": "pnpm add nuxt@npm:nuxt-nightly@latest && pnpm add nitropack@npm:nitropack-nightly@latest && pnpm install --force && node ./scripts/build-with-prerender-event-sink.mjs", "test:assert": "pnpm test:prod && pnpm test:dev", - "test:prod:import": "TEST_ENV=production-import playwright test" + "test:prod:import": "TEST_ENV=production-import playwright test", + "test:build:tunnel": "pnpm install && E2E_TEST_TUNNEL=1 node ./scripts/build-with-prerender-event-sink.mjs", + "test:build:tunnel-basepath": "pnpm install && E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app node ./scripts/build-with-prerender-event-sink.mjs", + "test:assert:tunnel": "E2E_TEST_TUNNEL=1 TEST_ENV=production playwright test tunnel", + "test:assert:tunnel-basepath": "E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app TEST_ENV=production playwright test tunnel", + "test:assert:tunnel-dev": "E2E_TEST_TUNNEL=1 TEST_ENV=development playwright test tunnel", + "test:assert:tunnel-basepath-dev": "E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app TEST_ENV=development playwright test tunnel" }, "//": "Pin ioredis to 5.10.1 because that's the last version before it publishes its own diagnostics channels", "dependencies": { @@ -45,6 +51,26 @@ "build-command": "pnpm test:build", "assert-command": "pnpm test:prod:import", "label": "nuxt-4 (--import compat)" + }, + { + "build-command": "pnpm test:build:tunnel", + "assert-command": "pnpm test:assert:tunnel", + "label": "nuxt-4 (tunnel)" + }, + { + "build-command": "pnpm test:build:tunnel-basepath", + "assert-command": "pnpm test:assert:tunnel-basepath", + "label": "nuxt-4 (tunnel-basepath)" + }, + { + "build-command": "pnpm install", + "assert-command": "pnpm test:assert:tunnel-dev", + "label": "nuxt-4 (tunnel-dev)" + }, + { + "build-command": "pnpm install", + "assert-command": "pnpm test:assert:tunnel-basepath-dev", + "label": "nuxt-4 (tunnel-basepath-dev)" } ], "optionalVariants": [ diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.client.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.client.config.ts index 7b5d97ff0b09..64c786d5f7e8 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.client.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.client.config.ts @@ -3,7 +3,7 @@ import { usePinia, useRuntimeConfig } from '#imports'; Sentry.init({ dsn: useRuntimeConfig().public.sentry.dsn, - tunnel: `http://localhost:3031/`, // proxy server + tunnel: useRuntimeConfig().public.e2eTunnel ? true : `http://localhost:3031/`, // proxy server tracesSampleRate: 1.0, integrations: [ Sentry.piniaIntegration(usePinia(), { diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.server.config.ts index acce04cc9d04..1e29b95f161a 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.server.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.server.config.ts @@ -1,9 +1,9 @@ import * as Sentry from '@sentry/nuxt'; Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', + dsn: process.env.E2E_TEST_TUNNEL ? 'http://public@localhost:3031/1337' : 'https://public@dsn.ingest.sentry.io/1337', tracesSampleRate: 1.0, // Capture 100% of the transactions - tunnel: 'http://localhost:3031/', // proxy server + tunnel: process.env.E2E_TEST_TUNNEL ? true : 'http://localhost:3031/', // proxy server // Opt into the Sentry OpenTelemetry tracer provider in the "(tracer provider)" e2e variant. // Leaving it `undefined` otherwise keeps the SDK's default (no provider). enableOpenTelemetrySetup: process.env.E2E_TEST_OTEL_SETUP === 'true' ? true : undefined, diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/server/middleware/03.auth.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/server/middleware/03.auth.ts index 6dcd9a075589..471d1d32d7a4 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/server/middleware/03.auth.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/server/middleware/03.auth.ts @@ -1,6 +1,11 @@ -import { defineEventHandler, setHeader, getQuery } from '#imports'; +import { createError, defineEventHandler, getHeader, setHeader, getQuery } from '#imports'; export default defineEventHandler(async event => { + // The tunnel variant checks that the managed tunnel answers before an auth middleware like this one. + if (process.env.E2E_TEST_TUNNEL && event.method === 'POST' && getHeader(event, 'x-auth') !== 'ok') { + throw createError({ statusCode: 401, statusMessage: 'blocked by user auth' }); + } + // Check if we should throw an error const query = getQuery(event); if (query.throwError === 'true') { diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tunnel.test.ts new file mode 100644 index 000000000000..1e48be35166c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tunnel.test.ts @@ -0,0 +1,42 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +test.skip(!process.env.E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); + +const BASE_PATH = process.env.E2E_TEST_BASE_PATH ?? ''; +const TUNNEL_PATH = new RegExp(`^${BASE_PATH}/[a-z0-9]{1,7}$`); + +test('answers the managed tunnel before the app auth middleware', async ({ page }) => { + // The variant's auth middleware rejects every other POST. + expect((await page.request.post(`${BASE_PATH}/api/anything`, { data: 'x' })).status()).toBe(401); + + const tunnelServerSpanPromise = waitForStreamedSpan( + 'nuxt-4', + span => getSpanOp(span) === 'http.server' && TUNNEL_PATH.test(String(span.attributes['url.path']?.value ?? '')), + ); + const tunnelResponsePromise = page.waitForResponse( + response => response.request().method() === 'POST' && TUNNEL_PATH.test(new URL(response.url()).pathname), + ); + + await page.goto(`${BASE_PATH}/`); + const tunnelResponse = await tunnelResponsePromise; + + expect(tunnelResponse.status()).toBe(200); + expect(new URL(tunnelResponse.url()).origin).toBe(new URL(page.url()).origin); + + // Anchor on a regular server span requested after the tunnel traffic: spans flush in order, so a tunnel span + // would arrive first. + const anchorSpanPromise = waitForStreamedSpan( + 'nuxt-4', + span => + getSpanOp(span) === 'http.server' && + String(span.attributes['url.path']?.value ?? '') === `${BASE_PATH}/api/test-param/123`, + ); + await page.request.get(`${BASE_PATH}/api/test-param/123`); + + const winner = await Promise.race([ + tunnelServerSpanPromise.then(() => 'tunnel' as const), + anchorSpanPromise.then(() => 'anchor' as const), + ]); + expect(winner).toBe('anchor'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/nuxt.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/nuxt.config.ts index bdef334cfa88..8016834d1167 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/nuxt.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/nuxt.config.ts @@ -1,4 +1,6 @@ // https://nuxt.com/docs/api/configuration/nuxt-config +const tunnelVariant = !!process.env.E2E_TEST_TUNNEL; + export default defineNuxtConfig({ compatibilityDate: '2025-07-15', imports: { autoImport: false }, @@ -12,12 +14,17 @@ export default defineNuxtConfig({ '/rendering-modes/pre-rendered-page': { prerender: true }, }, + // The base-path variant serves the app, and so the tunnel, under `/app`. + ...(process.env.E2E_TEST_BASE_PATH && { app: { baseURL: `${process.env.E2E_TEST_BASE_PATH}/` } }), + modules: ['@pinia/nuxt', '@sentry/nuxt/module'], runtimeConfig: { public: { sentry: { - dsn: 'https://public@dsn.ingest.sentry.io/1337', + // The tunnel variant forwards to the test proxy, so the DSN has to point there. + dsn: tunnelVariant ? 'http://public@localhost:3031/1337' : 'https://public@dsn.ingest.sentry.io/1337', }, + e2eTunnel: tunnelVariant, }, }, nitro: { diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/package.json b/dev-packages/e2e-tests/test-applications/nuxt-5/package.json index 6e84b33b97fa..1b04f519e281 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/package.json @@ -15,7 +15,12 @@ "test:dev": "TEST_ENV=development playwright test environment", "test:build": "pnpm install && pnpm build", "test:build-canary": "pnpm add nuxt@npm:nuxt-nightly@latest && pnpm add nitro@npm:nitro-nightly@latest && pnpm install --force && pnpm build", - "test:assert": "pnpm test:prod && pnpm test:dev" + "test:assert": "pnpm test:prod && pnpm test:dev", + "test:build:tunnel": "pnpm install && E2E_TEST_TUNNEL=1 pnpm build", + "test:build:tunnel-basepath": "pnpm install && E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app pnpm build", + "test:assert:tunnel": "E2E_TEST_TUNNEL=1 TEST_ENV=production playwright test tunnel", + "test:assert:tunnel-basepath": "E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app TEST_ENV=production playwright test tunnel", + "test:assert:tunnel-dev": "E2E_TEST_TUNNEL=1 TEST_ENV=development playwright test tunnel" }, "//": [ "Currently, we need to install the latest version of Nitro and the Nuxt nightlies as those contain Nuxt v5", @@ -38,7 +43,24 @@ "@sentry-internal/test-utils": "link:../../../test-utils" }, "sentryTest": { - "optional": true + "optional": true, + "variants": [ + { + "build-command": "pnpm test:build:tunnel", + "assert-command": "pnpm test:assert:tunnel", + "label": "nuxt-5 (tunnel)" + }, + { + "build-command": "pnpm test:build:tunnel-basepath", + "assert-command": "pnpm test:assert:tunnel-basepath", + "label": "nuxt-5 (tunnel-basepath)" + }, + { + "build-command": "pnpm install", + "assert-command": "pnpm test:assert:tunnel-dev", + "label": "nuxt-5 (tunnel-dev)" + } + ] }, "volta": { "extends": "../../package.json" diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/sentry.client.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/sentry.client.config.ts index 900df0811faa..f5a30830107b 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/sentry.client.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/sentry.client.config.ts @@ -3,7 +3,7 @@ import { /* usePinia,*/ useRuntimeConfig } from '#imports'; Sentry.init({ dsn: useRuntimeConfig().public.sentry.dsn, - tunnel: `http://localhost:3031/`, // proxy server + tunnel: useRuntimeConfig().public.e2eTunnel ? true : `http://localhost:3031/`, // proxy server tracesSampleRate: 1.0, integrations: [ /* Sentry.piniaIntegration(usePinia(), { diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/sentry.server.config.ts index 58d541b5228b..ae05c60355bb 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/sentry.server.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/sentry.server.config.ts @@ -1,9 +1,9 @@ import * as Sentry from '@sentry/nuxt'; Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', + dsn: process.env.E2E_TEST_TUNNEL ? 'http://public@localhost:3031/1337' : 'https://public@dsn.ingest.sentry.io/1337', tracesSampleRate: 1.0, // Capture 100% of the transactions - tunnel: 'http://localhost:3031/', // proxy server + tunnel: process.env.E2E_TEST_TUNNEL ? true : 'http://localhost:3031/', // proxy server // Several tests trigger the same server error one after another, which Dedupe would drop. integrations: integrations => integrations.filter(integration => integration.name !== 'Dedupe'), }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/03.auth.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/03.auth.ts index 7216e9fc7560..62d2eebd07e0 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/03.auth.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/03.auth.ts @@ -1,7 +1,12 @@ import { defineHandler } from 'nitro'; -import { getQuery } from 'nitro/h3'; +import { getQuery, HTTPError } from 'nitro/h3'; export default defineHandler(async event => { + // The tunnel variant checks that the managed tunnel answers before an auth middleware like this one. + if (process.env.E2E_TEST_TUNNEL && event.req.method === 'POST' && event.req.headers.get('x-auth') !== 'ok') { + throw new HTTPError({ status: 401, statusText: 'blocked by user auth' }); + } + // Check if we should throw an error const query = getQuery(event); if (query.throwError === 'true') { diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tunnel.test.ts new file mode 100644 index 000000000000..d814f3fab1c9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tunnel.test.ts @@ -0,0 +1,42 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +test.skip(!process.env.E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); + +const BASE_PATH = process.env.E2E_TEST_BASE_PATH ?? ''; +const TUNNEL_PATH = new RegExp(`^${BASE_PATH}/[a-z0-9]{1,7}$`); + +test('answers the managed tunnel before the app auth middleware', async ({ page }) => { + // The variant's auth middleware rejects every other POST. + expect((await page.request.post(`${BASE_PATH}/api/anything`, { data: 'x' })).status()).toBe(401); + + const tunnelServerSpanPromise = waitForStreamedSpan( + 'nuxt-5', + span => getSpanOp(span) === 'http.server' && TUNNEL_PATH.test(String(span.attributes['url.path']?.value ?? '')), + ); + const tunnelResponsePromise = page.waitForResponse( + response => response.request().method() === 'POST' && TUNNEL_PATH.test(new URL(response.url()).pathname), + ); + + await page.goto(`${BASE_PATH}/`); + const tunnelResponse = await tunnelResponsePromise; + + expect(tunnelResponse.status()).toBe(200); + expect(new URL(tunnelResponse.url()).origin).toBe(new URL(page.url()).origin); + + // Anchor on a regular server span requested after the tunnel traffic: spans flush in order, so a tunnel span + // would arrive first. + const anchorSpanPromise = waitForStreamedSpan( + 'nuxt-5', + span => + getSpanOp(span) === 'http.server' && + String(span.attributes['url.path']?.value ?? '') === `${BASE_PATH}/api/test-param/123`, + ); + await page.request.get(`${BASE_PATH}/api/test-param/123`); + + const winner = await Promise.race([ + tunnelServerSpanPromise.then(() => 'tunnel' as const), + anchorSpanPromise.then(() => 'anchor' as const), + ]); + expect(winner).toBe('anchor'); +}); diff --git a/packages/nitro/src/module.ts b/packages/nitro/src/module.ts index 1a4e5b0478d1..8df28a8a2550 100644 --- a/packages/nitro/src/module.ts +++ b/packages/nitro/src/module.ts @@ -1,3 +1,4 @@ +import { getClient, setManagedTunnelBasePath } from '@sentry/core'; import type { NitroModule } from 'nitro/types'; import type { SentryNitroOptions } from './config'; import { instrumentServer } from './instruments/instrumentServer'; @@ -12,6 +13,12 @@ export function createNitroModule(sentryOptions?: SentryNitroOptions, sentryEnab setup: nitro => { instrumentServer(nitro); setupSourceMaps(nitro, sentryOptions, sentryEnabledSourcemaps); + + // In dev, Vite's server takes each request in this process before Nitro handles it, and its `http.server` span + // comes from the SDK `--import` set up here. The runtime plugin only reaches the client where Nitro runs. + if (nitro.options.dev) { + setManagedTunnelBasePath(getClient(), nitro.options.baseURL); + } }, }; } diff --git a/packages/nitro/src/runtime/plugins/server.ts b/packages/nitro/src/runtime/plugins/server.ts index 9fd3f93a6f40..e28d8ae9282a 100644 --- a/packages/nitro/src/runtime/plugins/server.ts +++ b/packages/nitro/src/runtime/plugins/server.ts @@ -1,4 +1,6 @@ +import { getClient, handleTunnelRequestIfMatched, setManagedTunnelBasePath } from '@sentry/core'; import { definePlugin } from 'nitro'; +import { useRuntimeConfig } from 'nitro/runtime-config'; import { captureErrorHook } from '../hooks/captureErrorHook'; import { captureStorageEvents } from '../hooks/captureStorageEvents'; import { captureTracingEvents } from '../hooks/captureTracingEvents'; @@ -6,6 +8,19 @@ import { captureTracingEvents } from '../hooks/captureTracingEvents'; export default definePlugin(nitroApp => { nitroApp.hooks.hook('error', captureErrorHook); + // Nitro runs the app's `middleware/` before anything a module registers, so an auth middleware there would block + // the tunnel. Nitro has no public way to go first, but its own generated code wires middleware through this array. + // Requests still carry Nitro's `baseURL` here, so the tunnel is served under it. + const applyBaseURL = (): void => + setManagedTunnelBasePath(getClient(), (useRuntimeConfig() as { app?: { baseURL?: string } }).app?.baseURL); + applyBaseURL(); + nitroApp.h3?.['~middleware'].unshift((event, next) => { + // `init` may run after this plugin, so this also covers the first requests after it. + applyBaseURL(); + + return handleTunnelRequestIfMatched(event.req) ?? next(); + }); + captureTracingEvents(); captureStorageEvents(); }); diff --git a/packages/nitro/test/module.test.ts b/packages/nitro/test/module.test.ts new file mode 100644 index 000000000000..f967e66da9bb --- /dev/null +++ b/packages/nitro/test/module.test.ts @@ -0,0 +1,42 @@ +import type { Client } from '@sentry/core'; +import { getCurrentScope, makeDsn } from '@sentry/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createNitroModule } from '../src/module'; + +vi.mock('../src/instruments/instrumentServer', () => ({ instrumentServer: vi.fn() })); +vi.mock('../src/sourceMaps', () => ({ setupSourceMaps: vi.fn() })); + +const DSN = 'https://public@o1.ingest.sentry.io/1337'; + +function setClient(managedTunnel: object | undefined): Record { + const options = { dsn: DSN, _managedTunnel: managedTunnel }; + getCurrentScope().setClient({ getDsn: () => makeDsn(DSN), getOptions: () => options } as unknown as Client); + + return options; +} + +function setup(options: { dev: boolean; baseURL: string }): void { + (createNitroModule().setup as (nitro: unknown) => void)({ options }); +} + +describe('createNitroModule', () => { + afterEach(() => { + getCurrentScope().setClient(undefined); + }); + + it("serves the dev server's managed tunnel under `baseURL`", () => { + const options = setClient({}); + + setup({ dev: true, baseURL: '/app/' }); + + expect(options._managedTunnel).toEqual({ basePath: '/app' }); + }); + + it('leaves the client alone in a build, where the runtime plugin applies the base path', () => { + const options = setClient({}); + + setup({ dev: false, baseURL: '/app/' }); + + expect(options._managedTunnel).toEqual({}); + }); +}); diff --git a/packages/nuxt/src/client/sdk.ts b/packages/nuxt/src/client/sdk.ts index 76428543a716..4aa7891645b8 100644 --- a/packages/nuxt/src/client/sdk.ts +++ b/packages/nuxt/src/client/sdk.ts @@ -1,6 +1,7 @@ import { getDefaultIntegrations as getBrowserDefaultIntegrations, init as initBrowser } from '@sentry/browser'; import type { Client } from '@sentry/core'; import { applySdkMetadata, DEFAULT_ENVIRONMENT, DEV_ENVIRONMENT } from '@sentry/core'; +import { applyNuxtTunnelBasePath } from '../common/tunnel'; import type { SentryNuxtClientOptions } from '../common/types'; /** @@ -17,6 +18,7 @@ export function init(options: SentryNuxtClientOptions): Client | undefined { ...options, }; + applyNuxtTunnelBasePath(sentryOptions); applySdkMetadata(sentryOptions, 'nuxt', ['nuxt', 'vue']); return initBrowser(sentryOptions); diff --git a/packages/nuxt/src/common/tunnel.ts b/packages/nuxt/src/common/tunnel.ts new file mode 100644 index 000000000000..58b23d06b854 --- /dev/null +++ b/packages/nuxt/src/common/tunnel.ts @@ -0,0 +1,16 @@ +import type { Options } from '@sentry/core'; +import { applyTunnelDefaults, GLOBAL_OBJ, normalizeTunnelBasePath } from '@sentry/core'; + +/** Set by the generated client config plugin to the app's runtime `app.baseURL`. */ +export const NUXT_TUNNEL_BASE_PATH_GLOBAL = '__sentry_nuxt_base_path'; + +/** + * Serves the managed tunnel under the app's `app.baseURL`, which a proxy in front of the app may be the only path + * forwarded to it. It's read from runtime config, so a `NUXT_APP_BASE_URL` set at runtime is honoured too. + * A `basePath` the user set wins. + */ +export function applyNuxtTunnelBasePath(options: Pick): void { + const baseURL = (GLOBAL_OBJ as { [NUXT_TUNNEL_BASE_PATH_GLOBAL]?: string })[NUXT_TUNNEL_BASE_PATH_GLOBAL]; + const basePath = normalizeTunnelBasePath(baseURL); + options.tunnel = applyTunnelDefaults(options.tunnel, basePath ? { basePath } : undefined); +} diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index 10f22891ac2d..643fc3bcd2eb 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -3,6 +3,7 @@ import { addPlugin, addPluginTemplate, addTemplate, addVitePlugin, createResolve import type {} from '@nuxt/nitro-server'; import * as path from 'path'; +import { NUXT_TUNNEL_BASE_PATH_GLOBAL } from './common/tunnel'; import type { SentryNuxtModuleOptions } from './common/types'; import { setupNitroServer } from './vite/nitroServer'; import { setupOrchestrion } from './vite/orchestrion'; @@ -55,11 +56,12 @@ export default defineNuxtModule({ // Dynamic import of config file to wrap it within a Nuxt context (here: defineNuxtPlugin) // Makes it possible to call useRuntimeConfig() in the user-defined sentry config file getContents: () => ` - import { defineNuxtPlugin } from "#imports"; + import { defineNuxtPlugin, useRuntimeConfig } from "#imports"; export default defineNuxtPlugin({ name: 'sentry-client-config', async setup() { + globalThis.${NUXT_TUNNEL_BASE_PATH_GLOBAL} = useRuntimeConfig().app.baseURL; await import("${buildDirResolver.resolve(`/${clientConfigFile}`)}") } });`, diff --git a/packages/nuxt/src/runtime/plugins/tunnel-legacy.server.ts b/packages/nuxt/src/runtime/plugins/tunnel-legacy.server.ts new file mode 100644 index 000000000000..4a3b282bbc64 --- /dev/null +++ b/packages/nuxt/src/runtime/plugins/tunnel-legacy.server.ts @@ -0,0 +1,30 @@ +import { getClient, handleTunnelRequestIfMatched, isTunnelRequest, setManagedTunnelBasePath } from '@sentry/core'; +import { sendWebResponse, toWebRequest } from 'h3'; +import type { NitroAppPlugin } from 'nitropack'; +import { useRuntimeConfig } from 'nitropack/runtime'; + +/** + * Answers managed tunnel requests on Nuxt v3 and v4 (Nitro v2). The `request` hook runs before route rules and + * the app's `server/middleware`. + */ +export default (nitroApp => { + // The `request` hook sees the path before Nitro strips `app.baseURL`, so the tunnel is served under it. + // `useRuntimeConfig` also picks up a `NUXT_APP_BASE_URL` set at runtime, the same value the browser gets. + const applyBaseURL = (): void => setManagedTunnelBasePath(getClient(), useRuntimeConfig().app?.baseURL); + applyBaseURL(); + + nitroApp.hooks.hook('request', async event => { + // `init` may run after this plugin, so this also covers the first requests after it. + applyBaseURL(); + + // Checked on the method and path first, so no other request's body gets wrapped in a web `Request`. + if (!isTunnelRequest({ method: event.method, url: event.path })) { + return; + } + + const tunnelResponse = handleTunnelRequestIfMatched(toWebRequest(event)); + if (tunnelResponse) { + await sendWebResponse(event, await tunnelResponse); + } + }); +}) satisfies NitroAppPlugin; diff --git a/packages/nuxt/src/runtime/plugins/tunnel.server.ts b/packages/nuxt/src/runtime/plugins/tunnel.server.ts new file mode 100644 index 000000000000..8ebf122e5ad2 --- /dev/null +++ b/packages/nuxt/src/runtime/plugins/tunnel.server.ts @@ -0,0 +1,22 @@ +import { getClient, handleTunnelRequestIfMatched, setManagedTunnelBasePath } from '@sentry/core'; +import { useRuntimeConfig } from 'nitro/runtime-config'; +import type { NitroAppPlugin, NitroApp } from 'nitro/types'; + +/** + * Answers managed tunnel requests on Nuxt v5+ (Nitro v3+). + */ +export default ((nitroApp: NitroApp) => { + // Nitro runs the app's `server/middleware` before anything a module registers, so an auth middleware there would + // block the tunnel. Nitro has no public way to go first, but its own generated code wires middleware through + // this same array. + // Requests still carry Nuxt's `app.baseURL` here, so the tunnel is served under it. + const applyBaseURL = (): void => + setManagedTunnelBasePath(getClient(), (useRuntimeConfig() as { app?: { baseURL?: string } }).app?.baseURL); + applyBaseURL(); + nitroApp.h3?.['~middleware'].unshift((event, next) => { + // `init` may run after this plugin, so this also covers the first requests after it. + applyBaseURL(); + + return handleTunnelRequestIfMatched(event.req) ?? next(); + }); +}) satisfies NitroAppPlugin; diff --git a/packages/nuxt/src/vite/nitroServer.ts b/packages/nuxt/src/vite/nitroServer.ts index 500e94f1f8f6..eebfd8e12ccf 100644 --- a/packages/nuxt/src/vite/nitroServer.ts +++ b/packages/nuxt/src/vite/nitroServer.ts @@ -50,9 +50,11 @@ export function setupNitroServer( if (isNitroV3) { addNitroPlugin(moduleDirResolver.resolve('./runtime/plugins/handler.server')); addNitroPlugin(moduleDirResolver.resolve('./runtime/plugins/update-route-name.server')); + addNitroPlugin(moduleDirResolver.resolve('./runtime/plugins/tunnel.server')); } else { addNitroPlugin(moduleDirResolver.resolve('./runtime/plugins/handler-legacy.server')); addNitroPlugin(moduleDirResolver.resolve('./runtime/plugins/update-route-name-legacy.server')); + addNitroPlugin(moduleDirResolver.resolve('./runtime/plugins/tunnel-legacy.server')); } addNitroPlugin(moduleDirResolver.resolve('./runtime/plugins/sentry.server')); diff --git a/packages/nuxt/test/common/tunnel.test.ts b/packages/nuxt/test/common/tunnel.test.ts new file mode 100644 index 000000000000..ed491b077fdd --- /dev/null +++ b/packages/nuxt/test/common/tunnel.test.ts @@ -0,0 +1,40 @@ +import { GLOBAL_OBJ } from '@sentry/core'; +import { afterEach, describe, expect, it } from 'vitest'; +import { applyNuxtTunnelBasePath, NUXT_TUNNEL_BASE_PATH_GLOBAL } from '../../src/common/tunnel'; + +const globalWithBasePath = GLOBAL_OBJ as { [NUXT_TUNNEL_BASE_PATH_GLOBAL]?: string }; + +describe('applyNuxtTunnelBasePath', () => { + afterEach(() => { + delete globalWithBasePath.__sentry_nuxt_base_path; + }); + + it('serves the managed tunnel under `app.baseURL`', () => { + globalWithBasePath[NUXT_TUNNEL_BASE_PATH_GLOBAL] = '/app/'; + const options: Parameters[0] = { tunnel: true }; + + applyNuxtTunnelBasePath(options); + + expect(options.tunnel).toEqual({ basePath: '/app' }); + }); + + it('keeps a base path the user set', () => { + globalWithBasePath[NUXT_TUNNEL_BASE_PATH_GLOBAL] = '/app/'; + const options: Parameters[0] = { tunnel: { basePath: '/custom' } }; + + applyNuxtTunnelBasePath(options); + + expect(options.tunnel).toEqual({ basePath: '/custom' }); + }); + + it('serves from the root for the default and relative base URLs', () => { + for (const baseURL of ['/', './']) { + globalWithBasePath[NUXT_TUNNEL_BASE_PATH_GLOBAL] = baseURL; + const options: Parameters[0] = { tunnel: true }; + + applyNuxtTunnelBasePath(options); + + expect(options.tunnel).toBe(true); + } + }); +}); diff --git a/packages/nuxt/test/vite/nitroServer.test.ts b/packages/nuxt/test/vite/nitroServer.test.ts index a1ab7e7d40e2..44649c1c536f 100644 --- a/packages/nuxt/test/vite/nitroServer.test.ts +++ b/packages/nuxt/test/vite/nitroServer.test.ts @@ -65,26 +65,32 @@ describe('setupNitroServer', () => { serverApi: 'nitro2', handler: 'handler-legacy.server', updateRouteName: 'update-route-name-legacy.server', + tunnel: 'tunnel-legacy.server', storage: 'storage-legacy.server', }, { serverApi: 'nitro3', handler: 'handler.server', updateRouteName: 'update-route-name.server', + tunnel: 'tunnel.server', storage: 'storage.server', }, - ] as const)('registers the $handler plugins on $serverApi', ({ serverApi, handler, updateRouteName, storage }) => { - const { nuxt } = createFakeNuxt(); - - setupNitroServer(nuxt, serverApi, SERVER_CONFIG, MODULE_OPTIONS); - - expect(addNitroPluginMock).toHaveBeenCalledTimes(5); - expect(addNitroPluginMock).toHaveBeenNthCalledWith(1, `${APP_ROOT}/.nuxt/sentry-server-config-plugin.mjs`); - expect(addNitroPluginMock).toHaveBeenNthCalledWith(2, `./runtime/plugins/${handler}`); - expect(addNitroPluginMock).toHaveBeenNthCalledWith(3, `./runtime/plugins/${updateRouteName}`); - expect(addNitroPluginMock).toHaveBeenNthCalledWith(4, './runtime/plugins/sentry.server'); - expect(addNitroPluginMock).toHaveBeenNthCalledWith(5, `./runtime/plugins/${storage}`); - }); + ] as const)( + 'registers the $handler plugins on $serverApi', + ({ serverApi, handler, updateRouteName, tunnel, storage }) => { + const { nuxt } = createFakeNuxt(); + + setupNitroServer(nuxt, serverApi, SERVER_CONFIG, MODULE_OPTIONS); + + expect(addNitroPluginMock).toHaveBeenCalledTimes(6); + expect(addNitroPluginMock).toHaveBeenNthCalledWith(1, `${APP_ROOT}/.nuxt/sentry-server-config-plugin.mjs`); + expect(addNitroPluginMock).toHaveBeenNthCalledWith(2, `./runtime/plugins/${handler}`); + expect(addNitroPluginMock).toHaveBeenNthCalledWith(3, `./runtime/plugins/${updateRouteName}`); + expect(addNitroPluginMock).toHaveBeenNthCalledWith(4, `./runtime/plugins/${tunnel}`); + expect(addNitroPluginMock).toHaveBeenNthCalledWith(5, './runtime/plugins/sentry.server'); + expect(addNitroPluginMock).toHaveBeenNthCalledWith(6, `./runtime/plugins/${storage}`); + }, + ); it('instruments the middleware and adds the config shim once Nitro initializes', () => { const { nuxt, callHook } = createFakeNuxt();