Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
6be9e8c
ref(nextjs): Forward tunnel route requests with core's `handleTunnelR…
logaretm Oct 28, 2026
a070356
feat(nextjs): Serve the browser's managed tunnel under the app's `bas…
logaretm Oct 29, 2026
2e193f6
feat(nextjs): Serve the managed tunnel under the app's `basePath` on …
logaretm Oct 29, 2026
5270706
test(nextjs): Cover the managed tunnel under a base path
logaretm Oct 30, 2026
a7bf47a
feat(nextjs): Serve the managed tunnel on Vercel
logaretm Nov 2, 2026
aa79eeb
feat(nextjs): Deprecate `tunnelRoute`
logaretm Nov 2, 2026
10e5651
ref(nextjs): Use the renamed tunnel defaults
logaretm Nov 3, 2026
8fb222e
ref(nextjs): Use the renamed browser tunnel defaults
logaretm Nov 3, 2026
0c9528e
ref(nextjs): Generate the Vercel tunnel route with the public POST ha…
logaretm Nov 3, 2026
a74485e
fix(nextjs): Skip the Vercel tunnel route when a page has its path
logaretm Nov 3, 2026
dce8636
fix(nextjs): Post to the managed tunnel with Next's trailing slash
logaretm Nov 5, 2026
9ce025b
fix(nextjs): Write the Vercel tunnel route only in Vercel production …
logaretm Nov 11, 2026
ebeb874
feat(nextjs): Serve `tunnelRoute` with the managed tunnel
logaretm Nov 11, 2026
8e364d0
ref(nextjs): Un-deprecate the tunnel route handler `POST`
logaretm Nov 11, 2026
e1db9b1
feat(nextjs): Rewrite envelope requests on any unrouted path to the V…
logaretm Nov 11, 2026
a888c60
fix(nextjs): Send a fixed `tunnelRoute` path with the envelope conten…
logaretm Nov 11, 2026
a128721
fix(nextjs): Drop the Next.js spans of tunnel requests on the paths d…
logaretm Nov 11, 2026
cb3d517
ref(nextjs): Match tunnel request spans against the client that start…
logaretm Nov 11, 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 @@ -7,5 +7,5 @@ const nextConfig: NextConfig = {

export default withSentryConfig(nextConfig, {
silent: true,
tunnelRoute: '/monitoring',
tunnelRoute: true,
});
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,6 @@ export async function proxy(request: NextRequest) {
}

export const config = {
matcher: ['/api/proxy-isolation', '/api/proxy-ok', '/monitoring'],
// Every path, so the proxy also runs for the tunnel path, which is derived from the DSN.
matcher: '/:path*',
};
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { expect, test } from '@playwright/test';
import { HTTP_TARGET } from '@sentry/conventions/attributes';
import { waitForStreamedSpan, waitForStreamedSpans } from '@sentry-internal/test-utils';
import { getTunnelPath } from '@sentry/nextjs';

test('drops the `Next.request` root span of tunnel requests through the proxy', async ({ request }) => {
// The callback never returns true, so this promise is deliberately left unsettled. The assertions read the array.
Expand All @@ -18,13 +19,14 @@ test('drops the `Next.request` root span of tunnel requests through the proxy',
);
});

const tunnelResponse = await request.post('/monitoring?o=123&p=456', { data: '{}' });
// The SDK answers only a POST before the proxy. A GET reaches the proxy, like a tunnel request on Vercel, where the
// proxy runs outside of the server the SDK hooks into.
const tunnelPath = getTunnelPath(process.env.E2E_TEST_DSN);
const tunnelResponse = await request.get(tunnelPath!);
expect(tunnelResponse.headers()['x-middleware-next']).toBe('1');

await request.get('/api/proxy-ok');
await proxyOkSpanPromise;

expect(segmentSpans.filter(span => String(span.attributes[HTTP_TARGET]?.value).startsWith('/monitoring'))).toEqual(
[],
);
expect(segmentSpans.filter(span => String(span.attributes[HTTP_TARGET]?.value).startsWith(tunnelPath!))).toEqual([]);
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import { SENTRY_DSN } from './sentry-dsn';
Sentry.init({
environment: 'qa', // dynamic sampling bias to keep transactions
dsn: SENTRY_DSN,
// No tunnel option - using tunnelRoute from withSentryConfig
// The other variants use the tunnelRoute of withSentryConfig
tunnel: process.env.NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE === 'managed' ? true : undefined,
tracesSampleRate: 1.0,
});

Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { withSentryConfig } from '@sentry/nextjs/config';
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {};
// The managed variants serve the managed tunnel under a base path and with trailing slashes, without a tunnel route.
const tunnelMode = process.env.NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE;
const isManagedMode = tunnelMode === 'managed';

const nextConfig: NextConfig = isManagedMode ? { basePath: '/docs', trailingSlash: true } : {};

export default withSentryConfig(nextConfig, {
silent: true,
tunnelRoute: true,
tunnelRoute: isManagedMode ? undefined : tunnelMode === 'path' ? '/monitoring' : true,
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,25 @@
"private": true,
"scripts": {
"dev": " next dev",
"build": "_SENTRY_TUNNEL_DESTINATION_OVERRIDE=http://localhost:3031/ next build > .tmp_build_stdout 2> .tmp_build_stderr || (cat .tmp_build_stdout && cat .tmp_build_stderr && exit 1)",
"build": "next build > .tmp_build_stdout 2> .tmp_build_stderr || (cat .tmp_build_stdout && cat .tmp_build_stderr && exit 1)",
"clean": "npx rimraf node_modules pnpm-lock.yaml .tmp_dev_server_logs",
"dev:webpack": "_SENTRY_TUNNEL_DESTINATION_OVERRIDE=http://localhost:3031/ next dev --webpack",
"build-webpack": "_SENTRY_TUNNEL_DESTINATION_OVERRIDE=http://localhost:3031/ next build --webpack > .tmp_build_stdout 2> .tmp_build_stderr || (cat .tmp_build_stdout && cat .tmp_build_stderr && exit 1)",
"start": "_SENTRY_TUNNEL_DESTINATION_OVERRIDE=http://localhost:3031/ next start",
"dev:webpack": "next dev --webpack",
"build-webpack": "next build --webpack > .tmp_build_stdout 2> .tmp_build_stderr || (cat .tmp_build_stdout && cat .tmp_build_stderr && exit 1)",
"start": "next start",
"lint": "eslint",
"test:prod": "TEST_ENV=production playwright test",
"test:dev": "TEST_ENV=development _SENTRY_TUNNEL_DESTINATION_OVERRIDE=http://localhost:3031/ playwright test",
"test:dev-webpack": "TEST_ENV=development-webpack _SENTRY_TUNNEL_DESTINATION_OVERRIDE=http://localhost:3031/ playwright test",
"test:dev": "TEST_ENV=development playwright test",
"test:dev-webpack": "TEST_ENV=development-webpack playwright test",
"test:build": "pnpm install && pnpm build",
"test:build-webpack": "pnpm install && pnpm build-webpack",
"test:build-handler": "pnpm install && cp -R tunnel-handler/node/sentry-tunnel app/ && pnpm build",
"test:build-handler-webpack": "pnpm install && cp -R tunnel-handler/edge/sentry-tunnel app/ && pnpm build-webpack",
"test:build-middleware-webpack": "pnpm install && rm proxy.ts && cp tunnel-middleware/middleware.ts middleware.ts && pnpm build-webpack",
"test:build-canary": "pnpm install && pnpm add next@canary && pnpm build",
"test:build-latest": "pnpm install && pnpm add next@latest && pnpm build",
"test:build-latest-webpack": "pnpm install && pnpm add next@latest && pnpm build-webpack",
"test:build-canary-webpack": "pnpm install && pnpm add next@canary && pnpm build-webpack",
"test:assert": "pnpm test:prod && pnpm test:dev",
"test:assert-webpack": "pnpm test:prod && pnpm test:dev-webpack",
"test:build:cloudflare": "pnpm install && _SENTRY_TUNNEL_DESTINATION_OVERRIDE=http://localhost:3031/ opennextjs-cloudflare build",
"test:build:cloudflare": "pnpm install && opennextjs-cloudflare build",
"start:cloudflare": "opennextjs-cloudflare preview"
},
"dependencies": {
Expand Down Expand Up @@ -54,23 +53,28 @@
"variants": [
{
"build-command": "pnpm test:build-webpack",
"label": "nextjs-16-tunnel (webpack)",
"label": "nextjs-16-tunnel (tunnelRoute: true, webpack)",
"assert-command": "pnpm test:assert-webpack"
},
{
"build-command": "pnpm test:build",
"label": "nextjs-16-tunnel (turbopack)",
"label": "nextjs-16-tunnel (tunnelRoute: true, turbopack)",
"assert-command": "pnpm test:assert"
},
{
"build-command": "NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE=handler pnpm test:build-handler",
"label": "nextjs-16-tunnel (handler, turbopack)",
"assert-command": "NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE=handler pnpm test:assert"
"build-command": "NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE=path pnpm test:build",
"label": "nextjs-16-tunnel (tunnelRoute path, turbopack)",
"assert-command": "NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE=path pnpm test:assert"
},
{
"build-command": "NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE=handler pnpm test:build-handler-webpack",
"label": "nextjs-16-tunnel (handler, webpack, edge)",
"assert-command": "NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE=handler pnpm test:assert-webpack"
"build-command": "NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE=managed pnpm test:build",
"label": "nextjs-16-tunnel (managed, turbopack)",
"assert-command": "NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE=managed pnpm test:assert"
},
{
"build-command": "NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE=managed pnpm test:build-middleware-webpack",
"label": "nextjs-16-tunnel (managed, webpack, edge middleware)",
"assert-command": "NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE=managed pnpm test:assert-webpack"
}
],
"optionalVariants": [
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
// The handler variants send the server events to the event proxy too, so the tests also see server spans.
export const SENTRY_DSN =
process.env.NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE === 'handler'
? 'http://public@localhost:3031/1337'
: // A fake but properly formatted Sentry SaaS DSN, because the rewrite mode tunnels only SaaS DSNs
'https://public@o12345.ingest.us.sentry.io/67890';
// The server forwards the tunnel requests to the host of the DSN, so the event proxy gets the browser events and the
// server spans.
export const SENTRY_DSN = 'http://public@localhost:3031/1337';
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import { SENTRY_DSN } from './sentry-dsn';
Sentry.init({
environment: 'qa', // dynamic sampling bias to keep transactions
dsn: SENTRY_DSN,
// No tunnel option - using tunnelRoute from withSentryConfig
// Serves the managed tunnel of the managed variants
integrations: process.env.NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE === 'managed' ? [Sentry.serveTunnelIntegration()] : [],
tracesSampleRate: 1.0,
// debug: true,
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import { SENTRY_DSN } from './sentry-dsn';
Sentry.init({
environment: 'qa', // dynamic sampling bias to keep transactions
dsn: SENTRY_DSN,
// No tunnel option - using tunnelRoute from withSentryConfig
// Serves the managed tunnel of the managed variants
integrations: process.env.NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE === 'managed' ? [Sentry.serveTunnelIntegration()] : [],
tracesSampleRate: 1.0,
// debug: true,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { expect, test } from '@playwright/test';
import { HTTP_REQUEST_METHOD, URL_FULL } from '@sentry/conventions/attributes';
import { getSpanOp, waitForRequest, waitForStreamedSpan, waitForStreamedSpans } from '@sentry-internal/test-utils';
import type { Event, SerializedStreamedSpan } from '@sentry/core';

test.skip(process.env.NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE !== 'managed', 'Only the managed variants use it');

const BASE_PATH = '/docs';

function getStringAttribute(span: SerializedStreamedSpan, key: string): string | undefined {
const attribute = span.attributes[key];
return attribute?.type === 'string' ? attribute.value : undefined;
}

// With `trailingSlash`, a POST to the path without the slash would get a redirect.
function isTunnelPath(pathname: string): boolean {
return /^\/docs\/[a-z0-9]+\/$/.test(pathname);
}

test('sends the pageload span through the managed tunnel under the base path', async ({ page, baseURL }) => {
const pageloadSpanPromise = waitForStreamedSpan('nextjs-16-tunnel', span => {
return getSpanOp(span) === 'pageload' && span.is_segment;
});
const tunnelResponsePromise = page.waitForResponse(
response =>
response.request().method() === 'POST' &&
new URL(response.url()).origin === baseURL &&
(response.request().postData() ?? '').includes('pageload'),
);

await page.goto(BASE_PATH);

const tunnelResponse = await tunnelResponsePromise;
const pageloadSpan = await pageloadSpanPromise;

expect(isTunnelPath(new URL(tunnelResponse.url()).pathname)).toBe(true);
expect(tunnelResponse.status()).toBe(200);
expect(pageloadSpan.status).toBe('ok');
});

test('forwards events without the cookies of the browser', async ({ page, context, baseURL }) => {
await context.addCookies([{ name: 'session', value: '8f2c1d', url: baseURL }]);
const proxyRequestPromise = waitForRequest('nextjs-16-tunnel', ({ envelope }) =>
envelope[1].some(
([itemHeader, item]) =>
itemHeader.type === 'event' && (item as Event).exception?.values?.[0]?.value === 'Tunnel managed cookie test',
),
);

await page.goto(BASE_PATH);
await page.waitForFunction(() => typeof (window as { __SENTRY__?: unknown }).__SENTRY__ === 'object');
const tunnelRequestPromise = page.waitForRequest(
request =>
request.method() === 'POST' &&
new URL(request.url()).origin === baseURL &&
(request.postData() ?? '').includes('Tunnel managed cookie test'),
);
await page.evaluate(() => {
setTimeout(() => {
throw new Error('Tunnel managed cookie test');
});
});

const tunnelRequest = await tunnelRequestPromise;
const proxyRequest = await proxyRequestPromise;

expect(isTunnelPath(new URL(tunnelRequest.url()).pathname)).toBe(true);
expect((await tunnelRequest.allHeaders()).cookie).toBe('session=8f2c1d');
expect(proxyRequest.rawProxyRequestHeaders.cookie).toBeUndefined();
expect(proxyRequest.rawProxyRequestHeaders['content-type']).toBe('application/x-sentry-envelope');
});

test('does not create server spans for tunnel requests', async ({ page, baseURL }) => {
// One listener, so it gets every span that the proxy received before the barrier span.
const spansBeforeBarrier: SerializedStreamedSpan[] = [];
const barrierSpanPromise = waitForStreamedSpans('nextjs-16-tunnel', spans => {
spansBeforeBarrier.push(...spans);
return spans.some(span => span.is_segment && span.name === 'GET /api/flush');
});
const pageloadTunnelResponsePromise = page.waitForResponse(
response =>
response.request().method() === 'POST' &&
new URL(response.url()).origin === baseURL &&
(response.request().postData() ?? '').includes('pageload'),
);

await page.goto(BASE_PATH);
await pageloadTunnelResponsePromise;
// The first flush sends the tunnel spans, the second one the barrier span of the first flush.
await page.request.get(`${BASE_PATH}/api/flush`);
await page.request.get(`${BASE_PATH}/api/flush`);
await barrierSpanPromise;

// The middleware span of the page request arrived, so the ones of the tunnel requests would have too.
expect(spansBeforeBarrier.some(span => span.name === 'middleware GET')).toBe(true);

const postSpans = spansBeforeBarrier.filter(
span =>
span.name.includes('POST') ||
getStringAttribute(span, HTTP_REQUEST_METHOD) === 'POST' ||
getStringAttribute(span, 'http.method') === 'POST',
);
const ingestSpans = spansBeforeBarrier.filter(
span =>
getSpanOp(span) === 'http.client' && !!getStringAttribute(span, URL_FULL)?.startsWith('http://localhost:3031/'),
);
expect(postSpans).toEqual([]);
expect(ingestSpans).toEqual([]);
});
Loading
Loading