diff --git a/CHANGELOG.md b/CHANGELOG.md index 24bbe48170a5..540215190a07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +### Important Changes + +- **feat(v10/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. + + With the handler, tunnel requests go through your middleware. If your middleware blocks unauthenticated requests, exclude the tunnel route in its `matcher`. + + 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. + ## 10.76.2 - feat(v10/deps): Bump bundler plugins to 5.4.1 ([#25147](https://github.com/getsentry/sentry-javascript/pull/25147)) diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/.gitignore b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/.gitignore index ae044ec5ad53..f4f88faea7c9 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/.gitignore +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/.gitignore @@ -12,6 +12,9 @@ event-dumps +# Copied by `test:build-handler*` +/app/sentry-tunnel/ + # testing /coverage diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/app/api/flush/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/app/api/flush/route.ts new file mode 100644 index 000000000000..1173242c5d10 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/app/api/flush/route.ts @@ -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 { + await Sentry.flush(); + return new Response(null, { status: 204 }); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/instrumentation-client.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/instrumentation-client.ts index 6c55f3f0ebfa..f99631394d72 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/instrumentation-client.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/instrumentation-client.ts @@ -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, dataCollection: { userInfo: true }, diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json index 7dfa8f923f6e..967dc0cee0c1 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json @@ -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", @@ -36,6 +38,7 @@ "devDependencies": { "@playwright/test": "~1.56.0", "@sentry-internal/test-utils": "link:../../../test-utils", + "@sentry/conventions": "0.16.0", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", @@ -57,6 +60,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" } ] } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry-dsn.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry-dsn.ts new file mode 100644 index 000000000000..fa0ad467f9bc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry-dsn.ts @@ -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'; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.edge.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.edge.config.ts index 98d677005900..130efdfca86f 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.edge.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.edge.config.ts @@ -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, dataCollection: { userInfo: true }, diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.server.config.ts index 98d677005900..130efdfca86f 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.server.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.server.config.ts @@ -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, dataCollection: { userInfo: true }, diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-route.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-route.test.ts index a8bd7b4d925e..f3f2bd9b7363 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-route.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-route.test.ts @@ -1,5 +1,7 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { HTTP_REQUEST_METHOD, URL_FULL } from '@sentry/conventions/attributes'; +import { waitForRequest, waitForTransaction } from '@sentry-internal/test-utils'; +import type { Event } from '@sentry/core'; test('Tunnel route should proxy pageload transaction to Sentry', async ({ page }) => { // Wait for the pageload transaction to be sent through the tunnel @@ -130,3 +132,75 @@ test('Tunnel requests should not create middleware or fetch spans', async ({ pag expect(sentryFetchTransactions).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 transactions for tunnel requests', async ({ page, baseURL }) => { + // One listener, so it gets every transaction that the proxy received before the barrier transaction. + const transactionsBeforeBarrier: Event[] = []; + const barrierTransactionPromise = waitForTransaction('nextjs-16-tunnel', transaction => { + transactionsBeforeBarrier.push(transaction); + return transaction.transaction === '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 transactions, the second one the barrier transaction of the first flush. + await page.request.get('/sentry-tunnel'); + await page.request.get('/api/flush'); + await page.request.get('/api/flush'); + await barrierTransactionPromise; + + const postTransactions = transactionsBeforeBarrier.filter( + transaction => + transaction.transaction?.startsWith('POST') || + transaction.contexts?.trace?.data?.[HTTP_REQUEST_METHOD] === 'POST', + ); + const ingestSpans = transactionsBeforeBarrier + .flatMap(transaction => transaction.spans ?? []) + .filter(span => span.op === 'http.client' && String(span.data?.[URL_FULL]).startsWith('http://localhost:3031/')); + expect(postTransactions).toEqual([]); + expect(ingestSpans).toEqual([]); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tunnel-handler/edge/sentry-tunnel/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tunnel-handler/edge/sentry-tunnel/route.ts new file mode 100644 index 000000000000..4eeb13efd97f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tunnel-handler/edge/sentry-tunnel/route.ts @@ -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 { + await Sentry.flush(); + return new Response(null, { status: 204 }); +} + +export const runtime = 'edge'; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tunnel-handler/node/sentry-tunnel/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tunnel-handler/node/sentry-tunnel/route.ts new file mode 100644 index 000000000000..23c975fb750d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tunnel-handler/node/sentry-tunnel/route.ts @@ -0,0 +1 @@ +export { POST } from '@sentry/nextjs/tunnel'; diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 3df8f7bb2d75..fada41ca3ab5 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -50,6 +50,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" diff --git a/packages/nextjs/rollup.npm.config.mjs b/packages/nextjs/rollup.npm.config.mjs index 89271a21e9d3..e3d7595c0d26 100644 --- a/packages/nextjs/rollup.npm.config.mjs +++ b/packages/nextjs/rollup.npm.config.mjs @@ -12,6 +12,7 @@ export default [ 'src/server/index.ts', 'src/edge/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 diff --git a/packages/nextjs/src/client/tunnelRoute.ts b/packages/nextjs/src/client/tunnelRoute.ts index 64025fdd5f9d..ba82260e17df 100644 --- a/packages/nextjs/src/client/tunnelRoute.ts +++ b/packages/nextjs/src/client/tunnelRoute.ts @@ -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; }; /** @@ -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]; diff --git a/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts b/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts index c4442beaa132..5337c32c2dd4 100644 --- a/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts +++ b/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts @@ -1,12 +1,26 @@ import { HTTP_TARGET } from '@sentry/conventions/attributes'; -import { getClient, GLOBAL_OBJ, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, type Span, type SpanAttributes } from '@sentry/core'; +import { + type ClientOptions, + escapeStringForRegex, + getClient, + GLOBAL_OBJ, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + type Span, + type SpanAttributes, +} from '@sentry/core'; import { isSentryRequestSpan } from '@sentry/opentelemetry'; -import { ATTR_NEXT_SPAN_TYPE } from '../nextSpanAttributes'; -import { isPathnameUnderSentryTunnelRoute } from './tunnelPathnameMatch'; +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; }; /** @@ -65,3 +79,42 @@ function isTunnelRouteSpan(spanAttributes: Record): boolean { 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 { + 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]; +} diff --git a/packages/nextjs/src/common/utils/tunnelPathnameMatch.ts b/packages/nextjs/src/common/utils/tunnelPathnameMatch.ts index ce8cacd0d264..4b560de9cb55 100644 --- a/packages/nextjs/src/common/utils/tunnelPathnameMatch.ts +++ b/packages/nextjs/src/common/utils/tunnelPathnameMatch.ts @@ -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 @@ -31,3 +45,18 @@ export function isSentryTunnelRequest(request: Request, tunnelPath: string): boo return values.length > 0 && values.every(value => /^\d+$/.test(value)); }); } + +/** + * 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})?$`); +} diff --git a/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts b/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts index cb2d070d7206..f840e2f0c481 100644 --- a/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts +++ b/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts @@ -12,8 +12,9 @@ import { winterCGRequestToRequestData, withIsolationScope, } from '@sentry/core'; +import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION } from '../common/span-attributes-with-logic-attached'; import { flushSafelyWithTimeout, waitUntil } from '../common/utils/responseEnd'; -import { isSentryTunnelRequest } from '../common/utils/tunnelPathnameMatch'; +import { getTunnelRouteRequestPattern, isSentryTunnelRequest } from '../common/utils/tunnelPathnameMatch'; import type { EdgeRouteHandler } from '../edge/types'; /** @@ -113,6 +114,10 @@ export function wrapMiddlewareWithSentry( attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: spanSource, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.nextjs.wrap_middleware', + // The `ignoreSpans` entries for tunnel requests cannot match this span, because it has no request target. + ...(isTunnelRouteHandlerRequest(req, tunnelRoute) && { + [TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION]: true, + }), }, }, runMiddleware, @@ -121,3 +126,16 @@ export function wrapMiddlewareWithSentry( }, }); } + +function isTunnelRouteHandlerRequest(req: unknown, tunnelRoute: unknown): boolean { + if ( + !(req instanceof Request) || + typeof tunnelRoute !== 'string' || + (globalThis as Record)._sentryTunnelRouteMode !== 'handler' + ) { + return false; + } + + const url = new URL(req.url); + return getTunnelRouteRequestPattern(tunnelRoute, 'handler').test(`${url.pathname}${url.search}`); +} diff --git a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts index 1e18aa57bb31..20c549db3bbb 100644 --- a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts +++ b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts @@ -11,6 +11,7 @@ import type { TurbopackOptions, } from '../types'; import { supportsNativeDebugIds, supportsTurbopackRuleCondition } from '../util'; +import { getTunnelRouteInjectedValues } from '../withSentryConfig/tunnel'; import { generateValueInjectionRules } from './generateValueInjectionRules'; /** @@ -46,17 +47,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, }); diff --git a/packages/nextjs/src/config/turbopack/generateValueInjectionRules.ts b/packages/nextjs/src/config/turbopack/generateValueInjectionRules.ts index 8bca01cb2ce4..22c81f00102b 100644 --- a/packages/nextjs/src/config/turbopack/generateValueInjectionRules.ts +++ b/packages/nextjs/src/config/turbopack/generateValueInjectionRules.ts @@ -2,6 +2,7 @@ import * as path from 'path'; import type { VercelCronsConfig } from '../../common/types'; import type { RouteManifest } from '../manifest/types'; import type { JSONValue, TurbopackMatcherWithRule } from '../types'; +import type { TunnelRouteInjectedValues } from '../withSentryConfig/tunnel'; import { getPackageModules, supportsTurbopackRuleCondition } from '../util'; /** @@ -10,16 +11,16 @@ import { getPackageModules, supportsTurbopackRuleCondition } from '../util'; export function generateValueInjectionRules({ routeManifest, nextJsVersion, - tunnelPath, + tunnelRouteValues, vercelCronsConfig, }: { routeManifest?: RouteManifest; nextJsVersion?: string; - tunnelPath?: string; + tunnelRouteValues?: TunnelRouteInjectedValues; vercelCronsConfig?: VercelCronsConfig; }): TurbopackMatcherWithRule[] { const rules: TurbopackMatcherWithRule[] = []; - const isomorphicValues: Record = {}; + const isomorphicValues: Record = { ...tunnelRouteValues }; let clientValues: Record = {}; let serverValues: Record = {}; @@ -32,11 +33,6 @@ export function generateValueInjectionRules({ clientValues._sentryRouteManifest = JSON.stringify(routeManifest); } - // Inject tunnel route path for both client and server - if (tunnelPath) { - isomorphicValues._sentryRewritesTunnelPath = tunnelPath; - } - // Inject Vercel crons config for server-side cron auto-instrumentation if (vercelCronsConfig) { serverValues._sentryVercelCronsConfig = JSON.stringify(vercelCronsConfig); diff --git a/packages/nextjs/src/config/types.ts b/packages/nextjs/src/config/types.ts index 23ec8d4d8e8d..43f99bb00f77 100644 --- a/packages/nextjs/src/config/types.ts +++ b/packages/nextjs/src/config/types.ts @@ -612,6 +612,22 @@ export type SentryBuildOptions = { * - Pass `true` to auto-generate a random, ad-blocker-resistant route for each build * - Pass a string path (e.g., '/monitoring') to use a custom route * + * By default, the tunnel route proxies each request to Sentry with all its headers, cookies included. To forward + * only the event payload, create `app/sentry-tunnel/route.ts` (or `src/app/sentry-tunnel/route.ts`, not in a route + * group) with: + * + * ```ts + * export { POST } from '@sentry/nextjs/tunnel'; + * ``` + * + * This handler accepts the DSN of the server SDK, also a self-hosted one. For other DSNs, use + * `createSentryTunnelRoute` from the same import. + * + * With this handler, tunnel requests go through your middleware (`proxy.ts` / `middleware.ts`) like any other + * request. If your middleware redirects or blocks unauthenticated requests, exclude the tunnel route in its + * `matcher` so events can reach Sentry. Matchers have to be static, so use a fixed string route in that case + * rather than `true`. + * * NOTE: This feature only works with Next.js 11+ */ tunnelRoute?: string | boolean; diff --git a/packages/nextjs/src/config/util.ts b/packages/nextjs/src/config/util.ts index 5f0990ac6829..1473b86bd745 100644 --- a/packages/nextjs/src/config/util.ts +++ b/packages/nextjs/src/config/util.ts @@ -3,6 +3,9 @@ import * as fs from 'fs'; import { createRequire } from 'module'; import * as path from 'path'; +// Default page extensions per https://github.com/vercel/next.js/blob/f1dbc9260d48c7995f6c52f8fbcc65f08e627992/packages/next/server/config-shared.ts#L161 +export const DEFAULT_PAGE_EXTENSIONS = ['tsx', 'ts', 'jsx', 'js']; + /** * Returns the version of Next.js installed in the project, or undefined if it cannot be determined. */ diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts index 1d6697f2e105..48ed3bc26116 100644 --- a/packages/nextjs/src/config/webpack.ts +++ b/packages/nextjs/src/config/webpack.ts @@ -24,8 +24,9 @@ import type { WebpackPluginInstance, } from './types'; import { loadOrchestrionBundler } from './loadOrchestrionBundler'; -import { getNextjsVersion, getPackageModules } from './util'; +import { DEFAULT_PAGE_EXTENSIONS, getNextjsVersion, getPackageModules } from './util'; import type { VercelCronsConfigResult } from './withSentryConfig/getFinalConfigObjectUtils'; +import { getTunnelRouteInjectedValues } from './withSentryConfig/tunnel'; // Next.js runs webpack 3 times, once for the client, the server, and for edge. Because we don't want to print certain // warnings 3 times, we keep track of them here. @@ -69,8 +70,7 @@ export function constructWebpackConfigFunction({ ): WebpackConfigObject { const { isServer, dev: isDev, dir: projectDir } = buildContext; const runtime = isServer ? (buildContext.nextRuntime === 'edge' ? 'edge' : 'server') : 'client'; - // Default page extensions per https://github.com/vercel/next.js/blob/f1dbc9260d48c7995f6c52f8fbcc65f08e627992/packages/next/server/config-shared.ts#L161 - const pageExtensions = userNextConfig.pageExtensions || ['tsx', 'ts', 'jsx', 'js']; + const pageExtensions = userNextConfig.pageExtensions || DEFAULT_PAGE_EXTENSIONS; const dotPrefixedPageExtensions = pageExtensions.map(ext => `.${ext}`); const pageExtensionRegex = pageExtensions.map(escapeStringForRegex).join('|'); const nextVersion = nextJsVersion || getNextjsVersion(); @@ -722,13 +722,7 @@ function addValueInjectionLoader({ const releaseToInject = releaseName && shouldCreateRelease ? releaseName : undefined; const isomorphicValues = { - // `rewritesTunnel` set by the user in Next.js config - _sentryRewritesTunnelPath: - userSentryOptions.tunnelRoute !== undefined && - userNextConfig.output !== 'export' && - typeof userSentryOptions.tunnelRoute === 'string' - ? `${userNextConfig.basePath ?? ''}${userSentryOptions.tunnelRoute}` - : undefined, + ...getTunnelRouteInjectedValues(userNextConfig, userSentryOptions), // The webpack plugin's release injection breaks the `app` directory so we inject the release manually here instead. // Having a release defined in dev-mode spams releases in Sentry so we only set one in non-dev mode diff --git a/packages/nextjs/src/config/withSentryConfig/buildTime.ts b/packages/nextjs/src/config/withSentryConfig/buildTime.ts index e4d389b2833d..a50bc04f0fad 100644 --- a/packages/nextjs/src/config/withSentryConfig/buildTime.ts +++ b/packages/nextjs/src/config/withSentryConfig/buildTime.ts @@ -3,6 +3,7 @@ import * as fs from 'fs'; import * as path from 'path'; import type { NextConfigObject, SentryBuildOptions } from '../types'; import { hasOnlyAppRouterPages } from './pagesRouterDetection'; +import { getTunnelRouteInjectedValues } from './tunnel'; /** * Adds Sentry-related build-time variables to `nextConfig.env`. @@ -23,13 +24,6 @@ export function setUpBuildTimeVariables( const assetPrefix = userNextConfig.assetPrefix || userNextConfig.basePath || ''; const basePath = userNextConfig.basePath ?? ''; - const rewritesTunnelPath = - userSentryOptions.tunnelRoute !== undefined && - userNextConfig.output !== 'export' && - typeof userSentryOptions.tunnelRoute === 'string' - ? `${basePath}${userSentryOptions.tunnelRoute}` - : undefined; - const buildTimeVariables: Record = { // Make sure that if we have a windows path, the backslashes are interpreted as such (rather than as escape // characters) @@ -39,6 +33,7 @@ export function setUpBuildTimeVariables( _sentryRewriteFramesAssetPrefixPath: assetPrefix ? new URL(assetPrefix, 'http://dogs.are.great').pathname.replace(/\/$/, '') : '', + ...getTunnelRouteInjectedValues(userNextConfig, userSentryOptions, projectDir), }; if (userNextConfig.assetPrefix) { @@ -49,10 +44,6 @@ export function setUpBuildTimeVariables( buildTimeVariables._experimentalThirdPartyOriginStackFrames = 'true'; } - if (rewritesTunnelPath) { - buildTimeVariables._sentryRewritesTunnelPath = rewritesTunnelPath; - } - // Marker read by the server SDK to warn if the runtime opt-in call is missing. if (userSentryOptions._experimental?.useDiagnosticsChannelInjection) { buildTimeVariables._sentryUseDiagnosticsChannelInjection = 'true'; diff --git a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts index 2e6f76377d41..92ab96e0d8fa 100644 --- a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts +++ b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts @@ -8,9 +8,16 @@ import type { RouteManifest } from '../manifest/types'; import type { NextConfigObject, SentryBuildOptions } from '../types'; import { requiresInstrumentationHook } from '../util'; import { getGitRevision, getInstrumentationClientFileContents } from './buildTime'; -import { resolveTunnelRoute, setUpTunnelRewriteRules } from './tunnel'; +import { + findTunnelRouteHandlerFile, + resolveTunnelRoute, + setUpTunnelHandlerRewrite, + setUpTunnelRewriteRules, +} from './tunnel'; let showedExportModeTunnelWarning = false; +let showedUnusedTunnelRouteHandlerWarning = false; +let showedTunnelRouteHandlerModeLog = false; let showedExperimentalBuildModeWarning = false; /** @@ -26,7 +33,7 @@ export function resolveReleaseName(userSentryOptions: SentryBuildOptions): strin } /** - * Applies tunnel-route rewrites, if configured. + * Applies tunnel-route rewrites, if configured. Uses handler mode if the project has a tunnel route handler file. * * Note: this mutates `userSentryOptions` (to store the resolved tunnel route) and `incomingUserNextConfigObject`. */ @@ -34,6 +41,11 @@ export function maybeSetUpTunnelRouteRewriteRules( incomingUserNextConfigObject: NextConfigObject, userSentryOptions: SentryBuildOptions, ): void { + if (userSentryOptions.tunnelRoute === undefined) { + maybeWarnAboutUnusedTunnelRouteHandler(incomingUserNextConfigObject, userSentryOptions.silent); + return; + } + if (!userSentryOptions.tunnelRoute) { return; } @@ -53,7 +65,41 @@ export function maybeSetUpTunnelRouteRewriteRules( const resolvedTunnelRoute = resolveTunnelRoute(userSentryOptions.tunnelRoute); userSentryOptions.tunnelRoute = resolvedTunnelRoute || undefined; - setUpTunnelRewriteRules(incomingUserNextConfigObject, resolvedTunnelRoute); + const tunnelRouteHandlerFile = findTunnelRouteHandlerFile(incomingUserNextConfigObject.pageExtensions); + if (!tunnelRouteHandlerFile) { + setUpTunnelRewriteRules(incomingUserNextConfigObject, resolvedTunnelRoute); + return; + } + + setUpTunnelHandlerRewrite(incomingUserNextConfigObject, resolvedTunnelRoute); + + if (!showedTunnelRouteHandlerModeLog && !userSentryOptions.silent) { + showedTunnelRouteHandlerModeLog = true; + // eslint-disable-next-line no-console + console.log( + `[@sentry/nextjs] The tunnel route sends events through ${tunnelRouteHandlerFile}. Only the event payload goes to Sentry.`, + ); + } +} + +function maybeWarnAboutUnusedTunnelRouteHandler( + incomingUserNextConfigObject: NextConfigObject, + silent: boolean | undefined, +): void { + if (silent || showedUnusedTunnelRouteHandlerWarning || incomingUserNextConfigObject.output === 'export') { + return; + } + + const tunnelRouteHandlerFile = findTunnelRouteHandlerFile(incomingUserNextConfigObject.pageExtensions); + if (!tunnelRouteHandlerFile) { + return; + } + + showedUnusedTunnelRouteHandlerWarning = true; + // eslint-disable-next-line no-console + console.warn( + `[@sentry/nextjs] Found ${tunnelRouteHandlerFile}, but the \`tunnelRoute\` option is not set. Set \`tunnelRoute\` in \`withSentryConfig\` to send events through this route.`, + ); } /** diff --git a/packages/nextjs/src/config/withSentryConfig/tunnel.ts b/packages/nextjs/src/config/withSentryConfig/tunnel.ts index 78b050a525bd..d0b45bd6ec8c 100644 --- a/packages/nextjs/src/config/withSentryConfig/tunnel.ts +++ b/packages/nextjs/src/config/withSentryConfig/tunnel.ts @@ -1,5 +1,14 @@ import { _INTERNAL_safeMathRandom } from '@sentry/core'; -import type { NextConfigObject } from '../types'; +import * as fs from 'fs'; +import * as path from 'path'; +import { TUNNEL_ROUTE_HANDLER_PATH, type TunnelRouteMode } from '../../common/utils/tunnelPathnameMatch'; +import type { NextConfigObject, SentryBuildOptions } from '../types'; +import { DEFAULT_PAGE_EXTENSIONS } from '../util'; + +/** The values that the client, server and edge bundles read to find the tunnel route. */ +export type TunnelRouteInjectedValues = + | { _sentryRewritesTunnelPath: string; _sentryTunnelRouteMode: TunnelRouteMode } + | Record; /** * Generates a random tunnel route path that's less likely to be blocked by ad-blockers @@ -115,3 +124,79 @@ export function setUpTunnelRewriteRules(userNextConfig: NextConfigObject, tunnel } }; } + +/** + * Injects a rewrite rule that sends requests from the `tunnelPath` to the tunnel route handler. + */ +export function setUpTunnelHandlerRewrite(userNextConfig: NextConfigObject, tunnelPath: string): void { + const originalRewrites = userNextConfig.rewrites; + // Next.js adds the `basePath` and an optional trailing slash. + const handlerRewrite = { source: tunnelPath, destination: TUNNEL_ROUTE_HANDLER_PATH }; + + // `beforeFiles`: a `public/` file or a page at the same path wins over an `afterFiles` rewrite. + userNextConfig.rewrites = async () => { + const originalRewritesResult = typeof originalRewrites === 'function' ? await originalRewrites() : []; + + if (Array.isArray(originalRewritesResult)) { + return { beforeFiles: [handlerRewrite], afterFiles: originalRewritesResult }; + } + + return { + ...originalRewritesResult, + beforeFiles: [handlerRewrite, ...(originalRewritesResult.beforeFiles || [])], + }; + }; +} + +/** + * Returns the tunnel route handler file relative to `projectDir` (for example `app/sentry-tunnel/route.ts`), or + * `undefined` if the project has none. + * + * Route groups are not searched: the file must be directly in `app/sentry-tunnel/` or `src/app/sentry-tunnel/`. + */ +export function findTunnelRouteHandlerFile( + pageExtensions: string[] = DEFAULT_PAGE_EXTENSIONS, + projectDir: string = process.cwd(), +): string | undefined { + // Like Next.js, ignore `src/app` if there is a root `app` directory. + const appDir = ['app', 'src/app'].find(dir => getStats(path.join(projectDir, dir))?.isDirectory()); + if (!appDir) { + return undefined; + } + + return pageExtensions + .map(extension => `${appDir}${TUNNEL_ROUTE_HANDLER_PATH}/route.${extension}`) + .find(file => getStats(path.join(projectDir, file))?.isFile()); +} + +function getStats(filePath: string): fs.Stats | undefined { + try { + return fs.statSync(filePath); + } catch { + return undefined; + } +} + +/** + * Returns the tunnel path that the client sends envelopes to, and the tunnel route mode. + * Returns no values if there is no tunnel route, or if it cannot work (static export). + * + * Call it after `maybeSetUpTunnelRouteRewriteRules`, which resolves `tunnelRoute: true` to a path. + */ +export function getTunnelRouteInjectedValues( + userNextConfig: NextConfigObject, + userSentryOptions: SentryBuildOptions | undefined, + projectDir: string = process.cwd(), +): TunnelRouteInjectedValues { + const tunnelRoute = userSentryOptions?.tunnelRoute; + if (typeof tunnelRoute !== 'string' || !tunnelRoute || userNextConfig.output === 'export') { + return {}; + } + + return { + _sentryRewritesTunnelPath: `${userNextConfig.basePath ?? ''}${tunnelRoute}`, + _sentryTunnelRouteMode: findTunnelRouteHandlerFile(userNextConfig.pageExtensions, projectDir) + ? 'handler' + : 'rewrite', + }; +} diff --git a/packages/nextjs/src/edge/index.ts b/packages/nextjs/src/edge/index.ts index 75a7d51b53cc..9cc372a08b14 100644 --- a/packages/nextjs/src/edge/index.ts +++ b/packages/nextjs/src/edge/index.ts @@ -24,7 +24,7 @@ import { DEBUG_BUILD } from '../common/debug-build'; import { ATTR_NEXT_SPAN_TYPE } from '../common/nextSpanAttributes'; import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION } from '../common/span-attributes-with-logic-attached'; import { addHeadersAsAttributes } from '../common/utils/addHeadersAsAttributes'; -import { dropMiddlewareTunnelRequests } from '../common/utils/dropMiddlewareTunnelRequests'; +import { dropMiddlewareTunnelRequests, getTunnelRouteIgnoreSpans } from '../common/utils/dropMiddlewareTunnelRequests'; import { isBuild } from '../common/utils/isBuild'; import { flushSafelyWithTimeout, isCloudflareWaitUntilAvailable, waitUntil } from '../common/utils/responseEnd'; import { setUrlProcessingMetadata } from '../common/utils/setUrlProcessingMetadata'; @@ -90,6 +90,7 @@ export function init(options: VercelEdgeOptions = {}): void { const nextjsIgnoreSpans: NonNullable = [ // (set in `dropMiddlewareTunnelRequests` during `spanStart`) { attributes: { [TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION]: true } }, + ...getTunnelRouteIgnoreSpans(), ]; opts.ignoreSpans = [...(opts.ignoreSpans || []), ...nextjsIgnoreSpans]; diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index 736ffd235865..0cc12d03f09a 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -24,6 +24,7 @@ import { DEBUG_BUILD } from '../common/debug-build'; import { devErrorSymbolicationEventProcessor } from '../common/devErrorSymbolicationEventProcessor'; import { getVercelEnv } from '../common/getVercelEnv'; import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION } from '../common/span-attributes-with-logic-attached'; +import { getTunnelRouteIgnoreSpans } from '../common/utils/dropMiddlewareTunnelRequests'; import { isBuild } from '../common/utils/isBuild'; import { isCloudflareWaitUntilAvailable } from '../common/utils/responseEnd'; import { setUrlProcessingMetadata } from '../common/utils/setUrlProcessingMetadata'; @@ -193,6 +194,7 @@ export function init(options: NodeOptions): NodeClient | undefined { // Spans flagged via TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION // (set in `dropMiddlewareTunnelRequests` during `spanStart`) { attributes: { [TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION]: true } }, + ...getTunnelRouteIgnoreSpans(), ]; opts.ignoreSpans = [...(opts.ignoreSpans || []), ...nextjsIgnoreSpans]; diff --git a/packages/nextjs/src/tunnel/index.ts b/packages/nextjs/src/tunnel/index.ts new file mode 100644 index 000000000000..09a2c7cdcc43 --- /dev/null +++ b/packages/nextjs/src/tunnel/index.ts @@ -0,0 +1,76 @@ +import { dsnToString, getClient, handleTunnelRequest, makeDsn, suppressTracing } from '@sentry/core'; + +export interface CreateSentryTunnelRouteOptions { + /** + * The DSNs that the route accepts. It rejects envelopes for other DSNs with a 403. + * Defaults to the DSN of the server SDK. + */ + allowedDsns?: string[]; +} + +type TunnelRouteHandler = (request: Request) => Promise; + +/** + * Creates a route handler for the `tunnelRoute`, like `POST`, but with your own DSN allowlist. + * + * In most cases, use `export { POST } from '@sentry/nextjs/tunnel'` instead. Use this function only if the route must + * accept DSNs other than the one of the server SDK, for example a different browser DSN. + * + * @example + * ```ts + * // app/sentry-tunnel/route.ts + * import { createSentryTunnelRoute } from '@sentry/nextjs/tunnel'; + * + * export const { POST } = createSentryTunnelRoute({ + * allowedDsns: ['https://public@o0.ingest.sentry.io/0'], + * }); + * ``` + */ +export function createSentryTunnelRoute(options: CreateSentryTunnelRouteOptions = {}): { POST: TunnelRouteHandler } { + const allowedDsnsFromOptions = options.allowedDsns?.length ? normalizeDsns(options.allowedDsns) : undefined; + + return { + POST: async request => { + // Read on each request, because the client can be set up after this module loads. + const allowedDsns = allowedDsnsFromOptions ?? getServerDsns(); + if (!allowedDsns.length) { + return new Response( + allowedDsnsFromOptions + ? 'Tunnel route has no valid DSN in allowedDsns.' + : 'Tunnel route requires Sentry server SDK initialized with a DSN, or pass allowedDsns explicitly.', + { status: 500 }, + ); + } + + // Like the SDK transports, so that the forward creates no spans, breadcrumbs or trace headers. + return suppressTracing(() => handleTunnelRequest({ request, allowedDsns })); + }, + }; +} + +/** + * Route handler for the `tunnelRoute` of `withSentryConfig`. It forwards Sentry envelopes to Sentry, but not the + * request headers (such as cookies). + * + * It accepts envelopes only for the DSN of the server SDK. For other DSNs, use `createSentryTunnelRoute`. + * + * @example + * ```ts + * // app/sentry-tunnel/route.ts + * export { POST } from '@sentry/nextjs/tunnel'; + * ``` + */ +export const POST: TunnelRouteHandler = createSentryTunnelRoute().POST; + +function getServerDsns(): string[] { + const dsn = getClient()?.getDsn(); + return dsn ? [dsnToString(dsn)] : []; +} + +// The same form as the DSN in the envelope header, because `handleTunnelRequest` compares exact strings. +function normalizeDsns(dsns: string[]): string[] { + return dsns.flatMap(dsn => { + const dsnComponents = makeDsn(dsn.trim()); + return dsnComponents ? [dsnToString(dsnComponents)] : []; + }); +} diff --git a/packages/nextjs/test/common/wrapMiddlewareWithSentry.test.ts b/packages/nextjs/test/common/wrapMiddlewareWithSentry.test.ts index 808666694d3d..fd877e18b50a 100644 --- a/packages/nextjs/test/common/wrapMiddlewareWithSentry.test.ts +++ b/packages/nextjs/test/common/wrapMiddlewareWithSentry.test.ts @@ -1,7 +1,13 @@ import * as SentryCore from '@sentry/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION } from '../../src/common/span-attributes-with-logic-attached'; import { wrapMiddlewareWithSentry } from '../../src/common/wrapMiddlewareWithSentry'; +const globalWithInjectedValues = globalThis as typeof globalThis & { + _sentryRewritesTunnelPath?: string; + _sentryTunnelRouteMode?: string; +}; + describe('wrapMiddlewareWithSentry', () => { beforeEach(() => { vi.spyOn(SentryCore, 'captureException').mockReturnValue(''); @@ -9,6 +15,8 @@ describe('wrapMiddlewareWithSentry', () => { afterEach(() => { vi.restoreAllMocks(); + delete globalWithInjectedValues._sentryRewritesTunnelPath; + delete globalWithInjectedValues._sentryTunnelRouteMode; }); it('does not start its own span when the Next.js OTEL root span is already active (Next.js >= 14)', async () => { @@ -50,6 +58,61 @@ describe('wrapMiddlewareWithSentry', () => { ); }); + describe('on Next.js 13 with a tunnel route handler', () => { + beforeEach(() => { + vi.spyOn(SentryCore, 'getActiveSpan').mockReturnValue(undefined); + globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + globalWithInjectedValues._sentryTunnelRouteMode = 'handler'; + }); + + it('runs the middleware and marks its span for tunnel requests to be dropped', async () => { + const startSpanSpy = vi.spyOn(SentryCore, 'startSpan'); + const handler = vi.fn(async (_req: Request) => new Response('ok')); + + await wrapMiddlewareWithSentry(handler)(new Request('https://example.com/monitoring', { method: 'POST' })); + + expect(handler).toHaveBeenCalledTimes(1); + expect(startSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ [TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION]: true }), + }), + expect.any(Function), + ); + }); + + it.each([ + ['a sub-path of the tunnel route', 'https://example.com/monitoring/dashboard'], + ['a path that starts like the tunnel route', 'https://example.com/monitoring-old'], + ])('keeps the span of %s', async (_description, url) => { + const startSpanSpy = vi.spyOn(SentryCore, 'startSpan'); + + await wrapMiddlewareWithSentry(async (_req: Request) => new Response('ok'))(new Request(url, { method: 'POST' })); + + expect(startSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.not.objectContaining({ [TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION]: true }), + }), + expect.any(Function), + ); + }); + + it('keeps the span of tunnel requests in rewrite mode', async () => { + globalWithInjectedValues._sentryTunnelRouteMode = 'rewrite'; + const startSpanSpy = vi.spyOn(SentryCore, 'startSpan'); + + await wrapMiddlewareWithSentry(async (_req: Request) => new Response('ok'))( + new Request('https://example.com/monitoring?o=abc', { method: 'POST' }), + ); + + expect(startSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.not.objectContaining({ [TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION]: true }), + }), + expect.any(Function), + ); + }); + }); + it('captures errors thrown by the middleware when a root span is already active', async () => { vi.spyOn(SentryCore, 'getActiveSpan').mockReturnValue({} as SentryCore.Span); vi.spyOn(SentryCore, 'getRootSpan').mockReturnValue({} as SentryCore.Span); diff --git a/packages/nextjs/test/config/turbopack/generateValueInjectionRules.test.ts b/packages/nextjs/test/config/turbopack/generateValueInjectionRules.test.ts index d4fed2368101..d68c002f298b 100644 --- a/packages/nextjs/test/config/turbopack/generateValueInjectionRules.test.ts +++ b/packages/nextjs/test/config/turbopack/generateValueInjectionRules.test.ts @@ -305,6 +305,33 @@ describe('generateValueInjectionRules', () => { }); }); + describe('with tunnel route values', () => { + it('injects the tunnel path and mode into the client and server bundles', () => { + const result = generateValueInjectionRules({ + tunnelRouteValues: { _sentryRewritesTunnelPath: '/monitoring', _sentryTunnelRouteMode: 'handler' }, + }); + + const injectedValues = result.map(({ matcher, rule }) => [ + matcher, + (rule as { loaders: Array<{ options: { values: Record } }> }).loaders[0]?.options.values, + ]); + expect(injectedValues).toEqual([ + [ + '**/instrumentation-client.*', + { _sentryRewritesTunnelPath: '/monitoring', _sentryTunnelRouteMode: 'handler' }, + ], + [ + '**/instrumentation.*', + { + _sentryRewritesTunnelPath: '/monitoring', + _sentryTunnelRouteMode: 'handler', + __SENTRY_SERVER_MODULES__: expect.any(Object), + }, + ], + ]); + }); + }); + describe('path resolution', () => { it('should call path.resolve with correct arguments', () => { const pathResolveSpy = vi.spyOn(path, 'resolve'); diff --git a/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts b/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts index 42efbd997e9e..511d602c8e61 100644 --- a/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts +++ b/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts @@ -1,7 +1,10 @@ // mock helper functions not tested directly in this file import '../mocks'; import * as core from '@sentry/core'; -import { describe, expect, it, vi } from 'vitest'; +import * as fs from 'fs'; +import type * as os from 'os'; +import * as path from 'path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import * as getBuildPluginOptionsModule from '../../../src/config/getBuildPluginOptions'; import type * as loadOrchestrionBundlerModule from '../../../src/config/loadOrchestrionBundler'; import * as util from '../../../src/config/util'; @@ -17,6 +20,9 @@ import { } from '../fixtures'; import { materializeFinalNextConfig, materializeFinalWebpackConfig } from '../testUtils'; +const realFs = await vi.importActual('fs'); +const realOs = await vi.importActual('os'); + // Stub only the plugin factory. The externals handler under test needs the real // `resolveOrchestrionRuntimeRequest`. The bundler module loads via native `require`, which // `vi.mock` cannot intercept, so the stub goes on the loader. @@ -928,4 +934,39 @@ describe('constructWebpackConfigFunction()', () => { expect(finalWebpackConfig.externals).toBeUndefined(); }); }); + + describe('tunnel route value injection', () => { + let projectDir: string | undefined; + let cwdSpy: ReturnType | undefined; + + afterEach(() => { + vi.mocked(fs.statSync).mockReset(); + cwdSpy?.mockRestore(); + if (projectDir) { + realFs.rmSync(projectDir, { recursive: true, force: true }); + } + }); + + it('injects the tunnel path and the handler mode', async () => { + projectDir = realFs.mkdtempSync(path.join(realOs.tmpdir(), 'sentry-nextjs-webpack-tunnel-')); + realFs.mkdirSync(path.join(projectDir, 'app/sentry-tunnel'), { recursive: true }); + realFs.writeFileSync(path.join(projectDir, 'app/sentry-tunnel/route.ts'), ''); + cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(projectDir); + vi.mocked(fs.statSync).mockImplementation(realFs.statSync); + + const finalWebpackConfig = await materializeFinalWebpackConfig({ + exportedNextConfig: userNextConfig, + incomingWebpackConfig: clientWebpackConfig, + incomingWebpackBuildContext: clientBuildContext, + sentryBuildTimeOptions: { tunnelRoute: '/monitoring' }, + }); + + const valueInjectionLoader = finalWebpackConfig.module.rules + .flatMap(rule => rule.use ?? []) + .find(loader => String(loader.loader).endsWith('valueInjectionLoader.js')); + expect(valueInjectionLoader?.options).toMatchObject({ + values: { _sentryRewritesTunnelPath: '/monitoring', _sentryTunnelRouteMode: 'handler' }, + }); + }); + }); }); diff --git a/packages/nextjs/test/config/withSentryConfig/buildTime.test.ts b/packages/nextjs/test/config/withSentryConfig/buildTime.test.ts index b81e3c48ee86..c8bee1a00d39 100644 --- a/packages/nextjs/test/config/withSentryConfig/buildTime.test.ts +++ b/packages/nextjs/test/config/withSentryConfig/buildTime.test.ts @@ -57,4 +57,25 @@ describe('setUpBuildTimeVariables', () => { expect(nextConfig.env._sentryHasPagesRouter).toBe('true'); }); }); + + describe('tunnel route', () => { + it('adds the tunnel path and the handler mode for a project with a tunnel route handler', () => { + const nextConfig = { basePath: '/docs' }; + + setUpBuildTimeVariables( + nextConfig, + { tunnelRoute: '/monitoring' }, + undefined, + makeProject(['app/sentry-tunnel/route.ts']), + ); + + expect(nextConfig).toEqual({ + basePath: '/docs', + env: expect.objectContaining({ + _sentryRewritesTunnelPath: '/docs/monitoring', + _sentryTunnelRouteMode: 'handler', + }), + }); + }); + }); }); diff --git a/packages/nextjs/test/config/withSentryConfig/getFinalConfigObjectUtils.test.ts b/packages/nextjs/test/config/withSentryConfig/getFinalConfigObjectUtils.test.ts new file mode 100644 index 000000000000..cfda40eaa28f --- /dev/null +++ b/packages/nextjs/test/config/withSentryConfig/getFinalConfigObjectUtils.test.ts @@ -0,0 +1,123 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { NextConfigObject } from '../../../src/config/types'; +import { makeProject, removeProjects } from './projectFixture'; + +const HANDLER_REWRITE = { source: '/monitoring', destination: '/sentry-tunnel' }; + +afterEach(() => { + removeProjects(); +}); + +describe('maybeSetUpTunnelRouteRewriteRules', () => { + beforeEach(() => { + vi.restoreAllMocks(); + // A fresh module per test, because the function logs each message only once. + vi.resetModules(); + delete process.env.__SENTRY_TUNNEL_ROUTE__; + }); + + async function setUp(files: string[]) { + vi.spyOn(process, 'cwd').mockReturnValue(makeProject(files)); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const { maybeSetUpTunnelRouteRewriteRules } = + await import('../../../src/config/withSentryConfig/getFinalConfigObjectUtils'); + return { maybeSetUpTunnelRouteRewriteRules, logSpy, warnSpy }; + } + + it('sets up the handler rewrite when the project has a tunnel route handler', async () => { + const { maybeSetUpTunnelRouteRewriteRules } = await setUp(['app/sentry-tunnel/route.ts']); + const nextConfig: NextConfigObject = {}; + + maybeSetUpTunnelRouteRewriteRules(nextConfig, { tunnelRoute: '/monitoring' }); + + await expect(nextConfig.rewrites?.()).resolves.toEqual({ beforeFiles: [HANDLER_REWRITE], afterFiles: [] }); + }); + + it('logs the tunnel route handler file once', async () => { + const { maybeSetUpTunnelRouteRewriteRules, logSpy } = await setUp(['app/sentry-tunnel/route.ts']); + + maybeSetUpTunnelRouteRewriteRules({}, { tunnelRoute: '/monitoring' }); + maybeSetUpTunnelRouteRewriteRules({}, { tunnelRoute: '/monitoring' }); + + expect(logSpy).toHaveBeenCalledTimes(1); + expect(logSpy).toHaveBeenCalledWith( + '[@sentry/nextjs] The tunnel route sends events through app/sentry-tunnel/route.ts. Only the event payload goes to Sentry.', + ); + }); + + it('sets up the external rewrite when the project has no tunnel route handler', async () => { + const { maybeSetUpTunnelRouteRewriteRules, logSpy } = await setUp(['app/page.tsx']); + const nextConfig: NextConfigObject = {}; + + maybeSetUpTunnelRouteRewriteRules(nextConfig, { tunnelRoute: '/monitoring' }); + + await expect(nextConfig.rewrites?.()).resolves.toEqual([ + { + source: '/monitoring(/?)', + has: [ + { type: 'query', key: 'o', value: '(?\\d*)' }, + { type: 'query', key: 'p', value: '(?\\d*)' }, + { type: 'query', key: 'r', value: '(?[a-z]{2})' }, + ], + destination: 'https://o:orgid.ingest.:region.sentry.io/api/:projectid/envelope/?hsts=0', + }, + { + source: '/monitoring(/?)', + has: [ + { type: 'query', key: 'o', value: '(?\\d*)' }, + { type: 'query', key: 'p', value: '(?\\d*)' }, + ], + destination: 'https://o:orgid.ingest.sentry.io/api/:projectid/envelope/?hsts=0', + }, + ]); + expect(logSpy).not.toHaveBeenCalled(); + }); + + it('does not log with `silent`', async () => { + const { maybeSetUpTunnelRouteRewriteRules, logSpy } = await setUp(['app/sentry-tunnel/route.ts']); + + maybeSetUpTunnelRouteRewriteRules({}, { tunnelRoute: '/monitoring', silent: true }); + + expect(logSpy).not.toHaveBeenCalled(); + }); + + it('warns once when the project has a tunnel route handler but `tunnelRoute` is not set', async () => { + const { maybeSetUpTunnelRouteRewriteRules, warnSpy } = await setUp(['src/app/sentry-tunnel/route.tsx']); + const nextConfig: NextConfigObject = {}; + + maybeSetUpTunnelRouteRewriteRules(nextConfig, {}); + maybeSetUpTunnelRouteRewriteRules({}, {}); + + expect(nextConfig.rewrites).toBeUndefined(); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith( + '[@sentry/nextjs] Found src/app/sentry-tunnel/route.tsx, but the `tunnelRoute` option is not set. Set `tunnelRoute` in `withSentryConfig` to send events through this route.', + ); + }); + + it('sets up no rewrite for a static export with a tunnel route handler', async () => { + const { maybeSetUpTunnelRouteRewriteRules, logSpy, warnSpy } = await setUp(['app/sentry-tunnel/route.ts']); + const nextConfig: NextConfigObject = { output: 'export' }; + + maybeSetUpTunnelRouteRewriteRules(nextConfig, { tunnelRoute: '/monitoring' }); + + expect(nextConfig.rewrites).toBeUndefined(); + expect(logSpy).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('will not work in combination with Next.js static exports'), + ); + }); + + it.each([ + ['`tunnelRoute` is `false`', {}, { tunnelRoute: false }], + ['`tunnelRoute` is not set in a static export', { output: 'export' }, {}], + ])('does not warn about the tunnel route handler when %s', async (_description, nextConfig, sentryOptions) => { + const { maybeSetUpTunnelRouteRewriteRules, warnSpy } = await setUp(['app/sentry-tunnel/route.ts']); + + maybeSetUpTunnelRouteRewriteRules(nextConfig, sentryOptions); + + expect(warnSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/nextjs/test/config/withSentryConfig/projectFixture.ts b/packages/nextjs/test/config/withSentryConfig/projectFixture.ts new file mode 100644 index 000000000000..ececc652755b --- /dev/null +++ b/packages/nextjs/test/config/withSentryConfig/projectFixture.ts @@ -0,0 +1,23 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +const projectDirs: string[] = []; + +/** Creates a temporary project directory with empty `files`. `removeProjects` deletes it. */ +export function makeProject(files: string[]): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sentry-nextjs-project-')); + projectDirs.push(dir); + for (const file of files) { + const absolute = path.join(dir, file); + fs.mkdirSync(path.dirname(absolute), { recursive: true }); + fs.writeFileSync(absolute, ''); + } + return dir; +} + +export function removeProjects(): void { + for (const dir of projectDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +} diff --git a/packages/nextjs/test/config/withSentryConfig/tunnel.test.ts b/packages/nextjs/test/config/withSentryConfig/tunnel.test.ts new file mode 100644 index 000000000000..cc39280aa7d0 --- /dev/null +++ b/packages/nextjs/test/config/withSentryConfig/tunnel.test.ts @@ -0,0 +1,135 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import type { NextConfigObject } from '../../../src/config/types'; +import { + findTunnelRouteHandlerFile, + getTunnelRouteInjectedValues, + setUpTunnelHandlerRewrite, +} from '../../../src/config/withSentryConfig/tunnel'; +import { makeProject, removeProjects } from './projectFixture'; + +const HANDLER_REWRITE = { source: '/monitoring', destination: '/sentry-tunnel' }; + +afterEach(() => { + removeProjects(); +}); + +describe('findTunnelRouteHandlerFile', () => { + it.each([ + ['app/sentry-tunnel/route.ts', ['app/page.tsx', 'app/sentry-tunnel/route.ts']], + ['src/app/sentry-tunnel/route.js', ['src/app/page.tsx', 'src/app/sentry-tunnel/route.js']], + ])('finds %s', (expectedFile, files) => { + expect(findTunnelRouteHandlerFile(undefined, makeProject(files))).toBe(expectedFile); + }); + + // Next.js finds `pages` and `app` separately, so a root `pages` does not hide `src/app`, only `src/pages` + // Next.js 16 rejects this case at build time. + // https://github.com/vercel/next.js/blob/7a7d48862287fd5caecd99da41149ecbddf37320/packages/next/src/lib/find-pages-dir.ts#L4-L13 + it('finds the file in `src/app` next to a root `pages` directory', () => { + const projectDir = makeProject(['pages/index.tsx', 'src/app/sentry-tunnel/route.ts']); + + expect(findTunnelRouteHandlerFile(undefined, projectDir)).toBe('src/app/sentry-tunnel/route.ts'); + }); + + it('ignores `src/app` when there is a root `app` directory', () => { + const projectDir = makeProject(['app/page.tsx', 'src/app/sentry-tunnel/route.ts']); + + expect(findTunnelRouteHandlerFile(undefined, projectDir)).toBeUndefined(); + }); + + it('finds the file only with a whole `pageExtensions` entry', () => { + const projectDir = makeProject(['app/sentry-tunnel/route.ts', 'app/sentry-tunnel/route.page.ts']); + + expect(findTunnelRouteHandlerFile(['page.ts'], projectDir)).toBe('app/sentry-tunnel/route.page.ts'); + }); + + it('does not find a file that does not match any `pageExtensions` entry', () => { + const projectDir = makeProject(['app/sentry-tunnel/route.ts']); + + expect(findTunnelRouteHandlerFile(['mdx', 'tsx'], projectDir)).toBeUndefined(); + }); + + it.each([ + ['a route group', ['app/(monitoring)/sentry-tunnel/route.ts']], + ['a Pages Router project', ['pages/sentry-tunnel/route.ts']], + ['a directory named like the route file', ['app/sentry-tunnel/route.ts/page.tsx']], + ])('does not find the file in %s', (_description, files) => { + expect(findTunnelRouteHandlerFile(undefined, makeProject(files))).toBeUndefined(); + }); +}); + +describe('getTunnelRouteInjectedValues', () => { + it('returns the handler mode when the project has a tunnel route handler', () => { + const projectDir = makeProject(['app/sentry-tunnel/route.ts']); + + expect(getTunnelRouteInjectedValues({}, { tunnelRoute: '/monitoring' }, projectDir)).toEqual({ + _sentryRewritesTunnelPath: '/monitoring', + _sentryTunnelRouteMode: 'handler', + }); + }); + + it('returns the rewrite mode when the project has no tunnel route handler', () => { + const projectDir = makeProject(['app/page.tsx']); + + expect(getTunnelRouteInjectedValues({}, { tunnelRoute: '/monitoring' }, projectDir)).toEqual({ + _sentryRewritesTunnelPath: '/monitoring', + _sentryTunnelRouteMode: 'rewrite', + }); + }); + + it('adds the basePath to the tunnel path', () => { + const projectDir = makeProject(['app/sentry-tunnel/route.ts']); + + expect(getTunnelRouteInjectedValues({ basePath: '/docs' }, { tunnelRoute: '/monitoring' }, projectDir)).toEqual({ + _sentryRewritesTunnelPath: '/docs/monitoring', + _sentryTunnelRouteMode: 'handler', + }); + }); + + it.each([ + ['`tunnelRoute` is not set', {}, {}], + ['`tunnelRoute` is an empty string', { basePath: '/docs' }, { tunnelRoute: '' }], + ['`tunnelRoute` is still `true`', {}, { tunnelRoute: true }], + ['the app is a static export', { output: 'export' }, { tunnelRoute: '/monitoring' }], + ])('returns no values when %s', (_description, nextConfig, sentryOptions) => { + const projectDir = makeProject(['app/sentry-tunnel/route.ts']); + + expect(getTunnelRouteInjectedValues(nextConfig, sentryOptions, projectDir)).toEqual({}); + }); +}); + +describe('setUpTunnelHandlerRewrite', () => { + it('adds the handler rewrite to `beforeFiles` when there are no user rewrites', async () => { + const nextConfig: NextConfigObject = {}; + + setUpTunnelHandlerRewrite(nextConfig, '/monitoring'); + + await expect(nextConfig.rewrites?.()).resolves.toEqual({ beforeFiles: [HANDLER_REWRITE], afterFiles: [] }); + }); + + it('keeps user rewrites in array form as `afterFiles`', async () => { + const userRewrite = { source: '/blog/:slug', destination: '/news/:slug' }; + const nextConfig: NextConfigObject = { rewrites: async () => [userRewrite] }; + + setUpTunnelHandlerRewrite(nextConfig, '/monitoring'); + + await expect(nextConfig.rewrites?.()).resolves.toEqual({ + beforeFiles: [HANDLER_REWRITE], + afterFiles: [userRewrite], + }); + }); + + it('puts the handler rewrite before the user `beforeFiles` rewrites', async () => { + const userBeforeFilesRewrite = { source: '/old', destination: '/new' }; + const userFallbackRewrite = { source: '/:path*', destination: 'https://legacy.example.com/:path*' }; + const nextConfig: NextConfigObject = { + rewrites: async () => ({ beforeFiles: [userBeforeFilesRewrite], fallback: [userFallbackRewrite] }), + }; + + setUpTunnelHandlerRewrite(nextConfig, '/monitoring'); + + await expect(nextConfig.rewrites?.()).resolves.toEqual({ + beforeFiles: [HANDLER_REWRITE, userBeforeFilesRewrite], + fallback: [userFallbackRewrite], + }); + }); +}); diff --git a/packages/nextjs/test/tunnel/index.test.ts b/packages/nextjs/test/tunnel/index.test.ts new file mode 100644 index 000000000000..1e86f6925f1f --- /dev/null +++ b/packages/nextjs/test/tunnel/index.test.ts @@ -0,0 +1,146 @@ +import type * as SentryCore from '@sentry/core'; +import { createEnvelope, makeDsn, serializeEnvelope } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createSentryTunnelRoute, POST } from '../../src/tunnel'; + +const { mockGetClient } = vi.hoisted(() => ({ mockGetClient: vi.fn() })); + +vi.mock('@sentry/core', async importOriginal => ({ + ...(await importOriginal()), + getClient: mockGetClient, +})); + +const SERVER_DSN = 'https://public@o1.ingest.sentry.io/1'; +const BROWSER_DSN = 'https://browser@o1.ingest.sentry.io/2'; + +function createEnvelopeRequest(dsn: string): { request: Request; body: Uint8Array } { + const envelope = serializeEnvelope( + createEnvelope({ dsn, event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2' }, [ + [{ type: 'event' }, { event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2', message: 'Checkout failed' }], + ]), + ); + const body = typeof envelope === 'string' ? new TextEncoder().encode(envelope) : envelope; + const request = new Request('https://shop.example.com/monitoring', { + method: 'POST', + headers: { cookie: 'session=8f2c1d', 'content-type': 'text/plain;charset=UTF-8' }, + body, + }); + return { request, body }; +} + +function useClientDsn(dsn: string | undefined): void { + mockGetClient.mockReturnValue(dsn ? { getDsn: () => makeDsn(dsn) } : undefined); +} + +let fetchSpy: ReturnType; + +beforeEach(() => { + vi.restoreAllMocks(); + fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('{}', { status: 200 })); + useClientDsn(undefined); +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe('POST', () => { + it('forwards only the envelope to the Sentry host of an allowed DSN', async () => { + useClientDsn(SERVER_DSN); + const { request, body } = createEnvelopeRequest(SERVER_DSN); + + const response = await POST(request); + + expect(response.status).toBe(200); + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy).toHaveBeenCalledWith( + 'https://o1.ingest.sentry.io/api/1/envelope/?sentry_version=7&sentry_key=public', + { + method: 'POST', + headers: { 'Content-Type': 'application/x-sentry-envelope' }, + body, + }, + ); + }); + + it('responds with 500 when the server SDK has no DSN', async () => { + const { request } = createEnvelopeRequest(SERVER_DSN); + + const response = await POST(request); + + expect(response.status).toBe(500); + await expect(response.text()).resolves.toBe( + 'Tunnel route requires Sentry server SDK initialized with a DSN, or pass allowedDsns explicitly.', + ); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('rejects an envelope for a DSN that is not the server DSN', async () => { + useClientDsn(SERVER_DSN); + const { request } = createEnvelopeRequest(BROWSER_DSN); + + const response = await POST(request); + + expect(response.status).toBe(403); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('does not accept DSNs from environment variables', async () => { + useClientDsn(SERVER_DSN); + vi.stubEnv('SENTRY_DSN', BROWSER_DSN); + vi.stubEnv('NEXT_PUBLIC_SENTRY_DSN', BROWSER_DSN); + const { request } = createEnvelopeRequest(BROWSER_DSN); + + const response = await POST(request); + + expect(response.status).toBe(403); + }); +}); + +describe('createSentryTunnelRoute', () => { + it('accepts only the DSNs in `allowedDsns`', async () => { + useClientDsn(SERVER_DSN); + const { POST: handler } = createSentryTunnelRoute({ allowedDsns: [BROWSER_DSN] }); + + const allowedResponse = await handler(createEnvelopeRequest(BROWSER_DSN).request); + const rejectedResponse = await handler(createEnvelopeRequest(SERVER_DSN).request); + + expect(allowedResponse.status).toBe(200); + expect(rejectedResponse.status).toBe(403); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it.each([ + ['omitted', {}], + ['empty', { allowedDsns: [] }], + ])('accepts the server DSN when `allowedDsns` is %s', async (_description, options) => { + useClientDsn(SERVER_DSN); + const { POST: handler } = createSentryTunnelRoute(options); + + const response = await handler(createEnvelopeRequest(SERVER_DSN).request); + + expect(response.status).toBe(200); + }); + + it('accepts an allowed DSN with a secret and whitespace', async () => { + const { POST: handler } = createSentryTunnelRoute({ + allowedDsns: [' https://public:secret@o1.ingest.sentry.io/1\n'], + }); + + const response = await handler(createEnvelopeRequest(SERVER_DSN).request); + + expect(response.status).toBe(200); + }); + + it('responds with 500 when no allowed DSN is valid', async () => { + vi.spyOn(console, 'error').mockImplementation(() => undefined); + useClientDsn(SERVER_DSN); + const { POST: handler } = createSentryTunnelRoute({ allowedDsns: ['not-a-dsn'] }); + + const response = await handler(createEnvelopeRequest(SERVER_DSN).request); + + expect(response.status).toBe(500); + await expect(response.text()).resolves.toBe('Tunnel route has no valid DSN in allowedDsns.'); + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/nextjs/test/utils/dropMiddlewareTunnelRequests.test.ts b/packages/nextjs/test/utils/dropMiddlewareTunnelRequests.test.ts index 2d933373578d..653a31af5847 100644 --- a/packages/nextjs/test/utils/dropMiddlewareTunnelRequests.test.ts +++ b/packages/nextjs/test/utils/dropMiddlewareTunnelRequests.test.ts @@ -1,9 +1,16 @@ +import { HTTP_TARGET, SERVER_ADDRESS, URL_PATH } from '@sentry/conventions/attributes'; +import { shouldIgnoreSpan } from '@sentry/core'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { dropMiddlewareTunnelRequests } from '../../src/common/utils/dropMiddlewareTunnelRequests'; +import { + dropMiddlewareTunnelRequests, + getTunnelRouteIgnoreSpans, +} from '../../src/common/utils/dropMiddlewareTunnelRequests'; +import { ATTR_NEXT_ROUTE, ATTR_NEXT_SPAN_TYPE } from '../../src/common/nextSpanAttributes'; import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION } from '../../src/common/span-attributes-with-logic-attached'; const globalWithInjectedValues = global as typeof global & { _sentryRewritesTunnelPath?: string; + _sentryTunnelRouteMode?: string; }; vi.mock('@sentry/core', async requireActual => { @@ -31,6 +38,7 @@ function createMockSpan(): { setAttribute: ReturnType; attributes: beforeEach(() => { globalWithInjectedValues._sentryRewritesTunnelPath = undefined; + globalWithInjectedValues._sentryTunnelRouteMode = undefined; }); describe('dropMiddlewareTunnelRequests', () => { @@ -156,3 +164,101 @@ describe('dropMiddlewareTunnelRequests', () => { }); }); }); + +describe('getTunnelRouteIgnoreSpans', () => { + function isIgnored(attributes: Record): boolean { + return shouldIgnoreSpan({ description: 'POST', op: 'http.server', attributes }, getTunnelRouteIgnoreSpans()); + } + + // Next.js sets the deprecated `http.target` on its request spans. + function nextRequestSpanAttributes(spanType: string, target: string): Record { + // eslint-disable-next-line typescript/no-deprecated + return { [ATTR_NEXT_SPAN_TYPE]: spanType, [HTTP_TARGET]: target }; + } + + it('returns no entries when there is no tunnel route', () => { + expect(getTunnelRouteIgnoreSpans()).toEqual([]); + }); + + it.each([ + ['BaseServer.handleRequest', '/monitoring?o=123&p=456'], + ['BaseServer.handleRequest', '/monitoring/?o=123&p=456&r=us'], + ['Middleware.execute', '/monitoring?o=123&p=456'], + ])('ignores a %s span for %s in rewrite mode', (spanType, target) => { + globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + + expect(isIgnored(nextRequestSpanAttributes(spanType, target))).toBe(true); + }); + + it.each([ + '/monitoring', + '/monitoring?o=123', + '/monitoring?xo=123&p=456', + '/monitoring?o=123&p=456&o=abc', + '/monitoring?p=456&o=123', + '/monitoring/dashboard?o=123&p=456', + '/monitoring-dashboard?o=123&p=456', + '/api/monitoring?o=123&p=456', + ])('does not ignore a request span for %s in rewrite mode, which the rewrite does not serve', target => { + globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + + expect(isIgnored(nextRequestSpanAttributes('BaseServer.handleRequest', target))).toBe(false); + }); + + it.each(['/monitoring', '/monitoring/', '/monitoring?cache=0'])( + 'ignores a request span for %s in handler mode', + target => { + globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + globalWithInjectedValues._sentryTunnelRouteMode = 'handler'; + + expect(isIgnored(nextRequestSpanAttributes('BaseServer.handleRequest', target))).toBe(true); + }, + ); + + it('does not ignore a request span for a sub-path in handler mode', () => { + globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + globalWithInjectedValues._sentryTunnelRouteMode = 'handler'; + + expect(isIgnored(nextRequestSpanAttributes('BaseServer.handleRequest', '/monitoring/dashboard'))).toBe(false); + }); + + it('does not ignore an outgoing request with the tunnel path', () => { + globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + + const outgoingRequestAttributes = { + [URL_PATH]: '/monitoring', + // eslint-disable-next-line typescript/no-deprecated + [HTTP_TARGET]: '/monitoring', + [SERVER_ADDRESS]: 'api.example.com', + }; + + expect(isIgnored(outgoingRequestAttributes)).toBe(false); + }); + + it.each(['/sentry-tunnel', '/sentry-tunnel/route'])( + 'ignores the route handler span with next.route %s in handler mode', + nextRoute => { + globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + globalWithInjectedValues._sentryTunnelRouteMode = 'handler'; + + expect( + isIgnored({ [ATTR_NEXT_SPAN_TYPE]: 'AppRouteRouteHandlers.runHandler', [ATTR_NEXT_ROUTE]: nextRoute }), + ).toBe(true); + }, + ); + + it('does not ignore the route handler span in rewrite mode', () => { + globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + globalWithInjectedValues._sentryTunnelRouteMode = 'rewrite'; + + expect( + isIgnored({ [ATTR_NEXT_SPAN_TYPE]: 'AppRouteRouteHandlers.runHandler', [ATTR_NEXT_ROUTE]: '/sentry-tunnel' }), + ).toBe(false); + }); + + it('escapes regex characters in the tunnel path', () => { + globalWithInjectedValues._sentryRewritesTunnelPath = '/a.b'; + + expect(isIgnored(nextRequestSpanAttributes('BaseServer.handleRequest', '/axb?o=123&p=456'))).toBe(false); + }); +}); diff --git a/packages/nextjs/test/utils/tunnelRoute.test.ts b/packages/nextjs/test/utils/tunnelRoute.test.ts index 8382e66ca7d4..b901826d3f13 100644 --- a/packages/nextjs/test/utils/tunnelRoute.test.ts +++ b/packages/nextjs/test/utils/tunnelRoute.test.ts @@ -4,10 +4,13 @@ import { applyTunnelRouteOption } from '../../src/client/tunnelRoute'; const globalWithInjectedValues = global as typeof global & { _sentryRewritesTunnelPath?: string; + _sentryTunnelRouteMode?: string; }; beforeEach(() => { + vi.restoreAllMocks(); globalWithInjectedValues._sentryRewritesTunnelPath = undefined; + globalWithInjectedValues._sentryTunnelRouteMode = undefined; }); describe('applyTunnelRouteOption()', () => { @@ -118,3 +121,29 @@ describe('Random tunnel route generation', () => { expect(options.tunnel).toBeUndefined(); }); }); + +describe('applyTunnelRouteOption() in handler mode', () => { + it.each([ + ['a SaaS DSN', 'https://11111111111111111111111111111111@o2222222.ingest.us.sentry.io/3333333'], + ['a self-hosted DSN', 'https://11111111111111111111111111111111@sentry.example.com/3333333'], + ])('tunnels %s to the tunnel path with no query', (_description, dsn) => { + globalWithInjectedValues._sentryRewritesTunnelPath = '/my-error-monitoring-route'; + globalWithInjectedValues._sentryTunnelRouteMode = 'handler'; + const options: BrowserOptions = { dsn }; + + applyTunnelRouteOption(options); + + expect(options.tunnel).toBe('/my-error-monitoring-route'); + }); + + it("Doesn't apply `tunnelRoute` when DSN is invalid", () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + globalWithInjectedValues._sentryRewritesTunnelPath = '/my-error-monitoring-route'; + globalWithInjectedValues._sentryTunnelRouteMode = 'handler'; + const options: BrowserOptions = { dsn: 'invalidDsn' }; + + applyTunnelRouteOption(options); + + expect(options.tunnel).toBeUndefined(); + }); +});