Skip to content

Commit de8fd3d

Browse files
committed
feat(nextjs): Add tunnel route handler that forwards only the envelope
1 parent 5a11c08 commit de8fd3d

35 files changed

Lines changed: 1081 additions & 57 deletions

‎CHANGELOG.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,21 @@
44

55
- "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott
66

7+
### Important Changes
8+
9+
- **feat(nextjs): Add a tunnel route handler that sends only the event payload to Sentry ([#25456](https://github.com/getsentry/sentry-javascript/pull/25456))**
10+
11+
The `tunnelRoute` option can now send events through a route handler in your app. The handler forwards only the event payload to Sentry, and no request headers. To use it, add this file next to your `tunnelRoute` option:
12+
13+
```ts
14+
// app/sentry-tunnel/route.ts (or src/app/sentry-tunnel/route.ts)
15+
export { POST } from '@sentry/nextjs/tunnel';
16+
```
17+
18+
The handler accepts events for the DSN of your server SDK. To accept other DSNs, use `createSentryTunnelRoute({ allowedDsns })` from the same import. Without the file, the tunnel route works as before.
19+
20+
Also, the server no longer sends spans of tunnel requests when span streaming is on, because these spans only show the SDK's own traffic.
21+
722
## 11.8.0
823

924
### Important Changes

‎dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/.gitignore‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212

1313
event-dumps
1414

15+
# Copied by `test:build-handler*`
16+
/app/sentry-tunnel/
17+
1518
# testing
1619
/coverage
1720

‎dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/instrumentation-client.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import * as Sentry from '@sentry/nextjs';
2+
import { SENTRY_DSN } from './sentry-dsn';
23

34
Sentry.init({
45
environment: 'qa', // dynamic sampling bias to keep transactions
5-
// Use a fake but properly formatted Sentry SaaS DSN for tunnel route testing
6-
dsn: 'https://public@o12345.ingest.us.sentry.io/67890',
6+
dsn: SENTRY_DSN,
77
// No tunnel option - using tunnelRoute from withSentryConfig
88
tracesSampleRate: 1.0,
99
});

‎dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
"test:dev-webpack": "TEST_ENV=development-webpack _SENTRY_TUNNEL_DESTINATION_OVERRIDE=http://localhost:3031/ playwright test",
1616
"test:build": "pnpm install && pnpm build",
1717
"test:build-webpack": "pnpm install && pnpm build-webpack",
18+
"test:build-handler": "pnpm install && cp -R tunnel-handler/node/sentry-tunnel app/ && pnpm build",
19+
"test:build-handler-webpack": "pnpm install && cp -R tunnel-handler/edge/sentry-tunnel app/ && pnpm build-webpack",
1820
"test:build-canary": "pnpm install && pnpm add next@canary && pnpm build",
1921
"test:build-latest": "pnpm install && pnpm add next@latest && pnpm build",
2022
"test:build-latest-webpack": "pnpm install && pnpm add next@latest && pnpm build-webpack",
@@ -61,6 +63,16 @@
6163
"build-command": "pnpm test:build",
6264
"label": "nextjs-16-tunnel (turbopack)",
6365
"assert-command": "pnpm test:assert"
66+
},
67+
{
68+
"build-command": "NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE=handler pnpm test:build-handler",
69+
"label": "nextjs-16-tunnel (handler, turbopack)",
70+
"assert-command": "NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE=handler pnpm test:assert"
71+
},
72+
{
73+
"build-command": "NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE=handler pnpm test:build-handler-webpack",
74+
"label": "nextjs-16-tunnel (handler, webpack, edge)",
75+
"assert-command": "NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE=handler pnpm test:assert-webpack"
6476
}
6577
],
6678
"optionalVariants": [
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
// The handler variants send the server events to the event proxy too, so the tests also see server spans.
2+
export const SENTRY_DSN =
3+
process.env.NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE === 'handler'
4+
? 'http://public@localhost:3031/1337'
5+
: // A fake but properly formatted Sentry SaaS DSN, because the rewrite mode tunnels only SaaS DSNs
6+
'https://public@o12345.ingest.us.sentry.io/67890';

‎dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.edge.config.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import * as Sentry from '@sentry/nextjs';
2+
import { SENTRY_DSN } from './sentry-dsn';
23

34
Sentry.init({
45
environment: 'qa', // dynamic sampling bias to keep transactions
5-
// Use a fake but properly formatted Sentry SaaS DSN for tunnel route testing
6-
dsn: 'https://public@o12345.ingest.us.sentry.io/67890',
6+
dsn: SENTRY_DSN,
77
// No tunnel option - using tunnelRoute from withSentryConfig
88
tracesSampleRate: 1.0,
99
// debug: true,

‎dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.server.config.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import * as Sentry from '@sentry/nextjs';
2+
import { SENTRY_DSN } from './sentry-dsn';
23

34
Sentry.init({
45
environment: 'qa', // dynamic sampling bias to keep transactions
5-
// Use a fake but properly formatted Sentry SaaS DSN for tunnel route testing
6-
dsn: 'https://public@o12345.ingest.us.sentry.io/67890',
6+
dsn: SENTRY_DSN,
77
// No tunnel option - using tunnelRoute from withSentryConfig
88
tracesSampleRate: 1.0,
99
// debug: true,

‎dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-route.test.ts‎

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { expect, test } from '@playwright/test';
2-
import { getSpanOp, waitForStreamedSpan, waitForStreamedSpans } from '@sentry-internal/test-utils';
3-
import type { SerializedStreamedSpan } from '@sentry/core';
2+
import { getSpanOp, waitForRequest, waitForStreamedSpan, waitForStreamedSpans } from '@sentry-internal/test-utils';
3+
import type { Event, SerializedStreamedSpan } from '@sentry/core';
44

55
function getStringAttribute(span: SerializedStreamedSpan, key: string): string | undefined {
66
const attribute = span.attributes[key];
@@ -120,3 +120,68 @@ test('Tunnel requests should not create middleware or fetch spans', async ({ pag
120120

121121
expect(sentryFetchSpans).toHaveLength(0);
122122
});
123+
124+
test.describe('Tunnel route handler', () => {
125+
test.skip(process.env.NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE !== 'handler', 'Only the handler variants use the handler');
126+
127+
test('forwards events without the cookies of the browser', async ({ page, context, baseURL }) => {
128+
await context.addCookies([{ name: 'session', value: '8f2c1d', url: baseURL }]);
129+
const proxyRequestPromise = waitForRequest('nextjs-16-tunnel', ({ envelope }) =>
130+
envelope[1].some(
131+
([itemHeader, item]) =>
132+
itemHeader.type === 'event' &&
133+
(item as Event).exception?.values?.[0]?.value === 'Tunnel route handler cookie test',
134+
),
135+
);
136+
137+
await page.goto('/');
138+
await page.waitForFunction(() => typeof (window as { __SENTRY__?: unknown }).__SENTRY__ === 'object');
139+
const tunnelRequestPromise = page.waitForRequest(
140+
request =>
141+
request.method() === 'POST' &&
142+
new URL(request.url()).origin === baseURL &&
143+
(request.postData() ?? '').includes('Tunnel route handler cookie test'),
144+
);
145+
await page.evaluate(() => {
146+
setTimeout(() => {
147+
throw new Error('Tunnel route handler cookie test');
148+
});
149+
});
150+
151+
const tunnelRequest = await tunnelRequestPromise;
152+
const proxyRequest = await proxyRequestPromise;
153+
154+
expect(new URL(tunnelRequest.url()).search).toBe('');
155+
expect((await tunnelRequest.allHeaders()).cookie).toBe('session=8f2c1d');
156+
expect(proxyRequest.rawProxyRequestHeaders.cookie).toBeUndefined();
157+
expect(proxyRequest.rawProxyRequestHeaders['content-type']).toBe('application/x-sentry-envelope');
158+
});
159+
160+
test('does not create server spans for tunnel requests', async ({ page }) => {
161+
// The callback never returns true, so this promise is deliberately left unsettled.
162+
const allSpans: SerializedStreamedSpan[] = [];
163+
void waitForStreamedSpans('nextjs-16-tunnel', spans => {
164+
allSpans.push(...spans);
165+
return false;
166+
});
167+
const pageloadSpanPromise = waitForStreamedSpan('nextjs-16-tunnel', span => {
168+
return getSpanOp(span) === 'pageload' && span.is_segment;
169+
});
170+
171+
await page.goto('/');
172+
await pageloadSpanPromise;
173+
// Wait for the server to send the spans that the tunnel request would wrongly produce
174+
await page.waitForTimeout(3000);
175+
176+
const postSpans = allSpans.filter(
177+
span => span.name.startsWith('POST') || getStringAttribute(span, 'http.request.method') === 'POST',
178+
);
179+
const ingestSpans = allSpans.filter(
180+
span =>
181+
getSpanOp(span) === 'http.client' &&
182+
!!getStringAttribute(span, 'url.full')?.startsWith('http://localhost:3031/'),
183+
);
184+
expect(postSpans).toEqual([]);
185+
expect(ingestSpans).toEqual([]);
186+
});
187+
});
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { createSentryTunnelRoute } from '@sentry/nextjs/tunnel';
2+
import { SENTRY_DSN } from '@/sentry-dsn';
3+
4+
export const { POST } = createSentryTunnelRoute({ allowedDsns: [SENTRY_DSN] });
5+
6+
export const runtime = 'edge';
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { POST } from '@sentry/nextjs/tunnel';

0 commit comments

Comments
 (0)