From b88d103e39f1ed92a51d0ac79ef0b0b2d810d686 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 11:59:06 -0400 Subject: [PATCH 01/13] feat(nuxt, nitro): Serve the managed tunnel before the app's server middleware On Nitro v3, Nuxt and `@sentry/nitro` put the tunnel check first in h3's middleware list, since Nitro runs the app's `server/middleware` before anything a module registers and has no public way to go first. On Nitro v2, Nuxt answers from the `request` hook. --- .../test-applications/nitro-3/instrument.mjs | 4 +- .../test-applications/nitro-3/package.json | 13 +++++- .../nitro-3/server/middleware/test.ts | 7 +++- .../test-applications/nitro-3/src/main.ts | 6 ++- .../nitro-3/tests/tunnel.test.ts | 40 +++++++++++++++++++ .../test-applications/nitro-3/vite.config.ts | 3 ++ .../test-applications/nuxt-4/nuxt.config.ts | 6 ++- .../test-applications/nuxt-4/package.json | 9 ++++- .../nuxt-4/sentry.client.config.ts | 2 +- .../nuxt-4/sentry.server.config.ts | 4 +- .../nuxt-4/server/middleware/03.auth.ts | 7 +++- .../nuxt-4/tests/tunnel.test.ts | 40 +++++++++++++++++++ .../test-applications/nuxt-5/nuxt.config.ts | 6 ++- .../test-applications/nuxt-5/package.json | 13 +++++- .../nuxt-5/sentry.client.config.ts | 2 +- .../nuxt-5/sentry.server.config.ts | 4 +- .../nuxt-5/server/middleware/03.auth.ts | 7 +++- .../nuxt-5/tests/tunnel.test.ts | 40 +++++++++++++++++++ packages/nitro/src/runtime/plugins/server.ts | 5 +++ .../runtime/plugins/tunnel-legacy.server.ts | 21 ++++++++++ .../nuxt/src/runtime/plugins/tunnel.server.ts | 12 ++++++ packages/nuxt/src/vite/nitroServer.ts | 2 + packages/nuxt/test/vite/nitroServer.test.ts | 30 ++++++++------ 23 files changed, 252 insertions(+), 31 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/nitro-3/tests/tunnel.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4/tests/tunnel.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/tests/tunnel.test.ts create mode 100644 packages/nuxt/src/runtime/plugins/tunnel-legacy.server.ts create mode 100644 packages/nuxt/src/runtime/plugins/tunnel.server.ts 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 82836a613593..6d51ccb4eade 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,9 @@ "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:assert:tunnel": "E2E_TEST_TUNNEL=1 playwright test tunnel" }, "dependencies": { "@sentry/browser": "latest || *", @@ -26,5 +28,14 @@ }, "volta": { "extends": "../../package.json" + }, + "sentryTest": { + "variants": [ + { + "build-command": "pnpm test:build:tunnel", + "assert-command": "pnpm test:assert:tunnel", + "label": "nitro-3 (tunnel)" + } + ] } } 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..6c9d7e4d6e39 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,12 @@ import * as Sentry from '@sentry/browser'; +declare const __E2E_TEST_TUNNEL__: boolean; + // 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', + tunnel: __E2E_TEST_TUNNEL__ ? 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..0ae8c2181aa3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/tests/tunnel.test.ts @@ -0,0 +1,40 @@ +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 TUNNEL_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('/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('/'); + 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 ?? '') === '/api/test-transaction', + ); + await page.request.get('/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..3fa4ce57a69c 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,6 +3,9 @@ import { nitro } from 'nitro/vite'; import { defineConfig } from 'vite'; export default defineConfig({ + define: { + __E2E_TEST_TUNNEL__: JSON.stringify(!!process.env.E2E_TEST_TUNNEL), + }, plugins: [ nitro( // FIXME: Nitro plugin has a type issue 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..f8206a22f2f7 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 }, @@ -16,8 +18,10 @@ export default defineNuxtConfig({ 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 92fa2a41d87b..238871b5d182 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,9 @@ "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:assert:tunnel": "E2E_TEST_TUNNEL=1 TEST_ENV=production 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 +47,11 @@ "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)" } ], "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..aeccb68e67f5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tunnel.test.ts @@ -0,0 +1,40 @@ +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 TUNNEL_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('/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('/'); + 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 ?? '') === '/api/test-param/123', + ); + await page.request.get('/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..63557fb9a85b 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 }, @@ -16,8 +18,10 @@ export default defineNuxtConfig({ 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 ef7d5c1fbc3c..dfc03996bc77 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,9 @@ "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:assert:tunnel": "E2E_TEST_TUNNEL=1 TEST_ENV=production playwright test tunnel" }, "//": [ "Currently, we need to install the latest version of Nitro and the Nuxt nightlies as those contain Nuxt v5", @@ -38,7 +40,14 @@ "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz" }, "sentryTest": { - "optional": true + "optional": true, + "variants": [ + { + "build-command": "pnpm test:build:tunnel", + "assert-command": "pnpm test:assert:tunnel", + "label": "nuxt-5 (tunnel)" + } + ] }, "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..5dec3575dd0c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tunnel.test.ts @@ -0,0 +1,40 @@ +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 TUNNEL_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('/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('/'); + 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 ?? '') === '/api/test-param/123', + ); + await page.request.get('/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/runtime/plugins/server.ts b/packages/nitro/src/runtime/plugins/server.ts index f018d3eb6869..07dbc1958f69 100644 --- a/packages/nitro/src/runtime/plugins/server.ts +++ b/packages/nitro/src/runtime/plugins/server.ts @@ -1,6 +1,11 @@ +import { handleTunnelRequestIfMatched } from '@sentry/core'; import { definePlugin } from 'nitro'; import { captureErrorHook } from '../hooks/captureErrorHook'; 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. + nitroApp.h3?.['~middleware'].unshift((event, next) => handleTunnelRequestIfMatched(event.req) ?? next()); }); 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..f6c06bc20d43 --- /dev/null +++ b/packages/nuxt/src/runtime/plugins/tunnel-legacy.server.ts @@ -0,0 +1,21 @@ +import { handleTunnelRequestIfMatched, isTunnelRequest } from '@sentry/core'; +import { sendWebResponse, toWebRequest } from 'h3'; +import type { NitroAppPlugin } from 'nitropack'; + +/** + * 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 => { + nitroApp.hooks.hook('request', async event => { + // 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..5072b3023fab --- /dev/null +++ b/packages/nuxt/src/runtime/plugins/tunnel.server.ts @@ -0,0 +1,12 @@ +import { handleTunnelRequestIfMatched } from '@sentry/core'; +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. + nitroApp.h3?.['~middleware'].unshift((event, next) => 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/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(); From 375fb14e35c8f3362be23bbfd132499b1d69c383 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 16 Oct 2026 13:08:25 -0400 Subject: [PATCH 02/13] feat(nuxt, nitro): Serve the managed tunnel under the app's base URL --- .../test-applications/nitro-3/package.json | 9 ++++- .../test-applications/nitro-3/src/main.ts | 8 +++- .../nitro-3/tests/tunnel.test.ts | 12 +++--- .../test-applications/nitro-3/vite.config.ts | 2 + .../test-applications/nuxt-4/nuxt.config.ts | 3 ++ .../test-applications/nuxt-4/package.json | 9 ++++- .../nuxt-4/tests/tunnel.test.ts | 12 +++--- .../test-applications/nuxt-5/nuxt.config.ts | 3 ++ .../test-applications/nuxt-5/package.json | 9 ++++- .../nuxt-5/tests/tunnel.test.ts | 12 +++--- packages/nitro/src/runtime/plugins/server.ts | 14 ++++++- packages/nuxt/src/client/sdk.ts | 2 + packages/nuxt/src/common/tunnel.ts | 17 ++++++++ packages/nuxt/src/module.ts | 4 +- .../runtime/plugins/tunnel-legacy.server.ts | 11 ++++- .../nuxt/src/runtime/plugins/tunnel.server.ts | 14 ++++++- packages/nuxt/test/common/tunnel.test.ts | 40 +++++++++++++++++++ 17 files changed, 156 insertions(+), 25 deletions(-) create mode 100644 packages/nuxt/src/common/tunnel.ts create mode 100644 packages/nuxt/test/common/tunnel.test.ts 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 6d51ccb4eade..01a2c88afa62 100644 --- a/dev-packages/e2e-tests/test-applications/nitro-3/package.json +++ b/dev-packages/e2e-tests/test-applications/nitro-3/package.json @@ -11,7 +11,9 @@ "test:build": "pnpm install && pnpm build", "test:assert": "pnpm test", "test:build:tunnel": "pnpm install && E2E_TEST_TUNNEL=1 pnpm build", - "test:assert:tunnel": "E2E_TEST_TUNNEL=1 playwright test tunnel" + "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" }, "dependencies": { "@sentry/browser": "latest || *", @@ -35,6 +37,11 @@ "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)" } ] } 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 6c9d7e4d6e39..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,12 +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: __E2E_TEST_TUNNEL__ ? 'http://public@localhost:3031/1337' : 'https://public@dsn.ingest.sentry.io/1337', - tunnel: __E2E_TEST_TUNNEL__ ? true : 'http://localhost:3031/', // proxy server + // 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 index 0ae8c2181aa3..f32d0563ff3a 100644 --- 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 @@ -3,11 +3,12 @@ import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; test.skip(!process.env.E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); -const TUNNEL_PATH = /^\/[a-z0-9]{1,7}$/; +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('/api/anything', { data: 'x' })).status()).toBe(401); + expect((await page.request.post(`${BASE_PATH}/api/anything`, { data: 'x' })).status()).toBe(401); const tunnelServerSpanPromise = waitForStreamedSpan( 'nitro-3', @@ -17,7 +18,7 @@ test('answers the managed tunnel before the app auth middleware', async ({ page response => response.request().method() === 'POST' && TUNNEL_PATH.test(new URL(response.url()).pathname), ); - await page.goto('/'); + await page.goto(`${BASE_PATH}/`); const tunnelResponse = await tunnelResponsePromise; expect(tunnelResponse.status()).toBe(200); @@ -28,9 +29,10 @@ test('answers the managed tunnel before the app auth middleware', async ({ page const anchorSpanPromise = waitForStreamedSpan( 'nitro-3', span => - getSpanOp(span) === 'http.server' && String(span.attributes['url.path']?.value ?? '') === '/api/test-transaction', + getSpanOp(span) === 'http.server' && + String(span.attributes['url.path']?.value ?? '') === `${BASE_PATH}/api/test-transaction`, ); - await page.request.get('/api/test-transaction'); + await page.request.get(`${BASE_PATH}/api/test-transaction`); const winner = await Promise.race([ tunnelServerSpanPromise.then(() => 'tunnel' as const), 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 3fa4ce57a69c..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 @@ -5,6 +5,7 @@ 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( @@ -12,6 +13,7 @@ export default defineConfig({ // @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 f8206a22f2f7..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 @@ -14,6 +14,9 @@ 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: { 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 238871b5d182..129e2c7e9bd2 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json @@ -18,7 +18,9 @@ "test:assert": "pnpm test:prod && pnpm test:dev", "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:assert:tunnel": "E2E_TEST_TUNNEL=1 TEST_ENV=production playwright test tunnel" + "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" }, "//": "Pin ioredis to 5.10.1 because that's the last version before it publishes its own diagnostics channels", "dependencies": { @@ -52,6 +54,11 @@ "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)" } ], "optionalVariants": [ 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 index aeccb68e67f5..1e48be35166c 100644 --- 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 @@ -3,11 +3,12 @@ import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; test.skip(!process.env.E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); -const TUNNEL_PATH = /^\/[a-z0-9]{1,7}$/; +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('/api/anything', { data: 'x' })).status()).toBe(401); + expect((await page.request.post(`${BASE_PATH}/api/anything`, { data: 'x' })).status()).toBe(401); const tunnelServerSpanPromise = waitForStreamedSpan( 'nuxt-4', @@ -17,7 +18,7 @@ test('answers the managed tunnel before the app auth middleware', async ({ page response => response.request().method() === 'POST' && TUNNEL_PATH.test(new URL(response.url()).pathname), ); - await page.goto('/'); + await page.goto(`${BASE_PATH}/`); const tunnelResponse = await tunnelResponsePromise; expect(tunnelResponse.status()).toBe(200); @@ -28,9 +29,10 @@ test('answers the managed tunnel before the app auth middleware', async ({ page const anchorSpanPromise = waitForStreamedSpan( 'nuxt-4', span => - getSpanOp(span) === 'http.server' && String(span.attributes['url.path']?.value ?? '') === '/api/test-param/123', + getSpanOp(span) === 'http.server' && + String(span.attributes['url.path']?.value ?? '') === `${BASE_PATH}/api/test-param/123`, ); - await page.request.get('/api/test-param/123'); + await page.request.get(`${BASE_PATH}/api/test-param/123`); const winner = await Promise.race([ tunnelServerSpanPromise.then(() => 'tunnel' as const), 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 63557fb9a85b..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 @@ -14,6 +14,9 @@ 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: { 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 dfc03996bc77..c0e0404a6794 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/package.json @@ -17,7 +17,9 @@ "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:build:tunnel": "pnpm install && E2E_TEST_TUNNEL=1 pnpm build", - "test:assert:tunnel": "E2E_TEST_TUNNEL=1 TEST_ENV=production playwright test tunnel" + "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" }, "//": [ "Currently, we need to install the latest version of Nitro and the Nuxt nightlies as those contain Nuxt v5", @@ -46,6 +48,11 @@ "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)" } ] }, 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 index 5dec3575dd0c..d814f3fab1c9 100644 --- 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 @@ -3,11 +3,12 @@ import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; test.skip(!process.env.E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); -const TUNNEL_PATH = /^\/[a-z0-9]{1,7}$/; +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('/api/anything', { data: 'x' })).status()).toBe(401); + expect((await page.request.post(`${BASE_PATH}/api/anything`, { data: 'x' })).status()).toBe(401); const tunnelServerSpanPromise = waitForStreamedSpan( 'nuxt-5', @@ -17,7 +18,7 @@ test('answers the managed tunnel before the app auth middleware', async ({ page response => response.request().method() === 'POST' && TUNNEL_PATH.test(new URL(response.url()).pathname), ); - await page.goto('/'); + await page.goto(`${BASE_PATH}/`); const tunnelResponse = await tunnelResponsePromise; expect(tunnelResponse.status()).toBe(200); @@ -28,9 +29,10 @@ test('answers the managed tunnel before the app auth middleware', async ({ page const anchorSpanPromise = waitForStreamedSpan( 'nuxt-5', span => - getSpanOp(span) === 'http.server' && String(span.attributes['url.path']?.value ?? '') === '/api/test-param/123', + getSpanOp(span) === 'http.server' && + String(span.attributes['url.path']?.value ?? '') === `${BASE_PATH}/api/test-param/123`, ); - await page.request.get('/api/test-param/123'); + await page.request.get(`${BASE_PATH}/api/test-param/123`); const winner = await Promise.race([ tunnelServerSpanPromise.then(() => 'tunnel' as const), diff --git a/packages/nitro/src/runtime/plugins/server.ts b/packages/nitro/src/runtime/plugins/server.ts index 07dbc1958f69..2e93a75c58f9 100644 --- a/packages/nitro/src/runtime/plugins/server.ts +++ b/packages/nitro/src/runtime/plugins/server.ts @@ -1,5 +1,6 @@ -import { handleTunnelRequestIfMatched } from '@sentry/core'; +import { getClient, handleTunnelRequestIfMatched, setManagedTunnelBasePath } from '@sentry/core'; import { definePlugin } from 'nitro'; +import { useRuntimeConfig } from 'nitro/runtime-config'; import { captureErrorHook } from '../hooks/captureErrorHook'; export default definePlugin(nitroApp => { @@ -7,5 +8,14 @@ export default definePlugin(nitroApp => { // 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. - nitroApp.h3?.['~middleware'].unshift((event, next) => handleTunnelRequestIfMatched(event.req) ?? next()); + // 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(); + }); }); 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..9b5423f06d66 --- /dev/null +++ b/packages/nuxt/src/common/tunnel.ts @@ -0,0 +1,17 @@ +import type { Options } from '@sentry/core'; +import { applyTunnelDefaults, GLOBAL_OBJ } 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]; + // A relative `./` base means the app is served from wherever it's deployed, which Nitro treats as the root. + const basePath = baseURL?.replace(/^\.\//, '/').replace(/\/+$/, ''); + 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 index f6c06bc20d43..4a3b282bbc64 100644 --- a/packages/nuxt/src/runtime/plugins/tunnel-legacy.server.ts +++ b/packages/nuxt/src/runtime/plugins/tunnel-legacy.server.ts @@ -1,13 +1,22 @@ -import { handleTunnelRequestIfMatched, isTunnelRequest } from '@sentry/core'; +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; diff --git a/packages/nuxt/src/runtime/plugins/tunnel.server.ts b/packages/nuxt/src/runtime/plugins/tunnel.server.ts index 5072b3023fab..8ebf122e5ad2 100644 --- a/packages/nuxt/src/runtime/plugins/tunnel.server.ts +++ b/packages/nuxt/src/runtime/plugins/tunnel.server.ts @@ -1,4 +1,5 @@ -import { handleTunnelRequestIfMatched } from '@sentry/core'; +import { getClient, handleTunnelRequestIfMatched, setManagedTunnelBasePath } from '@sentry/core'; +import { useRuntimeConfig } from 'nitro/runtime-config'; import type { NitroAppPlugin, NitroApp } from 'nitro/types'; /** @@ -8,5 +9,14 @@ 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. - nitroApp.h3?.['~middleware'].unshift((event, next) => handleTunnelRequestIfMatched(event.req) ?? next()); + // 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/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); + } + }); +}); From 79a261871f9b796329f7f59edf6fe8b8d9a11dbe Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 16 Oct 2026 14:46:09 -0400 Subject: [PATCH 03/13] fix(nitro): Drop the tunnel's dev server spans under `baseURL` too --- .../test-applications/nitro-3/package.json | 14 ++++++- .../nitro-3/playwright.config.mjs | 5 ++- .../test-applications/nuxt-4/package.json | 14 ++++++- .../test-applications/nuxt-5/package.json | 8 +++- packages/nitro/src/module.ts | 7 ++++ packages/nitro/test/module.test.ts | 42 +++++++++++++++++++ 6 files changed, 86 insertions(+), 4 deletions(-) create mode 100644 packages/nitro/test/module.test.ts 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 01a2c88afa62..ad381acb1c93 100644 --- a/dev-packages/e2e-tests/test-applications/nitro-3/package.json +++ b/dev-packages/e2e-tests/test-applications/nitro-3/package.json @@ -13,7 +13,9 @@ "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-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 || *", @@ -42,6 +44,16 @@ "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/nuxt-4/package.json b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json index 129e2c7e9bd2..1d8a052ab63b 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json @@ -20,7 +20,9 @@ "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-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": { @@ -59,6 +61,16 @@ "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-5/package.json b/dev-packages/e2e-tests/test-applications/nuxt-5/package.json index c0e0404a6794..0897f15ce65e 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/package.json @@ -19,7 +19,8 @@ "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-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", @@ -53,6 +54,11 @@ "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)" } ] }, 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/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({}); + }); +}); From 8830ffae7ec30a66f42da9c4b108d81ad871f034 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 16 Oct 2026 17:00:43 -0400 Subject: [PATCH 04/13] fix(nuxt): Normalize the tunnel base path with the core util --- packages/nuxt/src/common/tunnel.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/nuxt/src/common/tunnel.ts b/packages/nuxt/src/common/tunnel.ts index 9b5423f06d66..58b23d06b854 100644 --- a/packages/nuxt/src/common/tunnel.ts +++ b/packages/nuxt/src/common/tunnel.ts @@ -1,5 +1,5 @@ import type { Options } from '@sentry/core'; -import { applyTunnelDefaults, GLOBAL_OBJ } 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'; @@ -11,7 +11,6 @@ export const NUXT_TUNNEL_BASE_PATH_GLOBAL = '__sentry_nuxt_base_path'; */ export function applyNuxtTunnelBasePath(options: Pick): void { const baseURL = (GLOBAL_OBJ as { [NUXT_TUNNEL_BASE_PATH_GLOBAL]?: string })[NUXT_TUNNEL_BASE_PATH_GLOBAL]; - // A relative `./` base means the app is served from wherever it's deployed, which Nitro treats as the root. - const basePath = baseURL?.replace(/^\.\//, '/').replace(/\/+$/, ''); + const basePath = normalizeTunnelBasePath(baseURL); options.tunnel = applyTunnelDefaults(options.tunnel, basePath ? { basePath } : undefined); } From 82911ff4905d2ce568306d5754c1f74fcb286c49 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sat, 17 Oct 2026 21:08:30 -0400 Subject: [PATCH 05/13] fix(nuxt, nitro): Serve the managed tunnel from `nitroApp.fetch` so route rules can't skip it --- .../test-applications/nitro-3/package.json | 9 ++++++++- .../test-applications/nitro-3/tests/tunnel.test.ts | 13 +++++++++++++ .../test-applications/nitro-3/vite.config.ts | 7 +++++++ packages/nitro/src/runtime/plugins/server.ts | 11 ++++++----- packages/nuxt/src/runtime/plugins/tunnel.server.ts | 11 ++++++----- 5 files changed, 40 insertions(+), 11 deletions(-) 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 ad381acb1c93..f494cbe2507f 100644 --- a/dev-packages/e2e-tests/test-applications/nitro-3/package.json +++ b/dev-packages/e2e-tests/test-applications/nitro-3/package.json @@ -15,7 +15,9 @@ "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" + "test:assert:tunnel-basepath-dev": "TEST_ENV=development E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app playwright test tunnel", + "test:build:tunnel-no-middleware": "pnpm install && E2E_TEST_TUNNEL=1 E2E_TEST_NO_MIDDLEWARE=1 pnpm build", + "test:assert:tunnel-no-middleware": "E2E_TEST_TUNNEL=1 E2E_TEST_NO_MIDDLEWARE=1 playwright test tunnel -g 'without app middleware'" }, "dependencies": { "@sentry/browser": "latest || *", @@ -45,6 +47,11 @@ "assert-command": "pnpm test:assert:tunnel-basepath", "label": "nitro-3 (tunnel-basepath)" }, + { + "build-command": "pnpm test:build:tunnel-no-middleware", + "assert-command": "pnpm test:assert:tunnel-no-middleware", + "label": "nitro-3 (tunnel-no-middleware)" + }, { "build-command": "pnpm install", "assert-command": "pnpm test:assert:tunnel-dev", 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 index f32d0563ff3a..3782c67a86a0 100644 --- 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 @@ -1,5 +1,6 @@ import { expect, test } from '@playwright/test'; import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; +import { getTunnelPath } from '@sentry/core'; test.skip(!process.env.E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); @@ -40,3 +41,15 @@ test('answers the managed tunnel before the app auth middleware', async ({ page ]); expect(winner).toBe('anchor'); }); + +test('answers the managed tunnel without app middleware', async ({ request }) => { + const dsn = 'http://public@localhost:3031/1337'; + const envelopeHeader = { dsn, sent_at: new Date().toISOString() }; + const response = await request.post(`${BASE_PATH}${getTunnelPath(dsn)}`, { + data: `${JSON.stringify(envelopeHeader)}\n${JSON.stringify({ type: 'client_report' })}\n${JSON.stringify({ timestamp: Date.now() / 1000, discarded_events: [] })}\n`, + headers: { 'Content-Type': 'application/x-sentry-envelope' }, + }); + + expect(response.headers()['x-sentry-tunnel']).toBe('1'); + expect(response.status()).toBe(200); +}); 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 b7c6de66d47c..083005ad7ac4 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 @@ -14,6 +14,13 @@ export default defineConfig({ withSentryConfig({ serverDir: './server', ...(process.env.E2E_TEST_BASE_PATH && { baseURL: `${process.env.E2E_TEST_BASE_PATH}/` }), + // Without static assets, global middleware or routed middleware, Nitro skips its middleware array whenever + // route rules exist, as on serverless presets. + ...(process.env.E2E_TEST_NO_MIDDLEWARE && { + serveStatic: false, + ignore: ['**/middleware/**'], + routeRules: { '/api/**': { headers: { 'x-e2e-route-rule': '1' } } }, + }), }), ), ], diff --git a/packages/nitro/src/runtime/plugins/server.ts b/packages/nitro/src/runtime/plugins/server.ts index 2e93a75c58f9..b835868abdd7 100644 --- a/packages/nitro/src/runtime/plugins/server.ts +++ b/packages/nitro/src/runtime/plugins/server.ts @@ -7,15 +7,16 @@ 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. + // the tunnel. Every preset hands requests to `nitroApp.fetch` after the plugins ran, so wrapping it goes first, + // before route rules and middleware. 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) => { + const appFetch = nitroApp.fetch; + nitroApp.fetch = request => { // `init` may run after this plugin, so this also covers the first requests after it. applyBaseURL(); - return handleTunnelRequestIfMatched(event.req) ?? next(); - }); + return handleTunnelRequestIfMatched(request) ?? appFetch(request); + }; }); diff --git a/packages/nuxt/src/runtime/plugins/tunnel.server.ts b/packages/nuxt/src/runtime/plugins/tunnel.server.ts index 8ebf122e5ad2..8f57371e9eb6 100644 --- a/packages/nuxt/src/runtime/plugins/tunnel.server.ts +++ b/packages/nuxt/src/runtime/plugins/tunnel.server.ts @@ -7,16 +7,17 @@ import type { NitroAppPlugin, NitroApp } from 'nitro/types'; */ 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. + // block the tunnel. Every preset hands requests to `nitroApp.fetch` after the plugins ran, so wrapping it goes + // first, before route rules and middleware. // 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) => { + const appFetch = nitroApp.fetch; + nitroApp.fetch = request => { // `init` may run after this plugin, so this also covers the first requests after it. applyBaseURL(); - return handleTunnelRequestIfMatched(event.req) ?? next(); - }); + return handleTunnelRequestIfMatched(request) ?? appFetch(request); + }; }) satisfies NitroAppPlugin; From a714964fda2ae77aecfc5d6fcf6fa9a38dd95603 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sun, 18 Oct 2026 21:00:51 -0400 Subject: [PATCH 06/13] fix(nuxt): Serve the Nitro v2 tunnel from the first h3 layer so route rules don't run after it --- .../test-applications/nuxt-4/nuxt.config.ts | 2 ++ .../nuxt-4/tests/tunnel.test.ts | 32 +++++++++++++++++- .../runtime/plugins/tunnel-legacy.server.ts | 33 ++++++++++--------- 3 files changed, 51 insertions(+), 16 deletions(-) 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 b137ec2b0e52..4f456ce2efdf 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 @@ -12,6 +12,8 @@ export default defineNuxtConfig({ '/rendering-modes/swr-cached-page': { swr: true }, '/rendering-modes/swr-1h-cached-page': { swr: 3600 }, '/rendering-modes/pre-rendered-page': { prerender: true }, + // A headers rule on every path, like a security headers setup, also covers the tunnel's path. + ...(tunnelVariant && { '/**': { headers: { 'x-e2e-route-rule': '1' } } }), }, // The base-path variant serves the app, and so the tunnel, under `/app`. 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 index 1e48be35166c..20fa9eed14a9 100644 --- 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 @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; test.skip(!process.env.E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); @@ -40,3 +40,33 @@ test('answers the managed tunnel before the app auth middleware', async ({ page ]); expect(winner).toBe('anchor'); }); + +test('answers the managed tunnel once, without errors from route rules that run after it', async ({ request }) => { + const dsn = 'http://public@localhost:3031/1337'; + // The path the SDK derives from `dsn`. + const tunnelPath = '/qpo967'; + const tunnelErrorPromise = waitForError( + 'nuxt-4', + event => !!event.exception?.values?.[0]?.value?.includes('Cannot set headers'), + ); + const anchorErrorPromise = waitForError( + 'nuxt-4', + event => event.exception?.values?.[0]?.value === 'Nuxt 4 Server error', + ); + + const response = await request.post(`${BASE_PATH}${tunnelPath}`, { + data: `${JSON.stringify({ dsn, sent_at: new Date().toISOString() })}\n${JSON.stringify({ type: 'client_report' })}\n${JSON.stringify({ timestamp: Date.now() / 1000, discarded_events: [] })}\n`, + headers: { 'Content-Type': 'application/x-sentry-envelope' }, + }); + expect(response.headers()['x-sentry-tunnel']).toBe('1'); + expect(response.status()).toBe(200); + + // Errors are sent in order, so one from the tunnel request would arrive before this later one. + await request.get(`${BASE_PATH}/api/server-error`); + + const winner = await Promise.race([ + tunnelErrorPromise.then(() => 'tunnel' as const), + anchorErrorPromise.then(() => 'anchor' as const), + ]); + expect(winner).toBe('anchor'); +}); diff --git a/packages/nuxt/src/runtime/plugins/tunnel-legacy.server.ts b/packages/nuxt/src/runtime/plugins/tunnel-legacy.server.ts index 4a3b282bbc64..648eda88c08a 100644 --- a/packages/nuxt/src/runtime/plugins/tunnel-legacy.server.ts +++ b/packages/nuxt/src/runtime/plugins/tunnel-legacy.server.ts @@ -1,30 +1,33 @@ import { getClient, handleTunnelRequestIfMatched, isTunnelRequest, setManagedTunnelBasePath } from '@sentry/core'; -import { sendWebResponse, toWebRequest } from 'h3'; +import { eventHandler, 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`. + * Answers managed tunnel requests on Nuxt v3 and v4 (Nitro v2), from the first layer of the h3 app. That 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. + // The first layer 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(); + // Not the `request` hook: h3 still runs the first layer after a hook answered, and with a `headers` route rule + // that layer throws for writing headers on the sent response. + nitroApp.h3App.stack.unshift({ + route: '', + handler: eventHandler(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; - } + // 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 undefined; + } - const tunnelResponse = handleTunnelRequestIfMatched(toWebRequest(event)); - if (tunnelResponse) { - await sendWebResponse(event, await tunnelResponse); - } + // A returned `Response` is sent and ends the request, while `undefined` hands it to the next layer. + return handleTunnelRequestIfMatched(toWebRequest(event)); + }), }); }) satisfies NitroAppPlugin; From fb5f06bd24775749e91a16c768c08f939d6d5a3e Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sun, 18 Oct 2026 22:29:15 -0400 Subject: [PATCH 07/13] test(nitro): Check a client dropping the tunnel request isn't reported as an error --- .../nitro-3/tests/tunnel.test.ts | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) 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 index 3782c67a86a0..2735ea4a7193 100644 --- 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 @@ -1,6 +1,7 @@ import { expect, test } from '@playwright/test'; -import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; import { getTunnelPath } from '@sentry/core'; +import { connect } from 'node:net'; test.skip(!process.env.E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); @@ -53,3 +54,30 @@ test('answers the managed tunnel without app middleware', async ({ request }) => expect(response.headers()['x-sentry-tunnel']).toBe('1'); expect(response.status()).toBe(200); }); + +test('does not report a client that drops the connection before the envelope header as an error', async ({ + request, +}) => { + const dsn = 'http://public@localhost:3031/1337'; + const firstErrorPromise = waitForError('nitro-3', () => true); + + // Part of the envelope header line, without its newline, then the connection drops. + await new Promise(resolve => { + const socket = connect(3030, 'localhost', () => { + socket.write( + `POST ${BASE_PATH}${getTunnelPath(dsn)} HTTP/1.1\r\nHost: localhost:3030\r\nContent-Type: application/x-sentry-envelope\r\nContent-Length: 1000000\r\n\r\n`, + ); + socket.write(JSON.stringify({ dsn, padding: 'x'.repeat(1000) }).slice(0, 500)); + setTimeout(() => { + socket.destroy(); + resolve(); + }, 300); + }); + }); + + // Errors are sent in order, so one from the dropped request would arrive before this one. + await request.get(`${BASE_PATH}/api/test-error`); + + const firstError = await firstErrorPromise; + expect(firstError.exception?.values?.[0]?.value).toBe('This is a test error'); +}); From a876a373d7490c7f622ada0c3caba877bdca837f Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sun, 18 Oct 2026 23:28:39 -0400 Subject: [PATCH 08/13] fix(nuxt): Serve the managed tunnel when the SDK is set up with sentryCloudflareNitroPlugin --- .../nuxt-4-cloudflare/nuxt.config.ts | 4 ++++ .../nuxt-4-cloudflare/package.json | 13 ++++++++++++- .../nuxt-4-cloudflare/server/plugins/sentry.ts | 7 +++++-- .../nuxt-4-cloudflare/tests/tunnel.test.ts | 15 +++++++++++++++ packages/nuxt/src/module.ts | 5 ++++- packages/nuxt/src/vite/nitroServer.ts | 17 +++++++++++++++-- 6 files changed, 55 insertions(+), 6 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/tests/tunnel.test.ts diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/nuxt.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/nuxt.config.ts index 63f6e2e2633b..27cc219f447f 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/nuxt.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/nuxt.config.ts @@ -14,5 +14,9 @@ export default defineNuxtConfig({ alias: { 'readable-stream': 'node:stream', }, + // The tunnel variant serves the managed tunnel. + replace: { + __E2E_TEST_TUNNEL__: JSON.stringify(!!process.env.E2E_TEST_TUNNEL), + }, }, }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/package.json b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/package.json index e58f8c4efe55..8b337c500493 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/package.json @@ -8,7 +8,9 @@ "preview": "wrangler dev --var \"E2E_TEST_DSN:$E2E_TEST_DSN\" --port 3030 --log-level=$(test $CI && echo 'none' || echo 'log')", "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:assert:tunnel": "E2E_TEST_TUNNEL=1 pnpm test tunnel" }, "dependencies": { "@sentry/nuxt": "file:../../packed/sentry-nuxt-packed.tgz", @@ -23,5 +25,14 @@ }, "volta": { "extends": "../../package.json" + }, + "sentryTest": { + "variants": [ + { + "build-command": "pnpm test:build:tunnel", + "assert-command": "pnpm test:assert:tunnel", + "label": "nuxt-4-cloudflare (tunnel)" + } + ] } } diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/server/plugins/sentry.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/server/plugins/sentry.ts index faa56e1db7ef..794c67678b48 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/server/plugins/sentry.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/server/plugins/sentry.ts @@ -1,10 +1,13 @@ import { defineNitroPlugin } from '#imports'; import { sentryCloudflareNitroPlugin } from '@sentry/nuxt/module/plugins'; +declare const __E2E_TEST_TUNNEL__: boolean; + export default defineNitroPlugin( sentryCloudflareNitroPlugin({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', + // The tunnel forwards to the DSN host, which is the event proxy in the tunnel variant. + dsn: __E2E_TEST_TUNNEL__ ? 'http://public@localhost:3031/1337' : 'https://public@dsn.ingest.sentry.io/1337', tracesSampleRate: 1.0, - tunnel: 'http://localhost:3031/', // proxy server + tunnel: __E2E_TEST_TUNNEL__ ? true : 'http://localhost:3031/', // proxy server }), ); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/tests/tunnel.test.ts new file mode 100644 index 000000000000..b239562b2194 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/tests/tunnel.test.ts @@ -0,0 +1,15 @@ +import { expect, test } from '@playwright/test'; + +test.skip(!process.env.E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); + +test('answers the managed tunnel when the SDK is set up with `sentryCloudflareNitroPlugin`', async ({ request }) => { + const dsn = 'http://public@localhost:3031/1337'; + // The path the SDK derives from `dsn`. + const response = await request.post('/qpo967', { + data: `${JSON.stringify({ dsn, sent_at: new Date().toISOString() })}\n${JSON.stringify({ type: 'client_report' })}\n${JSON.stringify({ timestamp: Date.now() / 1000, discarded_events: [] })}\n`, + headers: { 'Content-Type': 'application/x-sentry-envelope' }, + }); + + expect(response.headers()['x-sentry-tunnel']).toBe('1'); + expect(response.status()).toBe(200); +}); diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index 643fc3bcd2eb..5d23d675f3fc 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -5,7 +5,7 @@ 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 { addTunnelPlugin, setupNitroServer } from './vite/nitroServer'; import { setupOrchestrion } from './vite/orchestrion'; import { setupSourceMaps } from './vite/sourceMaps'; import { findDefaultSdkInitFile, resolveServerApi } from './vite/utils'; @@ -93,6 +93,9 @@ export default defineNuxtModule({ } else { addPlugin({ src: moduleDirResolver.resolve('./runtime/plugins/route-detector-legacy.server'), mode: 'server' }); } + } else { + // The Cloudflare setup initializes the SDK from `sentryCloudflareNitroPlugin`, so it needs the tunnel too. + addTunnelPlugin(serverApi); } if (clientConfigFile || serverConfigFile) { diff --git a/packages/nuxt/src/vite/nitroServer.ts b/packages/nuxt/src/vite/nitroServer.ts index eebfd8e12ccf..270576ab49d7 100644 --- a/packages/nuxt/src/vite/nitroServer.ts +++ b/packages/nuxt/src/vite/nitroServer.ts @@ -50,13 +50,13 @@ 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')); } + addTunnelPlugin(serverApi); + addNitroPlugin(moduleDirResolver.resolve('./runtime/plugins/sentry.server')); // Preps the middleware instrumentation module. @@ -125,3 +125,16 @@ function setupDeprecatedInjectMode( } } } + +/** + * Adds the plugin that answers the managed tunnel. The Cloudflare setup has no server config file, so it skips + * `setupNitroServer` and adds this on its own. + */ +export function addTunnelPlugin(serverApi: ServerApi): void { + const moduleDirResolver = createResolver(import.meta.url); + addNitroPlugin( + moduleDirResolver.resolve( + serverApi === 'nitro3' ? './runtime/plugins/tunnel.server' : './runtime/plugins/tunnel-legacy.server', + ), + ); +} From 8544c027ae16b5339a4cf6806d72251d525221bd Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 19 Oct 2026 00:57:09 -0400 Subject: [PATCH 09/13] ref(nuxt, nitro): Report the base URL once with setManagedTunnelDefaults so Cloudflare clients start with it --- .../nuxt-4-cloudflare/nuxt.config.ts | 2 ++ .../nuxt-4-cloudflare/server/api/anchor.ts | 4 +++ .../nuxt-4-cloudflare/tests/tunnel.test.ts | 30 ++++++++++++++++++- packages/nitro/src/module.ts | 4 +-- packages/nitro/src/runtime/plugins/server.ts | 13 ++------ .../runtime/plugins/tunnel-legacy.server.ts | 8 ++--- .../nuxt/src/runtime/plugins/tunnel.server.ts | 16 ++++------ 7 files changed, 47 insertions(+), 30 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/server/api/anchor.ts diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/nuxt.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/nuxt.config.ts index 27cc219f447f..b83483ac0363 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/nuxt.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/nuxt.config.ts @@ -2,6 +2,8 @@ export default defineNuxtConfig({ compatibilityDate: '2025-06-06', modules: ['@sentry/nuxt/module'], + // The tunnel variant serves the app, and so the tunnel, under a base URL. + ...(process.env.E2E_TEST_TUNNEL && { app: { baseURL: '/app/' } }), nitro: { preset: 'cloudflare_module', cloudflare: { diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/server/api/anchor.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/server/api/anchor.ts new file mode 100644 index 000000000000..cee7601e363b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/server/api/anchor.ts @@ -0,0 +1,4 @@ +import { defineEventHandler } from '#imports'; + +// A plain route for tests to request after the tunnel traffic. +export default defineEventHandler(() => 'ok'); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/tests/tunnel.test.ts index b239562b2194..7f1cd7bd7a1e 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/tests/tunnel.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/tests/tunnel.test.ts @@ -1,11 +1,39 @@ 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'); +// First in this file: the isolate's first tunnel request is the one a late span filter would let through. +test('drops the server span of a tunnel request under the base URL', async ({ request }) => { + const dsn = 'http://public@localhost:3031/1337'; + const tunnelSpanPromise = waitForStreamedSpan( + 'nuxt-4-cloudflare', + span => + getSpanOp(span) === 'http.server' && String(span.attributes['url.path']?.value ?? '').startsWith('/app/qpo967'), + ); + const anchorSpanPromise = waitForStreamedSpan( + 'nuxt-4-cloudflare', + span => getSpanOp(span) === 'http.server' && span.attributes['url.path']?.value === '/app/api/anchor', + ); + + await request.post('/app/qpo967', { + data: `${JSON.stringify({ dsn, sent_at: new Date().toISOString() })}\n${JSON.stringify({ type: 'client_report' })}\n${JSON.stringify({ timestamp: Date.now() / 1000, discarded_events: [] })}\n`, + headers: { 'Content-Type': 'application/x-sentry-envelope' }, + }); + // Spans flush in order, so a tunnel span would arrive before this one. + await request.get('/app/api/anchor'); + + const winner = await Promise.race([ + tunnelSpanPromise.then(() => 'tunnel' as const), + anchorSpanPromise.then(() => 'anchor' as const), + ]); + expect(winner).toBe('anchor'); +}); + test('answers the managed tunnel when the SDK is set up with `sentryCloudflareNitroPlugin`', async ({ request }) => { const dsn = 'http://public@localhost:3031/1337'; // The path the SDK derives from `dsn`. - const response = await request.post('/qpo967', { + const response = await request.post('/app/qpo967', { data: `${JSON.stringify({ dsn, sent_at: new Date().toISOString() })}\n${JSON.stringify({ type: 'client_report' })}\n${JSON.stringify({ timestamp: Date.now() / 1000, discarded_events: [] })}\n`, headers: { 'Content-Type': 'application/x-sentry-envelope' }, }); diff --git a/packages/nitro/src/module.ts b/packages/nitro/src/module.ts index 8df28a8a2550..51f76c39eae3 100644 --- a/packages/nitro/src/module.ts +++ b/packages/nitro/src/module.ts @@ -1,4 +1,4 @@ -import { getClient, setManagedTunnelBasePath } from '@sentry/core'; +import { setManagedTunnelDefaults } from '@sentry/core/server'; import type { NitroModule } from 'nitro/types'; import type { SentryNitroOptions } from './config'; import { instrumentServer } from './instruments/instrumentServer'; @@ -17,7 +17,7 @@ export function createNitroModule(sentryOptions?: SentryNitroOptions, sentryEnab // 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); + setManagedTunnelDefaults({ basePath: nitro.options.baseURL }); } }, }; diff --git a/packages/nitro/src/runtime/plugins/server.ts b/packages/nitro/src/runtime/plugins/server.ts index b835868abdd7..b9b096162d1a 100644 --- a/packages/nitro/src/runtime/plugins/server.ts +++ b/packages/nitro/src/runtime/plugins/server.ts @@ -1,4 +1,4 @@ -import { getClient, handleTunnelRequestIfMatched, setManagedTunnelBasePath } from '@sentry/core'; +import { handleTunnelRequestIfMatched, setManagedTunnelDefaults } from '@sentry/core/server'; import { definePlugin } from 'nitro'; import { useRuntimeConfig } from 'nitro/runtime-config'; import { captureErrorHook } from '../hooks/captureErrorHook'; @@ -9,14 +9,7 @@ export default definePlugin(nitroApp => { // Nitro runs the app's `middleware/` before anything a module registers, so an auth middleware there would block // the tunnel. Every preset hands requests to `nitroApp.fetch` after the plugins ran, so wrapping it goes first, // before route rules and middleware. 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(); + setManagedTunnelDefaults({ basePath: (useRuntimeConfig() as { app?: { baseURL?: string } }).app?.baseURL }); const appFetch = nitroApp.fetch; - nitroApp.fetch = request => { - // `init` may run after this plugin, so this also covers the first requests after it. - applyBaseURL(); - - return handleTunnelRequestIfMatched(request) ?? appFetch(request); - }; + nitroApp.fetch = request => handleTunnelRequestIfMatched(request) ?? appFetch(request); }); diff --git a/packages/nuxt/src/runtime/plugins/tunnel-legacy.server.ts b/packages/nuxt/src/runtime/plugins/tunnel-legacy.server.ts index 648eda88c08a..fca620bf1a3d 100644 --- a/packages/nuxt/src/runtime/plugins/tunnel-legacy.server.ts +++ b/packages/nuxt/src/runtime/plugins/tunnel-legacy.server.ts @@ -1,4 +1,4 @@ -import { getClient, handleTunnelRequestIfMatched, isTunnelRequest, setManagedTunnelBasePath } from '@sentry/core'; +import { handleTunnelRequestIfMatched, isTunnelRequest, setManagedTunnelDefaults } from '@sentry/core/server'; import { eventHandler, toWebRequest } from 'h3'; import type { NitroAppPlugin } from 'nitropack'; import { useRuntimeConfig } from 'nitropack/runtime'; @@ -10,17 +10,13 @@ import { useRuntimeConfig } from 'nitropack/runtime'; export default (nitroApp => { // The first layer 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(); + setManagedTunnelDefaults({ basePath: useRuntimeConfig().app?.baseURL }); // Not the `request` hook: h3 still runs the first layer after a hook answered, and with a `headers` route rule // that layer throws for writing headers on the sent response. nitroApp.h3App.stack.unshift({ route: '', handler: eventHandler(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 undefined; diff --git a/packages/nuxt/src/runtime/plugins/tunnel.server.ts b/packages/nuxt/src/runtime/plugins/tunnel.server.ts index 8f57371e9eb6..f84440a06781 100644 --- a/packages/nuxt/src/runtime/plugins/tunnel.server.ts +++ b/packages/nuxt/src/runtime/plugins/tunnel.server.ts @@ -1,4 +1,4 @@ -import { getClient, handleTunnelRequestIfMatched, setManagedTunnelBasePath } from '@sentry/core'; +import { handleTunnelRequestIfMatched, setManagedTunnelDefaults } from '@sentry/core/server'; import { useRuntimeConfig } from 'nitro/runtime-config'; import type { NitroAppPlugin, NitroApp } from 'nitro/types'; @@ -9,15 +9,9 @@ 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. Every preset hands requests to `nitroApp.fetch` after the plugins ran, so wrapping it goes // first, before route rules and middleware. - // 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(); + // Requests still carry Nuxt's `app.baseURL` here, so the tunnel is served under it. `useRuntimeConfig` also picks + // up a `NUXT_APP_BASE_URL` set at runtime. + setManagedTunnelDefaults({ basePath: (useRuntimeConfig() as { app?: { baseURL?: string } }).app?.baseURL }); const appFetch = nitroApp.fetch; - nitroApp.fetch = request => { - // `init` may run after this plugin, so this also covers the first requests after it. - applyBaseURL(); - - return handleTunnelRequestIfMatched(request) ?? appFetch(request); - }; + nitroApp.fetch = request => handleTunnelRequestIfMatched(request) ?? appFetch(request); }) satisfies NitroAppPlugin; From 77530e47cc880b5b40e0402aa7f456c3af98ba6c Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 19 Oct 2026 13:07:42 -0400 Subject: [PATCH 10/13] test(nuxt): Wait for the dev server before the tunnel tests `nuxt dev` opens the port before it can serve and answers 503 until then, while Playwright only waits for the port. --- .../test-applications/nuxt-4/tests/tunnel.test.ts | 7 +++++++ .../test-applications/nuxt-5/tests/tunnel.test.ts | 7 +++++++ 2 files changed, 14 insertions(+) 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 index 20fa9eed14a9..e662f3274297 100644 --- 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 @@ -3,6 +3,13 @@ import { getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/t test.skip(!process.env.E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); +test.beforeAll(async ({ request }) => { + // `nuxt dev` opens the port before it's ready and answers 503 until then, and Playwright only waits for the port. + await expect + .poll(async () => (await request.get(`${process.env.E2E_TEST_BASE_PATH ?? ''}/`)).status(), { timeout: 30_000 }) + .not.toBe(503); +}); + const BASE_PATH = process.env.E2E_TEST_BASE_PATH ?? ''; const TUNNEL_PATH = new RegExp(`^${BASE_PATH}/[a-z0-9]{1,7}$`); 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 index d814f3fab1c9..fa8e57028f45 100644 --- 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 @@ -3,6 +3,13 @@ import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; test.skip(!process.env.E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); +test.beforeAll(async ({ request }) => { + // `nuxt dev` opens the port before it's ready and answers 503 until then, and Playwright only waits for the port. + await expect + .poll(async () => (await request.get(`${process.env.E2E_TEST_BASE_PATH ?? ''}/`)).status(), { timeout: 30_000 }) + .not.toBe(503); +}); + const BASE_PATH = process.env.E2E_TEST_BASE_PATH ?? ''; const TUNNEL_PATH = new RegExp(`^${BASE_PATH}/[a-z0-9]{1,7}$`); From b8d2481e6fc32bc8f8b238dca5e23cd2ac7018b1 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 19 Oct 2026 15:58:10 -0400 Subject: [PATCH 11/13] fix(nuxt): Don't add the tunnel plugin to a host without Nitro --- packages/nuxt/src/vite/nitroServer.ts | 4 ++++ packages/nuxt/test/vite/nitroServer.test.ts | 8 +++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/nuxt/src/vite/nitroServer.ts b/packages/nuxt/src/vite/nitroServer.ts index 270576ab49d7..a67c5d9f8c52 100644 --- a/packages/nuxt/src/vite/nitroServer.ts +++ b/packages/nuxt/src/vite/nitroServer.ts @@ -131,6 +131,10 @@ function setupDeprecatedInjectMode( * `setupNitroServer` and adds this on its own. */ export function addTunnelPlugin(serverApi: ServerApi): void { + if (serverApi === 'nuxt') { + return; + } + const moduleDirResolver = createResolver(import.meta.url); addNitroPlugin( moduleDirResolver.resolve( diff --git a/packages/nuxt/test/vite/nitroServer.test.ts b/packages/nuxt/test/vite/nitroServer.test.ts index 44649c1c536f..0839d8375f99 100644 --- a/packages/nuxt/test/vite/nitroServer.test.ts +++ b/packages/nuxt/test/vite/nitroServer.test.ts @@ -1,6 +1,6 @@ import type { Nuxt } from '@nuxt/schema'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { setupNitroServer } from '../../src/vite/nitroServer'; +import { addTunnelPlugin, setupNitroServer } from '../../src/vite/nitroServer'; const addNitroPluginMock = vi.hoisted(() => vi.fn()); const addServerImportsMock = vi.hoisted(() => vi.fn()); @@ -92,6 +92,12 @@ describe('setupNitroServer', () => { }, ); + it('adds no tunnel plugin to a host without Nitro', () => { + addTunnelPlugin('nuxt'); + + expect(addNitroPluginMock).not.toHaveBeenCalled(); + }); + it('instruments the middleware and adds the config shim once Nitro initializes', () => { const { nuxt, callHook } = createFakeNuxt(); const nitro = { options: { preset: 'node-server', plugins: [] }, hooks: { hook: vi.fn() } }; From a977574ce5b8e4dfc77ccdb69f4ca2d074d0bbb6 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 19 Oct 2026 15:58:10 -0400 Subject: [PATCH 12/13] test(nuxt, nitro): Race the tunnel span with the shared helper, and drop the variants the base path ones cover --- .../test-applications/nitro-3/package.json | 22 +--------- .../nitro-3/tests/tunnel.test.ts | 42 +++++++------------ .../test-applications/nitro-3/vite.config.ts | 7 ---- .../nuxt-4-cloudflare/tests/tunnel.test.ts | 37 ++++------------ .../test-applications/nuxt-4/package.json | 13 ------ .../nuxt-4/tests/tunnel.test.ts | 39 +++++++---------- .../test-applications/nuxt-5/package.json | 7 ---- .../nuxt-5/tests/tunnel.test.ts | 32 ++++---------- dev-packages/test-utils/src/index.ts | 2 +- dev-packages/test-utils/src/tunnel.ts | 7 ++++ 10 files changed, 54 insertions(+), 154 deletions(-) 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 f494cbe2507f..cf6241cf5ff0 100644 --- a/dev-packages/e2e-tests/test-applications/nitro-3/package.json +++ b/dev-packages/e2e-tests/test-applications/nitro-3/package.json @@ -10,14 +10,9 @@ "test": "playwright test", "test:build": "pnpm install && pnpm build", "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", - "test:build:tunnel-no-middleware": "pnpm install && E2E_TEST_TUNNEL=1 E2E_TEST_NO_MIDDLEWARE=1 pnpm build", - "test:assert:tunnel-no-middleware": "E2E_TEST_TUNNEL=1 E2E_TEST_NO_MIDDLEWARE=1 playwright test tunnel -g 'without app middleware'" + "test:assert:tunnel-basepath-dev": "TEST_ENV=development E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app playwright test tunnel" }, "dependencies": { "@sentry/browser": "latest || *", @@ -37,26 +32,11 @@ }, "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 test:build:tunnel-no-middleware", - "assert-command": "pnpm test:assert:tunnel-no-middleware", - "label": "nitro-3 (tunnel-no-middleware)" - }, - { - "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", 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 index 2735ea4a7193..64f71280d8c0 100644 --- 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 @@ -1,5 +1,10 @@ import { expect, test } from '@playwright/test'; -import { getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; +import { + isServerSpanAt, + makeClientReportEnvelope, + waitForError, + watchForTunnelSpan, +} from '@sentry-internal/test-utils'; import { getTunnelPath } from '@sentry/core'; import { connect } from 'node:net'; @@ -12,42 +17,25 @@ 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 raceTunnelSpan = watchForTunnelSpan('nitro-3', isServerSpanAt(TUNNEL_PATH)); 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'); + expect((await tunnelResponsePromise).status()).toBe(200); + expect( + await raceTunnelSpan(isServerSpanAt(`${BASE_PATH}/api/test-transaction`), () => + page.request.get(`${BASE_PATH}/api/test-transaction`), + ), + ).toBe('anchor'); }); -test('answers the managed tunnel without app middleware', async ({ request }) => { +test('answers a request to the managed tunnel itself', async ({ request }) => { const dsn = 'http://public@localhost:3031/1337'; - const envelopeHeader = { dsn, sent_at: new Date().toISOString() }; const response = await request.post(`${BASE_PATH}${getTunnelPath(dsn)}`, { - data: `${JSON.stringify(envelopeHeader)}\n${JSON.stringify({ type: 'client_report' })}\n${JSON.stringify({ timestamp: Date.now() / 1000, discarded_events: [] })}\n`, + data: makeClientReportEnvelope(dsn), headers: { 'Content-Type': 'application/x-sentry-envelope' }, }); 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 083005ad7ac4..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 @@ -14,13 +14,6 @@ export default defineConfig({ withSentryConfig({ serverDir: './server', ...(process.env.E2E_TEST_BASE_PATH && { baseURL: `${process.env.E2E_TEST_BASE_PATH}/` }), - // Without static assets, global middleware or routed middleware, Nitro skips its middleware array whenever - // route rules exist, as on serverless presets. - ...(process.env.E2E_TEST_NO_MIDDLEWARE && { - serveStatic: false, - ignore: ['**/middleware/**'], - routeRules: { '/api/**': { headers: { 'x-e2e-route-rule': '1' } } }, - }), }), ), ], diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/tests/tunnel.test.ts index 7f1cd7bd7a1e..51e9df40bff3 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/tests/tunnel.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/tests/tunnel.test.ts @@ -1,43 +1,22 @@ import { expect, test } from '@playwright/test'; -import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; +import { isServerSpanAt, makeClientReportEnvelope, watchForTunnelSpan } from '@sentry-internal/test-utils'; test.skip(!process.env.E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); -// First in this file: the isolate's first tunnel request is the one a late span filter would let through. -test('drops the server span of a tunnel request under the base URL', async ({ request }) => { +// The isolate's first tunnel request is the one a late span filter would let through, so this stays the only test. +test('answers the managed tunnel set up by `sentryCloudflareNitroPlugin`, without a span of it', async ({ + request, +}) => { const dsn = 'http://public@localhost:3031/1337'; - const tunnelSpanPromise = waitForStreamedSpan( - 'nuxt-4-cloudflare', - span => - getSpanOp(span) === 'http.server' && String(span.attributes['url.path']?.value ?? '').startsWith('/app/qpo967'), - ); - const anchorSpanPromise = waitForStreamedSpan( - 'nuxt-4-cloudflare', - span => getSpanOp(span) === 'http.server' && span.attributes['url.path']?.value === '/app/api/anchor', - ); + const raceTunnelSpan = watchForTunnelSpan('nuxt-4-cloudflare', isServerSpanAt(/^\/app\/qpo967/)); - await request.post('/app/qpo967', { - data: `${JSON.stringify({ dsn, sent_at: new Date().toISOString() })}\n${JSON.stringify({ type: 'client_report' })}\n${JSON.stringify({ timestamp: Date.now() / 1000, discarded_events: [] })}\n`, - headers: { 'Content-Type': 'application/x-sentry-envelope' }, - }); - // Spans flush in order, so a tunnel span would arrive before this one. - await request.get('/app/api/anchor'); - - const winner = await Promise.race([ - tunnelSpanPromise.then(() => 'tunnel' as const), - anchorSpanPromise.then(() => 'anchor' as const), - ]); - expect(winner).toBe('anchor'); -}); - -test('answers the managed tunnel when the SDK is set up with `sentryCloudflareNitroPlugin`', async ({ request }) => { - const dsn = 'http://public@localhost:3031/1337'; // The path the SDK derives from `dsn`. const response = await request.post('/app/qpo967', { - data: `${JSON.stringify({ dsn, sent_at: new Date().toISOString() })}\n${JSON.stringify({ type: 'client_report' })}\n${JSON.stringify({ timestamp: Date.now() / 1000, discarded_events: [] })}\n`, + data: makeClientReportEnvelope(dsn), headers: { 'Content-Type': 'application/x-sentry-envelope' }, }); expect(response.headers()['x-sentry-tunnel']).toBe('1'); expect(response.status()).toBe(200); + expect(await raceTunnelSpan(isServerSpanAt('/app/api/anchor'), () => request.get('/app/api/anchor'))).toBe('anchor'); }); 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 1d8a052ab63b..1b467cf5d7e6 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json @@ -17,11 +17,8 @@ "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: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", @@ -52,21 +49,11 @@ "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", 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 index e662f3274297..85a6f1953a9a 100644 --- 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 @@ -1,5 +1,10 @@ import { expect, test } from '@playwright/test'; -import { getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; +import { + isServerSpanAt, + makeClientReportEnvelope, + waitForError, + watchForTunnelSpan, +} from '@sentry-internal/test-utils'; test.skip(!process.env.E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); @@ -17,35 +22,19 @@ 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 raceTunnelSpan = watchForTunnelSpan('nuxt-4', isServerSpanAt(TUNNEL_PATH)); 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'); + expect((await tunnelResponsePromise).status()).toBe(200); + expect( + await raceTunnelSpan(isServerSpanAt(`${BASE_PATH}/api/test-param/123`), () => + page.request.get(`${BASE_PATH}/api/test-param/123`), + ), + ).toBe('anchor'); }); test('answers the managed tunnel once, without errors from route rules that run after it', async ({ request }) => { @@ -62,7 +51,7 @@ test('answers the managed tunnel once, without errors from route rules that run ); const response = await request.post(`${BASE_PATH}${tunnelPath}`, { - data: `${JSON.stringify({ dsn, sent_at: new Date().toISOString() })}\n${JSON.stringify({ type: 'client_report' })}\n${JSON.stringify({ timestamp: Date.now() / 1000, discarded_events: [] })}\n`, + data: makeClientReportEnvelope(dsn), headers: { 'Content-Type': 'application/x-sentry-envelope' }, }); expect(response.headers()['x-sentry-tunnel']).toBe('1'); 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 0897f15ce65e..af98a6c5d4a3 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/package.json @@ -16,9 +16,7 @@ "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: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" }, @@ -45,11 +43,6 @@ "sentryTest": { "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", 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 index fa8e57028f45..51e8a022c623 100644 --- 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 @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; +import { isServerSpanAt, watchForTunnelSpan } from '@sentry-internal/test-utils'; test.skip(!process.env.E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); @@ -17,33 +17,17 @@ 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 raceTunnelSpan = watchForTunnelSpan('nuxt-5', isServerSpanAt(TUNNEL_PATH)); 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'); + expect((await tunnelResponsePromise).status()).toBe(200); + expect( + await raceTunnelSpan(isServerSpanAt(`${BASE_PATH}/api/test-param/123`), () => + page.request.get(`${BASE_PATH}/api/test-param/123`), + ), + ).toBe('anchor'); }); diff --git a/dev-packages/test-utils/src/index.ts b/dev-packages/test-utils/src/index.ts index bbb9a8bf0014..2598fe61b886 100644 --- a/dev-packages/test-utils/src/index.ts +++ b/dev-packages/test-utils/src/index.ts @@ -17,7 +17,7 @@ export { getSpanOp, } from './event-proxy-server'; export type { SerializedStreamedSpan } from '@sentry/core'; -export { isServerSpanAt, watchForTunnelSpan } from './tunnel'; +export { isServerSpanAt, makeClientReportEnvelope, watchForTunnelSpan } from './tunnel'; export { findAbsolutePathImports, diff --git a/dev-packages/test-utils/src/tunnel.ts b/dev-packages/test-utils/src/tunnel.ts index 8c52955d385c..e0497174e357 100644 --- a/dev-packages/test-utils/src/tunnel.ts +++ b/dev-packages/test-utils/src/tunnel.ts @@ -35,3 +35,10 @@ export function watchForTunnelSpan( ]); }; } + +/** + * An envelope with an empty client report, which Sentry accepts without creating anything. + */ +export function makeClientReportEnvelope(dsn: string): string { + return `${JSON.stringify({ dsn, sent_at: new Date().toISOString() })}\n${JSON.stringify({ type: 'client_report' })}\n${JSON.stringify({ timestamp: Date.now() / 1000, discarded_events: [] })}\n`; +} From eaaa8738385fe51465fd6f1e4701625219b90697 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 19 Oct 2026 15:58:10 -0400 Subject: [PATCH 13/13] ref(nuxt): Let core normalize the base URL, and drop the tests that repeat core's --- packages/nuxt/src/common/tunnel.ts | 7 +++---- packages/nuxt/test/common/tunnel.test.ts | 19 ++++--------------- 2 files changed, 7 insertions(+), 19 deletions(-) diff --git a/packages/nuxt/src/common/tunnel.ts b/packages/nuxt/src/common/tunnel.ts index 58b23d06b854..a99ab4b95d50 100644 --- a/packages/nuxt/src/common/tunnel.ts +++ b/packages/nuxt/src/common/tunnel.ts @@ -1,5 +1,5 @@ import type { Options } from '@sentry/core'; -import { applyTunnelDefaults, GLOBAL_OBJ, normalizeTunnelBasePath } from '@sentry/core'; +import { applyTunnelDefaults, GLOBAL_OBJ } 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'; @@ -10,7 +10,6 @@ export const NUXT_TUNNEL_BASE_PATH_GLOBAL = '__sentry_nuxt_base_path'; * 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); + const basePath = (GLOBAL_OBJ as { [NUXT_TUNNEL_BASE_PATH_GLOBAL]?: string })[NUXT_TUNNEL_BASE_PATH_GLOBAL]; + options.tunnel = applyTunnelDefaults(options.tunnel, { basePath }); } diff --git a/packages/nuxt/test/common/tunnel.test.ts b/packages/nuxt/test/common/tunnel.test.ts index ed491b077fdd..b5076d2cc083 100644 --- a/packages/nuxt/test/common/tunnel.test.ts +++ b/packages/nuxt/test/common/tunnel.test.ts @@ -18,23 +18,12 @@ describe('applyNuxtTunnelBasePath', () => { 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' } }; + it('serves from the root for the default base URL', () => { + globalWithBasePath[NUXT_TUNNEL_BASE_PATH_GLOBAL] = '/'; + const options: Parameters[0] = { tunnel: true }; 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); - } + expect(options.tunnel).toBe(true); }); });