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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,21 @@

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

### Important Changes

- **feat(nextjs): Add a tunnel route handler that sends only the event payload to Sentry ([#25456](https://github.com/getsentry/sentry-javascript/pull/25456))**

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:

```ts
// app/sentry-tunnel/route.ts (or src/app/sentry-tunnel/route.ts)
export { POST } from '@sentry/nextjs/tunnel';
```

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.

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.

## 11.8.0

### Important Changes
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@

event-dumps

# Copied by `test:build-handler*`
/app/sentry-tunnel/

# testing
/coverage

Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import * as Sentry from '@sentry/nextjs';
import { SENTRY_DSN } from './sentry-dsn';

Sentry.init({
environment: 'qa', // dynamic sampling bias to keep transactions
// Use a fake but properly formatted Sentry SaaS DSN for tunnel route testing
dsn: 'https://public@o12345.ingest.us.sentry.io/67890',
dsn: SENTRY_DSN,
// No tunnel option - using tunnelRoute from withSentryConfig
tracesSampleRate: 1.0,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
"test:dev-webpack": "TEST_ENV=development-webpack _SENTRY_TUNNEL_DESTINATION_OVERRIDE=http://localhost:3031/ 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-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",
Expand Down Expand Up @@ -61,6 +63,16 @@
"build-command": "pnpm test:build",
"label": "nextjs-16-tunnel (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=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"
}
],
"optionalVariants": [
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// 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';
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import * as Sentry from '@sentry/nextjs';
import { SENTRY_DSN } from './sentry-dsn';

Sentry.init({
environment: 'qa', // dynamic sampling bias to keep transactions
// Use a fake but properly formatted Sentry SaaS DSN for tunnel route testing
dsn: 'https://public@o12345.ingest.us.sentry.io/67890',
dsn: SENTRY_DSN,
// No tunnel option - using tunnelRoute from withSentryConfig
tracesSampleRate: 1.0,
// debug: true,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import * as Sentry from '@sentry/nextjs';
import { SENTRY_DSN } from './sentry-dsn';

Sentry.init({
environment: 'qa', // dynamic sampling bias to keep transactions
// Use a fake but properly formatted Sentry SaaS DSN for tunnel route testing
dsn: 'https://public@o12345.ingest.us.sentry.io/67890',
dsn: SENTRY_DSN,
// No tunnel option - using tunnelRoute from withSentryConfig
tracesSampleRate: 1.0,
// debug: true,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
import { getSpanOp, waitForStreamedSpan, waitForStreamedSpans } from '@sentry-internal/test-utils';
import type { SerializedStreamedSpan } from '@sentry/core';
import { getSpanOp, waitForRequest, waitForStreamedSpan, waitForStreamedSpans } from '@sentry-internal/test-utils';
import type { Event, SerializedStreamedSpan } from '@sentry/core';

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

expect(sentryFetchSpans).toHaveLength(0);
});

test.describe('Tunnel route handler', () => {
test.skip(process.env.NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE !== 'handler', 'Only the handler variants use the handler');

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 route handler cookie test',
),
);

await page.goto('/');
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 route handler cookie test'),
);
await page.evaluate(() => {
setTimeout(() => {
throw new Error('Tunnel route handler cookie test');
});
});

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

expect(new URL(tunnelRequest.url()).search).toBe('');
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 }) => {
// The callback never returns true, so this promise is deliberately left unsettled.
const allSpans: SerializedStreamedSpan[] = [];
void waitForStreamedSpans('nextjs-16-tunnel', spans => {
allSpans.push(...spans);
return false;
});
const pageloadSpanPromise = waitForStreamedSpan('nextjs-16-tunnel', span => {
return getSpanOp(span) === 'pageload' && span.is_segment;
});

await page.goto('/');
await pageloadSpanPromise;
// Wait for the server to send the spans that the tunnel request would wrongly produce
await page.waitForTimeout(3000);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tunnel test sleeps to assert absence

Low Severity

The new handler test asserts that tunnel requests create no server spans by sleeping for three seconds after pageload. Absence checks that wait on a timeout stay racy under load and can pass while a late span still arrives.

Fix in Cursor Fix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bot

Reviewed by Cursor Bugbot for commit de8fd3d. Configure here.


const postSpans = allSpans.filter(
span => span.name.startsWith('POST') || getStringAttribute(span, 'http.request.method') === 'POST',
);
const ingestSpans = allSpans.filter(
span =>
getSpanOp(span) === 'http.client' &&
!!getStringAttribute(span, 'url.full')?.startsWith('http://localhost:3031/'),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tunnel tests hardcode span attributes

Low Severity

New span assertions compare http.request.method and url.full (and http.target / next.route in the unit tests) as raw strings. Those names already have constants in @sentry/conventions.

Additional Locations (1)
Fix in Cursor Fix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bot

Reviewed by Cursor Bugbot for commit de8fd3d. Configure here.

);
expect(postSpans).toEqual([]);
expect(ingestSpans).toEqual([]);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { createSentryTunnelRoute } from '@sentry/nextjs/tunnel';
import { SENTRY_DSN } from '@/sentry-dsn';

export const { POST } = createSentryTunnelRoute({ allowedDsns: [SENTRY_DSN] });

export const runtime = 'edge';
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { POST } from '@sentry/nextjs/tunnel';
5 changes: 5 additions & 0 deletions packages/nextjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@
"types": "./build/types/config/index.d.ts",
"default": "./build/cjs/config/index.js"
},
"./tunnel": {
"types": "./build/types/tunnel/index.d.ts",
"import": "./build/esm/tunnel/index.js",
"require": "./build/cjs/tunnel/index.js"
},
"./async-storage-shim": {
"import": {
"default": "./build/esm/config/templates/requestAsyncStorageShim.js"
Expand Down
1 change: 1 addition & 0 deletions packages/nextjs/rollup.npm.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export default [
'src/edge/index.ts',
'src/cloudflare/index.ts',
'src/config/index.ts',
'src/tunnel/index.ts',
],

// prevent this internal nextjs code from ending up in our built package (this doesn't happen automatically because
Expand Down
11 changes: 11 additions & 0 deletions packages/nextjs/src/client/tunnelRoute.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { debug, dsnFromString, GLOBAL_OBJ } from '@sentry/core';
import type { BrowserOptions } from '@sentry/react';
import { DEBUG_BUILD } from '../common/debug-build';
import type { TunnelRouteMode } from '../common/utils/tunnelPathnameMatch';

const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & {
_sentryRewritesTunnelPath?: string;
_sentryTunnelRouteMode?: TunnelRouteMode;
};

/**
Expand All @@ -16,6 +18,15 @@ export function applyTunnelRouteOption(options: BrowserOptions): void {
if (!dsnComponents) {
return;
}

// The handler reads the DSN from the envelope, so it needs no query and no SaaS DSN.
const tunnelRouteMode = process.env._sentryTunnelRouteMode || globalWithInjectedValues._sentryTunnelRouteMode;
if (tunnelRouteMode === 'handler') {
options.tunnel = tunnelRouteOption;
DEBUG_BUILD && debug.log(`Tunneling events to "${tunnelRouteOption}"`);
return;
}

const sentrySaasDsnMatch = dsnComponents.host.match(/^o(\d+)\.ingest(?:\.([a-z]{2}))?\.sentry\.io$/);
if (sentrySaasDsnMatch) {
const orgId = sentrySaasDsnMatch[1];
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
import { HTTP_TARGET, URL_FULL, URL_PATH, SENTRY_ORIGIN } from '@sentry/conventions/attributes';
import type { RawAttributes } from '@sentry/core';
import { getClient, GLOBAL_OBJ, isSentryRequestUrl, type Span } from '@sentry/core';
import { ATTR_NEXT_SPAN_TYPE } from '../nextSpanAttributes';
import { isPathnameUnderSentryTunnelRoute } from './tunnelPathnameMatch';
import type { ClientOptions, RawAttributes } from '@sentry/core';
import { escapeStringForRegex, getClient, GLOBAL_OBJ, isSentryRequestUrl, type Span } from '@sentry/core';
import { ATTR_NEXT_ROUTE, ATTR_NEXT_SPAN_TYPE } from '../nextSpanAttributes';
import {
getTunnelRouteRequestPattern,
isPathnameUnderSentryTunnelRoute,
TUNNEL_ROUTE_HANDLER_PATH,
type TunnelRouteMode,
} from './tunnelPathnameMatch';
import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION } from '../span-attributes-with-logic-attached';

const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & {
_sentryRewritesTunnelPath?: string;
_sentryTunnelRouteMode?: TunnelRouteMode;
};

/**
Expand Down Expand Up @@ -82,3 +88,42 @@ export function isTunnelRouteSpan(spanAttributes: Record<string, unknown>): bool

return false;
}

/**
* Returns `ignoreSpans` entries for the spans of incoming tunnel requests.
*
* Span streaming checks `ignoreSpans` at span start, before `dropMiddlewareTunnelRequests` runs. The entries match only
* Next.js request spans, so other spans with the same path are kept.
*/
export function getTunnelRouteIgnoreSpans(): NonNullable<ClientOptions['ignoreSpans']> {
const tunnelPath = globalWithInjectedValues._sentryRewritesTunnelPath || process.env._sentryRewritesTunnelPath;
if (!tunnelPath) {
return [];
}

const tunnelRouteMode =
(globalWithInjectedValues._sentryTunnelRouteMode || process.env._sentryTunnelRouteMode) === 'handler'
? 'handler'
: 'rewrite';
const tunnelRequestIgnoreSpan = {
attributes: {
[ATTR_NEXT_SPAN_TYPE]: /^(?:BaseServer\.handleRequest|Middleware\.execute)$/,
// Next.js sets only `http.target` at span start.
// eslint-disable-next-line typescript/no-deprecated
[HTTP_TARGET]: getTunnelRouteRequestPattern(tunnelPath, tunnelRouteMode),
},
};

if (tunnelRouteMode !== 'handler') {
return [tunnelRequestIgnoreSpan];
}

// On edge, the root span of a route handler has only `next.route`, with a `/route` suffix before Next.js 16.
const tunnelRouteHandlerIgnoreSpan = {
attributes: {
[ATTR_NEXT_SPAN_TYPE]: /^AppRouteRouteHandlers\.runHandler$/,
[ATTR_NEXT_ROUTE]: new RegExp(`^${escapeStringForRegex(TUNNEL_ROUTE_HANDLER_PATH)}(?:/route)?$`),
},
};
return [tunnelRequestIgnoreSpan, tunnelRouteHandlerIgnoreSpan];
}
29 changes: 29 additions & 0 deletions packages/nextjs/src/common/utils/tunnelPathnameMatch.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,17 @@
import { escapeStringForRegex } from '@sentry/core';

/**
* The path of the tunnel route handler (`app/sentry-tunnel/route.ts`).
* Users create the file at this path, so the value is a public contract.
*/
export const TUNNEL_ROUTE_HANDLER_PATH = '/sentry-tunnel';

/**
* `rewrite`: the tunnel route proxies requests to Sentry with all their headers.
* `handler`: the tunnel route goes to the tunnel route handler, which forwards only the envelope.
*/
export type TunnelRouteMode = 'rewrite' | 'handler';

/**
* Returns true when `pathname` is exactly the Sentry tunnel route or a sub-path
* (`tunnelPath` + `/...`). A plain `startsWith(tunnelPath)` is unsafe: e.g. tunnel
Expand All @@ -6,3 +20,18 @@
export function isPathnameUnderSentryTunnelRoute(pathname: string, tunnelPath: string): boolean {
return pathname === tunnelPath || pathname.startsWith(`${tunnelPath}/`);
}

/**
* Returns a pattern for the request targets (path and query) that the tunnel rewrite serves.
* It must never match more than the rewrite, see GHSA-mchm-gjx6-7h55.
*
* - `handler`: the path, with an optional trailing slash and any query.
* - `rewrite`: also exactly the query that `applyTunnelRouteOption` sends. A looser query match is unsafe, because
* Next.js reads the last value of a repeated param.
*/
export function getTunnelRouteRequestPattern(tunnelPath: string, tunnelRouteMode: TunnelRouteMode): RegExp {
const pathPattern = `^${escapeStringForRegex(tunnelPath)}/?`;
return tunnelRouteMode === 'handler'
? new RegExp(`${pathPattern}(?:\\?|$)`)
: new RegExp(`${pathPattern}\\?o=\\d+&p=\\d+(?:&r=[a-z]{2})?$`);
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type {
TurbopackOptions,
} from '../types';
import { supportsNativeDebugIds, supportsTurbopackRuleCondition } from '../util';
import { getTunnelRouteInjectedValues } from '../withSentryConfig/tunnel';
import { generateValueInjectionRules } from './generateValueInjectionRules';

/**
Expand Down Expand Up @@ -53,17 +54,10 @@ export function constructTurbopackConfig({
...(shouldEnableNativeDebugIds ? { debugIds: true } : {}),
};

const tunnelPath =
userSentryOptions?.tunnelRoute !== undefined &&
userNextConfig.output !== 'export' &&
typeof userSentryOptions.tunnelRoute === 'string'
? `${userNextConfig.basePath ?? ''}${userSentryOptions.tunnelRoute}`
: undefined;

const valueInjectionRules = generateValueInjectionRules({
routeManifest,
nextJsVersion,
tunnelPath,
tunnelRouteValues: getTunnelRouteInjectedValues(userNextConfig, userSentryOptions),
vercelCronsConfig,
});

Expand Down
Loading
Loading