diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/next.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/next.config.ts index 660eddbbfe1f..a71f436dd215 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/next.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/next.config.ts @@ -7,5 +7,5 @@ const nextConfig: NextConfig = { export default withSentryConfig(nextConfig, { silent: true, - tunnelRoute: '/monitoring', + tunnelRoute: true, }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/proxy.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/proxy.ts index b5cda0cfaa92..23b61c572e9a 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/proxy.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/proxy.ts @@ -20,5 +20,6 @@ export async function proxy(request: NextRequest) { } export const config = { - matcher: ['/api/proxy-isolation', '/api/proxy-ok', '/monitoring'], + // Every path, so the proxy also runs for the tunnel path, which is derived from the DSN. + matcher: '/:path*', }; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/tests/proxy-tunnel.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/tests/proxy-tunnel.test.ts index 4c8807745ea0..2e0ddf911ba3 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/tests/proxy-tunnel.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/tests/proxy-tunnel.test.ts @@ -1,6 +1,7 @@ import { expect, test } from '@playwright/test'; import { HTTP_TARGET } from '@sentry/conventions/attributes'; import { waitForStreamedSpan, waitForStreamedSpans } from '@sentry-internal/test-utils'; +import { getTunnelPath } from '@sentry/nextjs'; test('drops the `Next.request` root span of tunnel requests through the proxy', async ({ request }) => { // The callback never returns true, so this promise is deliberately left unsettled. The assertions read the array. @@ -18,13 +19,14 @@ test('drops the `Next.request` root span of tunnel requests through the proxy', ); }); - const tunnelResponse = await request.post('/monitoring?o=123&p=456', { data: '{}' }); + // The SDK answers only a POST before the proxy. A GET reaches the proxy, like a tunnel request on Vercel, where the + // proxy runs outside of the server the SDK hooks into. + const tunnelPath = getTunnelPath(process.env.E2E_TEST_DSN); + const tunnelResponse = await request.get(tunnelPath!); expect(tunnelResponse.headers()['x-middleware-next']).toBe('1'); await request.get('/api/proxy-ok'); await proxyOkSpanPromise; - expect(segmentSpans.filter(span => String(span.attributes[HTTP_TARGET]?.value).startsWith('/monitoring'))).toEqual( - [], - ); + expect(segmentSpans.filter(span => String(span.attributes[HTTP_TARGET]?.value).startsWith(tunnelPath!))).toEqual([]); }); 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 84b61b956e4a..3762bb3d7d8c 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 @@ -4,7 +4,8 @@ import { SENTRY_DSN } from './sentry-dsn'; Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions dsn: SENTRY_DSN, - // No tunnel option - using tunnelRoute from withSentryConfig + // The other variants use the tunnelRoute of withSentryConfig + tunnel: process.env.NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE === 'managed' ? true : undefined, tracesSampleRate: 1.0, }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/next.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/next.config.ts index ac79f882a572..d060076a192c 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/next.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/next.config.ts @@ -1,9 +1,13 @@ import { withSentryConfig } from '@sentry/nextjs/config'; import type { NextConfig } from 'next'; -const nextConfig: NextConfig = {}; +// The managed variants serve the managed tunnel under a base path and with trailing slashes, without a tunnel route. +const tunnelMode = process.env.NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE; +const isManagedMode = tunnelMode === 'managed'; + +const nextConfig: NextConfig = isManagedMode ? { basePath: '/docs', trailingSlash: true } : {}; export default withSentryConfig(nextConfig, { silent: true, - tunnelRoute: true, + tunnelRoute: isManagedMode ? undefined : tunnelMode === 'path' ? '/monitoring' : true, }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json index c079ce13dadd..54d25e0afd9b 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,26 +4,25 @@ "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-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-middleware-webpack": "pnpm install && rm proxy.ts && cp tunnel-middleware/middleware.ts middleware.ts && 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", "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": { @@ -54,23 +53,28 @@ "variants": [ { "build-command": "pnpm test:build-webpack", - "label": "nextjs-16-tunnel (webpack)", + "label": "nextjs-16-tunnel (tunnelRoute: true, webpack)", "assert-command": "pnpm test:assert-webpack" }, { "build-command": "pnpm test:build", - "label": "nextjs-16-tunnel (turbopack)", + "label": "nextjs-16-tunnel (tunnelRoute: true, 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=path pnpm test:build", + "label": "nextjs-16-tunnel (tunnelRoute path, turbopack)", + "assert-command": "NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE=path 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" + "build-command": "NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE=managed pnpm test:build", + "label": "nextjs-16-tunnel (managed, turbopack)", + "assert-command": "NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE=managed pnpm test:assert" + }, + { + "build-command": "NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE=managed pnpm test:build-middleware-webpack", + "label": "nextjs-16-tunnel (managed, webpack, edge middleware)", + "assert-command": "NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE=managed 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 index fa0ad467f9bc..1030f61fd5cb 100644 --- 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 @@ -1,6 +1,3 @@ -// 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'; +// The server forwards the tunnel requests to the host of the DSN, so the event proxy gets the browser events and the +// server spans. +export const SENTRY_DSN = 'http://public@localhost:3031/1337'; 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 0333aa29412b..4a999c80e920 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 @@ -4,7 +4,8 @@ import { SENTRY_DSN } from './sentry-dsn'; Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions dsn: SENTRY_DSN, - // No tunnel option - using tunnelRoute from withSentryConfig + // Serves the managed tunnel of the managed variants + integrations: process.env.NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE === 'managed' ? [Sentry.serveTunnelIntegration()] : [], 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 0333aa29412b..4a999c80e920 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 @@ -4,7 +4,8 @@ import { SENTRY_DSN } from './sentry-dsn'; Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions dsn: SENTRY_DSN, - // No tunnel option - using tunnelRoute from withSentryConfig + // Serves the managed tunnel of the managed variants + integrations: process.env.NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE === 'managed' ? [Sentry.serveTunnelIntegration()] : [], tracesSampleRate: 1.0, // debug: true, }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-managed.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-managed.test.ts new file mode 100644 index 000000000000..fc4d05998566 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-managed.test.ts @@ -0,0 +1,109 @@ +import { expect, test } from '@playwright/test'; +import { HTTP_REQUEST_METHOD, URL_FULL } from '@sentry/conventions/attributes'; +import { getSpanOp, waitForRequest, waitForStreamedSpan, waitForStreamedSpans } from '@sentry-internal/test-utils'; +import type { Event, SerializedStreamedSpan } from '@sentry/core'; + +test.skip(process.env.NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE !== 'managed', 'Only the managed variants use it'); + +const BASE_PATH = '/docs'; + +function getStringAttribute(span: SerializedStreamedSpan, key: string): string | undefined { + const attribute = span.attributes[key]; + return attribute?.type === 'string' ? attribute.value : undefined; +} + +// With `trailingSlash`, a POST to the path without the slash would get a redirect. +function isTunnelPath(pathname: string): boolean { + return /^\/docs\/[a-z0-9]+\/$/.test(pathname); +} + +test('sends the pageload span through the managed tunnel under the base path', async ({ page, baseURL }) => { + const pageloadSpanPromise = waitForStreamedSpan('nextjs-16-tunnel', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + const tunnelResponsePromise = page.waitForResponse( + response => + response.request().method() === 'POST' && + new URL(response.url()).origin === baseURL && + (response.request().postData() ?? '').includes('pageload'), + ); + + await page.goto(BASE_PATH); + + const tunnelResponse = await tunnelResponsePromise; + const pageloadSpan = await pageloadSpanPromise; + + expect(isTunnelPath(new URL(tunnelResponse.url()).pathname)).toBe(true); + expect(tunnelResponse.status()).toBe(200); + expect(pageloadSpan.status).toBe('ok'); +}); + +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 managed cookie test', + ), + ); + + await page.goto(BASE_PATH); + 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 managed cookie test'), + ); + await page.evaluate(() => { + setTimeout(() => { + throw new Error('Tunnel managed cookie test'); + }); + }); + + const tunnelRequest = await tunnelRequestPromise; + const proxyRequest = await proxyRequestPromise; + + expect(isTunnelPath(new URL(tunnelRequest.url()).pathname)).toBe(true); + expect((await tunnelRequest.allHeaders()).cookie).toBe('session=8f2c1d'); + expect(proxyRequest.rawProxyRequestHeaders.cookie).toBeUndefined(); + expect(proxyRequest.rawProxyRequestHeaders['content-type']).toBe('application/x-sentry-envelope'); +}); + +test('does not create server spans for tunnel requests', async ({ page, baseURL }) => { + // One listener, so it gets every span that the proxy received before the barrier span. + const spansBeforeBarrier: SerializedStreamedSpan[] = []; + const barrierSpanPromise = waitForStreamedSpans('nextjs-16-tunnel', spans => { + spansBeforeBarrier.push(...spans); + return spans.some(span => span.is_segment && span.name === 'GET /api/flush'); + }); + const pageloadTunnelResponsePromise = page.waitForResponse( + response => + response.request().method() === 'POST' && + new URL(response.url()).origin === baseURL && + (response.request().postData() ?? '').includes('pageload'), + ); + + await page.goto(BASE_PATH); + await pageloadTunnelResponsePromise; + // The first flush sends the tunnel spans, the second one the barrier span of the first flush. + await page.request.get(`${BASE_PATH}/api/flush`); + await page.request.get(`${BASE_PATH}/api/flush`); + await barrierSpanPromise; + + // The middleware span of the page request arrived, so the ones of the tunnel requests would have too. + expect(spansBeforeBarrier.some(span => span.name === 'middleware GET')).toBe(true); + + const postSpans = spansBeforeBarrier.filter( + span => + span.name.includes('POST') || + getStringAttribute(span, HTTP_REQUEST_METHOD) === 'POST' || + getStringAttribute(span, 'http.method') === 'POST', + ); + const ingestSpans = spansBeforeBarrier.filter( + span => + getSpanOp(span) === 'http.client' && !!getStringAttribute(span, URL_FULL)?.startsWith('http://localhost:3031/'), + ); + expect(postSpans).toEqual([]); + expect(ingestSpans).toEqual([]); +}); 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 e0c1b5e273ae..f75d167f9deb 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,134 +1,70 @@ import { expect, test } from '@playwright/test'; -import { HTTP_REQUEST_METHOD, SERVER_ADDRESS, URL_FULL } from '@sentry/conventions/attributes'; +import { HTTP_REQUEST_METHOD, URL_FULL } from '@sentry/conventions/attributes'; import { getSpanOp, waitForRequest, waitForStreamedSpan, waitForStreamedSpans } from '@sentry-internal/test-utils'; import type { Event, SerializedStreamedSpan } from '@sentry/core'; +test.skip( + process.env.NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE === 'managed', + 'The managed variants have no tunnel route, see tunnel-managed.test.ts', +); + function getStringAttribute(span: SerializedStreamedSpan, key: string): string | undefined { const attribute = span.attributes[key]; return attribute?.type === 'string' ? attribute.value : undefined; } -test('Tunnel route should proxy pageload span to Sentry', async ({ page }) => { - // Wait for the pageload span to be sent through the tunnel +// `tunnelRoute: '/monitoring'` in the `path` variant, `tunnelRoute: true` (the path derived from the DSN) otherwise. +function isTunnelPath(pathname: string): boolean { + return process.env.NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE === 'path' + ? pathname === '/monitoring' + : /^\/[a-z0-9]{1,7}$/.test(pathname); +} + +test('sends the pageload span through the tunnel route', async ({ page, baseURL }) => { const pageloadSpanPromise = waitForStreamedSpan('nextjs-16-tunnel', span => { return getSpanOp(span) === 'pageload' && span.name === '/' && span.is_segment; }); + const tunnelResponsePromise = page.waitForResponse( + response => + response.request().method() === 'POST' && + new URL(response.url()).origin === baseURL && + (response.request().postData() ?? '').includes('pageload'), + ); - // Navigate to the page await page.goto('/'); + const tunnelResponse = await tunnelResponsePromise; const pageloadSpan = await pageloadSpanPromise; - // Verify the pageload span was received successfully - expect(pageloadSpan).toBeDefined(); - expect(pageloadSpan.name).toBe('/'); - expect(getSpanOp(pageloadSpan)).toBe('pageload'); + expect(isTunnelPath(new URL(tunnelResponse.url()).pathname)).toBe(true); + expect(tunnelResponse.status()).toBe(200); expect(pageloadSpan.status).toBe('ok'); }); -test('Tunnel route should send multiple pageload spans consistently', async ({ page }) => { - // This test verifies that the tunnel route remains consistent across multiple page loads - // (important for Turbopack which could generate different tunnel routes for client/server) - - // First pageload +test('sends the pageload spans of every page load through the tunnel route', async ({ page }) => { const firstPageloadPromise = waitForStreamedSpan('nextjs-16-tunnel', span => { return getSpanOp(span) === 'pageload' && span.name === '/' && span.is_segment; }); await page.goto('/'); - const firstPageload = await firstPageloadPromise; - expect(firstPageload).toBeDefined(); - expect(firstPageload.name).toBe('/'); - expect(getSpanOp(firstPageload)).toBe('pageload'); - expect(firstPageload.status).toBe('ok'); + expect((await firstPageloadPromise).status).toBe('ok'); - // Second pageload (reload) const secondPageloadPromise = waitForStreamedSpan('nextjs-16-tunnel', span => { return getSpanOp(span) === 'pageload' && span.name === '/' && span.is_segment; }); await page.reload(); - const secondPageload = await secondPageloadPromise; - expect(secondPageload).toBeDefined(); - expect(secondPageload.name).toBe('/'); - expect(getSpanOp(secondPageload)).toBe('pageload'); - expect(secondPageload.status).toBe('ok'); + expect((await secondPageloadPromise).status).toBe('ok'); }); -test('Tunnel requests should not create middleware or fetch spans', async ({ page }) => { - // This test verifies that our span filtering logic works correctly - // The proxy runs on all routes, so we'll get a middleware span for `/` - // But we should NOT get middleware or fetch spans for the tunnel route itself - - // Accumulate every streamed span for the duration of the test. The callback never returns true, - // so this promise is deliberately left unsettled - the assertions below read the array instead. - const allSpans: SerializedStreamedSpan[] = []; - void waitForStreamedSpans('nextjs-16-tunnel', spans => { - allSpans.push(...spans); - return false; - }); - - // Wait for pageload span - const pageloadPromise = waitForStreamedSpan('nextjs-16-tunnel', span => { - return getSpanOp(span) === 'pageload' && span.is_segment; - }); - - await page.goto('/'); - const pageloadSpan = await pageloadPromise; - - // Trigger errors to force tunnel POST requests - await page - .evaluate(() => { - throw new Error('Test tunnel error 1'); - }) - .catch(() => { - // Expected to throw - }); - - await page - .evaluate(() => { - throw new Error('Test tunnel error 2'); - }) - .catch(() => { - // Expected to throw - }); - - // Wait for events to be sent through tunnel, and for the spans they would wrongly produce to arrive - await page.waitForTimeout(3000); - - // We should have received the pageload span - expect(pageloadSpan).toBeDefined(); - expect(getSpanOp(pageloadSpan)).toBe('pageload'); - - const middlewareSpans = allSpans.filter(span => getSpanOp(span) === 'middleware'); - - // We WILL have a middleware span for GET / (the pageload) - // But we should NOT have middleware spans for POST requests (tunnel route) - const postMiddlewareSpans = middlewareSpans.filter( - span => span.name.includes('POST') || getStringAttribute(span, HTTP_REQUEST_METHOD) === 'POST', - ); - - expect(postMiddlewareSpans).toHaveLength(0); - - // We should NOT have any fetch spans to Sentry ingest. Matched on the host attribute rather than a - // substring of the full URL, which would also match an arbitrary host with `sentry.io` elsewhere in it. - const sentryFetchSpans = allSpans.filter(span => { - const host = getStringAttribute(span, SERVER_ADDRESS) ?? ''; - return getSpanOp(span) === 'http.client' && (host === 'sentry.io' || host.endsWith('.sentry.io')); - }); - - expect(sentryFetchSpans).toHaveLength(0); -}); - -test('Tunnel requests are sent without the cookies of the browser', async ({ page, context, baseURL }) => { - test.skip(process.env.NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE === 'handler', 'The handler variants send the cookies'); +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 cookie test', + itemHeader.type === 'event' && (item as Event).exception?.values?.[0]?.value === 'Tunnel route cookie test', ), ); @@ -138,89 +74,59 @@ test('Tunnel requests are sent without the cookies of the browser', async ({ pag request => request.method() === 'POST' && new URL(request.url()).origin === baseURL && - (request.postData() ?? '').includes('Tunnel cookie test'), + (request.postData() ?? '').includes('Tunnel route cookie test'), ); await page.evaluate(() => { setTimeout(() => { - throw new Error('Tunnel cookie test'); + throw new Error('Tunnel route cookie test'); }); }); const tunnelRequest = await tunnelRequestPromise; const proxyRequest = await proxyRequestPromise; - expect(await page.evaluate(() => document.cookie)).toBe('session=8f2c1d'); - expect((await tunnelRequest.allHeaders()).cookie).toBeUndefined(); + expect(isTunnelPath(new URL(tunnelRequest.url()).pathname)).toBe(true); + // The Vercel rewrite only matches envelope requests. + expect((await tunnelRequest.allHeaders())['content-type']).toBe('application/x-sentry-envelope'); + expect((await tunnelRequest.allHeaders()).cookie).toBe('session=8f2c1d'); expect(proxyRequest.rawProxyRequestHeaders.cookie).toBeUndefined(); + expect(proxyRequest.rawProxyRequestHeaders['content-type']).toBe('application/x-sentry-envelope'); }); -test.describe('Tunnel route handler', () => { - test.skip(process.env.NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE !== 'handler', 'Only the handler variants use the handler'); - - test('forwards events without the cookies of the browser', async ({ page, context, baseURL }) => { - await context.addCookies([{ name: 'session', value: '8f2c1d', url: baseURL }]); - const proxyRequestPromise = waitForRequest('nextjs-16-tunnel', ({ envelope }) => - envelope[1].some( - ([itemHeader, item]) => - itemHeader.type === 'event' && - (item as Event).exception?.values?.[0]?.value === 'Tunnel route handler cookie test', - ), - ); - - await page.goto('/'); - await page.waitForFunction(() => typeof (window as { __SENTRY__?: unknown }).__SENTRY__ === 'object'); - const tunnelRequestPromise = page.waitForRequest( - request => - request.method() === 'POST' && - new URL(request.url()).origin === baseURL && - (request.postData() ?? '').includes('Tunnel route handler cookie test'), - ); - await page.evaluate(() => { - setTimeout(() => { - throw new Error('Tunnel route handler cookie test'); - }); - }); - - const tunnelRequest = await tunnelRequestPromise; - const proxyRequest = await proxyRequestPromise; - - expect(new URL(tunnelRequest.url()).search).toBe(''); - expect((await tunnelRequest.allHeaders()).cookie).toBe('session=8f2c1d'); - expect(proxyRequest.rawProxyRequestHeaders.cookie).toBeUndefined(); - expect(proxyRequest.rawProxyRequestHeaders['content-type']).toBe('application/x-sentry-envelope'); +test('does not create server spans for tunnel requests', async ({ page, baseURL }) => { + // One listener, so it gets every span that the proxy received before the barrier span. + const spansBeforeBarrier: SerializedStreamedSpan[] = []; + const barrierSpanPromise = waitForStreamedSpans('nextjs-16-tunnel', spans => { + spansBeforeBarrier.push(...spans); + return spans.some(span => span.is_segment && span.name === 'GET /api/flush'); }); + const pageloadTunnelResponsePromise = page.waitForResponse( + response => + response.request().method() === 'POST' && + new URL(response.url()).origin === baseURL && + (response.request().postData() ?? '').includes('pageload'), + ); - test('does not create server spans for tunnel requests', async ({ page, baseURL }) => { - // One listener, so it gets every span that the proxy received before the barrier span. - const spansBeforeBarrier: SerializedStreamedSpan[] = []; - const barrierSpanPromise = waitForStreamedSpans('nextjs-16-tunnel', spans => { - spansBeforeBarrier.push(...spans); - return spans.some(span => span.is_segment && span.name === 'GET /api/flush'); - }); - const pageloadTunnelResponsePromise = page.waitForResponse( - response => - response.request().method() === 'POST' && - new URL(response.url()).origin === baseURL && - (response.request().postData() ?? '').includes('pageload'), - ); - - await page.goto('/'); - await pageloadTunnelResponsePromise; - // Flush the edge client of the edge tunnel route (a 405 on the Node.js one), then the Node.js client twice: - // the first flush sends the tunnel spans, the second one the barrier span of the first flush. - await page.request.get('/sentry-tunnel'); - await page.request.get('/api/flush'); - await page.request.get('/api/flush'); - await barrierSpanPromise; - - const postSpans = spansBeforeBarrier.filter( - span => span.name.startsWith('POST') || getStringAttribute(span, HTTP_REQUEST_METHOD) === 'POST', - ); - const ingestSpans = spansBeforeBarrier.filter( - span => - getSpanOp(span) === 'http.client' && !!getStringAttribute(span, URL_FULL)?.startsWith('http://localhost:3031/'), - ); - expect(postSpans).toEqual([]); - expect(ingestSpans).toEqual([]); - }); + await page.goto('/'); + await pageloadTunnelResponsePromise; + // The first flush sends the tunnel spans, the second one the barrier span of the first flush. + await page.request.get('/api/flush'); + await page.request.get('/api/flush'); + await barrierSpanPromise; + + // The middleware span of the page request arrived, so the ones of the tunnel requests would have too. + expect(spansBeforeBarrier.some(span => span.name === 'middleware GET')).toBe(true); + + const postSpans = spansBeforeBarrier.filter( + span => + span.name.includes('POST') || + getStringAttribute(span, HTTP_REQUEST_METHOD) === 'POST' || + getStringAttribute(span, 'http.method') === 'POST', + ); + const ingestSpans = spansBeforeBarrier.filter( + span => + getSpanOp(span) === 'http.client' && !!getStringAttribute(span, URL_FULL)?.startsWith('http://localhost:3031/'), + ); + expect(postSpans).toEqual([]); + expect(ingestSpans).toEqual([]); }); 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 deleted file mode 100644 index 4eeb13efd97f..000000000000 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tunnel-handler/edge/sentry-tunnel/route.ts +++ /dev/null @@ -1,13 +0,0 @@ -import * as Sentry from '@sentry/nextjs'; -import { createSentryTunnelRoute } from '@sentry/nextjs/tunnel'; -import { SENTRY_DSN } from '@/sentry-dsn'; - -export const { POST } = createSentryTunnelRoute({ allowedDsns: [SENTRY_DSN] }); - -// Each edge route has its own SDK client, so the tests flush this one here. -export async function GET(): Promise { - await Sentry.flush(); - return new Response(null, { status: 204 }); -} - -export const runtime = 'edge'; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tunnel-handler/node/sentry-tunnel/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tunnel-handler/node/sentry-tunnel/route.ts deleted file mode 100644 index 23c975fb750d..000000000000 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tunnel-handler/node/sentry-tunnel/route.ts +++ /dev/null @@ -1 +0,0 @@ -export { POST } from '@sentry/nextjs/tunnel'; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tunnel-middleware/middleware.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tunnel-middleware/middleware.ts new file mode 100644 index 000000000000..49cfd8ee0af2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tunnel-middleware/middleware.ts @@ -0,0 +1,16 @@ +import * as Sentry from '@sentry/nextjs'; +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; + +export async function middleware(request: NextRequest) { + const tunnelResponse = Sentry.handleTunnelRequestIfMatched(request); + if (tunnelResponse) { + return tunnelResponse; + } + + return NextResponse.next(); +} + +export const config = { + matcher: '/:path*', +}; 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..43ae875d27cb 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,9 +5,9 @@ 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', - // Sends the server spans to the proxy server, so the tests see whether a tunnel request was sampled. - tunnel: 'http://localhost:3031/', + // Sends the server spans and the tunnel requests to the proxy server, so the tests see whether a tunnel request was + // sampled. + dsn: 'http://public@localhost:3031/1337', tracesSampleRate: 1.0, }), handler, diff --git a/packages/nextjs/src/client/index.ts b/packages/nextjs/src/client/index.ts index 9f1ed4e4f768..bd98c6a78a99 100644 --- a/packages/nextjs/src/client/index.ts +++ b/packages/nextjs/src/client/index.ts @@ -1,14 +1,21 @@ // import/export got a false positive, and affects most of our index barrel files // can be removed once following issue is fixed: https://github.com/import-js/eslint-plugin-import/issues/703 /* eslint-disable import/export */ -import type { Client, EventProcessor, Integration } from '@sentry/core'; -import { applySdkMetadata, consoleSandbox, getGlobalScope, GLOBAL_OBJ } from '@sentry/core'; +import type { BrowserTunnelOptions, Client, EventProcessor, Integration } from '@sentry/core'; +import { + _INTERNAL_applyBrowserTunnelDefaults as applyBrowserTunnelDefaults, + applySdkMetadata, + consoleSandbox, + getGlobalScope, + GLOBAL_OBJ, +} from '@sentry/core'; import type { BrowserOptions } from '@sentry/react'; import { getDefaultIntegrations as getReactDefaultIntegrations, init as reactInit } from '@sentry/react'; import { DEBUG_BUILD } from '../common/debug-build'; import { devErrorSymbolicationEventProcessor } from '../common/devErrorSymbolicationEventProcessor'; import { getClientVercelEnv } from '../common/getVercelEnv'; import { isRedirectNavigationError } from '../common/nextNavigationErrorUtils'; +import { getInjectedBasePath } from '../common/utils/basePath'; import { browserTracingIntegration } from './browserTracingIntegration'; import { nextjsClientStackFrameNormalizationIntegration } from './clientNormalizationIntegration'; import { createNextRouteProvider } from './routing/routeProvider'; @@ -24,7 +31,6 @@ export { captureRouterTransitionStart } from './routing/appRouterRoutingInstrume const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryRewriteFramesAssetPrefixPath: string; _sentryAssetPrefix?: string; - _sentryBasePath?: string; _sentryRelease?: string; _experimentalThirdPartyOriginStackFrames?: string; }; @@ -55,6 +61,13 @@ export function init(options: BrowserOptions): Client | undefined { } satisfies BrowserOptions; applyTunnelRouteOption(opts); + // The managed tunnel is served under the app's `basePath` and `trailingSlash`, like every other route. Options the + // user set win. + const tunnelDefaults: BrowserTunnelOptions = { basePath: getInjectedBasePath() }; + if (process.env._sentryTrailingSlash) { + tunnelDefaults.trailingSlash = true; + } + opts.tunnel = applyBrowserTunnelDefaults(opts.tunnel, tunnelDefaults); applySdkMetadata(opts, 'nextjs', ['nextjs', 'react']); opts.ignoreSpans = [ @@ -104,7 +117,7 @@ function getDefaultIntegrations(options: BrowserOptions): Integration[] { globalWithInjectedValues._sentryRewriteFramesAssetPrefixPath || ''; const assetPrefix = process.env._sentryAssetPrefix || globalWithInjectedValues._sentryAssetPrefix; - const basePath = process.env._sentryBasePath || globalWithInjectedValues._sentryBasePath; + const basePath = getInjectedBasePath(); const experimentalThirdPartyOriginStackFrames = process.env._experimentalThirdPartyOriginStackFrames === 'true' || globalWithInjectedValues._experimentalThirdPartyOriginStackFrames === 'true'; diff --git a/packages/nextjs/src/client/tunnelRoute.ts b/packages/nextjs/src/client/tunnelRoute.ts index 72f2855e32ae..d09fa8e53e40 100644 --- a/packages/nextjs/src/client/tunnelRoute.ts +++ b/packages/nextjs/src/client/tunnelRoute.ts @@ -1,50 +1,24 @@ -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; -}; +import { getInjectedTunnelRoute } from '../common/utils/tunnelPathnameMatch'; /** - * Applies the `tunnel` option to the Next.js SDK options based on `withSentryConfig`'s `tunnelRoute` option. + * Sends events through the managed tunnel when `withSentryConfig` has a `tunnelRoute`, unless `Sentry.init` sets its + * own `tunnel`. A fixed path goes in as `path`, so the browser sends the envelope content type the Vercel rewrite + * matches, and gets the trailing slash Next.js would otherwise redirect a POST to. */ 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; - } - - // 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 tunnelRoute = getInjectedTunnelRoute(); + if (!tunnelRoute || options.tunnel) { + 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; - // The tunnel route is same-origin, so fetch would include credentials (cookies, HTTP authentication) that the - // tunnel does not need. - options.transportOptions = { - ...options.transportOptions, - fetchOptions: { credentials: 'omit', ...options.transportOptions?.fetchOptions }, - }; - 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.'); - } + if (tunnelRoute === true) { + options.tunnel = true; + return; } + + options.tunnel = { + path: tunnelRoute, + trailingSlash: !!process.env._sentryTrailingSlash && !tunnelRoute.endsWith('/'), + }; } diff --git a/packages/nextjs/src/cloudflare/index.ts b/packages/nextjs/src/cloudflare/index.ts index 5c8b65007cd4..182c62276730 100644 --- a/packages/nextjs/src/cloudflare/index.ts +++ b/packages/nextjs/src/cloudflare/index.ts @@ -6,8 +6,14 @@ import { withSentry as withSentryCloudflare } from '@sentry/cloudflare'; import type { Integration } from '@sentry/core'; import { applySdkMetadata, extractTraceparentData, getRootSpan, GLOBAL_OBJ, spanToJSON } from '@sentry/core'; import { registerPrepareSpanScope, setOpenTelemetryContextAsyncContextStrategy } from '@sentry/opentelemetry'; +import { + _INTERNAL_forwardTunnelRequest as forwardTunnelRequest, + _INTERNAL_setTunnelDefaults as setTunnelDefaults, +} from '@sentry/core/server'; import { ATTR_NEXT_SPAN_TYPE } from '../common/nextSpanAttributes'; +import { getInjectedBasePath } from '../common/utils/basePath'; import { isTunnelRouteSpan } from '../common/utils/dropMiddlewareTunnelRequests'; +import { getTunnelRouteServerOptions } from '../common/utils/tunnelPathnameMatch'; import { dropReactControlFlowErrorsEventProcessor } from '../common/utils/dropReactControlFlowErrors'; import { markAsyncContextOwnedByNextjsCloudflare } from '../common/utils/responseEnd'; import { NextSentryPropagator } from '../server/nextSentryPropagator'; @@ -57,7 +63,7 @@ const nextjsIntegration = (): Integration => ({ registerPrepareSpanScope(client); // `dropMiddlewareTunnelRequests` only marks spans of Next.js, which are children of the root span of `withSentry`. client.on('beforeSampling', samplingData => { - if (isTunnelRouteSpan(samplingData.spanAttributes)) { + if (isTunnelRouteSpan(samplingData.spanAttributes, client)) { samplingData.parentSampled = false; } }); @@ -74,6 +80,12 @@ export const withSentry: typeof withSentryCloudflare = (optionsCallback, handler return withSentryCloudflare(env => { const options: CloudflareOptions = { enableOpenTelemetrySetup: true, ...optionsCallback(env) }; + // Read per request: the global is set once `instrumentation` loads, which can be after the Worker entry. + setTunnelDefaults({ + basePath: getInjectedBasePath(), + tunnel: getTunnelRouteServerOptions(), + forward: forwardTunnelRequest, + }); options.ignoreSpans = [ ...(options.ignoreSpans || []), ...NEXTJS_SERVER_IGNORE_SPANS, diff --git a/packages/nextjs/src/common/devErrorSymbolicationEventProcessor.ts b/packages/nextjs/src/common/devErrorSymbolicationEventProcessor.ts index f70990a173e8..46d491457c74 100644 --- a/packages/nextjs/src/common/devErrorSymbolicationEventProcessor.ts +++ b/packages/nextjs/src/common/devErrorSymbolicationEventProcessor.ts @@ -3,6 +3,7 @@ import { debug, GLOBAL_OBJ, parseSemver, suppressTracing } from '@sentry/core'; import type { StackFrame } from 'stacktrace-parser'; import * as stackTraceParser from 'stacktrace-parser'; import { DEBUG_BUILD } from './debug-build'; +import { getInjectedBasePath } from './utils/basePath'; import { URL_FULL } from '@sentry/conventions/attributes'; type OriginalStackFrameResponse = { @@ -12,7 +13,6 @@ type OriginalStackFrameResponse = { }; const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { - _sentryBasePath?: string; _sentryNextJsVersion: string | undefined; }; @@ -21,7 +21,7 @@ const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { * Returns only the base path when running in the browser (client-side) for relative URLs. */ function getDevServerBaseUrl(): string { - let basePath = process.env._sentryBasePath ?? globalWithInjectedValues._sentryBasePath ?? ''; + let basePath = getInjectedBasePath() ?? ''; // Prefix the basepath with a slash if it doesn't have one if (basePath !== '' && !basePath.match(/^\//)) { diff --git a/packages/nextjs/src/common/utils/basePath.ts b/packages/nextjs/src/common/utils/basePath.ts new file mode 100644 index 000000000000..b3dec3d72e9a --- /dev/null +++ b/packages/nextjs/src/common/utils/basePath.ts @@ -0,0 +1,13 @@ +import { GLOBAL_OBJ } from '@sentry/core'; + +const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { + _sentryBasePath?: string; +}; + +/** + * The app's `basePath`, which `withSentryConfig` injects. The client bundle reads it from `env`. On the server, + * `@sentry/nextjs` is external, so `env` isn't inlined there and it reads the injected global instead. + */ +export function getInjectedBasePath(): string | undefined { + return process.env._sentryBasePath || globalWithInjectedValues._sentryBasePath; +} diff --git a/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts b/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts index 8baa168fa031..596ff04d7a8d 100644 --- a/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts +++ b/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts @@ -1,19 +1,35 @@ import { HTTP_TARGET, URL_FULL, URL_PATH, SENTRY_ORIGIN } from '@sentry/conventions/attributes'; -import type { ClientOptions, RawAttributes } from '@sentry/core'; -import { escapeStringForRegex, getClient, GLOBAL_OBJ, isSentryRequestUrl, type Span } from '@sentry/core'; +import type { Client, ClientOptions, RawAttributes } from '@sentry/core'; +import { + _INTERNAL_normalizeTunnelBasePath as normalizeTunnelBasePath, + escapeStringForRegex, + getClient, + isSentryRequestUrl, + type Span, +} from '@sentry/core'; +import { _INTERNAL_getServedTunnel as getServedTunnel } from '@sentry/core/server'; 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; -}; +/** + * The paths of the managed tunnel the client serves, with the `basePath`: the ones derived from the DSNs, or the fixed + * one. + */ +function getServedTunnelPaths(client: Client | undefined): string[] { + const servedTunnel = getServedTunnel(client); + if (!servedTunnel) { + return []; + } + + const basePath = normalizeTunnelBasePath(servedTunnel.basePath); + + return servedTunnel.paths.map(path => `${basePath}${path}`); +} /** * Drops spans for tunnel requests from middleware, fetch instrumentation, or BaseServer.handleRequest. @@ -24,12 +40,12 @@ const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { export function dropMiddlewareTunnelRequests( span: Span, attrs: RawAttributes> | undefined, + client: Client | undefined = getClient(), ): void { // When the user brings their own OTel setup (enableOpenTelemetrySetup: false), we should not // mutate their spans with Sentry-internal attributes as it pollutes their tracing backends. if ( - (getClient()?.getOptions() as { enableOpenTelemetrySetup?: boolean } | undefined)?.enableOpenTelemetrySetup === - false + (client?.getOptions() as { enableOpenTelemetrySetup?: boolean } | undefined)?.enableOpenTelemetrySetup === false ) { return; } @@ -49,8 +65,8 @@ export function dropMiddlewareTunnelRequests( } // Check if this is either a tunnel route request or a Sentry ingest request - const isTunnel = isTunnelRouteSpan(attrs || {}); - const isSentry = isSentryRequestSpan(attrs || {}); + const isTunnel = isTunnelRouteSpan(attrs || {}, client); + const isSentry = isSentryRequestSpan(attrs || {}, client); if (isTunnel || isSentry) { // Mark the span to be dropped @@ -58,21 +74,24 @@ export function dropMiddlewareTunnelRequests( } } -function isSentryRequestSpan(attrs: RawAttributes>): boolean { +function isSentryRequestSpan(attrs: RawAttributes>, client: Client | undefined): boolean { const httpUrl = attrs[URL_FULL]; if (!httpUrl) { return false; } - return isSentryRequestUrl(httpUrl.toString(), getClient()); + return isSentryRequestUrl(httpUrl.toString(), client); } /** * 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) { +export function isTunnelRouteSpan( + spanAttributes: Record, + client: Client | undefined = getClient(), +): boolean { + const tunnelPaths = getServedTunnelPaths(client); + if (!tunnelPaths.length) { return false; } @@ -85,57 +104,42 @@ export function isTunnelRouteSpan(spanAttributes: Record): bool // Extract pathname from the target (e.g., "/tunnel?o=123&p=456" -> "/tunnel") const pathname = target.split('?')[0] || ''; - return isPathnameUnderSentryTunnelRoute(pathname, tunnelPath); + return tunnelPaths.some(tunnelPath => isPathnameUnderSentryTunnelRoute(pathname, tunnelPath)); } return false; } /** - * Returns `ignoreSpans` entries for the spans of incoming tunnel requests. + * Returns `ignoreSpans` entries for the spans of incoming requests to the tunnel the client serves. Call it after + * `init`, which sets up the served tunnel. * - * 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. + * Span streaming checks `ignoreSpans` at span start, before `dropMiddlewareTunnelRequests` runs. Next.js sets only + * `http.target` then, which the `url.path` filters of the managed tunnel don't read. 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) { +export function getTunnelRouteIgnoreSpans(client: Client | undefined): NonNullable { + const tunnelPaths = getServedTunnelPaths(client); + if (!tunnelPaths.length) { 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), - }, - }; - - // Since Next.js 16.4.3, a proxy invoked separately from the render runs under a `Next.request` root span. Its - // `http.target` is the pathname only, so the query of a rewrite-mode tunnel request can't be matched. - const proxyRootSpanIgnoreSpan = { - attributes: { - [ATTR_NEXT_SPAN_TYPE]: /^Next\.request$/, - // eslint-disable-next-line typescript/no-deprecated - [HTTP_TARGET]: new RegExp(`^${escapeStringForRegex(tunnelPath)}/?$`), + return [ + { + attributes: { + // Since Next.js 16.4.3, a proxy invoked separately from the render runs under a `Next.request` root span. + [ATTR_NEXT_SPAN_TYPE]: /^(?:BaseServer\.handleRequest|Middleware\.execute|Next\.request)$/, + // Next.js sets only `http.target` at span start. + // eslint-disable-next-line typescript/no-deprecated + [HTTP_TARGET]: getTunnelRouteRequestPattern(tunnelPaths), + }, }, - }; - - if (tunnelRouteMode !== 'handler') { - return [tunnelRequestIgnoreSpan, proxyRootSpanIgnoreSpan]; - } - - // 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)?$`), + { + // On edge, the root span of a route handler has only `next.route`, with a `/route` suffix before Next.js 16. + attributes: { + [ATTR_NEXT_SPAN_TYPE]: /^AppRouteRouteHandlers\.runHandler$/, + [ATTR_NEXT_ROUTE]: new RegExp(`^${escapeStringForRegex(TUNNEL_ROUTE_HANDLER_PATH)}(?:/route)?$`), + }, }, - }; - return [tunnelRequestIgnoreSpan, proxyRootSpanIgnoreSpan, tunnelRouteHandlerIgnoreSpan]; + ]; } diff --git a/packages/nextjs/src/common/utils/tunnelPathnameMatch.ts b/packages/nextjs/src/common/utils/tunnelPathnameMatch.ts index 2c5dc8bb1dd1..fb20dfd787da 100644 --- a/packages/nextjs/src/common/utils/tunnelPathnameMatch.ts +++ b/packages/nextjs/src/common/utils/tunnelPathnameMatch.ts @@ -1,4 +1,9 @@ -import { escapeStringForRegex } from '@sentry/core'; +import type { ServerTunnelOptions } from '@sentry/core'; +import { escapeStringForRegex, GLOBAL_OBJ } from '@sentry/core'; + +const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { + _sentryTunnelRoute?: string; +}; /** * The path of the tunnel route handler (`app/sentry-tunnel/route.ts`). @@ -7,10 +12,31 @@ import { escapeStringForRegex } from '@sentry/core'; 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. + * The `tunnelRoute` of `withSentryConfig`, which `withSentryConfig` injects: the fixed tunnel path with the `basePath`, + * or `true` for the path derived from the DSN. The client bundle reads it from `env`. On the server, `@sentry/nextjs` is + * external, so `env` isn't inlined there and it reads the injected global instead. + */ +export function getInjectedTunnelRoute(): string | true | undefined { + const tunnelRoute = process.env._sentryTunnelRoute || globalWithInjectedValues._sentryTunnelRoute; + if (!tunnelRoute) { + return undefined; + } + + return tunnelRoute === 'true' ? true : tunnelRoute; +} + +/** + * The managed tunnel the `tunnelRoute` enables on the server, like `serveTunnelIntegration` with these options. A fixed + * path already has the `basePath`. */ -export type TunnelRouteMode = 'rewrite' | 'handler'; +export function getTunnelRouteServerOptions(): Omit | undefined { + const tunnelRoute = getInjectedTunnelRoute(); + if (!tunnelRoute) { + return undefined; + } + + return tunnelRoute === true ? {} : { path: tunnelRoute }; +} /** * Returns true when `pathname` is exactly the Sentry tunnel route or a sub-path @@ -22,16 +48,9 @@ export function isPathnameUnderSentryTunnelRoute(pathname: string, tunnelPath: s } /** - * 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. + * Returns a pattern for the request targets (path and query) of the tunnel paths: a path, with an optional trailing + * slash and any query. It must never match more than the tunnel paths, see GHSA-mchm-gjx6-7h55. */ -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})?$`); +export function getTunnelRouteRequestPattern(tunnelPaths: string[]): RegExp { + return new RegExp(`^(?:${tunnelPaths.map(escapeStringForRegex).join('|')})/?(?:\\?|$)`); } diff --git a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts index da0ca895c246..bd124eec37ba 100644 --- a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts +++ b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts @@ -59,6 +59,7 @@ export function constructTurbopackConfig({ nextJsVersion, tunnelRouteValues: getTunnelRouteInjectedValues(userNextConfig, userSentryOptions), vercelCronsConfig, + basePath: userNextConfig.basePath, }); for (const { matcher, rule } of valueInjectionRules) { diff --git a/packages/nextjs/src/config/turbopack/generateValueInjectionRules.ts b/packages/nextjs/src/config/turbopack/generateValueInjectionRules.ts index 22c81f00102b..b334a096b1f1 100644 --- a/packages/nextjs/src/config/turbopack/generateValueInjectionRules.ts +++ b/packages/nextjs/src/config/turbopack/generateValueInjectionRules.ts @@ -13,11 +13,13 @@ export function generateValueInjectionRules({ nextJsVersion, tunnelRouteValues, vercelCronsConfig, + basePath, }: { routeManifest?: RouteManifest; nextJsVersion?: string; tunnelRouteValues?: TunnelRouteInjectedValues; vercelCronsConfig?: VercelCronsConfig; + basePath?: string; }): TurbopackMatcherWithRule[] { const rules: TurbopackMatcherWithRule[] = []; const isomorphicValues: Record = { ...tunnelRouteValues }; @@ -29,6 +31,11 @@ export function generateValueInjectionRules({ isomorphicValues._sentryNextJsVersion = nextJsVersion; } + // The server reads it to serve the managed tunnel under the base path. + if (basePath) { + isomorphicValues._sentryBasePath = basePath; + } + if (routeManifest) { clientValues._sentryRouteManifest = JSON.stringify(routeManifest); } diff --git a/packages/nextjs/src/config/types.ts b/packages/nextjs/src/config/types.ts index 7c990a480e51..d4a2da1c56a3 100644 --- a/packages/nextjs/src/config/types.ts +++ b/packages/nextjs/src/config/types.ts @@ -31,6 +31,8 @@ export type NextConfigObject = { assetPrefix?: string; // The root at which the nextjs app will be served (defaults to "/") basePath?: string; + // Whether URLs end with a slash, which Next.js redirects to for paths without one + trailingSlash?: boolean; // Config which will be available at runtime publicRuntimeConfig?: { [key: string]: unknown }; // File extensions that count as pages in the `pages/` directory @@ -282,31 +284,17 @@ export type SentryBuildOptions = Omit< /** * 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'). + * from being sent. It turns on the managed tunnel, like `tunnel: true` in the browser `Sentry.init` together with + * `serveTunnelIntegration()` in the server `Sentry.init`. * - * - Pass `true` to auto-generate a random, ad-blocker-resistant route for each build + * - Pass `true` to use the path derived from the DSN * - Pass a string path (e.g., '/monitoring') to use a custom route * - * By default, the tunnel route proxies each request to Sentry with its headers. 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: + * The server forwards only the event payload, for the DSN of the server SDK. A `tunnel` or `serveTunnelIntegration` + * you set in `Sentry.init` wins over this option. * - * ```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`. - * - * Without the route handler, the browser sends tunnel requests with `credentials: 'omit'`, so without cookies or HTTP - * authentication. If a layer in front of your app needs them (for example Vercel Deployment Protection or HTTP Basic authentication), set - * `transportOptions: { fetchOptions: { credentials: 'same-origin' } }` in `Sentry.init` of `instrumentation-client.ts`. - * - * NOTE: This feature only works with Next.js 11+ + * @deprecated Set `tunnel: true` in the browser `Sentry.init` and add `serveTunnelIntegration()` to the server + * `Sentry.init` instead. The server answers the tunnel requests, also on Vercel. */ tunnelRoute?: string | boolean; diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts index b7c1efa5cb65..0b885b50a2a2 100644 --- a/packages/nextjs/src/config/webpack.ts +++ b/packages/nextjs/src/config/webpack.ts @@ -714,7 +714,8 @@ function addValueInjectionLoader({ // 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, + // The server reads it to serve the managed tunnel under the base path, dev symbolication to reach the dev server. + _sentryBasePath: userNextConfig.basePath, // 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 836a3829ea5d..3af554d730e4 100644 --- a/packages/nextjs/src/config/withSentryConfig/buildTime.ts +++ b/packages/nextjs/src/config/withSentryConfig/buildTime.ts @@ -33,7 +33,7 @@ export function setUpBuildTimeVariables( _sentryRewriteFramesAssetPrefixPath: assetPrefix ? new URL(assetPrefix, 'http://dogs.are.great').pathname.replace(/\/$/, '') : '', - ...getTunnelRouteInjectedValues(userNextConfig, userSentryOptions, projectDir), + ...getTunnelRouteInjectedValues(userNextConfig, userSentryOptions), }; if (userNextConfig.assetPrefix) { @@ -48,6 +48,11 @@ export function setUpBuildTimeVariables( buildTimeVariables._sentryBasePath = basePath; } + // Next redirects a POST to the tunnel path without the slash, so the browser posts to the one with it. + if (userNextConfig.trailingSlash) { + buildTimeVariables._sentryTrailingSlash = 'true'; + } + if (userNextConfig.assetPrefix) { buildTimeVariables._sentryAssetPrefix = userNextConfig.assetPrefix; } diff --git a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts index a90529b8d24d..813f24b58df7 100644 --- a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts +++ b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts @@ -10,16 +10,10 @@ import type { RouteManifest } from '../manifest/types'; import type { NextConfigObject, SentryBuildOptions } from '../types'; import { DEFAULT_PAGE_EXTENSIONS, requiresInstrumentationHook } from '../util'; import { getGitRevision, getInstrumentationClientFileContents } from './buildTime'; -import { - findTunnelRouteHandlerFile, - resolveTunnelRoute, - setUpTunnelHandlerRewrite, - setUpTunnelRewriteRules, -} from './tunnel'; +import { setUpManagedTunnelVercelRoute } from './tunnel'; let showedExportModeTunnelWarning = false; -let showedUnusedTunnelRouteHandlerWarning = false; -let showedTunnelRouteHandlerModeLog = false; +let showedDeprecatedTunnelRouteWarning = false; let showedExperimentalBuildModeWarning = false; const MISSING_INSTRUMENTATION_FILE_WARNING_SHOWN = '__SENTRY_MISSING_INSTRUMENTATION_FILE_WARNING_SHOWN__'; @@ -37,70 +31,36 @@ export function resolveReleaseName(userSentryOptions: SentryBuildOptions): strin } /** - * Applies tunnel-route rewrites, if configured. Uses handler mode if the project has a tunnel route handler file. + * Sets up the route of the managed tunnel on Vercel. A `tunnelRoute` turns on the managed tunnel at its path, or at + * the path derived from the DSN for `true`, which the client, server and edge SDKs read from the injected values. * - * Note: this mutates `userSentryOptions` (to store the resolved tunnel route) and `incomingUserNextConfigObject`. + * Note: this mutates `incomingUserNextConfigObject`. */ export function maybeSetUpTunnelRouteRewriteRules( incomingUserNextConfigObject: NextConfigObject, userSentryOptions: SentryBuildOptions, ): void { - if (userSentryOptions.tunnelRoute === undefined) { - maybeWarnAboutUnusedTunnelRouteHandler(incomingUserNextConfigObject, userSentryOptions.silent); - return; - } - - if (!userSentryOptions.tunnelRoute) { - return; - } - - if (incomingUserNextConfigObject.output === 'export') { - if (!showedExportModeTunnelWarning) { - showedExportModeTunnelWarning = true; - getBuildLogger(userSentryOptions.silent).warn( - '[@sentry/nextjs] The Sentry Next.js SDK `tunnelRoute` option will not work in combination with Next.js static exports. The `tunnelRoute` option uses server-side features that cannot be accessed in export mode. If you still want to tunnel Sentry events, set up your own tunnel: https://docs.sentry.io/platforms/javascript/troubleshooting/#using-the-tunnel-option', - ); - } - return; - } - - // Update the global options object to use the resolved value everywhere - const resolvedTunnelRoute = resolveTunnelRoute(userSentryOptions.tunnelRoute); - userSentryOptions.tunnelRoute = resolvedTunnelRoute || undefined; - - 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.`, + // oxlint-disable-next-line typescript/no-deprecated -- reads the deprecated option to keep it working + const tunnelRoute = userSentryOptions.tunnelRoute; + if (tunnelRoute !== undefined && !showedDeprecatedTunnelRouteWarning) { + showedDeprecatedTunnelRouteWarning = true; + getBuildLogger(userSentryOptions.silent).warn( + '[@sentry/nextjs] The `tunnelRoute` option is deprecated. Set `tunnel: true` in the browser `Sentry.init` and add `serveTunnelIntegration()` to the server `Sentry.init` instead.', ); } -} -function maybeWarnAboutUnusedTunnelRouteHandler( - incomingUserNextConfigObject: NextConfigObject, - silent: boolean | undefined, -): void { - if (showedUnusedTunnelRouteHandlerWarning || incomingUserNextConfigObject.output === 'export') { + if (tunnelRoute === false || tunnelRoute === '') { return; } - const tunnelRouteHandlerFile = findTunnelRouteHandlerFile(incomingUserNextConfigObject.pageExtensions); - if (!tunnelRouteHandlerFile) { - return; + if (tunnelRoute && incomingUserNextConfigObject.output === 'export' && !showedExportModeTunnelWarning) { + showedExportModeTunnelWarning = true; + getBuildLogger(userSentryOptions.silent).warn( + '[@sentry/nextjs] The Sentry Next.js SDK `tunnelRoute` option will not work in combination with Next.js static exports. The `tunnelRoute` option uses server-side features that cannot be accessed in export mode. If you still want to tunnel Sentry events, set up your own tunnel: https://docs.sentry.io/platforms/javascript/troubleshooting/#using-the-tunnel-option', + ); } - 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.`, - ); + setUpManagedTunnelVercelRoute(incomingUserNextConfigObject); } /** diff --git a/packages/nextjs/src/config/withSentryConfig/tunnel.ts b/packages/nextjs/src/config/withSentryConfig/tunnel.ts index d0b45bd6ec8c..031e76604aca 100644 --- a/packages/nextjs/src/config/withSentryConfig/tunnel.ts +++ b/packages/nextjs/src/config/withSentryConfig/tunnel.ts @@ -1,152 +1,11 @@ -import { _INTERNAL_safeMathRandom } from '@sentry/core'; import * as fs from 'fs'; import * as path from 'path'; -import { TUNNEL_ROUTE_HANDLER_PATH, type TunnelRouteMode } from '../../common/utils/tunnelPathnameMatch'; +import { TUNNEL_ROUTE_HANDLER_PATH } 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 - */ -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 - */ -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__; - } - - const resolvedTunnelRoute = typeof tunnelRoute === 'string' ? tunnelRoute : generateRandomTunnelRoute(); - - // 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; -} - -/** - * Injects rewrite rules into the Next.js config provided by the user to tunnel - * requests from the `tunnelPath` to Sentry. - * - * See https://nextjs.org/docs/api-reference/next.config.js/rewrites. - */ -export function setUpTunnelRewriteRules(userNextConfig: NextConfigObject, tunnelPath: string): 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'; - - // 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; - } - - // @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 || [])], - }; - } - }; -} - -/** - * 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 || [])], - }; - }; -} +/** The values that the client, server and edge bundles read to enable the managed tunnel for the `tunnelRoute`. */ +export type TunnelRouteInjectedValues = { _sentryTunnelRoute: string } | Record; /** * Returns the tunnel route handler file relative to `projectDir` (for example `app/sentry-tunnel/route.ts`), or @@ -169,6 +28,21 @@ export function findTunnelRouteHandlerFile( .find(file => getStats(path.join(projectDir, file))?.isFile()); } +/** + * Returns true if the project has an App Router or Pages Router page at the tunnel route handler path. + */ +function hasTunnelRoutePage(pageExtensions: string[], projectDir: string): boolean { + const pageFiles = ['app', 'src/app'].map(dir => `${dir}${TUNNEL_ROUTE_HANDLER_PATH}/page`); + const pagesRouterFiles = ['pages', 'src/pages'].flatMap(dir => [ + `${dir}${TUNNEL_ROUTE_HANDLER_PATH}`, + `${dir}${TUNNEL_ROUTE_HANDLER_PATH}/index`, + ]); + + return [...pageFiles, ...pagesRouterFiles].some(file => + pageExtensions.some(extension => getStats(path.join(projectDir, `${file}.${extension}`))?.isFile()), + ); +} + function getStats(filePath: string): fs.Stats | undefined { try { return fs.statSync(filePath); @@ -178,25 +52,88 @@ function getStats(filePath: string): fs.Stats | 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. + * Returns the `tunnelRoute` for the client, server and edge bundles: the fixed path with the `basePath`, or `'true'` + * for the path derived from the DSN. Returns no values if there is no tunnel route, or if it cannot work (static + * export). */ export function getTunnelRouteInjectedValues( userNextConfig: NextConfigObject, userSentryOptions: SentryBuildOptions | undefined, - projectDir: string = process.cwd(), ): TunnelRouteInjectedValues { + // oxlint-disable-next-line typescript/no-deprecated -- reads the deprecated option to keep it working const tunnelRoute = userSentryOptions?.tunnelRoute; - if (typeof tunnelRoute !== 'string' || !tunnelRoute || userNextConfig.output === 'export') { + if (!tunnelRoute || userNextConfig.output === 'export') { return {}; } return { - _sentryRewritesTunnelPath: `${userNextConfig.basePath ?? ''}${tunnelRoute}`, - _sentryTunnelRouteMode: findTunnelRouteHandlerFile(userNextConfig.pageExtensions, projectDir) - ? 'handler' - : 'rewrite', + _sentryTunnelRoute: tunnelRoute === true ? 'true' : `${userNextConfig.basePath ?? ''}${tunnelRoute}`, + }; +} + +/** + * Makes the managed tunnel reachable on Vercel, which serves unknown paths without running the app. Writes the tunnel + * route handler if the app has none, and rewrites envelope requests on paths the app doesn't serve to it, so the + * derived path and a fixed one both reach it. + * + * Only runs in `next build` on Vercel, whose build checkout is thrown away afterwards. `vercel dev` sets `VERCEL` + * locally too, so `next dev` and development builds skip it to keep the file out of the repo. + */ +export function setUpManagedTunnelVercelRoute( + userNextConfig: NextConfigObject, + projectDir: string = process.cwd(), +): void { + // Next.js sets `NEXT_PHASE` only after it loads the config. + const isVercelBuild = + !!process.env.VERCEL && process.env.VERCEL_ENV !== 'development' && process.argv.includes('build'); + if (!isVercelBuild || userNextConfig.output === 'export') { + return; + } + + const pageExtensions = userNextConfig.pageExtensions ?? DEFAULT_PAGE_EXTENSIONS; + // A route handler next to a page at the same path fails the build. + if (hasTunnelRoutePage(pageExtensions, projectDir)) { + return; + } + + if (!findTunnelRouteHandlerFile(pageExtensions, projectDir)) { + // A Pages Router app gets an `app` directory with only this route, next to its `pages` (or in `src`). + const appDir = + ['app', 'src/app'].find(dir => getStats(path.join(projectDir, dir))?.isDirectory()) ?? + (getStats(path.join(projectDir, 'src/pages'))?.isDirectory() ? 'src/app' : 'app'); + const extension = ['js', 'ts'].find(ext => pageExtensions.includes(ext)); + if (!extension) { + return; + } + + const routeDir = path.join(projectDir, appDir, TUNNEL_ROUTE_HANDLER_PATH); + fs.mkdirSync(routeDir, { recursive: true }); + fs.writeFileSync( + path.join(routeDir, `route.${extension}`), + "// Generated by @sentry/nextjs for the managed tunnel on Vercel.\nexport { POST } from '@sentry/nextjs/tunnel';\n", + ); + } + + const originalRewrites = userNextConfig.rewrites; + const tunnelRewrite = { + // Next.js adds the `basePath`. The content type keeps other requests out, and the handler forwards only envelopes + // for allowed DSNs. + source: '/:path*', + has: [{ type: 'header', key: 'content-type', value: 'application/x-sentry-envelope' }], + destination: TUNNEL_ROUTE_HANDLER_PATH, + }; + + // `afterFiles`: the app's own routes win. + userNextConfig.rewrites = async () => { + const originalRewritesResult = typeof originalRewrites === 'function' ? await originalRewrites() : []; + + if (Array.isArray(originalRewritesResult)) { + return { afterFiles: [...originalRewritesResult, tunnelRewrite] }; + } + + return { + ...originalRewritesResult, + afterFiles: [...(originalRewritesResult.afterFiles || []), tunnelRewrite], + }; }; } diff --git a/packages/nextjs/src/edge/index.ts b/packages/nextjs/src/edge/index.ts index 132d5f50aef0..cbbba7242fe3 100644 --- a/packages/nextjs/src/edge/index.ts +++ b/packages/nextjs/src/edge/index.ts @@ -15,7 +15,12 @@ import { } from '@sentry/core'; import type { VercelEdgeOptions } from '@sentry/vercel-edge'; import { getDefaultIntegrations, init as vercelEdgeInit } from '@sentry/vercel-edge'; +import { + _INTERNAL_forwardTunnelRequest as forwardTunnelRequest, + _INTERNAL_setTunnelDefaults as setTunnelDefaults, +} from '@sentry/core/server'; import { DEBUG_BUILD } from '../common/debug-build'; +import { getInjectedBasePath } from '../common/utils/basePath'; import { ATTR_NEXT_SPAN_NAME, ATTR_NEXT_SPAN_TYPE } from '../common/nextSpanAttributes'; import { ATTR_NEXT_PAGES_API_ROUTE_TYPE, @@ -26,6 +31,7 @@ import { backfillHttpResponseStatusCode } from '../common/utils/backfillHttpResp import { backfillHttpServerStatus } from '../common/utils/backfillHttpServerStatus'; import { createLiveRootSpanAdapter } from '../common/utils/liveRootSpanAdapter'; import { dropMiddlewareTunnelRequests, getTunnelRouteIgnoreSpans } from '../common/utils/dropMiddlewareTunnelRequests'; +import { getTunnelRouteServerOptions } from '../common/utils/tunnelPathnameMatch'; import { maybeForkIsolationScopeForRootSpan } from '../common/utils/forkIsolationScopeForRootSpan'; import { getNormalizedRequestFromAttributes } from '../common/utils/getNormalizedRequestFromAttributes'; import { isBuild } from '../common/utils/isBuild'; @@ -66,7 +72,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. */ @@ -110,10 +115,16 @@ 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]; + // Before `init`, which applies it to the managed tunnel of `serveTunnelIntegration` or `tunnelRoute`. + setTunnelDefaults({ + basePath: getInjectedBasePath(), + tunnel: getTunnelRouteServerOptions(), + forward: forwardTunnelRequest, + }); + // Before the Cloudflare check, so that the events of the client of `withSentry` get the tag too. try { // @ts-expect-error `process.turbopack` is a magic string that will be replaced by Next.js @@ -143,6 +154,8 @@ export function init(options: VercelEdgeOptions = {}): void { } const client = vercelEdgeInit(opts); + const clientOptions = client.getOptions(); + clientOptions.ignoreSpans = [...(clientOptions.ignoreSpans || []), ...getTunnelRouteIgnoreSpans(client)]; // Next.js's OTel instrumentation samples root spans before the Sentry middleware wrapper can set // `normalizedRequest` on the isolation scope. Seed it from span attributes so `tracesSampler` has access. @@ -169,7 +182,7 @@ export function init(options: VercelEdgeOptions = {}): void { const rootSpan = getRootSpan(span); const isRootSpan = span === rootSpan; - dropMiddlewareTunnelRequests(span, spanAttributes); + dropMiddlewareTunnelRequests(span, spanAttributes, client); // Next.js names the incoming-request span after the raw URL. With span streaming, span names have to // be low cardinality, so we replace it here at span start; the `next.route` hoisting below renames it diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index 3a1bf1ab9250..1d21332676c3 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -11,11 +11,22 @@ import { GLOBAL_OBJ, } from '@sentry/core'; import type { NodeClient, NodeOptions } from '@sentry/node'; -import { getDefaultIntegrations, httpIntegration, init as nodeInit } from '@sentry/node'; +import { + _INTERNAL_installTunnelHooks as installTunnelHooks, + getDefaultIntegrations, + httpIntegration, + init as nodeInit, +} from '@sentry/node'; +import { + _INTERNAL_forwardTunnelRequest as forwardTunnelRequest, + _INTERNAL_setTunnelDefaults as setTunnelDefaults, +} from '@sentry/core/server'; import { DEBUG_BUILD } from '../common/debug-build'; +import { getInjectedBasePath } from '../common/utils/basePath'; import { devErrorSymbolicationEventProcessor } from '../common/devErrorSymbolicationEventProcessor'; import { dropReactControlFlowErrorsEventProcessor } from '../common/utils/dropReactControlFlowErrors'; import { getTunnelRouteIgnoreSpans } from '../common/utils/dropMiddlewareTunnelRequests'; +import { getTunnelRouteServerOptions } from '../common/utils/tunnelPathnameMatch'; import { isBuild } from '../common/utils/isBuild'; import { isAsyncContextOwnedByCloudflare, @@ -157,7 +168,14 @@ export function init(options: NodeOptions): NodeClient | undefined { ...cloudflareConfig, }; - opts.ignoreSpans = [...(opts.ignoreSpans || []), ...NEXTJS_SERVER_IGNORE_SPANS, ...getTunnelRouteIgnoreSpans()]; + opts.ignoreSpans = [...(opts.ignoreSpans || []), ...NEXTJS_SERVER_IGNORE_SPANS]; + + // Before `init`, which applies it to the managed tunnel of `serveTunnelIntegration` or `tunnelRoute`. + const tunnelRoute = getTunnelRouteServerOptions(); + setTunnelDefaults({ basePath: getInjectedBasePath(), tunnel: tunnelRoute, forward: forwardTunnelRequest }); + if (tunnelRoute) { + installTunnelHooks(); + } if (DEBUG_BUILD && opts.debug) { debug.enable(); @@ -181,6 +199,8 @@ export function init(options: NodeOptions): NodeClient | undefined { if (client) { addNextjsServerSpanHooks(client); + const clientOptions = client.getOptions(); + clientOptions.ignoreSpans = [...(clientOptions.ignoreSpans || []), ...getTunnelRouteIgnoreSpans(client)]; } else if (isOwnedByCloudflare) { setCloudflareWorkerRelease(opts.release); } diff --git a/packages/nextjs/src/server/serverSpanHooks.ts b/packages/nextjs/src/server/serverSpanHooks.ts index f243813d7a45..b1ad6f9991c5 100644 --- a/packages/nextjs/src/server/serverSpanHooks.ts +++ b/packages/nextjs/src/server/serverSpanHooks.ts @@ -168,7 +168,7 @@ export function handleOnSpanStart(span: Span, client: Client): void { const rootSpanAttributes = spanToJSON(rootSpan).attributes; const isRootSpan = span === rootSpan; - dropMiddlewareTunnelRequests(span, spanAttributes); + dropMiddlewareTunnelRequests(span, spanAttributes, client); // Next.js names the incoming-request span after the raw URL. With span streaming, span names have to // be low cardinality, so we replace it here at span start; the `next.route` hoisting below renames it diff --git a/packages/nextjs/src/tunnel/index.ts b/packages/nextjs/src/tunnel/index.ts index d86dd2d0a99d..14bba2e884aa 100644 --- a/packages/nextjs/src/tunnel/index.ts +++ b/packages/nextjs/src/tunnel/index.ts @@ -1,9 +1,9 @@ -import { dsnToString, getClient, handleTunnelRequest, makeDsn, suppressTracing } from '@sentry/core'; +import { handleTunnelRequest } 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. + * DSNs whose envelopes the route forwards, in addition to the DSN of the server SDK. It rejects envelopes for other + * DSNs with a 403. Defaults to the DSNs `serveTunnelIntegration` allows. */ allowedDsns?: string[]; } @@ -25,34 +25,24 @@ type TunnelRouteHandler = (request: Request) => Promise; * allowedDsns: ['https://public@o0.ingest.sentry.io/0'], * }); * ``` + * + * @deprecated Set `tunnel: true` in the browser `Sentry.init` and add `serveTunnelIntegration({ allowedDsns })` to the + * server `Sentry.init` instead. */ export function createSentryTunnelRoute(options: CreateSentryTunnelRouteOptions = {}): { POST: TunnelRouteHandler } { - const allowedDsnsFromOptions = options.allowedDsns?.length ? normalizeDsns(options.allowedDsns) : undefined; + const allowedDsns = options.allowedDsns?.length ? options.allowedDsns.map(dsn => dsn.trim()) : 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 })); - }, + POST: request => handleTunnelRequest(request, { allowedDsns }), }; } /** - * Route handler for the `tunnelRoute` of `withSentryConfig`. It forwards Sentry envelopes to Sentry, but not the - * request headers (such as cookies). + * Route handler of the managed tunnel on Vercel, which `withSentryConfig` writes to `app/sentry-tunnel/route.js` in + * Vercel builds. 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`. + * It accepts envelopes for the DSN of the server SDK, and the DSNs `serveTunnelIntegration` allows. Your own + * `app/sentry-tunnel/route.ts` with it is used instead of the generated one. * * @example * ```ts @@ -60,17 +50,4 @@ export function createSentryTunnelRoute(options: CreateSentryTunnelRouteOptions * 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)] : []; - }); -} +export const POST: TunnelRouteHandler = request => handleTunnelRequest(request); diff --git a/packages/nextjs/test/clientSdk.test.ts b/packages/nextjs/test/clientSdk.test.ts index 9f16580546c7..cf0b8eca9568 100644 --- a/packages/nextjs/test/clientSdk.test.ts +++ b/packages/nextjs/test/clientSdk.test.ts @@ -1,5 +1,5 @@ import type { Integration } from '@sentry/core'; -import { debug, getMainCarrier, GLOBAL_OBJ, SentryNonRecordingSpan, spanToJSON } from '@sentry/core'; +import { debug, getMainCarrier, getTunnelPath, GLOBAL_OBJ, SentryNonRecordingSpan, spanToJSON } from '@sentry/core'; import * as SentryReact from '@sentry/react'; import { close, getClient, getIsolationScope, WINDOW } from '@sentry/react'; import { JSDOM } from 'jsdom'; @@ -238,6 +238,58 @@ describe('Client init()', () => { }); }); + describe('managed tunnel', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("serves `tunnel: true` under the app's `basePath`", () => { + vi.stubEnv('_sentryBasePath', '/docs'); + + const client = init({ dsn: TEST_DSN, tunnel: true }); + + expect(client?.getOptions().tunnel).toBe(`/docs${getTunnelPath(TEST_DSN)}`); + }); + + it('keeps a `basePath` the user set', () => { + vi.stubEnv('_sentryBasePath', '/docs'); + + const client = init({ dsn: TEST_DSN, tunnel: { basePath: '/shop' } }); + + expect(client?.getOptions().tunnel).toBe(`/shop${getTunnelPath(TEST_DSN)}`); + }); + + it('keeps a `tunnel` URL as written', () => { + vi.stubEnv('_sentryBasePath', '/docs'); + + const client = init({ dsn: TEST_DSN, tunnel: '/monitoring' }); + + expect(client?.getOptions().tunnel).toBe('/monitoring'); + }); + + it('sends events to the fixed path of the `tunnelRoute`', () => { + vi.stubEnv('_sentryBasePath', '/docs'); + vi.stubEnv('_sentryTunnelRoute', '/docs/monitoring'); + + const client = init({ dsn: TEST_DSN }); + + expect(client?.getOptions().tunnel).toBe('/docs/monitoring'); + // The Vercel rewrite only matches envelope requests. + expect(client?.getOptions().transportOptions?.headers).toEqual({ + 'Content-Type': 'application/x-sentry-envelope', + }); + }); + + it("sends events to the path derived from the DSN under the app's `basePath` for `tunnelRoute: true`", () => { + vi.stubEnv('_sentryBasePath', '/docs'); + vi.stubEnv('_sentryTunnelRoute', 'true'); + + const client = init({ dsn: TEST_DSN }); + + expect(client?.getOptions().tunnel).toBe(`/docs${getTunnelPath(TEST_DSN)}`); + }); + }); + it('returns client from init', () => { expect(init({})).not.toBeUndefined(); }); diff --git a/packages/nextjs/test/cloudflare/withSentry.test.ts b/packages/nextjs/test/cloudflare/withSentry.test.ts index 46bc97e520be..d0a1904663f6 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 & { _sentryTunnelRoute?: string })._sentryTunnelRoute; delete (GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryRelease?: string })._sentryRelease; vi.restoreAllMocks(); trace.disable(); @@ -186,8 +186,7 @@ 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 & { _sentryTunnelRoute?: string })._sentryTunnelRoute = '/monitoring'; const sampledByPath: Record = {}; const handler = withSentry(() => ({ dsn: DSN, tracesSampleRate: 1 }), { fetch: (request: Request) => { @@ -212,8 +211,7 @@ 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 & { _sentryTunnelRoute?: string })._sentryTunnelRoute = '/monitoring'; const sampledByPath: Record = {}; const handler = withSentry(() => ({ dsn: DSN, tracesSampler: () => 1 }), { fetch: (request: Request) => { diff --git a/packages/nextjs/test/config/turbopack/generateValueInjectionRules.test.ts b/packages/nextjs/test/config/turbopack/generateValueInjectionRules.test.ts index d68c002f298b..7885f953ac51 100644 --- a/packages/nextjs/test/config/turbopack/generateValueInjectionRules.test.ts +++ b/packages/nextjs/test/config/turbopack/generateValueInjectionRules.test.ts @@ -306,9 +306,9 @@ describe('generateValueInjectionRules', () => { }); describe('with tunnel route values', () => { - it('injects the tunnel path and mode into the client and server bundles', () => { + it('injects the tunnel route into the client and server bundles', () => { const result = generateValueInjectionRules({ - tunnelRouteValues: { _sentryRewritesTunnelPath: '/monitoring', _sentryTunnelRouteMode: 'handler' }, + tunnelRouteValues: { _sentryTunnelRoute: '/monitoring' }, }); const injectedValues = result.map(({ matcher, rule }) => [ @@ -316,15 +316,11 @@ describe('generateValueInjectionRules', () => { (rule as { loaders: Array<{ options: { values: Record } }> }).loaders[0]?.options.values, ]); expect(injectedValues).toEqual([ - [ - '**/instrumentation-client.*', - { _sentryRewritesTunnelPath: '/monitoring', _sentryTunnelRouteMode: 'handler' }, - ], + ['**/instrumentation-client.*', { _sentryTunnelRoute: '/monitoring' }], [ '**/instrumentation.*', { - _sentryRewritesTunnelPath: '/monitoring', - _sentryTunnelRouteMode: 'handler', + _sentryTunnelRoute: '/monitoring', __SENTRY_SERVER_MODULES__: expect.any(Object), }, ], @@ -332,6 +328,21 @@ describe('generateValueInjectionRules', () => { }); }); + describe('with basePath', () => { + it('injects the base path into the client and server bundles', () => { + const result = generateValueInjectionRules({ basePath: '/docs' }); + + const injectedValues = result.map(({ matcher, rule }) => [ + matcher, + (rule as { loaders: Array<{ options: { values: Record } }> }).loaders[0]?.options.values, + ]); + expect(injectedValues).toEqual([ + ['**/instrumentation-client.*', { _sentryBasePath: '/docs' }], + ['**/instrumentation.*', { _sentryBasePath: '/docs', __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 766a05f583c8..60d06c115a29 100644 --- a/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts +++ b/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts @@ -859,8 +859,29 @@ describe('constructWebpackConfigFunction()', () => { .flatMap(rule => rule.use ?? []) .find(loader => String(loader.loader).endsWith('valueInjectionLoader.js')); expect(valueInjectionLoader?.options).toMatchObject({ - values: { _sentryRewritesTunnelPath: '/monitoring', _sentryTunnelRouteMode: 'handler' }, + values: { _sentryTunnelRoute: '/monitoring' }, }); }); }); }); + +describe('basePath value injection', () => { + it.each([ + ['server', serverWebpackConfig, serverBuildContext], + ['edge', serverWebpackConfig, edgeBuildContext], + ])( + 'injects the base path into production %s builds', + async (_target, incomingWebpackConfig, incomingWebpackBuildContext) => { + const finalWebpackConfig = await materializeFinalWebpackConfig({ + exportedNextConfig: { ...userNextConfig, basePath: '/docs' }, + incomingWebpackConfig, + incomingWebpackBuildContext, + }); + + const valueInjectionLoader = finalWebpackConfig.module.rules + .flatMap(rule => rule.use ?? []) + .find(loader => String(loader.loader).endsWith('valueInjectionLoader.js')); + expect(valueInjectionLoader?.options).toMatchObject({ values: { _sentryBasePath: '/docs' } }); + }, + ); +}); diff --git a/packages/nextjs/test/config/withSentryConfig.test.ts b/packages/nextjs/test/config/withSentryConfig.test.ts index 9cec2cc71410..8960683f59cb 100644 --- a/packages/nextjs/test/config/withSentryConfig.test.ts +++ b/packages/nextjs/test/config/withSentryConfig.test.ts @@ -917,7 +917,7 @@ describe('withSentryConfig', () => { expect(finalConfig.productionBrowserSourceMaps).toBe(true); expect(sentryOptions.sourcemaps).toHaveProperty('deleteSourcemapsAfterUpload', true); - expect(finalConfig.rewrites).toBeInstanceOf(Function); + expect(finalConfig.env).toHaveProperty('_sentryTunnelRoute', '/custom-tunnel'); }); it('works correctly with custom release configuration', () => { @@ -1786,8 +1786,8 @@ describe('withSentryConfig', () => { '[@sentry/nextjs] The configured `useRunAfterProductionCompileHook` option is not compatible with your current Next.js version. This option is only supported on Next.js version 15.4.1 or later. Will not run source map and release management logic.', ); - // Ensure other functionality still works (tunnel route creates rewrites function) - expect(finalConfig.rewrites).toBeInstanceOf(Function); + // Ensure other functionality still works + expect(finalConfig.env).toHaveProperty('_sentryTunnelRoute', '/tunnel'); // Release name should be set (from git or environment) expect(finalConfig.env).toHaveProperty('_sentryRelease'); diff --git a/packages/nextjs/test/config/withSentryConfig/buildTime.test.ts b/packages/nextjs/test/config/withSentryConfig/buildTime.test.ts index b30f6f708855..a0f66a40e2a0 100644 --- a/packages/nextjs/test/config/withSentryConfig/buildTime.test.ts +++ b/packages/nextjs/test/config/withSentryConfig/buildTime.test.ts @@ -59,21 +59,15 @@ describe('setUpBuildTimeVariables', () => { }); describe('tunnel route', () => { - it('adds the tunnel path and the handler mode for a project with a tunnel route handler', () => { + it('adds the tunnel path with the base path', () => { const nextConfig = { basePath: '/docs' }; - setUpBuildTimeVariables( - nextConfig, - { tunnelRoute: '/monitoring' }, - undefined, - makeProject(['app/sentry-tunnel/route.ts']), - ); + setUpBuildTimeVariables(nextConfig, { tunnelRoute: '/monitoring' }, undefined, makeProject(['app/page.tsx'])); expect(nextConfig).toEqual({ basePath: '/docs', env: expect.objectContaining({ - _sentryRewritesTunnelPath: '/docs/monitoring', - _sentryTunnelRouteMode: 'handler', + _sentryTunnelRoute: '/docs/monitoring', }), }); }); diff --git a/packages/nextjs/test/config/withSentryConfig/getFinalConfigObjectUtils.test.ts b/packages/nextjs/test/config/withSentryConfig/getFinalConfigObjectUtils.test.ts index 9cdc46448750..21162b901a20 100644 --- a/packages/nextjs/test/config/withSentryConfig/getFinalConfigObjectUtils.test.ts +++ b/packages/nextjs/test/config/withSentryConfig/getFinalConfigObjectUtils.test.ts @@ -3,7 +3,11 @@ import type { NextConfigObject } from '../../../src/config/types'; import { warnIfMissingInstrumentationFile } from '../../../src/config/withSentryConfig/getFinalConfigObjectUtils'; import { makeProject, removeProjects } from './projectFixture'; -const HANDLER_REWRITE = { source: '/monitoring', destination: '/sentry-tunnel' }; +const TUNNEL_REWRITE = { + source: '/:path*', + has: [{ type: 'header', key: 'content-type', value: 'application/x-sentry-envelope' }], + destination: '/sentry-tunnel', +}; afterEach(() => { removeProjects(); @@ -18,11 +22,17 @@ describe('maybeSetUpTunnelRouteRewriteRules', () => { await import('../../../src/config/withSentryConfig/getFinalConfigObjectUtils'); }); + const originalArgv = process.argv; + beforeEach(() => { vi.restoreAllMocks(); // A fresh module per test, because the function logs each message only once. vi.resetModules(); - delete process.env.__SENTRY_TUNNEL_ROUTE__; + }); + + afterEach(() => { + vi.unstubAllEnvs(); + process.argv = originalArgv; }); async function setUp(files: string[]) { @@ -34,101 +44,73 @@ describe('maybeSetUpTunnelRouteRewriteRules', () => { 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']); + function stubVercelBuild(): void { + vi.stubEnv('VERCEL', '1'); + vi.stubEnv('VERCEL_ENV', 'production'); + process.argv = [...originalArgv.slice(0, 2), 'build']; + } + + it('sets up no rewrite outside of Vercel and warns once about the deprecated option', async () => { + const { maybeSetUpTunnelRouteRewriteRules, warnSpy } = await setUp(['app/page.tsx']); 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.', - ); + expect(nextConfig.rewrites).toBeUndefined(); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('The `tunnelRoute` option is deprecated')); }); - 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 = {}; + it.each([ + ['`tunnelRoute` is not set', {}], + ['`tunnelRoute` is `true`', { tunnelRoute: true }], + ['`tunnelRoute` is a path', { tunnelRoute: '/monitoring' }], + ])( + 'rewrites envelope requests to the tunnel route handler on Vercel when %s', + async (_description, sentryOptions) => { + stubVercelBuild(); + const { maybeSetUpTunnelRouteRewriteRules } = await setUp(['app/page.tsx']); + const nextConfig: NextConfigObject = {}; - maybeSetUpTunnelRouteRewriteRules(nextConfig, { tunnelRoute: '/monitoring' }); + maybeSetUpTunnelRouteRewriteRules(nextConfig, sentryOptions); - 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(); - }); + await expect(nextConfig.rewrites?.()).resolves.toEqual({ afterFiles: [TUNNEL_REWRITE] }); + }, + ); - it('does not log with `silent`', async () => { - const { maybeSetUpTunnelRouteRewriteRules, logSpy } = await setUp(['app/sentry-tunnel/route.ts']); + it('does not warn about the deprecated option when `tunnelRoute` is not set', async () => { + const { maybeSetUpTunnelRouteRewriteRules, warnSpy } = await setUp(['app/page.tsx']); - maybeSetUpTunnelRouteRewriteRules({}, { tunnelRoute: '/monitoring', silent: true }); + maybeSetUpTunnelRouteRewriteRules({}, {}); - expect(logSpy).not.toHaveBeenCalled(); + expect(warnSpy).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']); + it('sets up no rewrite on Vercel when `tunnelRoute` is `false`', async () => { + stubVercelBuild(); + const { maybeSetUpTunnelRouteRewriteRules } = await setUp(['app/page.tsx']); const nextConfig: NextConfigObject = {}; - maybeSetUpTunnelRouteRewriteRules(nextConfig, {}); - maybeSetUpTunnelRouteRewriteRules({}, {}); + maybeSetUpTunnelRouteRewriteRules(nextConfig, { tunnelRoute: false }); 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']); + it('sets up no rewrite for a static export and warns that `tunnelRoute` does not work there', async () => { + stubVercelBuild(); + const { maybeSetUpTunnelRouteRewriteRules, warnSpy } = await setUp(['app/page.tsx']); const nextConfig: NextConfigObject = { output: 'export' }; maybeSetUpTunnelRouteRewriteRules(nextConfig, { tunnelRoute: '/monitoring' }); expect(nextConfig.rewrites).toBeUndefined(); - expect(logSpy).not.toHaveBeenCalled(); - expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledTimes(2); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('The `tunnelRoute` option is deprecated')); 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(); - }); }); describe('warnIfMissingInstrumentationFile', () => { diff --git a/packages/nextjs/test/config/withSentryConfig/tunnel.test.ts b/packages/nextjs/test/config/withSentryConfig/tunnel.test.ts index cc39280aa7d0..e3bd97ed61fd 100644 --- a/packages/nextjs/test/config/withSentryConfig/tunnel.test.ts +++ b/packages/nextjs/test/config/withSentryConfig/tunnel.test.ts @@ -1,13 +1,19 @@ -import { afterEach, describe, expect, it } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { NextConfigObject } from '../../../src/config/types'; import { findTunnelRouteHandlerFile, getTunnelRouteInjectedValues, - setUpTunnelHandlerRewrite, + setUpManagedTunnelVercelRoute, } from '../../../src/config/withSentryConfig/tunnel'; import { makeProject, removeProjects } from './projectFixture'; -const HANDLER_REWRITE = { source: '/monitoring', destination: '/sentry-tunnel' }; +const TUNNEL_REWRITE = { + source: '/:path*', + has: [{ type: 'header', key: 'content-type', value: 'application/x-sentry-envelope' }], + destination: '/sentry-tunnel', +}; afterEach(() => { removeProjects(); @@ -58,78 +64,128 @@ describe('findTunnelRouteHandlerFile', () => { }); describe('getTunnelRouteInjectedValues', () => { - it('returns the handler mode when the project has a tunnel route handler', () => { - const projectDir = makeProject(['app/sentry-tunnel/route.ts']); + it('returns the fixed path with the basePath', () => { + expect(getTunnelRouteInjectedValues({ basePath: '/docs' }, { tunnelRoute: '/monitoring' })).toEqual({ + _sentryTunnelRoute: '/docs/monitoring', + }); + }); - expect(getTunnelRouteInjectedValues({}, { tunnelRoute: '/monitoring' }, projectDir)).toEqual({ - _sentryRewritesTunnelPath: '/monitoring', - _sentryTunnelRouteMode: 'handler', + it('returns `true` for the path derived from the DSN', () => { + expect(getTunnelRouteInjectedValues({ basePath: '/docs' }, { tunnelRoute: true })).toEqual({ + _sentryTunnelRoute: 'true', }); }); - it('returns the rewrite mode when the project has no tunnel route handler', () => { - const projectDir = makeProject(['app/page.tsx']); + it.each([ + ['`tunnelRoute` is not set', {}, {}], + ['`tunnelRoute` is `false`', {}, { tunnelRoute: false }], + ['`tunnelRoute` is an empty string', { basePath: '/docs' }, { tunnelRoute: '' }], + ['the app is a static export', { output: 'export' }, { tunnelRoute: '/monitoring' }], + ] as const)('returns no values when %s', (_description, nextConfig, sentryOptions) => { + expect(getTunnelRouteInjectedValues(nextConfig, sentryOptions)).toEqual({}); + }); +}); - expect(getTunnelRouteInjectedValues({}, { tunnelRoute: '/monitoring' }, projectDir)).toEqual({ - _sentryRewritesTunnelPath: '/monitoring', - _sentryTunnelRouteMode: 'rewrite', - }); +describe('setUpManagedTunnelVercelRoute', () => { + const originalArgv = process.argv; + + beforeEach(() => { + vi.stubEnv('VERCEL', '1'); + vi.stubEnv('VERCEL_ENV', 'production'); + process.argv = [...originalArgv.slice(0, 2), 'build']; }); - it('adds the basePath to the tunnel path', () => { - const projectDir = makeProject(['app/sentry-tunnel/route.ts']); + afterEach(() => { + vi.unstubAllEnvs(); + process.argv = originalArgv; + }); - expect(getTunnelRouteInjectedValues({ basePath: '/docs' }, { tunnelRoute: '/monitoring' }, projectDir)).toEqual({ - _sentryRewritesTunnelPath: '/docs/monitoring', - _sentryTunnelRouteMode: 'handler', - }); + function hasRouteFile(projectDir: string): boolean { + return fs.readdirSync(projectDir, { recursive: true }).some(entry => String(entry).includes('route')); + } + + it('writes the tunnel route handler and rewrites envelope requests to it', async () => { + const projectDir = makeProject(['app/page.tsx']); + const nextConfig: NextConfigObject = {}; + + setUpManagedTunnelVercelRoute(nextConfig, projectDir); + + expect(fs.readFileSync(path.join(projectDir, 'app/sentry-tunnel/route.js'), 'utf8')).toContain( + "export { POST } from '@sentry/nextjs/tunnel';", + ); + await expect(nextConfig.rewrites?.()).resolves.toEqual({ afterFiles: [TUNNEL_REWRITE] }); }); 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']); + ['an App Router page', 'app/sentry-tunnel/page.tsx'], + ['an App Router page in `src`', 'src/app/sentry-tunnel/page.tsx'], + ['a Pages Router page', 'pages/sentry-tunnel.tsx'], + ['a Pages Router index page', 'src/pages/sentry-tunnel/index.js'], + ])('does nothing when the project has %s at the route path', (_description, file) => { + const projectDir = makeProject([file]); + const nextConfig: NextConfigObject = {}; - expect(getTunnelRouteInjectedValues(nextConfig, sentryOptions, projectDir)).toEqual({}); + setUpManagedTunnelVercelRoute(nextConfig, projectDir); + + expect(hasRouteFile(projectDir)).toBe(false); + expect(nextConfig.rewrites).toBeUndefined(); }); -}); -describe('setUpTunnelHandlerRewrite', () => { - it('adds the handler rewrite to `beforeFiles` when there are no user rewrites', async () => { + it.each([ + ['outside of Vercel', () => vi.stubEnv('VERCEL', '')], + ['in a Vercel development build', () => vi.stubEnv('VERCEL_ENV', 'development')], + ['outside of `next build`', () => (process.argv = [...originalArgv.slice(0, 2), 'dev'])], + ])('does nothing %s', (_description, setUp) => { + setUp(); + const projectDir = makeProject(['app/page.tsx']); const nextConfig: NextConfigObject = {}; - setUpTunnelHandlerRewrite(nextConfig, '/monitoring'); + setUpManagedTunnelVercelRoute(nextConfig, projectDir); + + expect(hasRouteFile(projectDir)).toBe(false); + expect(nextConfig.rewrites).toBeUndefined(); + }); + + it('does nothing for a static export', () => { + const projectDir = makeProject(['app/page.tsx']); + const nextConfig: NextConfigObject = { output: 'export' }; - await expect(nextConfig.rewrites?.()).resolves.toEqual({ beforeFiles: [HANDLER_REWRITE], afterFiles: [] }); + setUpManagedTunnelVercelRoute(nextConfig, projectDir); + + expect(hasRouteFile(projectDir)).toBe(false); + expect(nextConfig.rewrites).toBeUndefined(); }); - it('keeps user rewrites in array form as `afterFiles`', async () => { + it('appends the rewrite to the user rewrites in array form', async () => { const userRewrite = { source: '/blog/:slug', destination: '/news/:slug' }; const nextConfig: NextConfigObject = { rewrites: async () => [userRewrite] }; - setUpTunnelHandlerRewrite(nextConfig, '/monitoring'); + setUpManagedTunnelVercelRoute(nextConfig, makeProject(['app/page.tsx'])); - await expect(nextConfig.rewrites?.()).resolves.toEqual({ - beforeFiles: [HANDLER_REWRITE], - afterFiles: [userRewrite], - }); + await expect(nextConfig.rewrites?.()).resolves.toEqual({ afterFiles: [userRewrite, TUNNEL_REWRITE] }); }); - it('puts the handler rewrite before the user `beforeFiles` rewrites', async () => { - const userBeforeFilesRewrite = { source: '/old', destination: '/new' }; + it('appends the rewrite to the user `afterFiles` rewrites and keeps the other phases', async () => { + const userAfterFilesRewrite = { source: '/old', destination: '/new' }; const userFallbackRewrite = { source: '/:path*', destination: 'https://legacy.example.com/:path*' }; const nextConfig: NextConfigObject = { - rewrites: async () => ({ beforeFiles: [userBeforeFilesRewrite], fallback: [userFallbackRewrite] }), + rewrites: async () => ({ afterFiles: [userAfterFilesRewrite], fallback: [userFallbackRewrite] }), }; - setUpTunnelHandlerRewrite(nextConfig, '/monitoring'); + setUpManagedTunnelVercelRoute(nextConfig, makeProject(['app/page.tsx'])); await expect(nextConfig.rewrites?.()).resolves.toEqual({ - beforeFiles: [HANDLER_REWRITE, userBeforeFilesRewrite], + afterFiles: [userAfterFilesRewrite, TUNNEL_REWRITE], fallback: [userFallbackRewrite], }); }); + + it('keeps the tunnel route handler of the app', () => { + const projectDir = makeProject(['app/page.tsx', 'app/sentry-tunnel/route.ts']); + + setUpManagedTunnelVercelRoute({}, projectDir); + + expect(fs.readFileSync(path.join(projectDir, 'app/sentry-tunnel/route.ts'), 'utf8')).toBe(''); + expect(fs.existsSync(path.join(projectDir, 'app/sentry-tunnel/route.js'))).toBe(false); + }); }); diff --git a/packages/nextjs/test/config/wrappers.test.ts b/packages/nextjs/test/config/wrappers.test.ts index 9d121172eea9..973e097953a2 100644 --- a/packages/nextjs/test/config/wrappers.test.ts +++ b/packages/nextjs/test/config/wrappers.test.ts @@ -99,8 +99,8 @@ describe('data-fetching function wrappers should not create manual spans', () => describe('wrapMiddlewareWithSentry', () => { afterEach(() => { vi.clearAllMocks(); - if ('_sentryRewritesTunnelPath' in globalThis) { - delete (globalThis as any)._sentryRewritesTunnelPath; + if ('_sentryTunnelRoute' in globalThis) { + delete (globalThis as any)._sentryTunnelRoute; } }); @@ -144,7 +144,7 @@ describe('wrapMiddlewareWithSentry', () => { }); test('should run the middleware for requests to the tunnel route', async () => { - (globalThis as any)._sentryRewritesTunnelPath = '/monitoring'; + (globalThis as any)._sentryTunnelRoute = '/monitoring'; const mockReturnValue = { status: 200 }; const origFunction: EdgeRouteHandler = vi.fn(async (..._args) => mockReturnValue); diff --git a/packages/nextjs/test/edgeSdk.test.ts b/packages/nextjs/test/edgeSdk.test.ts index a3fb9dacc7b8..dde1de3cdff9 100644 --- a/packages/nextjs/test/edgeSdk.test.ts +++ b/packages/nextjs/test/edgeSdk.test.ts @@ -1,8 +1,21 @@ import type { Integration } from '@sentry/core'; -import { getGlobalScope, getMainCarrier, GLOBAL_OBJ, withIsolationScope } from '@sentry/core'; +import { + getClient, + getGlobalScope, + getMainCarrier, + getTunnelPath, + GLOBAL_OBJ, + shouldIgnoreSpan, + withIsolationScope, +} from '@sentry/core'; import { setAsyncLocalStorageAsyncContextStrategy } from '@sentry/server-utils'; import * as SentryVercelEdge from '@sentry/vercel-edge'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + _INTERNAL_resetTunnelDefaults, + handleTunnelRequestIfMatched, + serveTunnelIntegration, +} from '@sentry/core/server'; import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION } from '../src/common/span-attributes-with-logic-attached'; import { init } from '../src/edge'; @@ -11,6 +24,8 @@ import { init } from '../src/edge'; const vercelEdgeInit = vi.spyOn(SentryVercelEdge, 'init'); +const TEST_DSN = 'https://public@dsn.ingest.sentry.io/1337'; + function findIntegrationByName(integrations: Integration[] = [], name: string): Integration | undefined { return integrations.find(integration => integration.name === name); } @@ -89,6 +104,84 @@ describe('Edge init()', () => { expect(getGlobalScope().getScopeData().tags.turbopack).toBe(true); }); + describe('managed tunnel', () => { + const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { + _sentryBasePath?: string; + _sentryTunnelRoute?: string; + }; + const post = (path: string): Request => + new Request(`https://example.com${path}`, { method: 'POST', body: '{}', headers: { 'content-length': '2' } }); + + afterEach(() => { + delete globalWithInjectedValues._sentryBasePath; + delete globalWithInjectedValues._sentryTunnelRoute; + _INTERNAL_resetTunnelDefaults(); + }); + + it('serves the fixed path of the `tunnelRoute` without `serveTunnelIntegration`', () => { + globalWithInjectedValues._sentryBasePath = '/docs'; + globalWithInjectedValues._sentryTunnelRoute = '/docs/monitoring'; + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('{}')); + + init({ dsn: TEST_DSN }); + + expect(handleTunnelRequestIfMatched(post('/docs/monitoring'))).toBeInstanceOf(Promise); + expect(handleTunnelRequestIfMatched(post(`/docs${getTunnelPath(TEST_DSN)}`))).toBeUndefined(); + fetchSpy.mockRestore(); + }); + + it("serves the path derived from the DSN under the app's `basePath` for `tunnelRoute: true`", () => { + globalWithInjectedValues._sentryBasePath = '/docs'; + globalWithInjectedValues._sentryTunnelRoute = 'true'; + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('{}')); + + init({ dsn: TEST_DSN }); + + expect(handleTunnelRequestIfMatched(post(`/docs${getTunnelPath(TEST_DSN)}`))).toBeInstanceOf(Promise); + fetchSpy.mockRestore(); + }); + + it.each([ + ['`tunnelRoute: true`', () => ({ tunnelRoute: 'true', integrations: [] })], + ['`serveTunnelIntegration`', () => ({ tunnelRoute: undefined, integrations: [serveTunnelIntegration()] })], + ])('ignores the Next.js request spans of the tunnel path derived from the DSN with %s', (_description, setUp) => { + const { tunnelRoute, integrations } = setUp(); + globalWithInjectedValues._sentryBasePath = '/docs'; + globalWithInjectedValues._sentryTunnelRoute = tunnelRoute; + + init({ dsn: TEST_DSN, integrations }); + + const ignoreSpans = getClient()?.getOptions().ignoreSpans; + // Next.js sets only `http.target` at span start, e.g. for a proxy that runs outside of the server on Vercel. + const isIgnored = (spanType: string, target: string): boolean => + shouldIgnoreSpan( + { + description: 'middleware POST', + op: 'http.server', + attributes: { 'next.span_type': spanType, 'http.target': target }, + }, + ignoreSpans, + ); + const tunnelPath = `/docs${getTunnelPath(TEST_DSN)}`; + expect(isIgnored('Middleware.execute', tunnelPath)).toBe(true); + expect(isIgnored('Next.request', `${tunnelPath}/`)).toBe(true); + expect(isIgnored('BaseServer.handleRequest', tunnelPath)).toBe(true); + expect(isIgnored('Next.request', '/docs/pricing')).toBe(false); + }); + + it("serves the tunnel of `serveTunnelIntegration` under the app's `basePath`", () => { + globalWithInjectedValues._sentryBasePath = '/docs'; + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('{}')); + + init({ dsn: TEST_DSN, integrations: [serveTunnelIntegration()] }); + + const tunnelPath = getTunnelPath(TEST_DSN); + expect(handleTunnelRequestIfMatched(post(`/docs${tunnelPath}`))).toBeInstanceOf(Promise); + expect(handleTunnelRequestIfMatched(post(`${tunnelPath}`))).toBeUndefined(); + fetchSpy.mockRestore(); + }); + }); + describe('integrations', () => { // Options passed by `@sentry/nextjs`'s `init` to `@sentry/vercel-edge`'s `init` after modifying them type ModifiedInitOptions = { integrations?: Integration[]; defaultIntegrations: Integration[] }; diff --git a/packages/nextjs/test/serverSdk.test.ts b/packages/nextjs/test/serverSdk.test.ts index 7ef42977be47..559cc23e3ca6 100644 --- a/packages/nextjs/test/serverSdk.test.ts +++ b/packages/nextjs/test/serverSdk.test.ts @@ -1,9 +1,21 @@ import type { Integration } from '@sentry/core'; -import { GLOBAL_OBJ, getMainCarrier, withIsolationScope } from '@sentry/core'; +import { + GLOBAL_OBJ, + getClient, + getMainCarrier, + getTunnelPath, + shouldIgnoreSpan, + withIsolationScope, +} from '@sentry/core'; import { setAsyncLocalStorageAsyncContextStrategy } from '@sentry/server-utils'; import { close, getCurrentScope, getGlobalScope } from '@sentry/node'; import * as SentryNode from '@sentry/node'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + _INTERNAL_resetTunnelDefaults, + handleTunnelRequestIfMatched, + serveTunnelIntegration, +} from '@sentry/core/server'; import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION } from '../src/common/span-attributes-with-logic-attached'; import { init } from '../src/server'; import type * as NextSentryPropagatorModule from '../src/server/nextSentryPropagator'; @@ -19,6 +31,8 @@ vi.mock('../src/server/nextSentryPropagator', async importOriginal => ({ const nodeInit = vi.spyOn(SentryNode, 'init'); +const TEST_DSN = 'https://public@dsn.ingest.sentry.io/1337'; + function findIntegrationByName(integrations: Integration[] = [], name: string): Integration | undefined { return integrations.find(integration => integration.name === name); } @@ -76,6 +90,7 @@ describe('Server init()', () => { nodeInit.mockReturnValueOnce({ on: vi.fn(), addEventProcessor: vi.fn(), + getOptions: () => ({}), traceProvider, } as unknown as ReturnType); @@ -88,6 +103,7 @@ describe('Server init()', () => { nodeInit.mockReturnValueOnce({ on: vi.fn(), addEventProcessor: vi.fn(), + getOptions: () => ({}), traceProvider: undefined, } as unknown as ReturnType); @@ -97,6 +113,84 @@ describe('Server init()', () => { }); }); + describe('managed tunnel', () => { + const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { + _sentryBasePath?: string; + _sentryTunnelRoute?: string; + }; + const post = (path: string): Request => + new Request(`https://example.com${path}`, { method: 'POST', body: '{}', headers: { 'content-length': '2' } }); + + afterEach(() => { + delete globalWithInjectedValues._sentryBasePath; + delete globalWithInjectedValues._sentryTunnelRoute; + _INTERNAL_resetTunnelDefaults(); + }); + + it('serves the fixed path of the `tunnelRoute` without `serveTunnelIntegration`', () => { + globalWithInjectedValues._sentryBasePath = '/docs'; + globalWithInjectedValues._sentryTunnelRoute = '/docs/monitoring'; + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('{}')); + + init({ dsn: TEST_DSN }); + + expect(handleTunnelRequestIfMatched(post('/docs/monitoring'))).toBeInstanceOf(Promise); + expect(handleTunnelRequestIfMatched(post(`/docs${getTunnelPath(TEST_DSN)}`))).toBeUndefined(); + fetchSpy.mockRestore(); + }); + + it("serves the path derived from the DSN under the app's `basePath` for `tunnelRoute: true`", () => { + globalWithInjectedValues._sentryBasePath = '/docs'; + globalWithInjectedValues._sentryTunnelRoute = 'true'; + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('{}')); + + init({ dsn: TEST_DSN }); + + expect(handleTunnelRequestIfMatched(post(`/docs${getTunnelPath(TEST_DSN)}`))).toBeInstanceOf(Promise); + fetchSpy.mockRestore(); + }); + + it.each([ + ['`tunnelRoute: true`', () => ({ tunnelRoute: 'true', integrations: [] })], + ['`serveTunnelIntegration`', () => ({ tunnelRoute: undefined, integrations: [serveTunnelIntegration()] })], + ])('ignores the Next.js request spans of the tunnel path derived from the DSN with %s', (_description, setUp) => { + const { tunnelRoute, integrations } = setUp(); + globalWithInjectedValues._sentryBasePath = '/docs'; + globalWithInjectedValues._sentryTunnelRoute = tunnelRoute; + + init({ dsn: TEST_DSN, integrations }); + + const ignoreSpans = getClient()?.getOptions().ignoreSpans; + // Next.js sets only `http.target` at span start, e.g. for a proxy that runs outside of the server on Vercel. + const isIgnored = (spanType: string, target: string): boolean => + shouldIgnoreSpan( + { + description: 'middleware POST', + op: 'http.server', + attributes: { 'next.span_type': spanType, 'http.target': target }, + }, + ignoreSpans, + ); + const tunnelPath = `/docs${getTunnelPath(TEST_DSN)}`; + expect(isIgnored('Middleware.execute', tunnelPath)).toBe(true); + expect(isIgnored('Next.request', `${tunnelPath}/`)).toBe(true); + expect(isIgnored('BaseServer.handleRequest', tunnelPath)).toBe(true); + expect(isIgnored('Next.request', '/docs/pricing')).toBe(false); + }); + + it("serves the tunnel of `serveTunnelIntegration` under the app's `basePath`", () => { + globalWithInjectedValues._sentryBasePath = '/docs'; + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('{}')); + + init({ dsn: TEST_DSN, integrations: [serveTunnelIntegration()] }); + + const tunnelPath = getTunnelPath(TEST_DSN); + expect(handleTunnelRequestIfMatched(post(`/docs${tunnelPath}`))).toBeInstanceOf(Promise); + expect(handleTunnelRequestIfMatched(post(`${tunnelPath}`))).toBeUndefined(); + fetchSpy.mockRestore(); + }); + }); + it("doesn't reinitialize the node SDK if already initialized", () => { expect(nodeInit).toHaveBeenCalledTimes(0); init({}); diff --git a/packages/nextjs/test/tunnel/index.test.ts b/packages/nextjs/test/tunnel/index.test.ts index 516543a2b5a9..a6fd021e3417 100644 --- a/packages/nextjs/test/tunnel/index.test.ts +++ b/packages/nextjs/test/tunnel/index.test.ts @@ -1,15 +1,9 @@ -import type * as SentryCore from '@sentry/core'; -import { createEnvelope, makeDsn, serializeEnvelope } from '@sentry/core'; +import type { Client } from '@sentry/core'; +import { createEnvelope, getCurrentScope, makeDsn, serializeEnvelope } from '@sentry/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { serveTunnelIntegration } from '@sentry/core/server'; 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'; @@ -33,7 +27,8 @@ function createEnvelopeRequest(dsn: string): { request: Request; body: Uint8Arra } function useClientDsn(dsn: string | undefined): void { - mockGetClient.mockReturnValue(dsn ? { getDsn: () => makeDsn(dsn) } : undefined); + const client = dsn ? ({ getDsn: () => makeDsn(dsn), getOptions: () => ({}) } as unknown as Client) : undefined; + getCurrentScope().setClient(client); } let fetchSpy: ReturnType; @@ -75,9 +70,6 @@ describe('POST', () => { 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(); }); @@ -91,6 +83,22 @@ describe('POST', () => { expect(fetchSpy).not.toHaveBeenCalled(); }); + it('accepts the DSNs `serveTunnelIntegration` allows', async () => { + const integration = serveTunnelIntegration({ allowedDsns: [BROWSER_DSN] }); + const options = {}; + const client = { + getDsn: () => makeDsn(SERVER_DSN), + getOptions: () => options, + getIntegrationByName: () => integration, + } as unknown as Client; + integration.setup?.(client); + getCurrentScope().setClient(client); + + const response = await POST(createEnvelopeRequest(BROWSER_DSN).request); + + expect(response.status).toBe(200); + }); + it('does not accept DSNs from environment variables', async () => { useClientDsn(SERVER_DSN); vi.stubEnv('SENTRY_DSN', BROWSER_DSN); @@ -104,16 +112,18 @@ describe('POST', () => { }); describe('createSentryTunnelRoute', () => { - it('accepts only the DSNs in `allowedDsns`', async () => { + it('accepts the DSNs in `allowedDsns` and the server DSN', 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); + const browserResponse = await handler(createEnvelopeRequest(BROWSER_DSN).request); + const serverResponse = await handler(createEnvelopeRequest(SERVER_DSN).request); + const otherResponse = await handler(createEnvelopeRequest('https://other@o2.ingest.sentry.io/3').request); - expect(allowedResponse.status).toBe(200); - expect(rejectedResponse.status).toBe(403); - expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(browserResponse.status).toBe(200); + expect(serverResponse.status).toBe(200); + expect(otherResponse.status).toBe(403); + expect(fetchSpy).toHaveBeenCalledTimes(2); }); it.each([ @@ -138,15 +148,13 @@ describe('createSentryTunnelRoute', () => { expect(response.status).toBe(200); }); - it('responds with 500 when no allowed DSN is valid', async () => { + it('responds with 500 when no allowed DSN is valid and the server SDK has no DSN', 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 e5b506d02c81..d13bdfb0a84b 100644 --- a/packages/nextjs/test/utils/dropMiddlewareTunnelRequests.test.ts +++ b/packages/nextjs/test/utils/dropMiddlewareTunnelRequests.test.ts @@ -8,10 +8,18 @@ import { import { ATTR_NEXT_ROUTE, ATTR_NEXT_SPAN_TYPE } from '../../src/common/nextSpanAttributes'; import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION } from '../../src/common/span-attributes-with-logic-attached'; -const globalWithInjectedValues = global as typeof global & { - _sentryRewritesTunnelPath?: string; - _sentryTunnelRouteMode?: string; -}; +const served = vi.hoisted(() => ({ tunnel: undefined as { paths: string[]; basePath?: string } | undefined })); + +vi.mock('@sentry/core/server', async requireActual => { + return { + ...(await requireActual()), + _INTERNAL_getServedTunnel: () => served.tunnel, + }; +}); + +function serveTunnel(paths: string[], basePath?: string): void { + served.tunnel = { paths, basePath }; +} vi.mock('@sentry/core', async requireActual => { return { @@ -33,14 +41,13 @@ function createMockSpan(): { setAttribute: ReturnType; attributes: } beforeEach(() => { - globalWithInjectedValues._sentryRewritesTunnelPath = undefined; - globalWithInjectedValues._sentryTunnelRouteMode = undefined; + served.tunnel = undefined; }); describe('dropMiddlewareTunnelRequests', () => { describe('BaseServer.handleRequest spans', () => { it('marks BaseServer.handleRequest span for dropping when http.target matches tunnel path', () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + serveTunnel(['/monitoring']); const span = createMockSpan(); dropMiddlewareTunnelRequests(span as any, { @@ -52,7 +59,7 @@ describe('dropMiddlewareTunnelRequests', () => { }); it('marks BaseServer.handleRequest span for dropping when http.target exactly matches tunnel path', () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + serveTunnel(['/monitoring']); const span = createMockSpan(); dropMiddlewareTunnelRequests(span as any, { @@ -64,7 +71,7 @@ describe('dropMiddlewareTunnelRequests', () => { }); it('does not mark BaseServer.handleRequest span for dropping when http.target does not match tunnel path', () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + serveTunnel(['/monitoring']); const span = createMockSpan(); dropMiddlewareTunnelRequests(span as any, { @@ -76,7 +83,7 @@ describe('dropMiddlewareTunnelRequests', () => { }); it('does not mark BaseServer.handleRequest span for dropping when http.target shares tunnel path prefix', () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + serveTunnel(['/monitoring']); const span = createMockSpan(); dropMiddlewareTunnelRequests(span as any, { @@ -99,7 +106,7 @@ describe('dropMiddlewareTunnelRequests', () => { }); it('handles BaseServer.handleRequest span with basePath prefix in http.target', () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/basepath/monitoring'; + serveTunnel(['/basepath/monitoring']); const span = createMockSpan(); dropMiddlewareTunnelRequests(span as any, { @@ -113,7 +120,7 @@ describe('dropMiddlewareTunnelRequests', () => { describe('Middleware.execute spans', () => { it('marks middleware span for dropping when http.target matches tunnel path', () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + serveTunnel(['/monitoring']); const span = createMockSpan(); dropMiddlewareTunnelRequests(span as any, { @@ -127,7 +134,7 @@ describe('dropMiddlewareTunnelRequests', () => { describe('Next.request spans', () => { it('marks the proxy root span for dropping when http.target matches tunnel path', () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + serveTunnel(['/monitoring']); const span = createMockSpan(); dropMiddlewareTunnelRequests(span as any, { @@ -141,7 +148,7 @@ describe('dropMiddlewareTunnelRequests', () => { describe('unrelated spans', () => { it('does not process spans without matching span type or origin', () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + serveTunnel(['/monitoring']); const span = createMockSpan(); dropMiddlewareTunnelRequests(span as any, { @@ -160,7 +167,7 @@ describe('dropMiddlewareTunnelRequests', () => { getOptions: () => ({ enableOpenTelemetrySetup: false }), } as any); - globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + serveTunnel(['/monitoring']); const span = createMockSpan(); dropMiddlewareTunnelRequests(span as any, { @@ -177,7 +184,10 @@ describe('dropMiddlewareTunnelRequests', () => { describe('getTunnelRouteIgnoreSpans', () => { function isIgnored(attributes: Record): boolean { - return shouldIgnoreSpan({ description: 'POST', op: 'http.server', attributes }, getTunnelRouteIgnoreSpans()); + return shouldIgnoreSpan( + { description: 'POST', op: 'http.server', attributes }, + getTunnelRouteIgnoreSpans(undefined), + ); } // Next.js sets the deprecated `http.target` on its request spans. @@ -187,73 +197,50 @@ describe('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(nextRequestSpanAttributes(spanType, target))).toBe(true); - }); - - it.each([ - '/monitoring', - '/monitoring?o=123', - '/monitoring?xo=123&p=456', - '/monitoring?o=123&p=456&o=abc', - '/monitoring?p=456&o=123', - '/monitoring/dashboard?o=123&p=456', - '/monitoring-dashboard?o=123&p=456', - '/api/monitoring?o=123&p=456', - ])('does not ignore a request span for %s in rewrite mode, which the rewrite does not serve', target => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; - - expect(isIgnored(nextRequestSpanAttributes('BaseServer.handleRequest', target))).toBe(false); + expect(getTunnelRouteIgnoreSpans(undefined)).toEqual([]); }); - it.each(['/monitoring', '/monitoring/', '/monitoring?cache=0'])( - 'ignores a request span for %s in handler mode', + it.each(['/docs/k3x9q2a', '/docs/k3x9q2a/', '/docs/f00ba7'])( + 'ignores a request span for the derived path %s', target => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; - globalWithInjectedValues._sentryTunnelRouteMode = 'handler'; + serveTunnel(['/k3x9q2a', '/f00ba7'], '/docs'); - expect(isIgnored(nextRequestSpanAttributes('BaseServer.handleRequest', target))).toBe(true); + expect(isIgnored(nextRequestSpanAttributes('Middleware.execute', target))).toBe(true); + expect(isIgnored(nextRequestSpanAttributes('Next.request', target))).toBe(true); }, ); + it('does not ignore a request span for a derived path outside of the base path', () => { + serveTunnel(['/k3x9q2a'], '/docs'); + + expect(isIgnored(nextRequestSpanAttributes('Next.request', '/k3x9q2a'))).toBe(false); + }); + it.each([ - ['rewrite', '/monitoring'], - ['rewrite', '/monitoring/'], - ['handler', '/monitoring'], - ] as const)('ignores a Next.request proxy root span in %s mode for %s', (mode, target) => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; - globalWithInjectedValues._sentryTunnelRouteMode = mode; - - expect(isIgnored(nextRequestSpanAttributes('Next.request', target))).toBe(true); + ['BaseServer.handleRequest', '/monitoring'], + ['BaseServer.handleRequest', '/monitoring/'], + ['BaseServer.handleRequest', '/monitoring?cache=0'], + ['Middleware.execute', '/monitoring'], + ['Next.request', '/monitoring'], + ['Next.request', '/monitoring/'], + ])('ignores a %s span for %s', (spanType, target) => { + serveTunnel(['/monitoring']); + + expect(isIgnored(nextRequestSpanAttributes(spanType, target))).toBe(true); }); it.each(['/monitoring/dashboard', '/monitoring-dashboard', '/api/monitoring'])( - 'does not ignore a Next.request proxy root span for %s', + 'does not ignore a request span for %s', target => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + serveTunnel(['/monitoring']); + expect(isIgnored(nextRequestSpanAttributes('BaseServer.handleRequest', target))).toBe(false); expect(isIgnored(nextRequestSpanAttributes('Next.request', target))).toBe(false); }, ); - it('does not ignore a request span for a sub-path in handler mode', () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; - globalWithInjectedValues._sentryTunnelRouteMode = 'handler'; - - expect(isIgnored(nextRequestSpanAttributes('BaseServer.handleRequest', '/monitoring/dashboard'))).toBe(false); - }); - it('does not ignore an outgoing request with the tunnel path', () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + serveTunnel(['/monitoring']); const outgoingRequestAttributes = { [URL_PATH]: '/monitoring', @@ -266,10 +253,9 @@ describe('getTunnelRouteIgnoreSpans', () => { }); it.each(['/sentry-tunnel', '/sentry-tunnel/route'])( - 'ignores the route handler span with next.route %s in handler mode', + 'ignores the route handler span with next.route %s', nextRoute => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; - globalWithInjectedValues._sentryTunnelRouteMode = 'handler'; + serveTunnel(['/monitoring']); expect( isIgnored({ [ATTR_NEXT_SPAN_TYPE]: 'AppRouteRouteHandlers.runHandler', [ATTR_NEXT_ROUTE]: nextRoute }), @@ -277,18 +263,9 @@ describe('getTunnelRouteIgnoreSpans', () => { }, ); - it('does not ignore the route handler span in rewrite mode', () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; - globalWithInjectedValues._sentryTunnelRouteMode = 'rewrite'; - - expect( - isIgnored({ [ATTR_NEXT_SPAN_TYPE]: 'AppRouteRouteHandlers.runHandler', [ATTR_NEXT_ROUTE]: '/sentry-tunnel' }), - ).toBe(false); - }); - it('escapes regex characters in the tunnel path', () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/a.b'; + serveTunnel(['/a.b']); - expect(isIgnored(nextRequestSpanAttributes('BaseServer.handleRequest', '/axb?o=123&p=456'))).toBe(false); + expect(isIgnored(nextRequestSpanAttributes('BaseServer.handleRequest', '/axb'))).toBe(false); }); }); diff --git a/packages/nextjs/test/utils/tunnelRoute.test.ts b/packages/nextjs/test/utils/tunnelRoute.test.ts index 30dd3f840ba3..05ed6fdd4c22 100644 --- a/packages/nextjs/test/utils/tunnelRoute.test.ts +++ b/packages/nextjs/test/utils/tunnelRoute.test.ts @@ -1,193 +1,61 @@ import type { BrowserOptions } from '@sentry/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { applyTunnelRouteOption } from '../../src/client/tunnelRoute'; +const DSN = 'https://11111111111111111111111111111111@o2222222.ingest.sentry.io/3333333'; + const globalWithInjectedValues = global as typeof global & { - _sentryRewritesTunnelPath?: string; - _sentryTunnelRouteMode?: string; + _sentryTunnelRoute?: string; }; beforeEach(() => { - vi.restoreAllMocks(); - globalWithInjectedValues._sentryRewritesTunnelPath = undefined; - globalWithInjectedValues._sentryTunnelRouteMode = undefined; -}); - -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; - - applyTunnelRouteOption(options); - - expect(options.tunnel).toBe('/my-error-monitoring-route?o=2222222&p=3333333'); - }); - - it("Doesn't apply `tunnelRoute` when DSN is missing", () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/my-error-monitoring-route'; - const options: any = { - // no dsn - } as BrowserOptions; - - applyTunnelRouteOption(options); - - expect(options.tunnel).toBeUndefined(); - }); - - 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; - - applyTunnelRouteOption(options); - - expect(options.tunnel).toBeUndefined(); - - mockConsoleError.mockRestore(); - }); - - 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; - - applyTunnelRouteOption(options); - - expect(options.tunnel).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; - - applyTunnelRouteOption(options); - - expect(options.tunnel).toBeUndefined(); - }); - - 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; - - applyTunnelRouteOption(options); - - expect(options.tunnel).toBe('/my-error-monitoring-route?o=2222222&p=3333333&r=us'); - }); + globalWithInjectedValues._sentryTunnelRoute = undefined; }); -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; - - applyTunnelRouteOption(options); - - expect(options.tunnel).toBe('/abc123def?o=2222222&p=3333333'); - expect(options.tunnel).toMatch(/^\/[a-z0-9]+\?o=2222222&p=3333333$/); - }); - - 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; - - applyTunnelRouteOption(options); - - expect(options.tunnel).toBe('/x7h9k2m?o=2222222&p=3333333&r=eu'); - expect(options.tunnel).toMatch(/^\/[a-z0-9]+\?o=2222222&p=3333333&r=eu$/); - }); - - 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; - - applyTunnelRouteOption(options); - - expect(options.tunnel).toBeUndefined(); - }); +afterEach(() => { + vi.unstubAllEnvs(); }); -describe('applyTunnelRouteOption() credentials', () => { - const SAAS_DSN = 'https://11111111111111111111111111111111@o2222222.ingest.sentry.io/3333333'; - - beforeEach(() => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/my-error-monitoring-route'; - }); - - it("sends tunnel requests with `credentials: 'omit'`", () => { - const options: BrowserOptions = { dsn: SAAS_DSN }; - - applyTunnelRouteOption(options); - - expect(options.transportOptions).toEqual({ fetchOptions: { credentials: 'omit' } }); - }); - - it('keeps the fetch options of the user', () => { - const options: BrowserOptions = { - dsn: SAAS_DSN, - transportOptions: { bufferSize: 10, fetchOptions: { credentials: 'same-origin', priority: 'low' } }, - }; +describe('applyTunnelRouteOption()', () => { + it('sends events to the fixed path of the `tunnelRoute`', () => { + globalWithInjectedValues._sentryTunnelRoute = '/docs/monitoring'; + const options: BrowserOptions = { dsn: DSN }; applyTunnelRouteOption(options); - expect(options.transportOptions).toEqual({ - bufferSize: 10, - fetchOptions: { credentials: 'same-origin', priority: 'low' }, - }); + expect(options.tunnel).toEqual({ path: '/docs/monitoring', trailingSlash: false }); }); - it('does not change the transport options when the events are not tunneled', () => { - const options: BrowserOptions = { dsn: 'https://11111111111111111111111111111111@example.com/3333333' }; + it('sends events to the path derived from the DSN for `tunnelRoute: true`', () => { + globalWithInjectedValues._sentryTunnelRoute = 'true'; + const options: BrowserOptions = { dsn: DSN }; applyTunnelRouteOption(options); - expect(options.transportOptions).toBeUndefined(); + expect(options.tunnel).toBe(true); }); - it('does not change the transport options in handler mode', () => { - globalWithInjectedValues._sentryTunnelRouteMode = 'handler'; - const options: BrowserOptions = { dsn: SAAS_DSN }; + it('adds the trailing slash Next.js redirects to', () => { + vi.stubEnv('_sentryTrailingSlash', 'true'); + globalWithInjectedValues._sentryTunnelRoute = '/monitoring'; + const options: BrowserOptions = { dsn: DSN }; applyTunnelRouteOption(options); - expect(options.transportOptions).toBeUndefined(); + expect(options.tunnel).toEqual({ path: '/monitoring', trailingSlash: true }); }); -}); -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 }; + it('keeps the `tunnel` the user set', () => { + globalWithInjectedValues._sentryTunnelRoute = '/monitoring'; + const options: BrowserOptions = { dsn: DSN, tunnel: 'https://tunnel.example.com' }; applyTunnelRouteOption(options); - expect(options.tunnel).toBe('/my-error-monitoring-route'); + expect(options.tunnel).toBe('https://tunnel.example.com'); }); - 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' }; + it('does not set a `tunnel` without a `tunnelRoute`', () => { + const options: BrowserOptions = { dsn: DSN }; applyTunnelRouteOption(options);