Skip to content
Merged
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.

Work in this release was contributed by @adityareddy-dev, @jpdefo, @Jxxunnn, @philipatkinson, @Shubham-Padkonde, @mikamikasuki, and @mketiku. Thank you for your contributions!

## 11.8.0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
!.yarn/releases
!.yarn/versions

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

# testing
/coverage

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import * as Sentry from '@sentry/nextjs';

// Sends all buffered spans of the server, so a test can wait for them.
export async function GET(): Promise<Response> {
await Sentry.flush();
return new Response(null, { status: 204 });
}
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 All @@ -39,6 +41,7 @@
"devDependencies": {
"@playwright/test": "1.64.1",
"@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz",
"@sentry/conventions": "0.27.0",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
Expand All @@ -61,6 +64,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,7 @@
import { expect, test } from '@playwright/test';
import { getSpanOp, waitForStreamedSpan, waitForStreamedSpans } from '@sentry-internal/test-utils';
import type { SerializedStreamedSpan } from '@sentry/core';
import { HTTP_REQUEST_METHOD, SERVER_ADDRESS, URL_FULL } from '@sentry/conventions/attributes';
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 @@ -106,17 +107,88 @@ test('Tunnel requests should not create middleware or fetch spans', async ({ pag
// We WILL have a middleware span for GET / (the pageload)
// But we should NOT have middleware spans for POST requests (tunnel route)
const postMiddlewareSpans = middlewareSpans.filter(
span => span.name.includes('POST') || getStringAttribute(span, 'http.request.method') === 'POST',
span => span.name.includes('POST') || getStringAttribute(span, HTTP_REQUEST_METHOD) === 'POST',
);

expect(postMiddlewareSpans).toHaveLength(0);

// We should NOT have any fetch spans to Sentry ingest. Matched on the host attribute rather than a
// substring of the full URL, which would also match an arbitrary host with `sentry.io` elsewhere in it.
const sentryFetchSpans = allSpans.filter(span => {
const host = getStringAttribute(span, 'server.address') ?? '';
const host = getStringAttribute(span, SERVER_ADDRESS) ?? '';
return getSpanOp(span) === 'http.client' && (host === 'sentry.io' || host.endsWith('.sentry.io'));
});

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, 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('/');
await pageloadTunnelResponsePromise;
// Flush the edge client of the edge tunnel route (a 405 on the Node.js one), then the Node.js client twice:
// the first flush sends the tunnel spans, the second one the barrier span of the first flush.
await page.request.get('/sentry-tunnel');
await page.request.get('/api/flush');
await page.request.get('/api/flush');
await barrierSpanPromise;

const postSpans = spansBeforeBarrier.filter(
span => span.name.startsWith('POST') || getStringAttribute(span, HTTP_REQUEST_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([]);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import * as Sentry from '@sentry/nextjs';
import { createSentryTunnelRoute } from '@sentry/nextjs/tunnel';
import { SENTRY_DSN } from '@/sentry-dsn';

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

// Each edge route has its own SDK client, so the tests flush this one here.
export async function GET(): Promise<Response> {
await Sentry.flush();
return new Response(null, { status: 204 });
}

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})?$`);
}
Loading
Loading