diff --git a/CHANGELOG.md b/CHANGELOG.md index 66ad8165bd35..cc1a1866f251 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,21 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +### Important Changes + +- **feat(nextjs): Add a tunnel route handler that sends only the event payload to Sentry ([#25456](https://github.com/getsentry/sentry-javascript/pull/25456))** + + The `tunnelRoute` option can now send events through a route handler in your app. The handler forwards only the event payload to Sentry, and no request headers. To use it, add this file next to your `tunnelRoute` option: + + ```ts + // app/sentry-tunnel/route.ts (or src/app/sentry-tunnel/route.ts) + export { POST } from '@sentry/nextjs/tunnel'; + ``` + + The handler accepts events for the DSN of your server SDK. To accept other DSNs, use `createSentryTunnelRoute({ allowedDsns })` from the same import. Without the file, the tunnel route works as before. + + Also, the server no longer sends spans of tunnel requests when span streaming is on, because these spans only show the SDK's own traffic. + ## 11.8.0 ### Important Changes 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/instrumentation-client.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/instrumentation-client.ts index 6709e1cf54a7..84b61b956e4a 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, }); 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 0b8ace0062d4..32fce3b53fe5 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", @@ -61,6 +63,16 @@ "build-command": "pnpm test:build", "label": "nextjs-16-tunnel (turbopack)", "assert-command": "pnpm test:assert" + }, + { + "build-command": "NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE=handler pnpm test:build-handler", + "label": "nextjs-16-tunnel (handler, turbopack)", + "assert-command": "NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE=handler pnpm test:assert" + }, + { + "build-command": "NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE=handler pnpm test:build-handler-webpack", + "label": "nextjs-16-tunnel (handler, webpack, edge)", + "assert-command": "NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE=handler pnpm test:assert-webpack" } ], "optionalVariants": [ 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 4184d564044f..0333aa29412b 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, // debug: 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 4184d564044f..0333aa29412b 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, // debug: 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 3d44c8fc8108..9a6218c07b3b 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,6 +1,6 @@ import { expect, test } from '@playwright/test'; -import { getSpanOp, waitForStreamedSpan, waitForStreamedSpans } from '@sentry-internal/test-utils'; -import type { SerializedStreamedSpan } from '@sentry/core'; +import { getSpanOp, waitForRequest, waitForStreamedSpan, waitForStreamedSpans } from '@sentry-internal/test-utils'; +import type { Event, SerializedStreamedSpan } from '@sentry/core'; function getStringAttribute(span: SerializedStreamedSpan, key: string): string | undefined { const attribute = span.attributes[key]; @@ -120,3 +120,68 @@ test('Tunnel requests should not create middleware or fetch spans', async ({ pag expect(sentryFetchSpans).toHaveLength(0); }); + +test.describe('Tunnel route handler', () => { + test.skip(process.env.NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE !== 'handler', 'Only the handler variants use the handler'); + + test('forwards events without the cookies of the browser', async ({ page, context, baseURL }) => { + await context.addCookies([{ name: 'session', value: '8f2c1d', url: baseURL }]); + const proxyRequestPromise = waitForRequest('nextjs-16-tunnel', ({ envelope }) => + envelope[1].some( + ([itemHeader, item]) => + itemHeader.type === 'event' && + (item as Event).exception?.values?.[0]?.value === 'Tunnel route handler cookie test', + ), + ); + + await page.goto('/'); + await page.waitForFunction(() => typeof (window as { __SENTRY__?: unknown }).__SENTRY__ === 'object'); + const tunnelRequestPromise = page.waitForRequest( + request => + request.method() === 'POST' && + new URL(request.url()).origin === baseURL && + (request.postData() ?? '').includes('Tunnel route handler cookie test'), + ); + await page.evaluate(() => { + setTimeout(() => { + throw new Error('Tunnel route handler cookie test'); + }); + }); + + const tunnelRequest = await tunnelRequestPromise; + const proxyRequest = await proxyRequestPromise; + + expect(new URL(tunnelRequest.url()).search).toBe(''); + expect((await tunnelRequest.allHeaders()).cookie).toBe('session=8f2c1d'); + expect(proxyRequest.rawProxyRequestHeaders.cookie).toBeUndefined(); + expect(proxyRequest.rawProxyRequestHeaders['content-type']).toBe('application/x-sentry-envelope'); + }); + + test('does not create server spans for tunnel requests', async ({ page }) => { + // The callback never returns true, so this promise is deliberately left unsettled. + const allSpans: SerializedStreamedSpan[] = []; + void waitForStreamedSpans('nextjs-16-tunnel', spans => { + allSpans.push(...spans); + return false; + }); + const pageloadSpanPromise = waitForStreamedSpan('nextjs-16-tunnel', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto('/'); + await pageloadSpanPromise; + // Wait for the server to send the spans that the tunnel request would wrongly produce + await page.waitForTimeout(3000); + + const postSpans = allSpans.filter( + span => span.name.startsWith('POST') || getStringAttribute(span, 'http.request.method') === 'POST', + ); + const ingestSpans = allSpans.filter( + span => + getSpanOp(span) === 'http.client' && + !!getStringAttribute(span, 'url.full')?.startsWith('http://localhost:3031/'), + ); + expect(postSpans).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..abec69dd491f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tunnel-handler/edge/sentry-tunnel/route.ts @@ -0,0 +1,6 @@ +import { createSentryTunnelRoute } from '@sentry/nextjs/tunnel'; +import { SENTRY_DSN } from '@/sentry-dsn'; + +export const { POST } = createSentryTunnelRoute({ allowedDsns: [SENTRY_DSN] }); + +export const runtime = 'edge'; 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 b3ce9965ea31..d8d1635d2591 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -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" diff --git a/packages/nextjs/rollup.npm.config.mjs b/packages/nextjs/rollup.npm.config.mjs index bef1bd0f92d3..925a49507c0f 100644 --- a/packages/nextjs/rollup.npm.config.mjs +++ b/packages/nextjs/rollup.npm.config.mjs @@ -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 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 31064377e4ba..f2d4774f3c54 100644 --- a/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts +++ b/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts @@ -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; }; /** @@ -82,3 +88,42 @@ export function isTunnelRouteSpan(spanAttributes: Record): 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 { + 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 9f107d33636c..2c5dc8bb1dd1 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 @@ -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})?$`); +} diff --git a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts index 743f4abc35a8..3eb22ab2263d 100644 --- a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts +++ b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts @@ -18,6 +18,7 @@ import type { TurbopackOptions, } from '../types'; import { supportsNativeDebugIds, supportsTurbopackRuleCondition } from '../util'; +import { getTunnelRouteInjectedValues } from '../withSentryConfig/tunnel'; import { generateValueInjectionRules } from './generateValueInjectionRules'; /** @@ -53,17 +54,10 @@ export function constructTurbopackConfig({ ...(shouldEnableNativeDebugIds ? { debugIds: true } : {}), }; - const tunnelPath = - userSentryOptions?.tunnelRoute !== undefined && - userNextConfig.output !== 'export' && - typeof userSentryOptions.tunnelRoute === 'string' - ? `${userNextConfig.basePath ?? ''}${userSentryOptions.tunnelRoute}` - : undefined; - const valueInjectionRules = generateValueInjectionRules({ routeManifest, nextJsVersion, - tunnelPath, + tunnelRouteValues: getTunnelRouteInjectedValues(userNextConfig, userSentryOptions), vercelCronsConfig, }); 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 f1d3a8441bcb..6c9f725a8e49 100644 --- a/packages/nextjs/src/config/types.ts +++ b/packages/nextjs/src/config/types.ts @@ -272,6 +272,17 @@ export type SentryBuildOptions = Omit< * - 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. + * * 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`. 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 833965b69518..32bd6bee28e7 100644 --- a/packages/nextjs/src/config/webpack.ts +++ b/packages/nextjs/src/config/webpack.ts @@ -26,8 +26,9 @@ import type { WebpackPluginInstance, } from './types'; import { sentryOrchestrionWebpackPlugin } from '@sentry/server-utils/orchestrion/webpack'; -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. @@ -72,8 +73,7 @@ export function constructWebpackConfigFunction({ const { isServer, dev: isDev, dir: projectDir } = buildContext; const logger = getBuildLogger(userSentryOptions.silent); 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(); @@ -719,13 +719,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 b93d9ea2fc9c..8665b75fc8fb 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; - } - if (basePath) { buildTimeVariables._sentryBasePath = basePath; } diff --git a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts index c457cb75aaf0..8fa1cd446a25 100644 --- a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts +++ b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts @@ -9,9 +9,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; /** @@ -27,7 +34,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`. */ @@ -35,6 +42,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,39 @@ 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) { + showedTunnelRouteHandlerModeLog = true; + getBuildLogger(userSentryOptions.silent).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 (showedUnusedTunnelRouteHandlerWarning || incomingUserNextConfigObject.output === 'export') { + return; + } + + const tunnelRouteHandlerFile = findTunnelRouteHandlerFile(incomingUserNextConfigObject.pageExtensions); + if (!tunnelRouteHandlerFile) { + return; + } + + showedUnusedTunnelRouteHandlerWarning = true; + getBuildLogger(silent).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 b073b4822453..e479fa45bd47 100644 --- a/packages/nextjs/src/edge/index.ts +++ b/packages/nextjs/src/edge/index.ts @@ -25,7 +25,7 @@ import { addHeadersAsAttributes } from '../common/utils/addHeadersAsAttributes'; import { backfillHttpResponseStatusCode } from '../common/utils/backfillHttpResponseStatusCode'; import { backfillHttpServerStatus } from '../common/utils/backfillHttpServerStatus'; import { createLiveRootSpanAdapter } from '../common/utils/liveRootSpanAdapter'; -import { dropMiddlewareTunnelRequests } from '../common/utils/dropMiddlewareTunnelRequests'; +import { dropMiddlewareTunnelRequests, getTunnelRouteIgnoreSpans } from '../common/utils/dropMiddlewareTunnelRequests'; import { maybeForkIsolationScopeForRootSpan } from '../common/utils/forkIsolationScopeForRootSpan'; import { getNormalizedRequestFromAttributes } from '../common/utils/getNormalizedRequestFromAttributes'; import { isBuild } from '../common/utils/isBuild'; @@ -110,6 +110,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 6dea488a7d9d..fc1bc51571a2 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -15,6 +15,7 @@ import { getDefaultIntegrations, httpIntegration, init as nodeInit } from '@sent import { DEBUG_BUILD } from '../common/debug-build'; import { devErrorSymbolicationEventProcessor } from '../common/devErrorSymbolicationEventProcessor'; import { dropReactControlFlowErrorsEventProcessor } from '../common/utils/dropReactControlFlowErrors'; +import { getTunnelRouteIgnoreSpans } from '../common/utils/dropMiddlewareTunnelRequests'; import { isBuild } from '../common/utils/isBuild'; import { isAsyncContextOwnedByCloudflare, @@ -155,7 +156,7 @@ export function init(options: NodeOptions): NodeClient | undefined { ...cloudflareConfig, }; - opts.ignoreSpans = [...(opts.ignoreSpans || []), ...NEXTJS_SERVER_IGNORE_SPANS]; + opts.ignoreSpans = [...(opts.ignoreSpans || []), ...NEXTJS_SERVER_IGNORE_SPANS, ...getTunnelRouteIgnoreSpans()]; if (DEBUG_BUILD && opts.debug) { debug.enable(); 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/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 bab62624c86a..766a05f583c8 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 coreServer from '@sentry/core/server'; -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 { CLIENT_SDK_CONFIG_FILE, @@ -15,6 +18,9 @@ import { } from '../fixtures'; import { materializeFinalNextConfig, materializeFinalWebpackConfig } from '../testUtils'; +const realFs = await vi.importActual('fs'); +const realOs = await vi.importActual('os'); + // Only the plugin factory is stubbed — `resolveOrchestrionRuntimeRequest` must stay real because // the externals handler under test uses it. vi.mock('@sentry/server-utils/orchestrion/webpack', async importOriginal => ({ @@ -822,4 +828,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..14f40a32f7bd --- /dev/null +++ b/packages/nextjs/test/config/withSentryConfig/tunnel.test.ts @@ -0,0 +1,126 @@ +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); + }); + + 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 a635d5d65be5..6339aa23aea4 100644 --- a/packages/nextjs/test/utils/dropMiddlewareTunnelRequests.test.ts +++ b/packages/nextjs/test/utils/dropMiddlewareTunnelRequests.test.ts @@ -1,9 +1,14 @@ +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 { 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 => { @@ -27,6 +32,7 @@ function createMockSpan(): { setAttribute: ReturnType; attributes: beforeEach(() => { globalWithInjectedValues._sentryRewritesTunnelPath = undefined; + globalWithInjectedValues._sentryTunnelRouteMode = undefined; }); describe('dropMiddlewareTunnelRequests', () => { @@ -152,3 +158,90 @@ describe('dropMiddlewareTunnelRequests', () => { }); }); }); + +describe('getTunnelRouteIgnoreSpans', () => { + function isIgnored(attributes: Record): boolean { + return shouldIgnoreSpan({ description: 'POST', op: 'http.server', attributes }, getTunnelRouteIgnoreSpans()); + } + + 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({ 'next.span_type': spanType, 'http.target': 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({ 'next.span_type': 'BaseServer.handleRequest', 'http.target': 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({ 'next.span_type': 'BaseServer.handleRequest', 'http.target': 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({ 'next.span_type': 'BaseServer.handleRequest', 'http.target': '/monitoring/dashboard' })).toBe( + false, + ); + }); + + it('does not ignore an outgoing request with the tunnel path', () => { + globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + + expect( + isIgnored({ 'url.path': '/monitoring', 'http.target': '/monitoring', 'server.address': 'api.example.com' }), + ).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({ 'next.span_type': 'AppRouteRouteHandlers.runHandler', 'next.route': nextRoute })).toBe(true); + }, + ); + + it('does not ignore the route handler span in rewrite mode', () => { + globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + globalWithInjectedValues._sentryTunnelRouteMode = 'rewrite'; + + expect(isIgnored({ 'next.span_type': 'AppRouteRouteHandlers.runHandler', 'next.route': '/sentry-tunnel' })).toBe( + false, + ); + }); + + it('escapes regex characters in the tunnel path', () => { + globalWithInjectedValues._sentryRewritesTunnelPath = '/a.b'; + + expect(isIgnored({ 'next.span_type': 'BaseServer.handleRequest', 'http.target': '/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(); + }); +});