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..2aacb025d0f3 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 @@ -2,8 +2,8 @@ import * as Sentry from '@sentry/nextjs'; 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', + // The tunnel forwards to the DSN host, which is the event proxy here + dsn: 'http://public@localhost:3031/1337', // 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..b07ffb0f4a6b 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 @@ -4,15 +4,15 @@ "private": true, "scripts": { "dev": " next dev", - "build": "_SENTRY_TUNNEL_DESTINATION_OVERRIDE=http://localhost:3031/ next build > .tmp_build_stdout 2> .tmp_build_stderr || (cat .tmp_build_stdout && cat .tmp_build_stderr && exit 1)", + "build": "next build > .tmp_build_stdout 2> .tmp_build_stderr || (cat .tmp_build_stdout && cat .tmp_build_stderr && exit 1)", "clean": "npx rimraf node_modules pnpm-lock.yaml .tmp_dev_server_logs", - "dev:webpack": "_SENTRY_TUNNEL_DESTINATION_OVERRIDE=http://localhost:3031/ next dev --webpack", - "build-webpack": "_SENTRY_TUNNEL_DESTINATION_OVERRIDE=http://localhost:3031/ next build --webpack > .tmp_build_stdout 2> .tmp_build_stderr || (cat .tmp_build_stdout && cat .tmp_build_stderr && exit 1)", - "start": "_SENTRY_TUNNEL_DESTINATION_OVERRIDE=http://localhost:3031/ next start", + "dev:webpack": "next dev --webpack", + "build-webpack": "next build --webpack > .tmp_build_stdout 2> .tmp_build_stderr || (cat .tmp_build_stdout && cat .tmp_build_stderr && exit 1)", + "start": "next start", "lint": "eslint", "test:prod": "TEST_ENV=production playwright test", - "test:dev": "TEST_ENV=development _SENTRY_TUNNEL_DESTINATION_OVERRIDE=http://localhost:3031/ playwright test", - "test:dev-webpack": "TEST_ENV=development-webpack _SENTRY_TUNNEL_DESTINATION_OVERRIDE=http://localhost:3031/ playwright test", + "test:dev": "TEST_ENV=development playwright test", + "test:dev-webpack": "TEST_ENV=development-webpack playwright test", "test:build": "pnpm install && pnpm build", "test:build-webpack": "pnpm install && pnpm build-webpack", "test:build-canary": "pnpm install && pnpm add next@canary && pnpm build", @@ -21,7 +21,7 @@ "test:build-canary-webpack": "pnpm install && pnpm add next@canary && pnpm build-webpack", "test:assert": "pnpm test:prod && pnpm test:dev", "test:assert-webpack": "pnpm test:prod && pnpm test:dev-webpack", - "test:build:cloudflare": "pnpm install && _SENTRY_TUNNEL_DESTINATION_OVERRIDE=http://localhost:3031/ opennextjs-cloudflare build", + "test:build:cloudflare": "pnpm install && opennextjs-cloudflare build", "start:cloudflare": "opennextjs-cloudflare preview" }, "dependencies": { diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/proxy.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/proxy.ts index 28639f60bbe4..0b8645eca9e2 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/proxy.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/proxy.ts @@ -1,7 +1,13 @@ +import * as Sentry from '@sentry/nextjs'; import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; -export async function proxy(_request: NextRequest) { +export async function proxy(request: NextRequest) { + // Stands in for auth middleware, without blocking the other tests. + if (request.headers.get('x-e2e-require-auth') && !Sentry.isTunnelRequest(request)) { + return new NextResponse(null, { status: 401 }); + } + return NextResponse.next(); } 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..59e2f1e6cb93 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 @@ -2,8 +2,8 @@ import * as Sentry from '@sentry/nextjs'; 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', + // The tunnel forwards to the DSN host, which is the event proxy here + dsn: 'http://public@localhost:3031/1337', // 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..59e2f1e6cb93 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 @@ -2,8 +2,8 @@ import * as Sentry from '@sentry/nextjs'; 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', + // The tunnel forwards to the DSN host, which is the event proxy here + dsn: 'http://public@localhost:3031/1337', // 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-dsn-path.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-dsn-path.test.ts new file mode 100644 index 000000000000..2c110b6607ff --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-dsn-path.test.ts @@ -0,0 +1,79 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForError, waitForStreamedSpan, waitForStreamedSpans } from '@sentry-internal/test-utils'; +import type { SerializedStreamedSpan } from '@sentry/core'; +import { getTunnelPath } from '@sentry/core'; + +const TUNNEL_PATH = getTunnelPath('http://public@localhost:3031/1337'); + +test('sends events to the DSN-derived path, served by the app', async ({ page }) => { + const tunnelRequests: string[] = []; + page.on('request', request => { + if (request.method() === 'POST') { + tunnelRequests.push(new URL(request.url()).pathname); + } + }); + + const errorPromise = waitForError('nextjs-16-tunnel', event => { + return event.exception?.values?.[0]?.value === 'Managed tunnel error'; + }); + + await page.goto('/'); + await page.evaluate(() => { + setTimeout(() => { + throw new Error('Managed tunnel error'); + }); + }); + + expect(await errorPromise).toBeDefined(); + expect(tunnelRequests).toContain(TUNNEL_PATH); +}); + +test('does not create server spans for tunnel requests', async ({ page }) => { + const serverSpans: SerializedStreamedSpan[] = []; + void waitForStreamedSpans('nextjs-16-tunnel', spans => { + serverSpans.push(...spans.filter(span => span.is_segment)); + return false; + }); + + const errorPromise = waitForError('nextjs-16-tunnel', event => { + return event.exception?.values?.[0]?.value === 'Managed tunnel span error'; + }); + + await page.goto('/'); + await page.evaluate(() => { + setTimeout(() => { + throw new Error('Managed tunnel span error'); + }); + }); + await errorPromise; + + // Spans flush in order, so a span for the tunnel request would arrive before the one for this request. + const anchorSpanPromise = waitForStreamedSpan( + 'nextjs-16-tunnel', + span => span.is_segment && getSpanOp(span) === 'http.server' && span.name === 'GET /', + ); + await page.request.get('/'); + await anchorSpanPromise; + + const tunnelSpans = serverSpans.filter( + span => span.name.includes(TUNNEL_PATH!) || span.name.includes('sentry-tunnel'), + ); + expect(tunnelSpans).toEqual([]); +}); + +test('lets tunnel requests through auth middleware with `isTunnelRequest`', async ({ request }) => { + const headers = { 'content-type': 'application/x-sentry-envelope', 'x-e2e-require-auth': '1' }; + const data = `${JSON.stringify({ dsn: 'http://public@localhost:3031/1337' })}\n`; + + expect((await request.post(TUNNEL_PATH!, { headers, data })).status()).toBe(200); + expect((await request.post('/notatunnel', { headers, data })).status()).toBe(401); +}); + +test('answers 404 to an envelope POST on any other path', async ({ request }) => { + const response = await request.post('/notatunnel', { + headers: { 'content-type': 'application/x-sentry-envelope' }, + data: `${JSON.stringify({ dsn: 'http://public@localhost:3031/1337' })}\n`, + }); + + expect(response.status()).toBe(404); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/worker.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/worker.ts index 522d8c460dfd..03b360a7f5e4 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/worker.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/worker.ts @@ -5,7 +5,7 @@ import handler from './.open-next/worker.js'; export default Sentry.withSentry( () => ({ environment: 'qa', // dynamic sampling bias to keep transactions - dsn: 'https://public@o12345.ingest.us.sentry.io/67890', + dsn: 'http://public@localhost:3031/1337', // Sends the server spans to the proxy server, so the tests see whether a tunnel request was sampled. tunnel: 'http://localhost:3031/', tracesSampleRate: 1.0, diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/instrumentation-client.ts b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/instrumentation-client.ts index 6dd7cce7b75e..5ff9e9d67e61 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/instrumentation-client.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/instrumentation-client.ts @@ -2,8 +2,9 @@ import * as Sentry from '@sentry/nextjs'; Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions - dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, - tunnel: `http://localhost:3031/`, // proxy server + // The tunnel forwards to the DSN host, so the tunnel variant points it at the event proxy + dsn: process.env.NEXT_PUBLIC_E2E_TUNNEL ? 'http://public@localhost:3031/1337' : process.env.NEXT_PUBLIC_E2E_TEST_DSN, + tunnel: process.env.NEXT_PUBLIC_E2E_TUNNEL ? undefined : `http://localhost:3031/`, // proxy server tracesSampleRate: 1.0, }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/instrumentation.ts b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/instrumentation.ts index 0d67d33d4a94..f85d32b85ca9 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/instrumentation.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/instrumentation.ts @@ -4,8 +4,11 @@ export function register() { if (process.env.NEXT_RUNTIME === 'nodejs' || process.env.NEXT_RUNTIME === 'edge') { Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions - dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, - tunnel: `http://localhost:3031/`, // proxy server + // The tunnel forwards to the DSN host, so the tunnel variant points it at the event proxy + dsn: process.env.NEXT_PUBLIC_E2E_TUNNEL + ? 'http://public@localhost:3031/1337' + : process.env.NEXT_PUBLIC_E2E_TEST_DSN, + tunnel: process.env.NEXT_PUBLIC_E2E_TUNNEL ? undefined : `http://localhost:3031/`, // proxy server tracesSampleRate: 1.0, transportOptions: { // We are doing a lot of events at once in this test diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/middleware.ts b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/middleware.ts index abc565f438b4..374f1e621c2e 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/middleware.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/middleware.ts @@ -15,10 +15,16 @@ export async function middleware(request: NextRequest) { await fetch('http://localhost:3030/'); } + // Stands in for auth middleware, without blocking the other tests. + if (request.headers.has('x-e2e-require-auth') && !Sentry.isTunnelRequest(request)) { + return new NextResponse(null, { status: 401 }); + } + return NextResponse.next(); } // See "Matching Paths" below to learn more export const config = { - matcher: ['/api/endpoint-behind-middleware', '/api/endpoint-behind-faulty-middleware'], + // The tunnel path of the test DSN, and a path next to it for the tunnel variant. + matcher: ['/api/endpoint-behind-middleware', '/api/endpoint-behind-faulty-middleware', '/qpo967', '/notatunnel'], }; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/next.config.js b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/next.config.js index df1b0f5f4147..2961ce220c53 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/next.config.js +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/next.config.js @@ -9,6 +9,7 @@ const nextConfig = { module.exports = withSentryConfig(nextConfig, { debug: true, + tunnelRoute: !!process.env.NEXT_PUBLIC_E2E_TUNNEL, sourcemaps: { deleteSourcemapsAfterUpload: true, }, diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/package.json b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/package.json index 022d28be1c02..e77cccfaf8fa 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/package.json @@ -11,7 +11,9 @@ "test:build": "pnpm install && pnpm build", "test:test-build": "pnpm ts-node --script-mode assert-build.ts", "test:build-15": "pnpm install && pnpm add next@15 && pnpm add react@latest && pnpm add react-dom@latest && pnpm build", - "test:assert": "pnpm test:test-build && pnpm test:prod && pnpm test:dev" + "test:assert": "pnpm test:test-build && pnpm test:prod && pnpm test:dev", + "test:build:tunnel": "pnpm install && NEXT_PUBLIC_E2E_TUNNEL=1 pnpm build", + "test:assert:tunnel": "NEXT_PUBLIC_E2E_TUNNEL=1 pnpm test:prod tunnel-dsn-path" }, "dependencies": { "@sentry/nextjs": "file:../../packed/sentry-nextjs-packed.tgz", @@ -37,6 +39,11 @@ { "build-command": "pnpm test:build-15", "label": "nextjs-pages-dir (next@15)" + }, + { + "build-command": "pnpm test:build:tunnel", + "assert-command": "pnpm test:assert:tunnel", + "label": "nextjs-pages-dir (tunnel)" } ], "optionalVariants": [] diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/tunnel-dsn-path.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/tunnel-dsn-path.test.ts new file mode 100644 index 000000000000..cc6b54f6b15f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/tunnel-dsn-path.test.ts @@ -0,0 +1,81 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForError, waitForStreamedSpan, waitForStreamedSpans } from '@sentry-internal/test-utils'; +import type { SerializedStreamedSpan } from '@sentry/core'; +import { getTunnelPath } from '@sentry/core'; + +const TUNNEL_PATH = getTunnelPath('http://public@localhost:3031/1337'); + +test.skip(!process.env.NEXT_PUBLIC_E2E_TUNNEL, 'Only runs with `tunnelRoute`'); + +test('sends events to the DSN-derived path, served by the app', async ({ page }) => { + const tunnelRequests: string[] = []; + page.on('request', request => { + if (request.method() === 'POST') { + tunnelRequests.push(new URL(request.url()).pathname); + } + }); + + const errorPromise = waitForError('nextjs-pages-dir', event => { + return event.exception?.values?.[0]?.value === 'Managed tunnel error'; + }); + + await page.goto('/'); + await page.evaluate(() => { + setTimeout(() => { + throw new Error('Managed tunnel error'); + }); + }); + + expect(await errorPromise).toBeDefined(); + expect(tunnelRequests).toContain(TUNNEL_PATH); +}); + +test('does not create server spans for tunnel requests', async ({ page }) => { + const serverSpans: SerializedStreamedSpan[] = []; + void waitForStreamedSpans('nextjs-pages-dir', spans => { + serverSpans.push(...spans.filter(span => span.is_segment)); + return false; + }); + + const errorPromise = waitForError('nextjs-pages-dir', event => { + return event.exception?.values?.[0]?.value === 'Managed tunnel span error'; + }); + + await page.goto('/'); + await page.evaluate(() => { + setTimeout(() => { + throw new Error('Managed tunnel span error'); + }); + }); + await errorPromise; + + // Spans flush in order, so a span for the tunnel request would arrive before the one for this request. + const anchorSpanPromise = waitForStreamedSpan( + 'nextjs-pages-dir', + span => span.is_segment && getSpanOp(span) === 'http.server' && span.name === 'GET /', + ); + await page.request.get('/'); + await anchorSpanPromise; + + const tunnelSpans = serverSpans.filter( + span => span.name.includes(TUNNEL_PATH!) || span.name.includes('sentry-tunnel'), + ); + expect(tunnelSpans).toEqual([]); +}); + +test('lets tunnel requests through auth middleware with `isTunnelRequest`', async ({ request }) => { + const headers = { 'content-type': 'application/x-sentry-envelope', 'x-e2e-require-auth': '1' }; + const data = `${JSON.stringify({ dsn: 'http://public@localhost:3031/1337' })}\n`; + + expect((await request.post(TUNNEL_PATH!, { headers, data })).status()).toBe(200); + expect((await request.post('/notatunnel', { headers, data })).status()).toBe(401); +}); + +test('answers 404 to an envelope POST on any other path', async ({ request }) => { + const response = await request.post('/notatunnel', { + headers: { 'content-type': 'application/x-sentry-envelope' }, + data: `${JSON.stringify({ dsn: 'http://public@localhost:3031/1337' })}\n`, + }); + + expect(response.status()).toBe(404); +}); diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index b3ce9965ea31..738b0e894e96 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" @@ -73,6 +78,13 @@ "default": "./build/orchestrion-runtime/*.js" } }, + "typesVersions": { + "*": { + "tunnel": [ + "build/types/tunnel/index.d.ts" + ] + } + }, "publishConfig": { "access": "public" }, 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..ba6b187ad7fe 100644 --- a/packages/nextjs/src/client/tunnelRoute.ts +++ b/packages/nextjs/src/client/tunnelRoute.ts @@ -1,33 +1,12 @@ -import { debug, dsnFromString, GLOBAL_OBJ } from '@sentry/core'; import type { BrowserOptions } from '@sentry/react'; -import { DEBUG_BUILD } from '../common/debug-build'; - -const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { - _sentryRewritesTunnelPath?: string; -}; +import { getInjectedManagedTunnelOptions } from '../common/managedTunnelRoute'; /** * Applies the `tunnel` option to the Next.js SDK options based on `withSentryConfig`'s `tunnelRoute` option. */ export function applyTunnelRouteOption(options: BrowserOptions): void { - const tunnelRouteOption = process.env._sentryRewritesTunnelPath || globalWithInjectedValues._sentryRewritesTunnelPath; - if (tunnelRouteOption && options.dsn) { - const dsnComponents = dsnFromString(options.dsn); - if (!dsnComponents) { - return; - } - const sentrySaasDsnMatch = dsnComponents.host.match(/^o(\d+)\.ingest(?:\.([a-z]{2}))?\.sentry\.io$/); - if (sentrySaasDsnMatch) { - const orgId = sentrySaasDsnMatch[1]; - const regionCode = sentrySaasDsnMatch[2]; - let tunnelPath = `${tunnelRouteOption}?o=${orgId}&p=${dsnComponents.projectId}`; - if (regionCode) { - tunnelPath += `&r=${regionCode}`; - } - options.tunnel = tunnelPath; - DEBUG_BUILD && debug.log(`Tunneling events to "${tunnelPath}"`); - } else { - DEBUG_BUILD && debug.warn('Provided DSN is not a Sentry SaaS DSN. Will not tunnel events.'); - } + const tunnelOptions = options.tunnel === undefined && getInjectedManagedTunnelOptions(); + if (tunnelOptions) { + options.tunnel = tunnelOptions; } } diff --git a/packages/nextjs/src/cloudflare/index.ts b/packages/nextjs/src/cloudflare/index.ts index c97b7298a143..283d01afcfbb 100644 --- a/packages/nextjs/src/cloudflare/index.ts +++ b/packages/nextjs/src/cloudflare/index.ts @@ -11,6 +11,7 @@ import { setOpenTelemetryContextAsyncContextStrategy, } from '@sentry/opentelemetry'; import { ATTR_NEXT_SPAN_TYPE } from '../common/nextSpanAttributes'; +import { getInjectedManagedTunnelOptions, getManagedTunnelSpanFilter } from '../common/managedTunnelRoute'; import { isTunnelRouteSpan } from '../common/utils/dropMiddlewareTunnelRequests'; import { dropReactControlFlowErrorsEventProcessor } from '../common/utils/dropReactControlFlowErrors'; import { markAsyncContextOwnedByNextjsCloudflare } from '../common/utils/responseEnd'; @@ -68,9 +69,16 @@ export const withSentry: typeof withSentryCloudflare = (optionsCallback, handler return withSentryCloudflare(env => { const options: CloudflareOptions = { enableOpenTelemetrySetup: true, ...optionsCallback(env) }; + if (options.tunnel === undefined) { + options.tunnel = getInjectedManagedTunnelOptions(); + } + + const managedTunnelSpanFilter = + typeof options.tunnel === 'object' && getManagedTunnelSpanFilter(options.tunnel, options.dsn); options.ignoreSpans = [ ...(options.ignoreSpans || []), ...NEXTJS_SERVER_IGNORE_SPANS, + ...(managedTunnelSpanFilter ? [managedTunnelSpanFilter] : []), // The request spans of Next.js repeat the request span of `withSentry`. An ignored span does not become active, // so the other Next.js spans become children of the request span of `withSentry`, which then gets the route // from their `next.route` and its status from the response. diff --git a/packages/nextjs/src/common/index.ts b/packages/nextjs/src/common/index.ts index b9a652522349..bfe258d111af 100644 --- a/packages/nextjs/src/common/index.ts +++ b/packages/nextjs/src/common/index.ts @@ -12,3 +12,4 @@ export { wrapPageComponentWithSentry } from './pages-router-instrumentation/wrap export { wrapGenerationFunctionWithSentry } from './wrapGenerationFunctionWithSentry'; export { withServerActionInstrumentation } from './withServerActionInstrumentation'; export { captureRequestError } from './captureRequestError'; +export { isTunnelRequest } from './isTunnelRequest'; diff --git a/packages/nextjs/src/common/isTunnelRequest.ts b/packages/nextjs/src/common/isTunnelRequest.ts new file mode 100644 index 000000000000..0ffdbce08b85 --- /dev/null +++ b/packages/nextjs/src/common/isTunnelRequest.ts @@ -0,0 +1,20 @@ +import type { TunnelRequestLike } from '@sentry/core'; +import { getClient, isTunnelRequest as isManagedTunnelRequest } from '@sentry/core'; +import { getInjectedManagedTunnelOptions } from './managedTunnelRoute'; + +/** + * Returns true if the request targets the tunnel `tunnelRoute` serves, so middleware that blocks unauthenticated + * requests can let it through: + * + * ```ts + * if (Sentry.isTunnelRequest(request)) { + * return NextResponse.next(); + * } + * ``` + */ +export function isTunnelRequest(request: TunnelRequestLike): boolean { + // On OpenNext the client is set up before the injected options are, so they are read here as well. + const tunnelOptions = getClient()?.getOptions()._managedTunnel ?? getInjectedManagedTunnelOptions(); + + return !!tunnelOptions && isManagedTunnelRequest(request, tunnelOptions); +} diff --git a/packages/nextjs/src/common/managedTunnelRoute.ts b/packages/nextjs/src/common/managedTunnelRoute.ts new file mode 100644 index 000000000000..c9ca011548c8 --- /dev/null +++ b/packages/nextjs/src/common/managedTunnelRoute.ts @@ -0,0 +1,65 @@ +import { HTTP_TARGET } from '@sentry/conventions/attributes'; +import type { Options, TunnelOptions } from '@sentry/core'; +import { escapeStringForRegex, getTunnelPaths, GLOBAL_OBJ } from '@sentry/core'; + +/** + * The route the managed tunnel rewrite points at. The user's `app/sentry-tunnel/route.ts` answers it. + */ +export const MANAGED_TUNNEL_ROUTE = '/sentry-tunnel'; + +/** + * The query parameter that carries the original request path through the rewrite. + */ +export const MANAGED_TUNNEL_PATH_PARAM = 'p'; + +/** + * The tunnel options `withSentryConfig` injects for `tunnelRoute`. + */ +export type ManagedTunnelOptions = Pick & { basePath: string }; + +const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { + _sentryManagedTunnel?: ManagedTunnelOptions; +}; + +/** + * The tunnel options `withSentryConfig` injected, if `tunnelRoute` is set. + * + * The client bundle reads them from `env`. The server keeps `@sentry/nextjs` external, where `env` isn't inlined, + * so it reads the injected global instead. + */ +export function getInjectedManagedTunnelOptions(): ManagedTunnelOptions | undefined { + if (globalWithInjectedValues._sentryManagedTunnel) { + return globalWithInjectedValues._sentryManagedTunnel; + } + + const injected = process.env._sentryManagedTunnel; + + return injected ? (JSON.parse(injected) as ManagedTunnelOptions) : undefined; +} + +/** + * Drops Next.js' own spans for managed tunnel requests and for the tunnel route. + * + * Next.js sets the request path as `http.target`, not `url.path`, and streamed spans are only matched against + * `ignoreSpans` when they start, so core's `url.path` filter never sees them. + */ +export function getManagedTunnelSpanFilter( + tunnelOptions: TunnelOptions, + dsn: string | undefined, +): NonNullable[number] | undefined { + // `init` runs this before the client exists, so the own DSN is added here. + const paths = getTunnelPaths({ + ...tunnelOptions, + allowedDsns: [...(dsn ? [dsn] : []), ...(tunnelOptions.allowedDsns ?? [])], + }); + if (!paths.length) { + return undefined; + } + + const basePath = escapeStringForRegex(tunnelOptions.basePath?.replace(/\/$/, '') ?? ''); + const routes = [...paths, MANAGED_TUNNEL_ROUTE].map(escapeStringForRegex).join('|'); + + // Next.js still emits the old semantic conventions. + // oxlint-disable-next-line sdk/no-regexp-constructor, typescript/no-deprecated -- escaped config values, see above + return { attributes: { [HTTP_TARGET]: new RegExp(`^${basePath}(?:${routes})/?(?:\\?|$)`) } }; +} diff --git a/packages/nextjs/src/common/sentryTunnelRouteHandler.ts b/packages/nextjs/src/common/sentryTunnelRouteHandler.ts new file mode 100644 index 000000000000..b541eed68452 --- /dev/null +++ b/packages/nextjs/src/common/sentryTunnelRouteHandler.ts @@ -0,0 +1,30 @@ +import type { TunnelOptions } from '@sentry/core'; +import { getClient, handleTunnelRequest, isTunnelRequest } from '@sentry/core'; +import { getInjectedManagedTunnelOptions, MANAGED_TUNNEL_PATH_PARAM, MANAGED_TUNNEL_ROUTE } from './managedTunnelRoute'; + +/** + * Answers managed tunnel requests in `app/sentry-tunnel/route.ts`: + * + * ```ts + * export { POST } from '@sentry/nextjs/tunnel'; + * ``` + */ +export function sentryTunnelRouteHandler(request: Request): Promise { + // On OpenNext the client is set up before the injected options are, so they are read here as well. + const tunnelOptions: TunnelOptions | undefined = + getClient()?.getOptions()._managedTunnel ?? getInjectedManagedTunnelOptions(); + const basePath = tunnelOptions?.basePath?.replace(/\/$/, '') ?? ''; + const url = new URL(request.url); + + // The rewrite matches any envelope POST with a single path segment, so the path must still match the DSN. + const requestedPath = + url.pathname === `${basePath}${MANAGED_TUNNEL_ROUTE}` + ? `${basePath}/${url.searchParams.get(MANAGED_TUNNEL_PATH_PARAM) ?? ''}` + : url.pathname; + + if (!tunnelOptions || !isTunnelRequest({ method: request.method, url: requestedPath }, tunnelOptions)) { + return Promise.resolve(new Response(null, { status: 404 })); + } + + return handleTunnelRequest({ request, allowedDsns: tunnelOptions.allowedDsns }); +} diff --git a/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts b/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts index 31064377e4ba..3d92df95f9e7 100644 --- a/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts +++ b/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts @@ -1,14 +1,10 @@ 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 { getClient, isSentryRequestUrl, isTunnelRequest, type Span } from '@sentry/core'; +import { getInjectedManagedTunnelOptions, MANAGED_TUNNEL_ROUTE } from '../managedTunnelRoute'; import { ATTR_NEXT_SPAN_TYPE } from '../nextSpanAttributes'; -import { isPathnameUnderSentryTunnelRoute } from './tunnelPathnameMatch'; import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION } from '../span-attributes-with-logic-attached'; -const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { - _sentryRewritesTunnelPath?: string; -}; - /** * Drops spans for tunnel requests from middleware, fetch instrumentation, or BaseServer.handleRequest. * This catches: @@ -63,22 +59,26 @@ function isSentryRequestSpan(attrs: RawAttributes>): boo * Checks if a span's HTTP target matches the tunnel route. */ export function isTunnelRouteSpan(spanAttributes: Record): boolean { - const tunnelPath = globalWithInjectedValues._sentryRewritesTunnelPath || process.env._sentryRewritesTunnelPath; - if (!tunnelPath) { - return false; - } - // `http.target` is only read for spans from a user's own OpenTelemetry instrumentation, which // still emits the old semantic conventions; the SDK sets `url.path`. // eslint-disable-next-line typescript/no-deprecated const target = spanAttributes[URL_PATH] ?? spanAttributes[HTTP_TARGET]; + if (typeof target !== 'string') { + return false; + } - if (typeof target === 'string') { - // Extract pathname from the target (e.g., "/tunnel?o=123&p=456" -> "/tunnel") - const pathname = target.split('?')[0] || ''; + // Extract pathname from the target (e.g., "/tunnel?o=123&p=456" -> "/tunnel") + const pathname = target.split('?')[0] || ''; - return isPathnameUnderSentryTunnelRoute(pathname, tunnelPath); + const managedTunnel = getClient()?.getOptions()._managedTunnel ?? getInjectedManagedTunnelOptions(); + if (!managedTunnel) { + return false; } - return false; + const basePath = managedTunnel.basePath?.replace(/\/$/, '') ?? ''; + + return ( + pathname === `${basePath}${MANAGED_TUNNEL_ROUTE}` || + isTunnelRequest({ method: 'POST', url: pathname }, managedTunnel) + ); } diff --git a/packages/nextjs/src/common/utils/tunnelPathnameMatch.ts b/packages/nextjs/src/common/utils/tunnelPathnameMatch.ts deleted file mode 100644 index 9f107d33636c..000000000000 --- a/packages/nextjs/src/common/utils/tunnelPathnameMatch.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Returns true when `pathname` is exactly the Sentry tunnel route or a sub-path - * (`tunnelPath` + `/...`). A plain `startsWith(tunnelPath)` is unsafe: e.g. tunnel - * `/api/t` must not match `/api/things`. - */ -export function isPathnameUnderSentryTunnelRoute(pathname: string, tunnelPath: string): boolean { - return pathname === tunnelPath || pathname.startsWith(`${tunnelPath}/`); -} diff --git a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts index 743f4abc35a8..8f87e7750b30 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 { getManagedTunnelOptions } 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, + managedTunnel: getManagedTunnelOptions(userNextConfig, userSentryOptions?.tunnelRoute), vercelCronsConfig, }); diff --git a/packages/nextjs/src/config/turbopack/generateValueInjectionRules.ts b/packages/nextjs/src/config/turbopack/generateValueInjectionRules.ts index 8bca01cb2ce4..5bc3821dad27 100644 --- a/packages/nextjs/src/config/turbopack/generateValueInjectionRules.ts +++ b/packages/nextjs/src/config/turbopack/generateValueInjectionRules.ts @@ -1,4 +1,5 @@ import * as path from 'path'; +import type { ManagedTunnelOptions } from '../../common/managedTunnelRoute'; import type { VercelCronsConfig } from '../../common/types'; import type { RouteManifest } from '../manifest/types'; import type { JSONValue, TurbopackMatcherWithRule } from '../types'; @@ -10,12 +11,12 @@ import { getPackageModules, supportsTurbopackRuleCondition } from '../util'; export function generateValueInjectionRules({ routeManifest, nextJsVersion, - tunnelPath, + managedTunnel, vercelCronsConfig, }: { routeManifest?: RouteManifest; nextJsVersion?: string; - tunnelPath?: string; + managedTunnel?: ManagedTunnelOptions; vercelCronsConfig?: VercelCronsConfig; }): TurbopackMatcherWithRule[] { const rules: TurbopackMatcherWithRule[] = []; @@ -32,9 +33,8 @@ export function generateValueInjectionRules({ clientValues._sentryRouteManifest = JSON.stringify(routeManifest); } - // Inject tunnel route path for both client and server - if (tunnelPath) { - isomorphicValues._sentryRewritesTunnelPath = tunnelPath; + if (managedTunnel) { + isomorphicValues._sentryManagedTunnel = managedTunnel; } // Inject Vercel crons config for server-side cron auto-instrumentation diff --git a/packages/nextjs/src/config/types.ts b/packages/nextjs/src/config/types.ts index f1d3a8441bcb..a9149cad1621 100644 --- a/packages/nextjs/src/config/types.ts +++ b/packages/nextjs/src/config/types.ts @@ -4,6 +4,7 @@ import type { ModuleMetadata, ModuleMetadataCallback, ReactComponentAnnotationOptions, + TunnelOptions, } from '@sentry/core'; // The first argument to `withSentryConfig` (which is the user's next config). @@ -266,19 +267,27 @@ export type SentryBuildOptions = Omit< widenClientFileUpload?: boolean; /** - * Tunnel Sentry requests through this route on the Next.js server, to circumvent ad-blockers blocking Sentry events - * from being sent. This option should be a path (for example: '/error-monitoring'). + * Tunnel Sentry requests through your Next.js server, to circumvent ad-blockers blocking Sentry events from being + * sent. Your server forwards only the envelope, so cookies and auth headers never leave it. * - * - 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 + * - Pass `true` to derive an ad-blocker-resistant path from the DSN, the same on the server and in the browser + * - Pass a string path (e.g., '/monitoring') to use a fixed route + * - Pass `{ path, allowedDsns }` to also forward envelopes for other DSNs, e.g. when the browser uses a different + * DSN than the server. The server's own DSN is always forwarded. + * + * The tunnel is answered by `app/sentry-tunnel/route.ts`, which the build creates if it is missing: + * + * ```ts + * export { POST } from '@sentry/nextjs/tunnel'; + * ``` * * 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`. + * middleware redirects or blocks unauthenticated requests, let them through with `isTunnelRequest(request)` from + * `@sentry/nextjs`. Don't exempt requests by their `content-type` header, since any client can set it. * * NOTE: This feature only works with Next.js 11+ */ - tunnelRoute?: string | boolean; + tunnelRoute?: string | boolean | Pick; /** * Suppress the warning about the `onRouterTransitionStart` hook. diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts index 833965b69518..6cf567b300df 100644 --- a/packages/nextjs/src/config/webpack.ts +++ b/packages/nextjs/src/config/webpack.ts @@ -28,6 +28,7 @@ import type { import { sentryOrchestrionWebpackPlugin } from '@sentry/server-utils/orchestrion/webpack'; import { getNextjsVersion, getPackageModules } from './util'; import type { VercelCronsConfigResult } from './withSentryConfig/getFinalConfigObjectUtils'; +import { getManagedTunnelOptions } 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. @@ -719,19 +720,12 @@ 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, - // 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 // Only inject if release creation is not explicitly disabled (to maintain build determinism) SENTRY_RELEASE: releaseToInject && !buildContext.dev ? { id: releaseToInject } : undefined, _sentryBasePath: buildContext.dev ? userNextConfig.basePath : undefined, + _sentryManagedTunnel: getManagedTunnelOptions(userNextConfig, userSentryOptions.tunnelRoute), // This is used to determine version-based dev-symbolication behavior _sentryNextJsVersion: nextJsVersion, }; diff --git a/packages/nextjs/src/config/withSentryConfig/buildTime.ts b/packages/nextjs/src/config/withSentryConfig/buildTime.ts index b93d9ea2fc9c..fbfddce845d4 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 { getManagedTunnelOptions } from './tunnel'; /** * Adds Sentry-related build-time variables to `nextConfig.env`. @@ -23,12 +24,7 @@ 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 managedTunnel = getManagedTunnelOptions(userNextConfig, userSentryOptions.tunnelRoute); const buildTimeVariables: Record = { // Make sure that if we have a windows path, the backslashes are interpreted as such (rather than as escape @@ -49,8 +45,8 @@ export function setUpBuildTimeVariables( buildTimeVariables._experimentalThirdPartyOriginStackFrames = 'true'; } - if (rewritesTunnelPath) { - buildTimeVariables._sentryRewritesTunnelPath = rewritesTunnelPath; + if (managedTunnel) { + buildTimeVariables._sentryManagedTunnel = JSON.stringify(managedTunnel); } if (basePath) { diff --git a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts index c457cb75aaf0..b50ea78c9c85 100644 --- a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts +++ b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts @@ -2,6 +2,7 @@ import { debug, isMatchingPattern, parseSemver } from '@sentry/core'; import { getSentryRelease } from '@sentry/node'; import * as fs from 'fs'; import * as path from 'path'; +import { MANAGED_TUNNEL_ROUTE } from '../../common/managedTunnelRoute'; import type { VercelCronsConfig } from '../../common/types'; import { getBuildLogger } from '../buildLogger'; import { createRouteManifest } from '../manifest/createRouteManifest'; @@ -9,7 +10,7 @@ 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 { ensureTunnelRouteFile, getManagedTunnelOptions, setUpTunnelRewriteRules } from './tunnel'; let showedExportModeTunnelWarning = false; let showedExperimentalBuildModeWarning = false; @@ -29,7 +30,7 @@ export function resolveReleaseName(userSentryOptions: SentryBuildOptions): strin /** * Applies tunnel-route rewrites, if configured. * - * Note: this mutates `userSentryOptions` (to store the resolved tunnel route) and `incomingUserNextConfigObject`. + * Note: this mutates `incomingUserNextConfigObject`. */ export function maybeSetUpTunnelRouteRewriteRules( incomingUserNextConfigObject: NextConfigObject, @@ -49,11 +50,26 @@ export function maybeSetUpTunnelRouteRewriteRules( return; } - // Update the global options object to use the resolved value everywhere - const resolvedTunnelRoute = resolveTunnelRoute(userSentryOptions.tunnelRoute); - userSentryOptions.tunnelRoute = resolvedTunnelRoute || undefined; + const tunnelOptions = getManagedTunnelOptions(incomingUserNextConfigObject, userSentryOptions.tunnelRoute); + if (!tunnelOptions) { + return; + } + + try { + const createdRouteFile = ensureTunnelRouteFile(); + if (createdRouteFile) { + getBuildLogger(userSentryOptions.silent).log( + `[@sentry/nextjs] Created ${path.relative(process.cwd(), createdRouteFile)} to answer \`tunnelRoute\`. Commit it with your app.`, + ); + } + } catch (error) { + getBuildLogger(userSentryOptions.silent).warn( + `[@sentry/nextjs] Could not create the tunnel route. Add \`app${MANAGED_TUNNEL_ROUTE}/route.ts\` with \`export { POST } from '@sentry/nextjs/tunnel';\`.`, + error, + ); + } - setUpTunnelRewriteRules(incomingUserNextConfigObject, resolvedTunnelRoute); + setUpTunnelRewriteRules(incomingUserNextConfigObject, tunnelOptions); } /** diff --git a/packages/nextjs/src/config/withSentryConfig/tunnel.ts b/packages/nextjs/src/config/withSentryConfig/tunnel.ts index 78b050a525bd..6840aca84611 100644 --- a/packages/nextjs/src/config/withSentryConfig/tunnel.ts +++ b/packages/nextjs/src/config/withSentryConfig/tunnel.ts @@ -1,117 +1,115 @@ -import { _INTERNAL_safeMathRandom } from '@sentry/core'; -import type { NextConfigObject } from '../types'; +import type { ManagedTunnelOptions } from '../../common/managedTunnelRoute'; +import { MANAGED_TUNNEL_PATH_PARAM, MANAGED_TUNNEL_ROUTE } from '../../common/managedTunnelRoute'; +import * as fs from 'fs'; +import * as path from 'path'; +import type { NextConfigObject, SentryBuildOptions } from '../types'; /** - * Generates a random tunnel route path that's less likely to be blocked by ad-blockers - */ -function generateRandomTunnelRoute(): string { - // Generate a random 8-character alphanumeric string - const randomString = _INTERNAL_safeMathRandom().toString(36).substring(2, 10); - return `/${randomString}`; -} - -/** - * Resolves the tunnel route based on the user's configuration and the environment. - * @param tunnelRoute - The user-provided tunnel route option + * The managed tunnel options for `tunnelRoute`, or `undefined` when the tunnel is off. + * + * `true` derives the path from the DSN at runtime. A string keeps that fixed path. */ -export function resolveTunnelRoute(tunnelRoute: string | true): string { - if (process.env.__SENTRY_TUNNEL_ROUTE__) { - // Reuse cached value from previous build (server/client) - return process.env.__SENTRY_TUNNEL_ROUTE__; +export function getManagedTunnelOptions( + userNextConfig: NextConfigObject, + tunnelRoute: SentryBuildOptions['tunnelRoute'], +): ManagedTunnelOptions | undefined { + if (!tunnelRoute || userNextConfig.output === 'export') { + return undefined; } - const resolvedTunnelRoute = typeof tunnelRoute === 'string' ? tunnelRoute : generateRandomTunnelRoute(); + const { path, allowedDsns } = + typeof tunnelRoute === 'object' ? tunnelRoute : { path: typeof tunnelRoute === 'string' ? tunnelRoute : undefined }; - // Cache for subsequent builds (only during build time) - // Turbopack runs the config twice, so we need a shared context to avoid generating a new tunnel route for each build. - // env works well here - // https://linear.app/getsentry/issue/JS-549/adblock-plus-blocking-requests-to-sentry-and-monitoring-tunnel - if (resolvedTunnelRoute) { - process.env.__SENTRY_TUNNEL_ROUTE__ = resolvedTunnelRoute; - } - - return resolvedTunnelRoute; + return { + basePath: userNextConfig.basePath ?? '', + ...(path && { path }), + ...(allowedDsns && { allowedDsns }), + }; } /** - * Injects rewrite rules into the Next.js config provided by the user to tunnel - * requests from the `tunnelPath` to Sentry. + * Rewrites tunnel requests to the app's own tunnel route, in the same process. * - * See https://nextjs.org/docs/api-reference/next.config.js/rewrites. + * With a DSN-derived path the config doesn't know the path, since it doesn't know the DSN. The rewrite matches any + * single-segment envelope POST instead, and the route handler checks the path against the DSN. */ -export function setUpTunnelRewriteRules(userNextConfig: NextConfigObject, tunnelPath: string): void { +export function setUpTunnelRewriteRules(userNextConfig: NextConfigObject, tunnelOptions: ManagedTunnelOptions): void { const originalRewrites = userNextConfig.rewrites; - // Allow overriding the tunnel destination for E2E tests via environment variable - const destinationOverride = process.env._SENTRY_TUNNEL_DESTINATION_OVERRIDE; - // Make sure destinations are statically defined at build time - const destination = destinationOverride || 'https://o:orgid.ingest.sentry.io/api/:projectid/envelope/?hsts=0'; - const destinationWithRegion = - destinationOverride || 'https://o:orgid.ingest.:region.sentry.io/api/:projectid/envelope/?hsts=0'; + const tunnelRewrite = tunnelOptions.path + ? { + source: `${tunnelOptions.path}(/?)`, + destination: `${MANAGED_TUNNEL_ROUTE}?${MANAGED_TUNNEL_PATH_PARAM}=${encodeURIComponent(tunnelOptions.path.replace(/^\//, ''))}`, + } + : { + source: '/:sentryTunnelPath', + has: [{ type: 'header', key: 'content-type', value: 'application/x-sentry-envelope' }], + destination: `${MANAGED_TUNNEL_ROUTE}?${MANAGED_TUNNEL_PATH_PARAM}=:sentryTunnelPath`, + }; // This function doesn't take any arguments at the time of writing but we future-proof // here in case Next.js ever decides to pass some userNextConfig.rewrites = async (...args: unknown[]) => { - const tunnelRouteRewrite = { - // Matched rewrite routes will look like the following: `[tunnelPath]?o=[orgid]&p=[projectid]` - // Nextjs will automatically convert `source` into a regex for us - source: `${tunnelPath}(/?)`, - has: [ - { - type: 'query', - key: 'o', // short for orgId - we keep it short so matching is harder for ad-blockers - value: '(?\\d*)', - }, - { - type: 'query', - key: 'p', // short for projectId - we keep it short so matching is harder for ad-blockers - value: '(?\\d*)', - }, - ], - destination, - }; - - const tunnelRouteRewriteWithRegion = { - // Matched rewrite routes will look like the following: `[tunnelPath]?o=[orgid]&p=[projectid]?r=[region]` - // Nextjs will automatically convert `source` into a regex for us - source: `${tunnelPath}(/?)`, - has: [ - { - type: 'query', - key: 'o', // short for orgId - we keep it short so matching is harder for ad-blockers - value: '(?\\d*)', - }, - { - type: 'query', - key: 'p', // short for projectId - we keep it short so matching is harder for ad-blockers - value: '(?\\d*)', - }, - { - type: 'query', - key: 'r', // short for region - we keep it short so matching is harder for ad-blockers - value: '(?[a-z]{2})', - }, - ], - destination: destinationWithRegion, - }; - - // Order of these is important, they get applied first to last. - const newRewrites = [tunnelRouteRewriteWithRegion, tunnelRouteRewrite]; - if (typeof originalRewrites !== 'function') { - return newRewrites; + return { beforeFiles: [tunnelRewrite], afterFiles: [], fallback: [] }; } // @ts-expect-error Expected 0 arguments but got 1 - this is from the future-proofing mentioned above, so we don't care about it const originalRewritesResult = await originalRewrites(...args); if (Array.isArray(originalRewritesResult)) { - return [...newRewrites, ...originalRewritesResult]; - } else { - return { - ...originalRewritesResult, - beforeFiles: [...newRewrites, ...(originalRewritesResult.beforeFiles || [])], - }; + return { beforeFiles: [tunnelRewrite], afterFiles: originalRewritesResult, fallback: [] }; } + + return { + ...originalRewritesResult, + beforeFiles: [tunnelRewrite, ...(originalRewritesResult.beforeFiles || [])], + }; }; } + +const MANAGED_TUNNEL_ROUTE_FILE_CONTENTS = `// Answers Sentry's tunnel (\`tunnelRoute\`). Created by \`withSentryConfig\`, commit it with your app. +export { POST } from '@sentry/nextjs/tunnel'; +`; + +/** + * Creates the route the tunnel rewrite points at, unless the app already has it. + * + * Next.js only routes files it finds in the app directory, and a route created after it scanned them is missed, + * so the file is written before the build or dev server starts and kept in place. + * + * @returns The path of the file it created, if any. + */ +export function ensureTunnelRouteFile(projectDir: string = process.cwd()): string | undefined { + const appDir = resolveAppDir(projectDir); + const routeDir = path.join(appDir, MANAGED_TUNNEL_ROUTE); + const hasRouteFile = ['ts', 'tsx', 'js', 'jsx', 'mjs'].some(extension => + fs.existsSync(path.join(routeDir, `route.${extension}`)), + ); + if (hasRouteFile) { + return undefined; + } + + const extension = fs.existsSync(path.join(projectDir, 'tsconfig.json')) ? 'ts' : 'js'; + const routeFile = path.join(routeDir, `route.${extension}`); + fs.mkdirSync(routeDir, { recursive: true }); + fs.writeFileSync(routeFile, MANAGED_TUNNEL_ROUTE_FILE_CONTENTS); + + return routeFile; +} + +/** + * Next.js only reads `src/app` and `src/pages` when neither `app` nor `pages` exists in the project root. + */ +function resolveAppDir(projectDir: string): string { + const appDir = path.join(projectDir, 'app'); + if (isDirectory(appDir) || isDirectory(path.join(projectDir, 'pages'))) { + return appDir; + } + + return isDirectory(path.join(projectDir, 'src')) ? path.join(projectDir, 'src', 'app') : appDir; +} + +function isDirectory(dir: string): boolean { + return fs.existsSync(dir) && fs.lstatSync(dir).isDirectory(); +} diff --git a/packages/nextjs/src/edge/index.ts b/packages/nextjs/src/edge/index.ts index b073b4822453..fa5969d31501 100644 --- a/packages/nextjs/src/edge/index.ts +++ b/packages/nextjs/src/edge/index.ts @@ -16,6 +16,7 @@ import { import type { VercelEdgeOptions } from '@sentry/vercel-edge'; import { getDefaultIntegrations, init as vercelEdgeInit } from '@sentry/vercel-edge'; import { DEBUG_BUILD } from '../common/debug-build'; +import { getInjectedManagedTunnelOptions, getManagedTunnelSpanFilter } from '../common/managedTunnelRoute'; import { ATTR_NEXT_SPAN_NAME, ATTR_NEXT_SPAN_TYPE } from '../common/nextSpanAttributes'; import { ATTR_NEXT_PAGES_API_ROUTE_TYPE, @@ -66,7 +67,6 @@ export type EdgeOptions = VercelEdgeOptions; const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryRewriteFramesDistDir?: string; _sentryRelease?: string; - _sentryRewritesTunnelPath?: string; }; /** Inits the Sentry NextJS SDK on the Edge Runtime. */ @@ -107,6 +107,15 @@ export function init(options: VercelEdgeOptions = {}): void { ...(isRunningOnCloudflare && { runtime: { name: 'cloudflare' } }), }; + if (opts.tunnel === undefined) { + opts.tunnel = getInjectedManagedTunnelOptions(); + } + + const managedTunnelSpanFilter = typeof opts.tunnel === 'object' && getManagedTunnelSpanFilter(opts.tunnel, opts.dsn); + if (managedTunnelSpanFilter) { + opts.ignoreSpans = [...(opts.ignoreSpans || []), managedTunnelSpanFilter]; + } + const nextjsIgnoreSpans: NonNullable = [ // (set in `dropMiddlewareTunnelRequests` during `spanStart`) { attributes: { [TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION]: true } }, diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index 6dea488a7d9d..9a949548f645 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -13,6 +13,7 @@ import { import type { NodeClient, NodeOptions } from '@sentry/node'; import { getDefaultIntegrations, httpIntegration, init as nodeInit } from '@sentry/node'; import { DEBUG_BUILD } from '../common/debug-build'; +import { getInjectedManagedTunnelOptions, getManagedTunnelSpanFilter } from '../common/managedTunnelRoute'; import { devErrorSymbolicationEventProcessor } from '../common/devErrorSymbolicationEventProcessor'; import { dropReactControlFlowErrorsEventProcessor } from '../common/utils/dropReactControlFlowErrors'; import { isBuild } from '../common/utils/isBuild'; @@ -155,6 +156,15 @@ export function init(options: NodeOptions): NodeClient | undefined { ...cloudflareConfig, }; + if (opts.tunnel === undefined) { + opts.tunnel = getInjectedManagedTunnelOptions(); + } + + const managedTunnelSpanFilter = typeof opts.tunnel === 'object' && getManagedTunnelSpanFilter(opts.tunnel, opts.dsn); + if (managedTunnelSpanFilter) { + opts.ignoreSpans = [...(opts.ignoreSpans || []), managedTunnelSpanFilter]; + } + opts.ignoreSpans = [...(opts.ignoreSpans || []), ...NEXTJS_SERVER_IGNORE_SPANS]; if (DEBUG_BUILD && opts.debug) { diff --git a/packages/nextjs/src/tunnel/index.ts b/packages/nextjs/src/tunnel/index.ts new file mode 100644 index 000000000000..3589068f33a5 --- /dev/null +++ b/packages/nextjs/src/tunnel/index.ts @@ -0,0 +1 @@ +export { sentryTunnelRouteHandler as POST } from '../common/sentryTunnelRouteHandler'; diff --git a/packages/nextjs/test/cloudflare/withSentry.test.ts b/packages/nextjs/test/cloudflare/withSentry.test.ts index ff3395f62b4e..9a85d91fc282 100644 --- a/packages/nextjs/test/cloudflare/withSentry.test.ts +++ b/packages/nextjs/test/cloudflare/withSentry.test.ts @@ -28,7 +28,7 @@ describe('withSentry', () => { getMainCarrier().__SENTRY__ = undefined; (GLOBAL_OBJ as typeof GLOBAL_OBJ & { __SENTRY_CLOUDFLARE_CLIENT__?: unknown }).__SENTRY_CLOUDFLARE_CLIENT__ = undefined; - delete (GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryRewritesTunnelPath?: string })._sentryRewritesTunnelPath; + delete (GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryManagedTunnel?: unknown })._sentryManagedTunnel; delete (GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryRelease?: string })._sentryRelease; vi.restoreAllMocks(); trace.disable(); @@ -186,8 +186,10 @@ describe('withSentry', () => { }); it('does not sample the request span of a request to the tunnel route', async () => { - (GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryRewritesTunnelPath?: string })._sentryRewritesTunnelPath = - '/monitoring'; + (GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryManagedTunnel?: unknown })._sentryManagedTunnel = { + basePath: '', + path: '/monitoring', + }; const sampledByPath: Record = {}; const handler = withSentry(() => ({ dsn: DSN, tracesSampleRate: 1 }), { fetch: (request: Request) => { @@ -197,7 +199,7 @@ describe('withSentry', () => { }, }); - await handler.fetch?.(new Request('https://example.com/monitoring?o=1&p=2') as never, {}, { + await handler.fetch?.(new Request('https://example.com/monitoring', { method: 'POST' }) as never, {}, { waitUntil: vi.fn(), passThroughOnException: vi.fn(), props: {}, @@ -212,8 +214,10 @@ describe('withSentry', () => { }); it('does not sample the request span of a request to the tunnel route when a `tracesSampler` samples every span', async () => { - (GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryRewritesTunnelPath?: string })._sentryRewritesTunnelPath = - '/monitoring'; + (GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryManagedTunnel?: unknown })._sentryManagedTunnel = { + basePath: '', + path: '/monitoring', + }; const sampledByPath: Record = {}; const handler = withSentry(() => ({ dsn: DSN, tracesSampler: () => 1 }), { fetch: (request: Request) => { @@ -223,7 +227,7 @@ describe('withSentry', () => { }, }); - await handler.fetch?.(new Request('https://example.com/monitoring?o=1&p=2') as never, {}, { + await handler.fetch?.(new Request('https://example.com/monitoring', { method: 'POST' }) as never, {}, { waitUntil: vi.fn(), passThroughOnException: vi.fn(), props: {}, diff --git a/packages/nextjs/test/config/withSentryConfig.test.ts b/packages/nextjs/test/config/withSentryConfig.test.ts index cc749732eb35..c31e139a2777 100644 --- a/packages/nextjs/test/config/withSentryConfig.test.ts +++ b/packages/nextjs/test/config/withSentryConfig.test.ts @@ -6,10 +6,17 @@ import { } from '../../src/config/diagnosticsChannelInjection'; import type { SentryBuildOptions } from '../../src/config/types'; import * as util from '../../src/config/util'; +import type * as tunnelModule from '../../src/config/withSentryConfig/tunnel'; import { DEFAULT_SERVER_EXTERNAL_PACKAGES } from '../../src/config/withSentryConfig'; import { defaultRuntimePhase, defaultsObject, exportedNextConfig, userNextConfig } from './fixtures'; import { materializeFinalNextConfig } from './testUtils'; +// `tunnelRoute` creates the app's tunnel route file, which must not land in this package. +vi.mock('../../src/config/withSentryConfig/tunnel', async importOriginal => ({ + ...(await importOriginal()), + ensureTunnelRouteFile: vi.fn(), +})); + // Build-time instrumentation is on by default, so the bundle-safe packages are deliberately dropped // from the externals defaults (the loader transforms them) and the orchestrion runtime is added. // Asserted by exact equality, so a regression that left them external fails here. diff --git a/packages/nextjs/test/utils/dropMiddlewareTunnelRequests.test.ts b/packages/nextjs/test/utils/dropMiddlewareTunnelRequests.test.ts index a635d5d65be5..7a887a57fa10 100644 --- a/packages/nextjs/test/utils/dropMiddlewareTunnelRequests.test.ts +++ b/packages/nextjs/test/utils/dropMiddlewareTunnelRequests.test.ts @@ -2,15 +2,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { dropMiddlewareTunnelRequests } 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; -}; +let managedTunnel: { basePath: string; path?: string } | undefined; vi.mock('@sentry/core', async requireActual => { return { ...(await requireActual()), getClient: () => ({ - getOptions: () => ({}), + getOptions: () => ({ _managedTunnel: managedTunnel }), }), }; }); @@ -26,25 +24,25 @@ function createMockSpan(): { setAttribute: ReturnType; attributes: } beforeEach(() => { - globalWithInjectedValues._sentryRewritesTunnelPath = undefined; + managedTunnel = undefined; }); describe('dropMiddlewareTunnelRequests', () => { describe('BaseServer.handleRequest spans', () => { it('marks BaseServer.handleRequest span for dropping when http.target matches tunnel path', () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + managedTunnel = { basePath: '', path: '/monitoring' }; const span = createMockSpan(); dropMiddlewareTunnelRequests(span as any, { 'next.span_type': 'BaseServer.handleRequest', - 'http.target': '/monitoring?o=123&p=456', + 'http.target': '/monitoring?o=123', }); expect(span.setAttribute).toHaveBeenCalledWith(TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION, true); }); it('marks BaseServer.handleRequest span for dropping when http.target exactly matches tunnel path', () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + managedTunnel = { basePath: '', path: '/monitoring' }; const span = createMockSpan(); dropMiddlewareTunnelRequests(span as any, { @@ -56,7 +54,7 @@ describe('dropMiddlewareTunnelRequests', () => { }); it('does not mark BaseServer.handleRequest span for dropping when http.target does not match tunnel path', () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + managedTunnel = { basePath: '', path: '/monitoring' }; const span = createMockSpan(); dropMiddlewareTunnelRequests(span as any, { @@ -67,8 +65,20 @@ describe('dropMiddlewareTunnelRequests', () => { expect(span.setAttribute).not.toHaveBeenCalled(); }); + it('marks the span of the tunnel route the rewrite points at', () => { + managedTunnel = { basePath: '' }; + const span = createMockSpan(); + + dropMiddlewareTunnelRequests(span as any, { + 'next.span_type': 'BaseServer.handleRequest', + 'http.target': '/sentry-tunnel?p=abc123', + }); + + expect(span.setAttribute).toHaveBeenCalledWith(TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION, true); + }); + it('does not mark BaseServer.handleRequest span for dropping when http.target shares tunnel path prefix', () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + managedTunnel = { basePath: '', path: '/monitoring' }; const span = createMockSpan(); dropMiddlewareTunnelRequests(span as any, { @@ -91,12 +101,12 @@ describe('dropMiddlewareTunnelRequests', () => { }); it('handles BaseServer.handleRequest span with basePath prefix in http.target', () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/basepath/monitoring'; + managedTunnel = { basePath: '/basepath', path: '/monitoring' }; const span = createMockSpan(); dropMiddlewareTunnelRequests(span as any, { 'next.span_type': 'BaseServer.handleRequest', - 'http.target': '/basepath/monitoring?o=123&p=456', + 'http.target': '/basepath/monitoring', }); expect(span.setAttribute).toHaveBeenCalledWith(TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION, true); @@ -105,12 +115,12 @@ describe('dropMiddlewareTunnelRequests', () => { describe('Middleware.execute spans', () => { it('marks middleware span for dropping when http.target matches tunnel path', () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + managedTunnel = { basePath: '', path: '/monitoring' }; const span = createMockSpan(); dropMiddlewareTunnelRequests(span as any, { 'next.span_type': 'Middleware.execute', - 'http.target': '/monitoring?o=123&p=456', + 'http.target': '/monitoring?o=123', }); expect(span.setAttribute).toHaveBeenCalledWith(TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION, true); @@ -119,7 +129,7 @@ describe('dropMiddlewareTunnelRequests', () => { describe('unrelated spans', () => { it('does not process spans without matching span type or origin', () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + managedTunnel = { basePath: '', path: '/monitoring' }; const span = createMockSpan(); dropMiddlewareTunnelRequests(span as any, { @@ -138,7 +148,7 @@ describe('dropMiddlewareTunnelRequests', () => { getOptions: () => ({ enableOpenTelemetrySetup: false }), } as any); - globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + managedTunnel = { basePath: '', path: '/monitoring' }; const span = createMockSpan(); dropMiddlewareTunnelRequests(span as any, { diff --git a/packages/nextjs/test/utils/tunnelRoute.test.ts b/packages/nextjs/test/utils/tunnelRoute.test.ts index 8382e66ca7d4..73353c9d0da3 100644 --- a/packages/nextjs/test/utils/tunnelRoute.test.ts +++ b/packages/nextjs/test/utils/tunnelRoute.test.ts @@ -1,120 +1,198 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; import type { BrowserOptions } from '@sentry/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { getTunnelPath } from '@sentry/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { applyTunnelRouteOption } from '../../src/client/tunnelRoute'; +import { isTunnelRequest } from '../../src/common/isTunnelRequest'; +import { sentryTunnelRouteHandler } from '../../src/common/sentryTunnelRouteHandler'; +import { + ensureTunnelRouteFile, + getManagedTunnelOptions, + setUpTunnelRewriteRules, +} from '../../src/config/withSentryConfig/tunnel'; +import type { NextConfigObject } from '../../src/config/types'; const globalWithInjectedValues = global as typeof global & { - _sentryRewritesTunnelPath?: string; + _sentryManagedTunnel?: unknown; }; -beforeEach(() => { - globalWithInjectedValues._sentryRewritesTunnelPath = undefined; +afterEach(() => { + delete globalWithInjectedValues._sentryManagedTunnel; }); describe('applyTunnelRouteOption()', () => { - it('Correctly applies `tunnelRoute` option when conditions are met', () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/my-error-monitoring-route'; - const options: any = { - dsn: 'https://11111111111111111111111111111111@o2222222.ingest.sentry.io/3333333', - } as BrowserOptions; + it('applies the injected tunnel options', () => { + globalWithInjectedValues._sentryManagedTunnel = { basePath: '/base' }; + const options: BrowserOptions = { dsn: 'https://public@o1.ingest.sentry.io/1' }; applyTunnelRouteOption(options); - expect(options.tunnel).toBe('/my-error-monitoring-route?o=2222222&p=3333333'); + expect(options.tunnel).toEqual({ basePath: '/base' }); }); - it("Doesn't apply `tunnelRoute` when DSN is missing", () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/my-error-monitoring-route'; - const options: any = { - // no dsn - } as BrowserOptions; + it('keeps a `tunnel` the user set', () => { + globalWithInjectedValues._sentryManagedTunnel = { basePath: '' }; + const options: BrowserOptions = { tunnel: '/my-tunnel' }; applyTunnelRouteOption(options); - expect(options.tunnel).toBeUndefined(); + expect(options.tunnel).toBe('/my-tunnel'); }); - it("Doesn't apply `tunnelRoute` when DSN is invalid", () => { - // Avoid polluting the test output with error messages - const mockConsoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); - - globalWithInjectedValues._sentryRewritesTunnelPath = '/my-error-monitoring-route'; - const options: any = { - dsn: 'invalidDsn', - } as BrowserOptions; + it('leaves `tunnel` unset without `tunnelRoute`', () => { + const options: BrowserOptions = {}; applyTunnelRouteOption(options); expect(options.tunnel).toBeUndefined(); + }); +}); - mockConsoleError.mockRestore(); +describe('getManagedTunnelOptions()', () => { + it('derives the path from the DSN for `true` and keeps a string path', () => { + expect(getManagedTunnelOptions({ basePath: '/base' }, true)).toEqual({ basePath: '/base' }); + expect(getManagedTunnelOptions({}, '/monitoring')).toEqual({ basePath: '', path: '/monitoring' }); }); - it("Doesn't apply `tunnelRoute` option when `tunnelRoute` option wasn't injected", () => { - const options: any = { - dsn: 'https://11111111111111111111111111111111@o2222222.ingest.sentry.io/3333333', - } as BrowserOptions; + it('keeps the path and allowed DSNs of an options object', () => { + const allowedDsns = ['https://browser@o1.ingest.sentry.io/1']; - applyTunnelRouteOption(options); + expect(getManagedTunnelOptions({}, { allowedDsns })).toEqual({ basePath: '', allowedDsns }); + expect(getManagedTunnelOptions({}, { path: '/monitoring', allowedDsns })).toEqual({ + basePath: '', + path: '/monitoring', + allowedDsns, + }); + }); - expect(options.tunnel).toBeUndefined(); + it('is off without `tunnelRoute` and for static exports', () => { + expect(getManagedTunnelOptions({}, undefined)).toBeUndefined(); + expect(getManagedTunnelOptions({}, false)).toBeUndefined(); + expect(getManagedTunnelOptions({ output: 'export' }, true)).toBeUndefined(); }); +}); - it("Doesn't `tunnelRoute` option when DSN is not a SaaS DSN", () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/my-error-monitoring-route'; - const options: any = { - dsn: 'https://11111111111111111111111111111111@example.com/3333333', - } as BrowserOptions; +describe('sentryTunnelRouteHandler()', () => { + const BROWSER_DSN = 'https://browser@o1.ingest.sentry.io/1'; - applyTunnelRouteOption(options); + afterEach(() => { + vi.unstubAllGlobals(); + }); - expect(options.tunnel).toBeUndefined(); + function tunnelRequest(dsn: string): Request { + return new Request(`http://localhost/sentry-tunnel?p=${getTunnelPath(dsn)!.slice(1)}`, { + method: 'POST', + body: `${JSON.stringify({ dsn })}\n`, + }); + } + + it('forwards envelopes for the allowed DSNs of `tunnelRoute`', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response('ok')); + vi.stubGlobal('fetch', fetchMock); + globalWithInjectedValues._sentryManagedTunnel = { basePath: '', allowedDsns: [BROWSER_DSN] }; + + const response = await sentryTunnelRouteHandler(tunnelRequest(BROWSER_DSN)); + + expect(response.status).toBe(200); + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining('https://o1.ingest.sentry.io/api/1/envelope/'), + expect.anything(), + ); }); - it('Correctly applies `tunnelRoute` option to region DSNs', () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/my-error-monitoring-route'; - const options: any = { - dsn: 'https://11111111111111111111111111111111@o2222222.ingest.us.sentry.io/3333333', - } as BrowserOptions; + it('rejects envelopes for a DSN that is not allowed', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + globalWithInjectedValues._sentryManagedTunnel = { basePath: '', allowedDsns: [BROWSER_DSN] }; - applyTunnelRouteOption(options); + const response = await sentryTunnelRouteHandler(tunnelRequest('https://other@o1.ingest.sentry.io/2')); - expect(options.tunnel).toBe('/my-error-monitoring-route?o=2222222&p=3333333&r=us'); + expect(response.status).toBe(404); + expect(fetchMock).not.toHaveBeenCalled(); }); }); -describe('Random tunnel route generation', () => { - it('Works when tunnelRoute is true and generates random-looking paths', () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/abc123def'; // Simulated random path - const options: any = { - dsn: 'https://11111111111111111111111111111111@o2222222.ingest.sentry.io/3333333', - } as BrowserOptions; +describe('isTunnelRequest()', () => { + it('matches POSTs to the tunnel of the injected options', () => { + globalWithInjectedValues._sentryManagedTunnel = { basePath: '/base', path: '/monitoring' }; - applyTunnelRouteOption(options); + expect(isTunnelRequest({ method: 'POST', url: 'http://localhost/base/monitoring' })).toBe(true); + expect(isTunnelRequest({ method: 'GET', url: 'http://localhost/base/monitoring' })).toBe(false); + expect(isTunnelRequest({ method: 'POST', url: 'http://localhost/base/admin' })).toBe(false); + }); + + it('matches nothing without `tunnelRoute`', () => { + expect(isTunnelRequest({ method: 'POST', url: 'http://localhost/monitoring' })).toBe(false); + }); +}); - expect(options.tunnel).toBe('/abc123def?o=2222222&p=3333333'); - expect(options.tunnel).toMatch(/^\/[a-z0-9]+\?o=2222222&p=3333333$/); +describe('setUpTunnelRewriteRules()', () => { + it('rewrites envelope POSTs on a single segment to the tunnel route for a DSN-derived path', async () => { + const config: NextConfigObject = {}; + setUpTunnelRewriteRules(config, { basePath: '' }); + + expect(await config.rewrites!()).toEqual({ + beforeFiles: [ + { + source: '/:sentryTunnelPath', + has: [{ type: 'header', key: 'content-type', value: 'application/x-sentry-envelope' }], + destination: '/sentry-tunnel?p=:sentryTunnelPath', + }, + ], + afterFiles: [], + fallback: [], + }); }); - it('Works with region DSNs when tunnelRoute is true', () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/x7h9k2m'; // Simulated random path - const options: any = { - dsn: 'https://11111111111111111111111111111111@o2222222.ingest.eu.sentry.io/3333333', - } as BrowserOptions; + it('rewrites a fixed path and keeps the user rewrites', async () => { + const userRewrite = { source: '/old', destination: '/new' }; + const config: NextConfigObject = { rewrites: async () => [userRewrite] }; + setUpTunnelRewriteRules(config, { basePath: '', path: '/api/monitor' }); - applyTunnelRouteOption(options); + expect(await config.rewrites!()).toEqual({ + beforeFiles: [{ source: '/api/monitor(/?)', destination: '/sentry-tunnel?p=api%2Fmonitor' }], + afterFiles: [userRewrite], + fallback: [], + }); + }); +}); + +describe('ensureTunnelRouteFile()', () => { + const projectDirs: string[] = []; - expect(options.tunnel).toBe('/x7h9k2m?o=2222222&p=3333333&r=eu'); - expect(options.tunnel).toMatch(/^\/[a-z0-9]+\?o=2222222&p=3333333&r=eu$/); + function makeProjectDir(...dirs: string[]): string { + const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sentry-nextjs-tunnel-')); + projectDirs.push(projectDir); + dirs.forEach(dir => fs.mkdirSync(path.join(projectDir, dir), { recursive: true })); + return projectDir; + } + + afterEach(() => { + projectDirs.splice(0).forEach(dir => fs.rmSync(dir, { recursive: true, force: true })); }); - it('Does not apply tunnel when tunnelRoute is false', () => { - globalWithInjectedValues._sentryRewritesTunnelPath = undefined; - const options: any = { - dsn: 'https://11111111111111111111111111111111@o2222222.ingest.sentry.io/3333333', - } as BrowserOptions; + it('creates the route in `app` and leaves an existing one alone', () => { + const projectDir = makeProjectDir('app'); + fs.writeFileSync(path.join(projectDir, 'tsconfig.json'), '{}'); - applyTunnelRouteOption(options); + const routeFile = ensureTunnelRouteFile(projectDir); - expect(options.tunnel).toBeUndefined(); + expect(routeFile).toBe(path.join(projectDir, 'app', 'sentry-tunnel', 'route.ts')); + expect(fs.readFileSync(routeFile!, 'utf8')).toContain("export { POST } from '@sentry/nextjs/tunnel';"); + expect(ensureTunnelRouteFile(projectDir)).toBeUndefined(); + }); + + it('uses `src/app` in a `src` layout and JavaScript without a tsconfig', () => { + const projectDir = makeProjectDir('src/pages'); + + expect(ensureTunnelRouteFile(projectDir)).toBe(path.join(projectDir, 'src', 'app', 'sentry-tunnel', 'route.js')); + }); + + it('uses the root `app` when the root has `pages`, as Next.js then ignores `src`', () => { + const projectDir = makeProjectDir('pages', 'src/utils'); + + expect(ensureTunnelRouteFile(projectDir)).toBe(path.join(projectDir, 'app', 'sentry-tunnel', 'route.js')); }); });