Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
72edd0c
feat(nuxt, nitro): Serve the managed tunnel before the app's server m…
logaretm Oct 15, 2026
4d8c86c
feat(nuxt, nitro): Serve the managed tunnel under the app's base URL
logaretm Oct 16, 2026
6c4a1a1
fix(nitro): Drop the tunnel's dev server spans under `baseURL` too
logaretm Oct 16, 2026
fb959a5
fix(nuxt): Normalize the tunnel base path with the core util
logaretm Oct 16, 2026
e77d0ee
fix(nuxt, nitro): Serve the managed tunnel from `nitroApp.fetch` so r…
logaretm Oct 18, 2026
bffb551
fix(nuxt): Serve the Nitro v2 tunnel from the first h3 layer so route…
logaretm Oct 19, 2026
9d55385
test(nitro): Check a client dropping the tunnel request isn't reporte…
logaretm Oct 19, 2026
0f6271e
fix(nuxt): Serve the managed tunnel when the SDK is set up with sentr…
logaretm Oct 19, 2026
d543ed2
ref(nuxt, nitro): Report the base URL once with setManagedTunnelDefau…
logaretm Oct 19, 2026
2460154
test(nuxt): Wait for the dev server before the tunnel tests
logaretm Oct 19, 2026
867da3d
fix(nuxt): Don't add the tunnel plugin to a host without Nitro
logaretm Oct 19, 2026
818f159
test(nuxt, nitro): Race the tunnel span with the shared helper, and d…
logaretm Oct 19, 2026
8036b67
ref(nuxt): Let core normalize the base URL, and drop the tests that r…
logaretm Oct 19, 2026
a9dcd36
ref: Import the internal tunnel helpers under their `_INTERNAL_` names
logaretm Oct 22, 2026
f24e7e0
test: Stop checking the removed `X-Sentry-Tunnel` header
logaretm Oct 27, 2026
259e744
test(nuxt, nitro): Serve the managed tunnel with `serveTunnelIntegrat…
logaretm Oct 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import * as Sentry from '@sentry/nitro';

Sentry.init({
environment: 'qa', // dynamic sampling bias to keep transactions
dsn: process.env.E2E_TEST_DSN,
dsn: process.env.E2E_TEST_TUNNEL ? 'http://public@localhost:3031/1337' : process.env.E2E_TEST_DSN,
tunnel: `http://localhost:3031/`, // proxy server
integrations: process.env.E2E_TEST_TUNNEL ? [Sentry.serveTunnelIntegration()] : [],
tracesSampleRate: 1,
});
19 changes: 18 additions & 1 deletion dev-packages/e2e-tests/test-applications/nitro-3/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@
"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-basepath": "pnpm install && E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app pnpm build",
"test:assert:tunnel-basepath": "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"
},
"dependencies": {
"@sentry/browser": "latest || *",
Expand All @@ -26,5 +29,19 @@
},
"volta": {
"extends": "../../package.json"
},
"sentryTest": {
"variants": [
{
"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-basepath-dev",
"label": "nitro-3 (tunnel-basepath-dev)"
}
]
}
}
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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);
Expand Down
12 changes: 10 additions & 2 deletions dev-packages/e2e-tests/test-applications/nitro-3/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
import * as Sentry from '@sentry/browser';

declare const __E2E_TEST_TUNNEL__: boolean;
declare const __E2E_TEST_BASE_PATH__: string;

// Let's us test trace propagation
Sentry.init({
environment: 'qa',
dsn: 'https://public@dsn.ingest.sentry.io/1337',
tunnel: 'http://localhost:3031/', // proxy server
dsn: __E2E_TEST_TUNNEL__ ? 'http://public@localhost:3031/1337' : 'https://public@dsn.ingest.sentry.io/1337',
// Plain Nitro has no client SDK to pass `baseURL` on, so the browser names it. The server picks it up itself.
tunnel: __E2E_TEST_TUNNEL__
? __E2E_TEST_BASE_PATH__
? { basePath: __E2E_TEST_BASE_PATH__ }
: true
: 'http://localhost:3031/', // proxy server
integrations: [Sentry.browserTracingIntegration()],
tracesSampleRate: 1.0,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { expect, test } from '@playwright/test';
import {
isServerSpanAt,
makeClientReportEnvelope,
waitForError,
watchForTunnelSpan,
} 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');

const BASE_PATH = process.env.E2E_TEST_BASE_PATH ?? '';
const TUNNEL_PATH = new RegExp(`^${BASE_PATH}/[a-z0-9]{1,7}$`);

test('answers the managed tunnel before the app auth middleware', async ({ page }) => {
// The variant's auth middleware rejects every other POST.
expect((await page.request.post(`${BASE_PATH}/api/anything`, { data: 'x' })).status()).toBe(401);

const 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}/`);

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 a request to the managed tunnel itself', async ({ request }) => {
const dsn = 'http://public@localhost:3031/1337';
const response = await request.post(`${BASE_PATH}${getTunnelPath(dsn)}`, {
data: makeClientReportEnvelope(dsn),
headers: { 'Content-Type': 'application/x-sentry-envelope' },
});

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<void>(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);
Comment thread
logaretm marked this conversation as resolved.
});
});

// 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');
});
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,17 @@ import { nitro } from 'nitro/vite';
import { defineConfig } from 'vite';

export default defineConfig({
define: {
__E2E_TEST_TUNNEL__: JSON.stringify(!!process.env.E2E_TEST_TUNNEL),
__E2E_TEST_BASE_PATH__: JSON.stringify(process.env.E2E_TEST_BASE_PATH ?? ''),
},
plugins: [
nitro(
// FIXME: Nitro plugin has a type issue
// @ts-expect-error
withSentryConfig({
serverDir: './server',
...(process.env.E2E_TEST_BASE_PATH && { baseURL: `${process.env.E2E_TEST_BASE_PATH}/` }),
}),
),
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -14,5 +16,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),
},
},
});
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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)"
}
]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { defineEventHandler } from '#imports';

// A plain route for tests to request after the tunnel traffic.
export default defineEventHandler(() => 'ok');
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import { defineNitroPlugin } from '#imports';
import { serveTunnelIntegration } from '@sentry/cloudflare';
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
integrations: __E2E_TEST_TUNNEL__ ? [serveTunnelIntegration()] : [],
}),
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { expect, test } from '@playwright/test';
import { isServerSpanAt, makeClientReportEnvelope, watchForTunnelSpan } from '@sentry-internal/test-utils';

test.skip(!process.env.E2E_TEST_TUNNEL, 'Only runs in the tunnel variant');

// 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 raceTunnelSpan = watchForTunnelSpan('nuxt-4-cloudflare', isServerSpanAt(/^\/app\/qpo967/));

// The path the SDK derives from `dsn`.
const response = await request.post('/app/qpo967', {
data: makeClientReportEnvelope(dsn),
headers: { 'Content-Type': 'application/x-sentry-envelope' },
});

expect(response.status()).toBe(200);
expect(await raceTunnelSpan(isServerSpanAt('/app/api/anchor'), () => request.get('/app/api/anchor'))).toBe('anchor');
});
11 changes: 10 additions & 1 deletion dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts
Original file line number Diff line number Diff line change
@@ -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 },
Expand All @@ -11,14 +13,21 @@ 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`.
...(process.env.E2E_TEST_BASE_PATH && { app: { baseURL: `${process.env.E2E_TEST_BASE_PATH}/` } }),

modules: ['@pinia/nuxt', '@sentry/nuxt/module'],
runtimeConfig: {
public: {
sentry: {
dsn: 'https://public@dsn.ingest.sentry.io/1337',
// The tunnel variant forwards to the test proxy, so the DSN has to point there.
dsn: tunnelVariant ? 'http://public@localhost:3031/1337' : 'https://public@dsn.ingest.sentry.io/1337',
},
e2eTunnel: tunnelVariant,
},
},
nitro: {
Expand Down
15 changes: 14 additions & 1 deletion dev-packages/e2e-tests/test-applications/nuxt-4/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@
"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-basepath": "pnpm install && E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app node ./scripts/build-with-prerender-event-sink.mjs",
"test:assert:tunnel-basepath": "E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app TEST_ENV=production 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": {
Expand Down Expand Up @@ -51,6 +54,16 @@
"build-command": "E2E_TEST_VITE_SERVER=true pnpm test:build",
"assert-command": "E2E_TEST_VITE_SERVER=true pnpm test:prod",
"label": "nuxt-4 (Vite server)"
},
{
"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-basepath-dev",
"label": "nuxt-4 (tunnel-basepath-dev)"
}
],
"optionalVariants": [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(), {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
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
// 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,
// Several tests trigger the same server error one after another, which Dedupe would drop.
integrations: integrations => integrations.filter(integration => integration.name !== 'Dedupe'),
integrations: integrations => [
...integrations.filter(integration => integration.name !== 'Dedupe'),
...(process.env.E2E_TEST_TUNNEL ? [Sentry.serveTunnelIntegration()] : []),
],
});
Original file line number Diff line number Diff line change
@@ -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') {
Expand Down
Loading
Loading