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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
39 changes: 38 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,15 @@
"clean": "npx rimraf node_modules pnpm-lock.yaml .output",
"test": "playwright test",
"test:build": "pnpm install && pnpm build",
"test:assert": "pnpm test"
"test:assert": "pnpm test",
"test:build:tunnel": "pnpm install && E2E_TEST_TUNNEL=1 pnpm build",
"test:build:tunnel-basepath": "pnpm install && E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app pnpm build",
"test:assert:tunnel": "E2E_TEST_TUNNEL=1 playwright test tunnel",
"test:assert:tunnel-basepath": "E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app playwright test tunnel",
"test:assert:tunnel-dev": "TEST_ENV=development E2E_TEST_TUNNEL=1 playwright test tunnel",
"test:assert:tunnel-basepath-dev": "TEST_ENV=development E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app playwright test tunnel",
"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 || *",
Expand All @@ -25,5 +33,34 @@
},
"volta": {
"extends": "../../package.json"
},
"sentryTest": {
"variants": [
{
"build-command": "pnpm test:build:tunnel",
"assert-command": "pnpm test:assert:tunnel",
"label": "nitro-3 (tunnel)"
},
{
"build-command": "pnpm test:build:tunnel-basepath",
"assert-command": "pnpm test:assert:tunnel-basepath",
"label": "nitro-3 (tunnel-basepath)"
},
{
"build-command": "pnpm 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",
"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,83 @@
import { expect, test } from '@playwright/test';
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');

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

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

const tunnelServerSpanPromise = waitForStreamedSpan(
'nitro-3',
span => getSpanOp(span) === 'http.server' && TUNNEL_PATH.test(String(span.attributes['url.path']?.value ?? '')),
);
const tunnelResponsePromise = page.waitForResponse(
response => response.request().method() === 'POST' && TUNNEL_PATH.test(new URL(response.url()).pathname),
);

await page.goto(`${BASE_PATH}/`);
const tunnelResponse = await tunnelResponsePromise;

expect(tunnelResponse.status()).toBe(200);
expect(new URL(tunnelResponse.url()).origin).toBe(new URL(page.url()).origin);

// Anchor on a regular server span requested after the tunnel traffic: spans flush in order, so a tunnel span
// would arrive first.
const anchorSpanPromise = waitForStreamedSpan(
'nitro-3',
span =>
getSpanOp(span) === 'http.server' &&
String(span.attributes['url.path']?.value ?? '') === `${BASE_PATH}/api/test-transaction`,
);
await page.request.get(`${BASE_PATH}/api/test-transaction`);

const winner = await Promise.race([
tunnelServerSpanPromise.then(() => 'tunnel' as const),
anchorSpanPromise.then(() => 'anchor' as const),
]);
expect(winner).toBe('anchor');
});

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);
});

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);
});
});

// 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');
});
12 changes: 12 additions & 0 deletions dev-packages/e2e-tests/test-applications/nitro-3/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,24 @@ 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}/` }),
// 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' } } },
}),
}),
),
],
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,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
}),
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
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('/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' },
});

expect(response.headers()['x-sentry-tunnel']).toBe('1');
expect(response.status()).toBe(200);
});
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 @@ -10,14 +12,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
28 changes: 27 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,13 @@
"test:build": "pnpm install && node ./scripts/build-with-prerender-event-sink.mjs",
"test:build-canary": "pnpm add nuxt@npm:nuxt-nightly@latest && pnpm add nitropack@npm:nitropack-nightly@latest && pnpm install --force && node ./scripts/build-with-prerender-event-sink.mjs",
"test:assert": "pnpm test:prod && pnpm test:dev",
"test:prod:import": "TEST_ENV=production-import playwright test"
"test:prod:import": "TEST_ENV=production-import playwright test",
"test:build:tunnel": "pnpm install && E2E_TEST_TUNNEL=1 node ./scripts/build-with-prerender-event-sink.mjs",
"test:build:tunnel-basepath": "pnpm install && E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app node ./scripts/build-with-prerender-event-sink.mjs",
"test:assert:tunnel": "E2E_TEST_TUNNEL=1 TEST_ENV=production playwright test tunnel",
"test:assert:tunnel-basepath": "E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app TEST_ENV=production playwright test tunnel",
"test:assert:tunnel-dev": "E2E_TEST_TUNNEL=1 TEST_ENV=development playwright test tunnel",
"test:assert:tunnel-basepath-dev": "E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app TEST_ENV=development playwright test tunnel"
},
"//": "Pin ioredis to 5.10.1 because that's the last version before it publishes its own diagnostics channels",
"dependencies": {
Expand Down Expand Up @@ -45,6 +51,26 @@
"build-command": "pnpm test:build",
"assert-command": "pnpm test:prod:import",
"label": "nuxt-4 (--import compat)"
},
{
"build-command": "pnpm test:build:tunnel",
"assert-command": "pnpm test:assert:tunnel",
"label": "nuxt-4 (tunnel)"
},
{
"build-command": "pnpm test:build:tunnel-basepath",
"assert-command": "pnpm test:assert:tunnel-basepath",
"label": "nuxt-4 (tunnel-basepath)"
},
{
"build-command": "pnpm install",
"assert-command": "pnpm test:assert:tunnel-dev",
"label": "nuxt-4 (tunnel-dev)"
},
{
"build-command": "pnpm install",
"assert-command": "pnpm test:assert:tunnel-basepath-dev",
"label": "nuxt-4 (tunnel-basepath-dev)"
}
],
"optionalVariants": [
Expand Down
Loading
Loading