diff --git a/dev-packages/bun-integration-tests/node-suites/excludes.ts b/dev-packages/bun-integration-tests/node-suites/excludes.ts index 65edcd311712..5ca029909329 100644 --- a/dev-packages/bun-integration-tests/node-suites/excludes.ts +++ b/dev-packages/bun-integration-tests/node-suites/excludes.ts @@ -25,6 +25,7 @@ const NO_HTTP_SERVER_SPANS = [ 'suites/tracing/httpIntegration/test.ts', 'suites/tracing/httpServerSpans-unrouted/test.ts', 'suites/tracing/ignoreSpans/**', + 'suites/tracing/managed-tunnel/test.ts', 'suites/tracing/meta-tags-twp-errors/test.ts', 'suites/tracing/meta-tags/test.ts', 'suites/tracing/requestData/test.ts', diff --git a/dev-packages/e2e-tests/test-applications/astro-7/astro.config.mjs b/dev-packages/e2e-tests/test-applications/astro-7/astro.config.mjs index 061db869d94b..49370ed1e18b 100644 --- a/dev-packages/e2e-tests/test-applications/astro-7/astro.config.mjs +++ b/dev-packages/e2e-tests/test-applications/astro-7/astro.config.mjs @@ -4,8 +4,13 @@ import { defineConfig } from 'astro/config'; import node from '@astrojs/node'; +// The tunnel variant serves the app under a base path with trailing slashes to cover that the managed tunnel +// follows both. +const tunnelVariant = !!process.env.PUBLIC_E2E_TEST_TUNNEL; + // https://astro.build/config export default defineConfig({ + ...(tunnelVariant && { base: '/docs', trailingSlash: 'always' }), integrations: [ sentry({ debug: true, diff --git a/dev-packages/e2e-tests/test-applications/astro-7/package.json b/dev-packages/e2e-tests/test-applications/astro-7/package.json index 10618dfc3227..8a4fc043ce10 100644 --- a/dev-packages/e2e-tests/test-applications/astro-7/package.json +++ b/dev-packages/e2e-tests/test-applications/astro-7/package.json @@ -11,7 +11,10 @@ "test:build": "pnpm install && pnpm build", "test:assert": "pnpm test:prod && pnpm test:dev", "test:prod": "TEST_ENV=production playwright test", - "test:dev": "TEST_ENV=development playwright test db" + "test:dev": "TEST_ENV=development playwright test db", + "test:build:tunnel": "pnpm install && PUBLIC_E2E_TEST_TUNNEL=1 PUBLIC_E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm build", + "test:assert:tunnel": "PUBLIC_E2E_TEST_TUNNEL=1 PUBLIC_E2E_TEST_DSN=http://public@localhost:3031/1337 TEST_ENV=production playwright test tunnel", + "test:assert:tunnel-dev": "PUBLIC_E2E_TEST_TUNNEL=1 PUBLIC_E2E_TEST_DSN=http://public@localhost:3031/1337 TEST_ENV=development playwright test tunnel" }, "//": "Need to use ioredis 5.10.1 because that's the last version before they support tracing channels", "dependencies": { @@ -27,6 +30,18 @@ "extends": "../../package.json" }, "sentryTest": { - "optional": true + "optional": true, + "variants": [ + { + "build-command": "pnpm test:build:tunnel", + "assert-command": "pnpm test:assert:tunnel", + "label": "astro-7 (tunnel)" + }, + { + "label": "astro-7 (tunnel-dev)", + "build-command": "pnpm install", + "assert-command": "pnpm test:assert:tunnel-dev" + } + ] } } diff --git a/dev-packages/e2e-tests/test-applications/astro-7/sentry.client.config.js b/dev-packages/e2e-tests/test-applications/astro-7/sentry.client.config.js index 83573d36d0be..c0f094f0dd1a 100644 --- a/dev-packages/e2e-tests/test-applications/astro-7/sentry.client.config.js +++ b/dev-packages/e2e-tests/test-applications/astro-7/sentry.client.config.js @@ -4,7 +4,7 @@ Sentry.init({ dsn: import.meta.env.PUBLIC_E2E_TEST_DSN, environment: 'qa', tracesSampleRate: 1.0, - tunnel: 'http://localhost:3031/', // proxy server + tunnel: import.meta.env.PUBLIC_E2E_TEST_TUNNEL ? true : 'http://localhost:3031/', // proxy server integrations: [ Sentry.browserTracingIntegration({ beforeStartSpan: opts => { diff --git a/dev-packages/e2e-tests/test-applications/astro-7/sentry.server.config.js b/dev-packages/e2e-tests/test-applications/astro-7/sentry.server.config.js index bc90470cef38..ae5ecf038b62 100644 --- a/dev-packages/e2e-tests/test-applications/astro-7/sentry.server.config.js +++ b/dev-packages/e2e-tests/test-applications/astro-7/sentry.server.config.js @@ -4,6 +4,6 @@ Sentry.init({ dsn: import.meta.env.PUBLIC_E2E_TEST_DSN, environment: 'qa', tracesSampleRate: 1.0, - tunnel: 'http://localhost:3031/', // proxy server + tunnel: import.meta.env.PUBLIC_E2E_TEST_TUNNEL ? true : 'http://localhost:3031/', // proxy server debug: true, }); diff --git a/dev-packages/e2e-tests/test-applications/astro-7/src/middleware.ts b/dev-packages/e2e-tests/test-applications/astro-7/src/middleware.ts new file mode 100644 index 000000000000..fefaf0abe0b4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-7/src/middleware.ts @@ -0,0 +1,14 @@ +import { defineMiddleware } from 'astro:middleware'; + +// The tunnel variant checks that the managed tunnel answers before an auth middleware like this one. +export const onRequest = defineMiddleware((context, next) => { + if ( + import.meta.env.PUBLIC_E2E_TEST_TUNNEL && + context.request.method === 'POST' && + context.request.headers.get('x-auth') !== 'ok' + ) { + return new Response('blocked by user auth', { status: 401 }); + } + + return next(); +}); diff --git a/dev-packages/e2e-tests/test-applications/astro-7/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/astro-7/tests/tunnel.test.ts new file mode 100644 index 000000000000..96d07a6a5291 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-7/tests/tunnel.test.ts @@ -0,0 +1,43 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +test.skip(!process.env.PUBLIC_E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); + +const TUNNEL_PATH = /^\/docs\/[a-z0-9]{1,7}\/$/; + +test('answers the managed tunnel under the base path before the app middleware', async ({ page }) => { + // The variant's auth middleware rejects every other POST. + expect((await page.request.post('/docs/anything/', { data: 'x' })).status()).toBe(401); + + const tunnelServerSpanPromise = waitForStreamedSpan( + 'astro-7', + span => + getSpanOp(span) === 'http.server' && + /\/docs\/[a-z0-9]{1,7}\/?$/.test(String(span.attributes['url.path']?.value ?? '')), + ); + const tunnelResponsePromise = page.waitForResponse( + response => response.request().method() === 'POST' && TUNNEL_PATH.test(new URL(response.url()).pathname), + ); + + await page.goto('/docs/'); + const tunnelResponse = await tunnelResponsePromise; + + expect(tunnelResponse.status()).toBe(200); + expect(new URL(tunnelResponse.url()).origin).toBe(new URL(page.url()).origin); + + // Anchor on a regular server span requested after the tunnel traffic: spans flush in order, so a tunnel span + // would arrive first. + const anchorSpanPromise = waitForStreamedSpan( + 'astro-7', + span => + getSpanOp(span) === 'http.server' && + String(span.attributes['url.path']?.value ?? '').startsWith('/docs/test-ssr'), + ); + await page.request.get('/docs/test-ssr/'); + + const winner = await Promise.race([ + tunnelServerSpanPromise.then(() => 'tunnel' as const), + anchorSpanPromise.then(() => 'anchor' as const), + ]); + expect(winner).toBe('anchor'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/instrumentation-client.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/instrumentation-client.ts index 6709e1cf54a7..2aacb025d0f3 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/instrumentation-client.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/instrumentation-client.ts @@ -2,8 +2,8 @@ import * as Sentry from '@sentry/nextjs'; Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions - // Use a fake but properly formatted Sentry SaaS DSN for tunnel route testing - dsn: 'https://public@o12345.ingest.us.sentry.io/67890', + // The tunnel forwards to the DSN host, which is the event proxy here + dsn: 'http://public@localhost:3031/1337', // No tunnel option - using tunnelRoute from withSentryConfig tracesSampleRate: 1.0, }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json index 0b8ace0062d4..b07ffb0f4a6b 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json @@ -4,15 +4,15 @@ "private": true, "scripts": { "dev": " next dev", - "build": "_SENTRY_TUNNEL_DESTINATION_OVERRIDE=http://localhost:3031/ next build > .tmp_build_stdout 2> .tmp_build_stderr || (cat .tmp_build_stdout && cat .tmp_build_stderr && exit 1)", + "build": "next build > .tmp_build_stdout 2> .tmp_build_stderr || (cat .tmp_build_stdout && cat .tmp_build_stderr && exit 1)", "clean": "npx rimraf node_modules pnpm-lock.yaml .tmp_dev_server_logs", - "dev:webpack": "_SENTRY_TUNNEL_DESTINATION_OVERRIDE=http://localhost:3031/ next dev --webpack", - "build-webpack": "_SENTRY_TUNNEL_DESTINATION_OVERRIDE=http://localhost:3031/ next build --webpack > .tmp_build_stdout 2> .tmp_build_stderr || (cat .tmp_build_stdout && cat .tmp_build_stderr && exit 1)", - "start": "_SENTRY_TUNNEL_DESTINATION_OVERRIDE=http://localhost:3031/ next start", + "dev:webpack": "next dev --webpack", + "build-webpack": "next build --webpack > .tmp_build_stdout 2> .tmp_build_stderr || (cat .tmp_build_stdout && cat .tmp_build_stderr && exit 1)", + "start": "next start", "lint": "eslint", "test:prod": "TEST_ENV=production playwright test", - "test:dev": "TEST_ENV=development _SENTRY_TUNNEL_DESTINATION_OVERRIDE=http://localhost:3031/ playwright test", - "test:dev-webpack": "TEST_ENV=development-webpack _SENTRY_TUNNEL_DESTINATION_OVERRIDE=http://localhost:3031/ playwright test", + "test:dev": "TEST_ENV=development playwright test", + "test:dev-webpack": "TEST_ENV=development-webpack playwright test", "test:build": "pnpm install && pnpm build", "test:build-webpack": "pnpm install && pnpm build-webpack", "test:build-canary": "pnpm install && pnpm add next@canary && pnpm build", @@ -21,7 +21,7 @@ "test:build-canary-webpack": "pnpm install && pnpm add next@canary && pnpm build-webpack", "test:assert": "pnpm test:prod && pnpm test:dev", "test:assert-webpack": "pnpm test:prod && pnpm test:dev-webpack", - "test:build:cloudflare": "pnpm install && _SENTRY_TUNNEL_DESTINATION_OVERRIDE=http://localhost:3031/ opennextjs-cloudflare build", + "test:build:cloudflare": "pnpm install && opennextjs-cloudflare build", "start:cloudflare": "opennextjs-cloudflare preview" }, "dependencies": { diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/proxy.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/proxy.ts index 28639f60bbe4..0b8645eca9e2 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/proxy.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/proxy.ts @@ -1,7 +1,13 @@ +import * as Sentry from '@sentry/nextjs'; import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; -export async function proxy(_request: NextRequest) { +export async function proxy(request: NextRequest) { + // Stands in for auth middleware, without blocking the other tests. + if (request.headers.get('x-e2e-require-auth') && !Sentry.isTunnelRequest(request)) { + return new NextResponse(null, { status: 401 }); + } + return NextResponse.next(); } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.edge.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.edge.config.ts index 4184d564044f..59e2f1e6cb93 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.edge.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.edge.config.ts @@ -2,8 +2,8 @@ import * as Sentry from '@sentry/nextjs'; Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions - // Use a fake but properly formatted Sentry SaaS DSN for tunnel route testing - dsn: 'https://public@o12345.ingest.us.sentry.io/67890', + // The tunnel forwards to the DSN host, which is the event proxy here + dsn: 'http://public@localhost:3031/1337', // No tunnel option - using tunnelRoute from withSentryConfig tracesSampleRate: 1.0, // debug: true, diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.server.config.ts index 4184d564044f..59e2f1e6cb93 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.server.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.server.config.ts @@ -2,8 +2,8 @@ import * as Sentry from '@sentry/nextjs'; Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions - // Use a fake but properly formatted Sentry SaaS DSN for tunnel route testing - dsn: 'https://public@o12345.ingest.us.sentry.io/67890', + // The tunnel forwards to the DSN host, which is the event proxy here + dsn: 'http://public@localhost:3031/1337', // No tunnel option - using tunnelRoute from withSentryConfig tracesSampleRate: 1.0, // debug: true, diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-dsn-path.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-dsn-path.test.ts new file mode 100644 index 000000000000..2c110b6607ff --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-dsn-path.test.ts @@ -0,0 +1,79 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForError, waitForStreamedSpan, waitForStreamedSpans } from '@sentry-internal/test-utils'; +import type { SerializedStreamedSpan } from '@sentry/core'; +import { getTunnelPath } from '@sentry/core'; + +const TUNNEL_PATH = getTunnelPath('http://public@localhost:3031/1337'); + +test('sends events to the DSN-derived path, served by the app', async ({ page }) => { + const tunnelRequests: string[] = []; + page.on('request', request => { + if (request.method() === 'POST') { + tunnelRequests.push(new URL(request.url()).pathname); + } + }); + + const errorPromise = waitForError('nextjs-16-tunnel', event => { + return event.exception?.values?.[0]?.value === 'Managed tunnel error'; + }); + + await page.goto('/'); + await page.evaluate(() => { + setTimeout(() => { + throw new Error('Managed tunnel error'); + }); + }); + + expect(await errorPromise).toBeDefined(); + expect(tunnelRequests).toContain(TUNNEL_PATH); +}); + +test('does not create server spans for tunnel requests', async ({ page }) => { + const serverSpans: SerializedStreamedSpan[] = []; + void waitForStreamedSpans('nextjs-16-tunnel', spans => { + serverSpans.push(...spans.filter(span => span.is_segment)); + return false; + }); + + const errorPromise = waitForError('nextjs-16-tunnel', event => { + return event.exception?.values?.[0]?.value === 'Managed tunnel span error'; + }); + + await page.goto('/'); + await page.evaluate(() => { + setTimeout(() => { + throw new Error('Managed tunnel span error'); + }); + }); + await errorPromise; + + // Spans flush in order, so a span for the tunnel request would arrive before the one for this request. + const anchorSpanPromise = waitForStreamedSpan( + 'nextjs-16-tunnel', + span => span.is_segment && getSpanOp(span) === 'http.server' && span.name === 'GET /', + ); + await page.request.get('/'); + await anchorSpanPromise; + + const tunnelSpans = serverSpans.filter( + span => span.name.includes(TUNNEL_PATH!) || span.name.includes('sentry-tunnel'), + ); + expect(tunnelSpans).toEqual([]); +}); + +test('lets tunnel requests through auth middleware with `isTunnelRequest`', async ({ request }) => { + const headers = { 'content-type': 'application/x-sentry-envelope', 'x-e2e-require-auth': '1' }; + const data = `${JSON.stringify({ dsn: 'http://public@localhost:3031/1337' })}\n`; + + expect((await request.post(TUNNEL_PATH!, { headers, data })).status()).toBe(200); + expect((await request.post('/notatunnel', { headers, data })).status()).toBe(401); +}); + +test('answers 404 to an envelope POST on any other path', async ({ request }) => { + const response = await request.post('/notatunnel', { + headers: { 'content-type': 'application/x-sentry-envelope' }, + data: `${JSON.stringify({ dsn: 'http://public@localhost:3031/1337' })}\n`, + }); + + expect(response.status()).toBe(404); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/worker.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/worker.ts index 522d8c460dfd..03b360a7f5e4 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/worker.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/worker.ts @@ -5,7 +5,7 @@ import handler from './.open-next/worker.js'; export default Sentry.withSentry( () => ({ environment: 'qa', // dynamic sampling bias to keep transactions - dsn: 'https://public@o12345.ingest.us.sentry.io/67890', + dsn: 'http://public@localhost:3031/1337', // Sends the server spans to the proxy server, so the tests see whether a tunnel request was sampled. tunnel: 'http://localhost:3031/', tracesSampleRate: 1.0, diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/instrumentation-client.ts b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/instrumentation-client.ts index 6dd7cce7b75e..5ff9e9d67e61 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/instrumentation-client.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/instrumentation-client.ts @@ -2,8 +2,9 @@ import * as Sentry from '@sentry/nextjs'; Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions - dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, - tunnel: `http://localhost:3031/`, // proxy server + // The tunnel forwards to the DSN host, so the tunnel variant points it at the event proxy + dsn: process.env.NEXT_PUBLIC_E2E_TUNNEL ? 'http://public@localhost:3031/1337' : process.env.NEXT_PUBLIC_E2E_TEST_DSN, + tunnel: process.env.NEXT_PUBLIC_E2E_TUNNEL ? undefined : `http://localhost:3031/`, // proxy server tracesSampleRate: 1.0, }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/instrumentation.ts b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/instrumentation.ts index 0d67d33d4a94..f85d32b85ca9 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/instrumentation.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/instrumentation.ts @@ -4,8 +4,11 @@ export function register() { if (process.env.NEXT_RUNTIME === 'nodejs' || process.env.NEXT_RUNTIME === 'edge') { Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions - dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, - tunnel: `http://localhost:3031/`, // proxy server + // The tunnel forwards to the DSN host, so the tunnel variant points it at the event proxy + dsn: process.env.NEXT_PUBLIC_E2E_TUNNEL + ? 'http://public@localhost:3031/1337' + : process.env.NEXT_PUBLIC_E2E_TEST_DSN, + tunnel: process.env.NEXT_PUBLIC_E2E_TUNNEL ? undefined : `http://localhost:3031/`, // proxy server tracesSampleRate: 1.0, transportOptions: { // We are doing a lot of events at once in this test diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/middleware.ts b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/middleware.ts index abc565f438b4..374f1e621c2e 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/middleware.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/middleware.ts @@ -15,10 +15,16 @@ export async function middleware(request: NextRequest) { await fetch('http://localhost:3030/'); } + // Stands in for auth middleware, without blocking the other tests. + if (request.headers.has('x-e2e-require-auth') && !Sentry.isTunnelRequest(request)) { + return new NextResponse(null, { status: 401 }); + } + return NextResponse.next(); } // See "Matching Paths" below to learn more export const config = { - matcher: ['/api/endpoint-behind-middleware', '/api/endpoint-behind-faulty-middleware'], + // The tunnel path of the test DSN, and a path next to it for the tunnel variant. + matcher: ['/api/endpoint-behind-middleware', '/api/endpoint-behind-faulty-middleware', '/qpo967', '/notatunnel'], }; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/next.config.js b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/next.config.js index df1b0f5f4147..2961ce220c53 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/next.config.js +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/next.config.js @@ -9,6 +9,7 @@ const nextConfig = { module.exports = withSentryConfig(nextConfig, { debug: true, + tunnelRoute: !!process.env.NEXT_PUBLIC_E2E_TUNNEL, sourcemaps: { deleteSourcemapsAfterUpload: true, }, diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/package.json b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/package.json index 022d28be1c02..e77cccfaf8fa 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/package.json @@ -11,7 +11,9 @@ "test:build": "pnpm install && pnpm build", "test:test-build": "pnpm ts-node --script-mode assert-build.ts", "test:build-15": "pnpm install && pnpm add next@15 && pnpm add react@latest && pnpm add react-dom@latest && pnpm build", - "test:assert": "pnpm test:test-build && pnpm test:prod && pnpm test:dev" + "test:assert": "pnpm test:test-build && pnpm test:prod && pnpm test:dev", + "test:build:tunnel": "pnpm install && NEXT_PUBLIC_E2E_TUNNEL=1 pnpm build", + "test:assert:tunnel": "NEXT_PUBLIC_E2E_TUNNEL=1 pnpm test:prod tunnel-dsn-path" }, "dependencies": { "@sentry/nextjs": "file:../../packed/sentry-nextjs-packed.tgz", @@ -37,6 +39,11 @@ { "build-command": "pnpm test:build-15", "label": "nextjs-pages-dir (next@15)" + }, + { + "build-command": "pnpm test:build:tunnel", + "assert-command": "pnpm test:assert:tunnel", + "label": "nextjs-pages-dir (tunnel)" } ], "optionalVariants": [] diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/tunnel-dsn-path.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/tunnel-dsn-path.test.ts new file mode 100644 index 000000000000..cc6b54f6b15f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/tunnel-dsn-path.test.ts @@ -0,0 +1,81 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForError, waitForStreamedSpan, waitForStreamedSpans } from '@sentry-internal/test-utils'; +import type { SerializedStreamedSpan } from '@sentry/core'; +import { getTunnelPath } from '@sentry/core'; + +const TUNNEL_PATH = getTunnelPath('http://public@localhost:3031/1337'); + +test.skip(!process.env.NEXT_PUBLIC_E2E_TUNNEL, 'Only runs with `tunnelRoute`'); + +test('sends events to the DSN-derived path, served by the app', async ({ page }) => { + const tunnelRequests: string[] = []; + page.on('request', request => { + if (request.method() === 'POST') { + tunnelRequests.push(new URL(request.url()).pathname); + } + }); + + const errorPromise = waitForError('nextjs-pages-dir', event => { + return event.exception?.values?.[0]?.value === 'Managed tunnel error'; + }); + + await page.goto('/'); + await page.evaluate(() => { + setTimeout(() => { + throw new Error('Managed tunnel error'); + }); + }); + + expect(await errorPromise).toBeDefined(); + expect(tunnelRequests).toContain(TUNNEL_PATH); +}); + +test('does not create server spans for tunnel requests', async ({ page }) => { + const serverSpans: SerializedStreamedSpan[] = []; + void waitForStreamedSpans('nextjs-pages-dir', spans => { + serverSpans.push(...spans.filter(span => span.is_segment)); + return false; + }); + + const errorPromise = waitForError('nextjs-pages-dir', event => { + return event.exception?.values?.[0]?.value === 'Managed tunnel span error'; + }); + + await page.goto('/'); + await page.evaluate(() => { + setTimeout(() => { + throw new Error('Managed tunnel span error'); + }); + }); + await errorPromise; + + // Spans flush in order, so a span for the tunnel request would arrive before the one for this request. + const anchorSpanPromise = waitForStreamedSpan( + 'nextjs-pages-dir', + span => span.is_segment && getSpanOp(span) === 'http.server' && span.name === 'GET /', + ); + await page.request.get('/'); + await anchorSpanPromise; + + const tunnelSpans = serverSpans.filter( + span => span.name.includes(TUNNEL_PATH!) || span.name.includes('sentry-tunnel'), + ); + expect(tunnelSpans).toEqual([]); +}); + +test('lets tunnel requests through auth middleware with `isTunnelRequest`', async ({ request }) => { + const headers = { 'content-type': 'application/x-sentry-envelope', 'x-e2e-require-auth': '1' }; + const data = `${JSON.stringify({ dsn: 'http://public@localhost:3031/1337' })}\n`; + + expect((await request.post(TUNNEL_PATH!, { headers, data })).status()).toBe(200); + expect((await request.post('/notatunnel', { headers, data })).status()).toBe(401); +}); + +test('answers 404 to an envelope POST on any other path', async ({ request }) => { + const response = await request.post('/notatunnel', { + headers: { 'content-type': 'application/x-sentry-envelope' }, + data: `${JSON.stringify({ dsn: 'http://public@localhost:3031/1337' })}\n`, + }); + + expect(response.status()).toBe(404); +}); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/instrument.mjs b/dev-packages/e2e-tests/test-applications/nitro-3/instrument.mjs index 53b80d309a5b..ff6290e461a4 100644 --- a/dev-packages/e2e-tests/test-applications/nitro-3/instrument.mjs +++ b/dev-packages/e2e-tests/test-applications/nitro-3/instrument.mjs @@ -2,7 +2,7 @@ import * as Sentry from '@sentry/nitro'; Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions - dsn: process.env.E2E_TEST_DSN, - tunnel: `http://localhost:3031/`, // proxy server + dsn: process.env.E2E_TEST_TUNNEL ? 'http://public@localhost:3031/1337' : process.env.E2E_TEST_DSN, + tunnel: process.env.E2E_TEST_TUNNEL ? true : `http://localhost:3031/`, // proxy server tracesSampleRate: 1, }); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/package.json b/dev-packages/e2e-tests/test-applications/nitro-3/package.json index eb77a39f32fb..cfc11a05b65e 100644 --- a/dev-packages/e2e-tests/test-applications/nitro-3/package.json +++ b/dev-packages/e2e-tests/test-applications/nitro-3/package.json @@ -9,7 +9,15 @@ "clean": "npx rimraf node_modules pnpm-lock.yaml .output", "test": "playwright test", "test:build": "pnpm install && pnpm build", - "test:assert": "pnpm test" + "test:assert": "pnpm test", + "test:build:tunnel": "pnpm install && E2E_TEST_TUNNEL=1 pnpm build", + "test:build:tunnel-basepath": "pnpm install && E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app pnpm build", + "test:assert:tunnel": "E2E_TEST_TUNNEL=1 playwright test tunnel", + "test:assert:tunnel-basepath": "E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app playwright test tunnel", + "test:assert:tunnel-dev": "TEST_ENV=development E2E_TEST_TUNNEL=1 playwright test tunnel", + "test:assert:tunnel-basepath-dev": "TEST_ENV=development E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app playwright test tunnel", + "test:build:tunnel-no-middleware": "pnpm install && E2E_TEST_TUNNEL=1 E2E_TEST_NO_MIDDLEWARE=1 pnpm build", + "test:assert:tunnel-no-middleware": "E2E_TEST_TUNNEL=1 E2E_TEST_NO_MIDDLEWARE=1 playwright test tunnel -g 'without app middleware'" }, "dependencies": { "@sentry/browser": "latest || *", @@ -25,5 +33,34 @@ }, "volta": { "extends": "../../package.json" + }, + "sentryTest": { + "variants": [ + { + "build-command": "pnpm test:build:tunnel", + "assert-command": "pnpm test:assert:tunnel", + "label": "nitro-3 (tunnel)" + }, + { + "build-command": "pnpm test:build:tunnel-basepath", + "assert-command": "pnpm test:assert:tunnel-basepath", + "label": "nitro-3 (tunnel-basepath)" + }, + { + "build-command": "pnpm test:build:tunnel-no-middleware", + "assert-command": "pnpm test:assert:tunnel-no-middleware", + "label": "nitro-3 (tunnel-no-middleware)" + }, + { + "build-command": "pnpm install", + "assert-command": "pnpm test:assert:tunnel-dev", + "label": "nitro-3 (tunnel-dev)" + }, + { + "build-command": "pnpm install", + "assert-command": "pnpm test:assert:tunnel-basepath-dev", + "label": "nitro-3 (tunnel-basepath-dev)" + } + ] } } diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/nitro-3/playwright.config.mjs index 31f2b913b58b..62bc93244137 100644 --- a/dev-packages/e2e-tests/test-applications/nitro-3/playwright.config.mjs +++ b/dev-packages/e2e-tests/test-applications/nitro-3/playwright.config.mjs @@ -1,7 +1,10 @@ import { getPlaywrightConfig } from '@sentry-internal/test-utils'; const config = getPlaywrightConfig({ - startCommand: `pnpm start`, + startCommand: + process.env.TEST_ENV === 'development' + ? `PORT=3030 NODE_OPTIONS='--import ./instrument.mjs' pnpm exec vite dev --port 3030` + : `pnpm start`, }); export default config; diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/server/middleware/test.ts b/dev-packages/e2e-tests/test-applications/nitro-3/server/middleware/test.ts index 92d8f80c3756..0120ae6ce24e 100644 --- a/dev-packages/e2e-tests/test-applications/nitro-3/server/middleware/test.ts +++ b/dev-packages/e2e-tests/test-applications/nitro-3/server/middleware/test.ts @@ -1,6 +1,11 @@ -import { defineHandler, getQuery, setResponseHeader } from 'nitro/h3'; +import { defineHandler, getQuery, HTTPError, setResponseHeader } from 'nitro/h3'; export default defineHandler(event => { + // The tunnel variant checks that the managed tunnel answers before an auth middleware like this one. + if (process.env.E2E_TEST_TUNNEL && event.req.method === 'POST' && event.req.headers.get('x-auth') !== 'ok') { + throw new HTTPError({ status: 401, statusText: 'blocked by user auth' }); + } + setResponseHeader(event, 'x-sentry-test-middleware', 'executed'); const query = getQuery(event); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/src/main.ts b/dev-packages/e2e-tests/test-applications/nitro-3/src/main.ts index d27d0ba1763a..d09a43e411ed 100644 --- a/dev-packages/e2e-tests/test-applications/nitro-3/src/main.ts +++ b/dev-packages/e2e-tests/test-applications/nitro-3/src/main.ts @@ -1,10 +1,18 @@ import * as Sentry from '@sentry/browser'; +declare const __E2E_TEST_TUNNEL__: boolean; +declare const __E2E_TEST_BASE_PATH__: string; + // Let's us test trace propagation Sentry.init({ environment: 'qa', - dsn: 'https://public@dsn.ingest.sentry.io/1337', - tunnel: 'http://localhost:3031/', // proxy server + dsn: __E2E_TEST_TUNNEL__ ? 'http://public@localhost:3031/1337' : 'https://public@dsn.ingest.sentry.io/1337', + // Plain Nitro has no client SDK to pass `baseURL` on, so the browser names it. The server picks it up itself. + tunnel: __E2E_TEST_TUNNEL__ + ? __E2E_TEST_BASE_PATH__ + ? { basePath: __E2E_TEST_BASE_PATH__ } + : true + : 'http://localhost:3031/', // proxy server integrations: [Sentry.browserTracingIntegration()], tracesSampleRate: 1.0, }); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/nitro-3/tests/tunnel.test.ts new file mode 100644 index 000000000000..2735ea4a7193 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/tests/tunnel.test.ts @@ -0,0 +1,83 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; +import { getTunnelPath } from '@sentry/core'; +import { connect } from 'node:net'; + +test.skip(!process.env.E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); + +const BASE_PATH = process.env.E2E_TEST_BASE_PATH ?? ''; +const TUNNEL_PATH = new RegExp(`^${BASE_PATH}/[a-z0-9]{1,7}$`); + +test('answers the managed tunnel before the app auth middleware', async ({ page }) => { + // The variant's auth middleware rejects every other POST. + expect((await page.request.post(`${BASE_PATH}/api/anything`, { data: 'x' })).status()).toBe(401); + + const tunnelServerSpanPromise = waitForStreamedSpan( + 'nitro-3', + span => getSpanOp(span) === 'http.server' && TUNNEL_PATH.test(String(span.attributes['url.path']?.value ?? '')), + ); + const tunnelResponsePromise = page.waitForResponse( + response => response.request().method() === 'POST' && TUNNEL_PATH.test(new URL(response.url()).pathname), + ); + + await page.goto(`${BASE_PATH}/`); + const tunnelResponse = await tunnelResponsePromise; + + expect(tunnelResponse.status()).toBe(200); + expect(new URL(tunnelResponse.url()).origin).toBe(new URL(page.url()).origin); + + // Anchor on a regular server span requested after the tunnel traffic: spans flush in order, so a tunnel span + // would arrive first. + const anchorSpanPromise = waitForStreamedSpan( + 'nitro-3', + span => + getSpanOp(span) === 'http.server' && + String(span.attributes['url.path']?.value ?? '') === `${BASE_PATH}/api/test-transaction`, + ); + await page.request.get(`${BASE_PATH}/api/test-transaction`); + + const winner = await Promise.race([ + tunnelServerSpanPromise.then(() => 'tunnel' as const), + anchorSpanPromise.then(() => 'anchor' as const), + ]); + expect(winner).toBe('anchor'); +}); + +test('answers the managed tunnel without app middleware', async ({ request }) => { + const dsn = 'http://public@localhost:3031/1337'; + const envelopeHeader = { dsn, sent_at: new Date().toISOString() }; + const response = await request.post(`${BASE_PATH}${getTunnelPath(dsn)}`, { + data: `${JSON.stringify(envelopeHeader)}\n${JSON.stringify({ type: 'client_report' })}\n${JSON.stringify({ timestamp: Date.now() / 1000, discarded_events: [] })}\n`, + headers: { 'Content-Type': 'application/x-sentry-envelope' }, + }); + + expect(response.headers()['x-sentry-tunnel']).toBe('1'); + expect(response.status()).toBe(200); +}); + +test('does not report a client that drops the connection before the envelope header as an error', async ({ + request, +}) => { + const dsn = 'http://public@localhost:3031/1337'; + const firstErrorPromise = waitForError('nitro-3', () => true); + + // Part of the envelope header line, without its newline, then the connection drops. + await new Promise(resolve => { + const socket = connect(3030, 'localhost', () => { + socket.write( + `POST ${BASE_PATH}${getTunnelPath(dsn)} HTTP/1.1\r\nHost: localhost:3030\r\nContent-Type: application/x-sentry-envelope\r\nContent-Length: 1000000\r\n\r\n`, + ); + socket.write(JSON.stringify({ dsn, padding: 'x'.repeat(1000) }).slice(0, 500)); + setTimeout(() => { + socket.destroy(); + resolve(); + }, 300); + }); + }); + + // Errors are sent in order, so one from the dropped request would arrive before this one. + await request.get(`${BASE_PATH}/api/test-error`); + + const firstError = await firstErrorPromise; + expect(firstError.exception?.values?.[0]?.value).toBe('This is a test error'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/vite.config.ts b/dev-packages/e2e-tests/test-applications/nitro-3/vite.config.ts index d488f8298777..083005ad7ac4 100644 --- a/dev-packages/e2e-tests/test-applications/nitro-3/vite.config.ts +++ b/dev-packages/e2e-tests/test-applications/nitro-3/vite.config.ts @@ -3,12 +3,24 @@ import { nitro } from 'nitro/vite'; import { defineConfig } from 'vite'; export default defineConfig({ + define: { + __E2E_TEST_TUNNEL__: JSON.stringify(!!process.env.E2E_TEST_TUNNEL), + __E2E_TEST_BASE_PATH__: JSON.stringify(process.env.E2E_TEST_BASE_PATH ?? ''), + }, plugins: [ nitro( // FIXME: Nitro plugin has a type issue // @ts-expect-error withSentryConfig({ serverDir: './server', + ...(process.env.E2E_TEST_BASE_PATH && { baseURL: `${process.env.E2E_TEST_BASE_PATH}/` }), + // Without static assets, global middleware or routed middleware, Nitro skips its middleware array whenever + // route rules exist, as on serverless presets. + ...(process.env.E2E_TEST_NO_MIDDLEWARE && { + serveStatic: false, + ignore: ['**/middleware/**'], + routeRules: { '/api/**': { headers: { 'x-e2e-route-rule': '1' } } }, + }), }), ), ], diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/nuxt.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/nuxt.config.ts index 63f6e2e2633b..b83483ac0363 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/nuxt.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/nuxt.config.ts @@ -2,6 +2,8 @@ export default defineNuxtConfig({ compatibilityDate: '2025-06-06', modules: ['@sentry/nuxt/module'], + // The tunnel variant serves the app, and so the tunnel, under a base URL. + ...(process.env.E2E_TEST_TUNNEL && { app: { baseURL: '/app/' } }), nitro: { preset: 'cloudflare_module', cloudflare: { @@ -14,5 +16,9 @@ export default defineNuxtConfig({ alias: { 'readable-stream': 'node:stream', }, + // The tunnel variant serves the managed tunnel. + replace: { + __E2E_TEST_TUNNEL__: JSON.stringify(!!process.env.E2E_TEST_TUNNEL), + }, }, }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/package.json b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/package.json index 9947f20ef982..76dbfd4b6149 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/package.json @@ -8,7 +8,9 @@ "preview": "wrangler dev --var \"E2E_TEST_DSN:$E2E_TEST_DSN\" --port 3030 --log-level=$(test $CI && echo 'none' || echo 'log')", "test": "playwright test", "test:build": "pnpm install && pnpm build", - "test:assert": "pnpm test" + "test:assert": "pnpm test", + "test:build:tunnel": "pnpm install && E2E_TEST_TUNNEL=1 pnpm build", + "test:assert:tunnel": "E2E_TEST_TUNNEL=1 pnpm test tunnel" }, "dependencies": { "@sentry/nuxt": "file:../../packed/sentry-nuxt-packed.tgz", @@ -23,5 +25,14 @@ }, "volta": { "extends": "../../package.json" + }, + "sentryTest": { + "variants": [ + { + "build-command": "pnpm test:build:tunnel", + "assert-command": "pnpm test:assert:tunnel", + "label": "nuxt-4-cloudflare (tunnel)" + } + ] } } diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/server/api/anchor.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/server/api/anchor.ts new file mode 100644 index 000000000000..cee7601e363b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/server/api/anchor.ts @@ -0,0 +1,4 @@ +import { defineEventHandler } from '#imports'; + +// A plain route for tests to request after the tunnel traffic. +export default defineEventHandler(() => 'ok'); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/server/plugins/sentry.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/server/plugins/sentry.ts index faa56e1db7ef..794c67678b48 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/server/plugins/sentry.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/server/plugins/sentry.ts @@ -1,10 +1,13 @@ import { defineNitroPlugin } from '#imports'; import { sentryCloudflareNitroPlugin } from '@sentry/nuxt/module/plugins'; +declare const __E2E_TEST_TUNNEL__: boolean; + export default defineNitroPlugin( sentryCloudflareNitroPlugin({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', + // The tunnel forwards to the DSN host, which is the event proxy in the tunnel variant. + dsn: __E2E_TEST_TUNNEL__ ? 'http://public@localhost:3031/1337' : 'https://public@dsn.ingest.sentry.io/1337', tracesSampleRate: 1.0, - tunnel: 'http://localhost:3031/', // proxy server + tunnel: __E2E_TEST_TUNNEL__ ? true : 'http://localhost:3031/', // proxy server }), ); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/tests/tunnel.test.ts new file mode 100644 index 000000000000..7f1cd7bd7a1e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/tests/tunnel.test.ts @@ -0,0 +1,43 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +test.skip(!process.env.E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); + +// First in this file: the isolate's first tunnel request is the one a late span filter would let through. +test('drops the server span of a tunnel request under the base URL', async ({ request }) => { + const dsn = 'http://public@localhost:3031/1337'; + const tunnelSpanPromise = waitForStreamedSpan( + 'nuxt-4-cloudflare', + span => + getSpanOp(span) === 'http.server' && String(span.attributes['url.path']?.value ?? '').startsWith('/app/qpo967'), + ); + const anchorSpanPromise = waitForStreamedSpan( + 'nuxt-4-cloudflare', + span => getSpanOp(span) === 'http.server' && span.attributes['url.path']?.value === '/app/api/anchor', + ); + + await request.post('/app/qpo967', { + data: `${JSON.stringify({ dsn, sent_at: new Date().toISOString() })}\n${JSON.stringify({ type: 'client_report' })}\n${JSON.stringify({ timestamp: Date.now() / 1000, discarded_events: [] })}\n`, + headers: { 'Content-Type': 'application/x-sentry-envelope' }, + }); + // Spans flush in order, so a tunnel span would arrive before this one. + await request.get('/app/api/anchor'); + + const winner = await Promise.race([ + tunnelSpanPromise.then(() => 'tunnel' as const), + anchorSpanPromise.then(() => 'anchor' as const), + ]); + expect(winner).toBe('anchor'); +}); + +test('answers the managed tunnel when the SDK is set up with `sentryCloudflareNitroPlugin`', async ({ request }) => { + const dsn = 'http://public@localhost:3031/1337'; + // The path the SDK derives from `dsn`. + const response = await request.post('/app/qpo967', { + data: `${JSON.stringify({ dsn, sent_at: new Date().toISOString() })}\n${JSON.stringify({ type: 'client_report' })}\n${JSON.stringify({ timestamp: Date.now() / 1000, discarded_events: [] })}\n`, + headers: { 'Content-Type': 'application/x-sentry-envelope' }, + }); + + expect(response.headers()['x-sentry-tunnel']).toBe('1'); + expect(response.status()).toBe(200); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts index c7acd2b12328..4f456ce2efdf 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts @@ -1,4 +1,6 @@ // https://nuxt.com/docs/api/configuration/nuxt-config +const tunnelVariant = !!process.env.E2E_TEST_TUNNEL; + export default defineNuxtConfig({ compatibilityDate: '2025-06-06', imports: { autoImport: false }, @@ -10,14 +12,21 @@ export default defineNuxtConfig({ '/rendering-modes/swr-cached-page': { swr: true }, '/rendering-modes/swr-1h-cached-page': { swr: 3600 }, '/rendering-modes/pre-rendered-page': { prerender: true }, + // A headers rule on every path, like a security headers setup, also covers the tunnel's path. + ...(tunnelVariant && { '/**': { headers: { 'x-e2e-route-rule': '1' } } }), }, + // The base-path variant serves the app, and so the tunnel, under `/app`. + ...(process.env.E2E_TEST_BASE_PATH && { app: { baseURL: `${process.env.E2E_TEST_BASE_PATH}/` } }), + modules: ['@pinia/nuxt', '@sentry/nuxt/module'], runtimeConfig: { public: { sentry: { - dsn: 'https://public@dsn.ingest.sentry.io/1337', + // The tunnel variant forwards to the test proxy, so the DSN has to point there. + dsn: tunnelVariant ? 'http://public@localhost:3031/1337' : 'https://public@dsn.ingest.sentry.io/1337', }, + e2eTunnel: tunnelVariant, }, }, nitro: { diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json index 5f84fb085b6d..6a5c249f7aef 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json @@ -16,7 +16,13 @@ "test:build": "pnpm install && node ./scripts/build-with-prerender-event-sink.mjs", "test:build-canary": "pnpm add nuxt@npm:nuxt-nightly@latest && pnpm add nitropack@npm:nitropack-nightly@latest && pnpm install --force && node ./scripts/build-with-prerender-event-sink.mjs", "test:assert": "pnpm test:prod && pnpm test:dev", - "test:prod:import": "TEST_ENV=production-import playwright test" + "test:prod:import": "TEST_ENV=production-import playwright test", + "test:build:tunnel": "pnpm install && E2E_TEST_TUNNEL=1 node ./scripts/build-with-prerender-event-sink.mjs", + "test:build:tunnel-basepath": "pnpm install && E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app node ./scripts/build-with-prerender-event-sink.mjs", + "test:assert:tunnel": "E2E_TEST_TUNNEL=1 TEST_ENV=production playwright test tunnel", + "test:assert:tunnel-basepath": "E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app TEST_ENV=production playwright test tunnel", + "test:assert:tunnel-dev": "E2E_TEST_TUNNEL=1 TEST_ENV=development playwright test tunnel", + "test:assert:tunnel-basepath-dev": "E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app TEST_ENV=development playwright test tunnel" }, "//": "Pin ioredis to 5.10.1 because that's the last version before it publishes its own diagnostics channels", "dependencies": { @@ -45,6 +51,26 @@ "build-command": "pnpm test:build", "assert-command": "pnpm test:prod:import", "label": "nuxt-4 (--import compat)" + }, + { + "build-command": "pnpm test:build:tunnel", + "assert-command": "pnpm test:assert:tunnel", + "label": "nuxt-4 (tunnel)" + }, + { + "build-command": "pnpm test:build:tunnel-basepath", + "assert-command": "pnpm test:assert:tunnel-basepath", + "label": "nuxt-4 (tunnel-basepath)" + }, + { + "build-command": "pnpm install", + "assert-command": "pnpm test:assert:tunnel-dev", + "label": "nuxt-4 (tunnel-dev)" + }, + { + "build-command": "pnpm install", + "assert-command": "pnpm test:assert:tunnel-basepath-dev", + "label": "nuxt-4 (tunnel-basepath-dev)" } ], "optionalVariants": [ diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.client.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.client.config.ts index 7b5d97ff0b09..64c786d5f7e8 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.client.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.client.config.ts @@ -3,7 +3,7 @@ import { usePinia, useRuntimeConfig } from '#imports'; Sentry.init({ dsn: useRuntimeConfig().public.sentry.dsn, - tunnel: `http://localhost:3031/`, // proxy server + tunnel: useRuntimeConfig().public.e2eTunnel ? true : `http://localhost:3031/`, // proxy server tracesSampleRate: 1.0, integrations: [ Sentry.piniaIntegration(usePinia(), { diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.server.config.ts index acce04cc9d04..1e29b95f161a 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.server.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.server.config.ts @@ -1,9 +1,9 @@ import * as Sentry from '@sentry/nuxt'; Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', + dsn: process.env.E2E_TEST_TUNNEL ? 'http://public@localhost:3031/1337' : 'https://public@dsn.ingest.sentry.io/1337', tracesSampleRate: 1.0, // Capture 100% of the transactions - tunnel: 'http://localhost:3031/', // proxy server + tunnel: process.env.E2E_TEST_TUNNEL ? true : 'http://localhost:3031/', // proxy server // Opt into the Sentry OpenTelemetry tracer provider in the "(tracer provider)" e2e variant. // Leaving it `undefined` otherwise keeps the SDK's default (no provider). enableOpenTelemetrySetup: process.env.E2E_TEST_OTEL_SETUP === 'true' ? true : undefined, diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/server/middleware/03.auth.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/server/middleware/03.auth.ts index 6dcd9a075589..471d1d32d7a4 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/server/middleware/03.auth.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/server/middleware/03.auth.ts @@ -1,6 +1,11 @@ -import { defineEventHandler, setHeader, getQuery } from '#imports'; +import { createError, defineEventHandler, getHeader, setHeader, getQuery } from '#imports'; export default defineEventHandler(async event => { + // The tunnel variant checks that the managed tunnel answers before an auth middleware like this one. + if (process.env.E2E_TEST_TUNNEL && event.method === 'POST' && getHeader(event, 'x-auth') !== 'ok') { + throw createError({ statusCode: 401, statusMessage: 'blocked by user auth' }); + } + // Check if we should throw an error const query = getQuery(event); if (query.throwError === 'true') { diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tunnel.test.ts new file mode 100644 index 000000000000..20fa9eed14a9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tunnel.test.ts @@ -0,0 +1,72 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +test.skip(!process.env.E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); + +const BASE_PATH = process.env.E2E_TEST_BASE_PATH ?? ''; +const TUNNEL_PATH = new RegExp(`^${BASE_PATH}/[a-z0-9]{1,7}$`); + +test('answers the managed tunnel before the app auth middleware', async ({ page }) => { + // The variant's auth middleware rejects every other POST. + expect((await page.request.post(`${BASE_PATH}/api/anything`, { data: 'x' })).status()).toBe(401); + + const tunnelServerSpanPromise = waitForStreamedSpan( + 'nuxt-4', + span => getSpanOp(span) === 'http.server' && TUNNEL_PATH.test(String(span.attributes['url.path']?.value ?? '')), + ); + const tunnelResponsePromise = page.waitForResponse( + response => response.request().method() === 'POST' && TUNNEL_PATH.test(new URL(response.url()).pathname), + ); + + await page.goto(`${BASE_PATH}/`); + const tunnelResponse = await tunnelResponsePromise; + + expect(tunnelResponse.status()).toBe(200); + expect(new URL(tunnelResponse.url()).origin).toBe(new URL(page.url()).origin); + + // Anchor on a regular server span requested after the tunnel traffic: spans flush in order, so a tunnel span + // would arrive first. + const anchorSpanPromise = waitForStreamedSpan( + 'nuxt-4', + span => + getSpanOp(span) === 'http.server' && + String(span.attributes['url.path']?.value ?? '') === `${BASE_PATH}/api/test-param/123`, + ); + await page.request.get(`${BASE_PATH}/api/test-param/123`); + + const winner = await Promise.race([ + tunnelServerSpanPromise.then(() => 'tunnel' as const), + anchorSpanPromise.then(() => 'anchor' as const), + ]); + expect(winner).toBe('anchor'); +}); + +test('answers the managed tunnel once, without errors from route rules that run after it', async ({ request }) => { + const dsn = 'http://public@localhost:3031/1337'; + // The path the SDK derives from `dsn`. + const tunnelPath = '/qpo967'; + const tunnelErrorPromise = waitForError( + 'nuxt-4', + event => !!event.exception?.values?.[0]?.value?.includes('Cannot set headers'), + ); + const anchorErrorPromise = waitForError( + 'nuxt-4', + event => event.exception?.values?.[0]?.value === 'Nuxt 4 Server error', + ); + + const response = await request.post(`${BASE_PATH}${tunnelPath}`, { + data: `${JSON.stringify({ dsn, sent_at: new Date().toISOString() })}\n${JSON.stringify({ type: 'client_report' })}\n${JSON.stringify({ timestamp: Date.now() / 1000, discarded_events: [] })}\n`, + headers: { 'Content-Type': 'application/x-sentry-envelope' }, + }); + expect(response.headers()['x-sentry-tunnel']).toBe('1'); + expect(response.status()).toBe(200); + + // Errors are sent in order, so one from the tunnel request would arrive before this later one. + await request.get(`${BASE_PATH}/api/server-error`); + + const winner = await Promise.race([ + tunnelErrorPromise.then(() => 'tunnel' as const), + anchorErrorPromise.then(() => 'anchor' as const), + ]); + expect(winner).toBe('anchor'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/nuxt.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/nuxt.config.ts index bdef334cfa88..8016834d1167 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/nuxt.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/nuxt.config.ts @@ -1,4 +1,6 @@ // https://nuxt.com/docs/api/configuration/nuxt-config +const tunnelVariant = !!process.env.E2E_TEST_TUNNEL; + export default defineNuxtConfig({ compatibilityDate: '2025-07-15', imports: { autoImport: false }, @@ -12,12 +14,17 @@ export default defineNuxtConfig({ '/rendering-modes/pre-rendered-page': { prerender: true }, }, + // The base-path variant serves the app, and so the tunnel, under `/app`. + ...(process.env.E2E_TEST_BASE_PATH && { app: { baseURL: `${process.env.E2E_TEST_BASE_PATH}/` } }), + modules: ['@pinia/nuxt', '@sentry/nuxt/module'], runtimeConfig: { public: { sentry: { - dsn: 'https://public@dsn.ingest.sentry.io/1337', + // The tunnel variant forwards to the test proxy, so the DSN has to point there. + dsn: tunnelVariant ? 'http://public@localhost:3031/1337' : 'https://public@dsn.ingest.sentry.io/1337', }, + e2eTunnel: tunnelVariant, }, }, nitro: { diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/package.json b/dev-packages/e2e-tests/test-applications/nuxt-5/package.json index 6e84b33b97fa..1b04f519e281 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/package.json @@ -15,7 +15,12 @@ "test:dev": "TEST_ENV=development playwright test environment", "test:build": "pnpm install && pnpm build", "test:build-canary": "pnpm add nuxt@npm:nuxt-nightly@latest && pnpm add nitro@npm:nitro-nightly@latest && pnpm install --force && pnpm build", - "test:assert": "pnpm test:prod && pnpm test:dev" + "test:assert": "pnpm test:prod && pnpm test:dev", + "test:build:tunnel": "pnpm install && E2E_TEST_TUNNEL=1 pnpm build", + "test:build:tunnel-basepath": "pnpm install && E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app pnpm build", + "test:assert:tunnel": "E2E_TEST_TUNNEL=1 TEST_ENV=production playwright test tunnel", + "test:assert:tunnel-basepath": "E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app TEST_ENV=production playwright test tunnel", + "test:assert:tunnel-dev": "E2E_TEST_TUNNEL=1 TEST_ENV=development playwright test tunnel" }, "//": [ "Currently, we need to install the latest version of Nitro and the Nuxt nightlies as those contain Nuxt v5", @@ -38,7 +43,24 @@ "@sentry-internal/test-utils": "link:../../../test-utils" }, "sentryTest": { - "optional": true + "optional": true, + "variants": [ + { + "build-command": "pnpm test:build:tunnel", + "assert-command": "pnpm test:assert:tunnel", + "label": "nuxt-5 (tunnel)" + }, + { + "build-command": "pnpm test:build:tunnel-basepath", + "assert-command": "pnpm test:assert:tunnel-basepath", + "label": "nuxt-5 (tunnel-basepath)" + }, + { + "build-command": "pnpm install", + "assert-command": "pnpm test:assert:tunnel-dev", + "label": "nuxt-5 (tunnel-dev)" + } + ] }, "volta": { "extends": "../../package.json" diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/sentry.client.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/sentry.client.config.ts index 900df0811faa..f5a30830107b 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/sentry.client.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/sentry.client.config.ts @@ -3,7 +3,7 @@ import { /* usePinia,*/ useRuntimeConfig } from '#imports'; Sentry.init({ dsn: useRuntimeConfig().public.sentry.dsn, - tunnel: `http://localhost:3031/`, // proxy server + tunnel: useRuntimeConfig().public.e2eTunnel ? true : `http://localhost:3031/`, // proxy server tracesSampleRate: 1.0, integrations: [ /* Sentry.piniaIntegration(usePinia(), { diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/sentry.server.config.ts index 58d541b5228b..ae05c60355bb 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/sentry.server.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/sentry.server.config.ts @@ -1,9 +1,9 @@ import * as Sentry from '@sentry/nuxt'; Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', + dsn: process.env.E2E_TEST_TUNNEL ? 'http://public@localhost:3031/1337' : 'https://public@dsn.ingest.sentry.io/1337', tracesSampleRate: 1.0, // Capture 100% of the transactions - tunnel: 'http://localhost:3031/', // proxy server + tunnel: process.env.E2E_TEST_TUNNEL ? true : 'http://localhost:3031/', // proxy server // Several tests trigger the same server error one after another, which Dedupe would drop. integrations: integrations => integrations.filter(integration => integration.name !== 'Dedupe'), }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/03.auth.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/03.auth.ts index 7216e9fc7560..62d2eebd07e0 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/03.auth.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/03.auth.ts @@ -1,7 +1,12 @@ import { defineHandler } from 'nitro'; -import { getQuery } from 'nitro/h3'; +import { getQuery, HTTPError } from 'nitro/h3'; export default defineHandler(async event => { + // The tunnel variant checks that the managed tunnel answers before an auth middleware like this one. + if (process.env.E2E_TEST_TUNNEL && event.req.method === 'POST' && event.req.headers.get('x-auth') !== 'ok') { + throw new HTTPError({ status: 401, statusText: 'blocked by user auth' }); + } + // Check if we should throw an error const query = getQuery(event); if (query.throwError === 'true') { diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tunnel.test.ts new file mode 100644 index 000000000000..d814f3fab1c9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tunnel.test.ts @@ -0,0 +1,42 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +test.skip(!process.env.E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); + +const BASE_PATH = process.env.E2E_TEST_BASE_PATH ?? ''; +const TUNNEL_PATH = new RegExp(`^${BASE_PATH}/[a-z0-9]{1,7}$`); + +test('answers the managed tunnel before the app auth middleware', async ({ page }) => { + // The variant's auth middleware rejects every other POST. + expect((await page.request.post(`${BASE_PATH}/api/anything`, { data: 'x' })).status()).toBe(401); + + const tunnelServerSpanPromise = waitForStreamedSpan( + 'nuxt-5', + span => getSpanOp(span) === 'http.server' && TUNNEL_PATH.test(String(span.attributes['url.path']?.value ?? '')), + ); + const tunnelResponsePromise = page.waitForResponse( + response => response.request().method() === 'POST' && TUNNEL_PATH.test(new URL(response.url()).pathname), + ); + + await page.goto(`${BASE_PATH}/`); + const tunnelResponse = await tunnelResponsePromise; + + expect(tunnelResponse.status()).toBe(200); + expect(new URL(tunnelResponse.url()).origin).toBe(new URL(page.url()).origin); + + // Anchor on a regular server span requested after the tunnel traffic: spans flush in order, so a tunnel span + // would arrive first. + const anchorSpanPromise = waitForStreamedSpan( + 'nuxt-5', + span => + getSpanOp(span) === 'http.server' && + String(span.attributes['url.path']?.value ?? '') === `${BASE_PATH}/api/test-param/123`, + ); + await page.request.get(`${BASE_PATH}/api/test-param/123`); + + const winner = await Promise.race([ + tunnelServerSpanPromise.then(() => 'tunnel' as const), + anchorSpanPromise.then(() => 'anchor' as const), + ]); + expect(winner).toBe('anchor'); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/entry.client.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/entry.client.tsx index 005268b40ad0..0215cdd728b9 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/entry.client.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/entry.client.tsx @@ -6,8 +6,8 @@ import { HydratedRouter } from 'react-router/dom'; Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions // todo: get this from env - dsn: 'https://username@domain/123', - tunnel: `http://localhost:3031/`, // proxy server + dsn: __E2E_TEST_TUNNEL__ ? 'http://public@localhost:3031/1337' : 'https://username@domain/123', + tunnel: __E2E_TEST_TUNNEL__ ? true : `http://localhost:3031/`, // proxy server integrations: [Sentry.reactRouterTracingIntegration()], tracesSampleRate: 1.0, tracePropagationTargets: [/^\//], diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/globals.d.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/globals.d.ts new file mode 100644 index 000000000000..dc2e8cbcb587 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/globals.d.ts @@ -0,0 +1 @@ +declare const __E2E_TEST_TUNNEL__: boolean; diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/root.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/root.tsx index bc1b8f1236c0..3ae4b730b9e1 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/root.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/root.tsx @@ -2,6 +2,20 @@ import { Links, Meta, Outlet, Scripts, ScrollRestoration, isRouteErrorResponse } import type { Route } from './+types/root'; import stylesheet from './app.css?url'; +// The tunnel variant checks that the managed tunnel answers before an auth middleware like this one. Other variants +// get none, so their span assertions stay as they are. +export const middleware: Route.MiddlewareFunction[] = __E2E_TEST_TUNNEL__ + ? [ + ({ request }, next) => { + if (request.method === 'POST' && request.headers.get('x-auth') !== 'ok') { + return new Response('blocked by user auth', { status: 401 }); + } + + return next(); + }, + ] + : []; + export const links: Route.LinksFunction = () => [ { rel: 'preconnect', href: 'https://fonts.googleapis.com' }, { diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/instrument.mjs b/dev-packages/e2e-tests/test-applications/react-router-8-framework/instrument.mjs index c16240141b6d..8a52c804b85a 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/instrument.mjs +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/instrument.mjs @@ -1,8 +1,11 @@ import * as Sentry from '@sentry/react-router'; Sentry.init({ - dsn: 'https://username@domain/123', + dsn: process.env.E2E_TEST_TUNNEL ? 'http://public@localhost:3031/1337' : 'https://username@domain/123', environment: 'qa', // dynamic sampling bias to keep transactions tracesSampleRate: 1.0, - tunnel: `http://localhost:3031/`, // proxy server + // The tunnel variants also accept another app's DSN, so the server serves more than one tunnel path. + tunnel: process.env.E2E_TEST_TUNNEL + ? { allowedDsns: ['http://other@localhost:3031/1338'] } + : `http://localhost:3031/`, }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/package.json b/dev-packages/e2e-tests/test-applications/react-router-8-framework/package.json index 552e505b3457..db1993aa7905 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/package.json +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/package.json @@ -44,7 +44,13 @@ "test:build": "pnpm install && pnpm build", "test:assert": "pnpm test:ts && pnpm test:playwright", "test:ts": "pnpm typecheck", - "test:playwright": "playwright test" + "test:playwright": "playwright test", + "test:build:tunnel": "pnpm install && E2E_TEST_TUNNEL=1 pnpm build", + "test:build:tunnel-basepath": "pnpm install && E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app pnpm build", + "test:assert:tunnel": "E2E_TEST_TUNNEL=1 pnpm test:playwright tunnel", + "test:assert:tunnel-basepath": "E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app pnpm test:playwright tunnel", + "test:assert:tunnel-dev": "TEST_ENV=development E2E_TEST_TUNNEL=1 pnpm test:playwright tunnel", + "test:assert:tunnel-basepath-dev": "TEST_ENV=development E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app pnpm test:playwright tunnel" }, "eslintConfig": { "extends": [ @@ -85,6 +91,26 @@ { "runtime": "cloudflare", "label": "react-router-8-framework (cloudflare)" + }, + { + "build-command": "pnpm test:build:tunnel", + "assert-command": "pnpm test:assert:tunnel", + "label": "react-router-8-framework (tunnel)" + }, + { + "build-command": "pnpm test:build:tunnel-basepath", + "assert-command": "pnpm test:assert:tunnel-basepath", + "label": "react-router-8-framework (tunnel-basepath)" + }, + { + "label": "react-router-8-framework (tunnel-dev)", + "build-command": "pnpm install", + "assert-command": "pnpm test:assert:tunnel-dev" + }, + { + "label": "react-router-8-framework (tunnel-basepath-dev)", + "build-command": "pnpm install", + "assert-command": "pnpm test:assert:tunnel-basepath-dev" } ] } diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/react-router-8-framework/playwright.config.mjs index 6e93d6fc020e..2fa8a93bb8bc 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/playwright.config.mjs +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/playwright.config.mjs @@ -10,7 +10,7 @@ const startCommands = { const config = getPlaywrightConfig( { - startCommand: startCommands[getRuntime()], + startCommand: process.env.TEST_ENV === 'development' ? 'pnpm dev --port 3030' : startCommands[getRuntime()], port: 3030, }, // Boot Redis before the tests run, outside the webServer startup-timeout window. diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/react-router.config.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/react-router.config.ts index bb1f96469dd2..a74ae0b46048 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/react-router.config.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/react-router.config.ts @@ -2,5 +2,7 @@ import type { Config } from '@react-router/dev/config'; export default { ssr: true, + // The base-path variant serves the app, and so the tunnel, under `/app`. + ...(process.env.E2E_TEST_BASE_PATH && { basename: `${process.env.E2E_TEST_BASE_PATH}/` }), prerender: ['/performance/static'], } satisfies Config; diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/tunnel.test.ts new file mode 100644 index 000000000000..03f309305e78 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/tunnel.test.ts @@ -0,0 +1,43 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +test.skip(!process.env.E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); + +const APP_NAME = 'react-router-8-framework'; +const BASE_PATH = process.env.E2E_TEST_BASE_PATH ?? ''; +const TUNNEL_PATH = new RegExp(`^${BASE_PATH}/[a-z0-9]{1,7}$`); + +test('answers the managed tunnel before the root middleware', async ({ page }) => { + // The variant's root middleware rejects every other POST. + expect((await page.request.post(`${BASE_PATH}/`, { data: 'x' })).status()).toBe(401); + + const tunnelServerSpanPromise = waitForStreamedSpan( + APP_NAME, + span => getSpanOp(span) === 'http.server' && TUNNEL_PATH.test(String(span.attributes['url.path']?.value ?? '')), + ); + const tunnelResponsePromise = page.waitForResponse( + response => response.request().method() === 'POST' && TUNNEL_PATH.test(new URL(response.url()).pathname), + ); + + await page.goto(`${BASE_PATH}/`); + const tunnelResponse = await tunnelResponsePromise; + + expect(tunnelResponse.status()).toBe(200); + expect(new URL(tunnelResponse.url()).origin).toBe(new URL(page.url()).origin); + + // Anchor on a regular server span requested after the tunnel traffic: spans flush in order, so a tunnel span + // would arrive first. + const anchorSpanPromise = waitForStreamedSpan( + APP_NAME, + span => + getSpanOp(span) === 'http.server' && + String(span.attributes['url.path']?.value ?? '') === `${BASE_PATH}/performance`, + ); + await page.request.get(`${BASE_PATH}/performance`); + + const winner = await Promise.race([ + tunnelServerSpanPromise.then(() => 'tunnel' as const), + anchorSpanPromise.then(() => 'anchor' as const), + ]); + expect(winner).toBe('anchor'); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/vite.cloudflare.config.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/vite.cloudflare.config.ts index 002bb3a77fbe..032293400dbf 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/vite.cloudflare.config.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/vite.cloudflare.config.ts @@ -5,6 +5,9 @@ import { sentryReactRouter } from '@sentry/react-router/vite'; import { defineConfig } from 'vite'; export default defineConfig(async config => ({ + define: { + __E2E_TEST_TUNNEL__: JSON.stringify(!!process.env.E2E_TEST_TUNNEL), + }, plugins: [ cloudflare({ viteEnvironment: { name: 'ssr' } }), reactRouter(), diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/vite.config.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/vite.config.ts index 0fe4cdde8c7a..3663eb2a74b5 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/vite.config.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/vite.config.ts @@ -4,6 +4,9 @@ import { defineConfig } from 'vite'; export default defineConfig(config => { return { + define: { + __E2E_TEST_TUNNEL__: JSON.stringify(!!process.env.E2E_TEST_TUNNEL), + }, plugins: [reactRouter(), sentryReactRouter({ sourcemaps: { disable: true } }, config)], }; }); diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/public/entry.ts b/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/public/entry.ts index bbeee228783a..89bdd61f5491 100644 --- a/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/public/entry.ts +++ b/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/public/entry.ts @@ -9,7 +9,7 @@ declare const process: { env: Record }; Sentry.init({ dsn: process.env.E2E_TEST_DSN, - tunnel: 'http://localhost:3061/', + tunnel: process.env.E2E_TEST_TUNNEL ? true : 'http://localhost:3061/', tracesSampleRate: 1.0, }); diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/app/assets.ts b/dev-packages/e2e-tests/test-applications/remix-v3/app/assets.ts index 345e6b2eaffd..4bfe6672caf4 100644 --- a/dev-packages/e2e-tests/test-applications/remix-v3/app/assets.ts +++ b/dev-packages/e2e-tests/test-applications/remix-v3/app/assets.ts @@ -15,6 +15,7 @@ export const assets = createAssetServer({ // into a browser module. The asset server substitutes these when it compiles. define: { 'process.env.E2E_TEST_DSN': JSON.stringify(process.env.E2E_TEST_DSN), + 'process.env.E2E_TEST_TUNNEL': JSON.stringify(process.env.E2E_TEST_TUNNEL ?? ''), }, }, }); diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/app/router.ts b/dev-packages/e2e-tests/test-applications/remix-v3/app/router.ts index 03724dd7ea27..2102a5768372 100644 --- a/dev-packages/e2e-tests/test-applications/remix-v3/app/router.ts +++ b/dev-packages/e2e-tests/test-applications/remix-v3/app/router.ts @@ -14,8 +14,21 @@ declare module 'remix/router' { } } +// The tunnel variant checks that the managed tunnel answers before an auth middleware like this one. +function authMiddleware(context: { request: Request }, next: () => Promise): Promise | Response { + if ( + process.env.E2E_TEST_TUNNEL && + context.request.method === 'POST' && + context.request.headers.get('x-auth') !== 'ok' + ) { + return new Response('blocked by user auth', { status: 401 }); + } + + return next(); +} + export const router = createRouter({ - middleware: [renderMiddleware], + middleware: [authMiddleware, renderMiddleware], }); router.map(routes, controller); diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/package.json b/dev-packages/e2e-tests/test-applications/remix-v3/package.json index fe19e83132f1..da7831948456 100644 --- a/dev-packages/e2e-tests/test-applications/remix-v3/package.json +++ b/dev-packages/e2e-tests/test-applications/remix-v3/package.json @@ -8,7 +8,8 @@ "test": "playwright test", "clean": "npx rimraf node_modules pnpm-lock.yaml", "test:build": "pnpm install", - "test:assert": "pnpm test" + "test:assert": "pnpm test", + "test:assert:tunnel": "E2E_TEST_TUNNEL=1 E2E_TEST_DSN=http://public@localhost:3061/1337 playwright test tunnel" }, "dependencies": { "@sentry/remix": "file:../../packed/sentry-remix-packed.tgz", @@ -23,5 +24,14 @@ }, "volta": { "extends": "../../package.json" + }, + "sentryTest": { + "variants": [ + { + "build-command": "pnpm test:build", + "assert-command": "pnpm test:assert:tunnel", + "label": "remix-v3 (tunnel)" + } + ] } } diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/server.ts b/dev-packages/e2e-tests/test-applications/remix-v3/server.ts index c9ac4f00d087..9d500cc2a808 100644 --- a/dev-packages/e2e-tests/test-applications/remix-v3/server.ts +++ b/dev-packages/e2e-tests/test-applications/remix-v3/server.ts @@ -6,7 +6,7 @@ import { router } from './app/router.ts'; Sentry.init({ dsn: process.env.E2E_TEST_DSN, - tunnel: 'http://localhost:3061/', + tunnel: process.env.E2E_TEST_TUNNEL ? true : 'http://localhost:3061/', tracesSampleRate: 1.0, }); diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/remix-v3/tests/tunnel.test.ts new file mode 100644 index 000000000000..f3daf7506f8c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-v3/tests/tunnel.test.ts @@ -0,0 +1,40 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +test.skip(!process.env.E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); + +const APP_NAME = 'remix-v3'; +const TUNNEL_PATH = /^\/[a-z0-9]{1,7}$/; + +test('answers the managed tunnel before route matching and the app middleware', async ({ page }) => { + // The variant's auth middleware rejects every other POST. + expect((await page.request.post('/anything', { data: 'x' })).status()).toBe(401); + + const tunnelServerSpanPromise = waitForStreamedSpan( + APP_NAME, + span => getSpanOp(span) === 'http.server' && TUNNEL_PATH.test(String(span.attributes['url.path']?.value ?? '')), + ); + const tunnelResponsePromise = page.waitForResponse( + response => response.request().method() === 'POST' && TUNNEL_PATH.test(new URL(response.url()).pathname), + ); + + await page.goto('/'); + const tunnelResponse = await tunnelResponsePromise; + + expect(tunnelResponse.status()).toBe(200); + expect(new URL(tunnelResponse.url()).origin).toBe(new URL(page.url()).origin); + + // Anchor on a regular server span requested after the tunnel traffic: spans flush in order, so a tunnel span + // would arrive first. + const anchorSpanPromise = waitForStreamedSpan( + APP_NAME, + span => getSpanOp(span) === 'http.server' && String(span.attributes['url.path']?.value ?? '') === '/api/items/1', + ); + await page.request.get('/api/items/1'); + + const winner = await Promise.race([ + tunnelServerSpanPromise.then(() => 'tunnel' as const), + anchorSpanPromise.then(() => 'anchor' as const), + ]); + expect(winner).toBe('anchor'); +}); diff --git a/dev-packages/e2e-tests/test-applications/solidstart-2/package.json b/dev-packages/e2e-tests/test-applications/solidstart-2/package.json index 9fd694db92d4..04b5c40c783a 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-2/package.json +++ b/dev-packages/e2e-tests/test-applications/solidstart-2/package.json @@ -9,7 +9,10 @@ "start": "HOST=localhost PORT=3030 node .output/server/index.mjs", "test:prod": "TEST_ENV=production playwright test", "test:build": "pnpm install && pnpm build", - "test:assert": "pnpm test:prod" + "test:assert": "pnpm test:prod", + "test:build:tunnel": "pnpm install && E2E_TEST_TUNNEL=1 pnpm build", + "test:assert:tunnel": "E2E_TEST_TUNNEL=1 E2E_TEST_DSN=http://public@localhost:3031/1337 TEST_ENV=production playwright test tunnel", + "test:assert:tunnel-dev": "E2E_TEST_TUNNEL=1 E2E_TEST_DSN=http://public@localhost:3031/1337 TEST_ENV=development playwright test tunnel" }, "type": "module", "dependencies": { @@ -38,6 +41,18 @@ "node": ">=24" }, "sentryTest": { - "optional": true + "optional": true, + "variants": [ + { + "build-command": "pnpm test:build:tunnel", + "assert-command": "pnpm test:assert:tunnel", + "label": "solidstart-2 (tunnel)" + }, + { + "label": "solidstart-2 (tunnel-dev)", + "build-command": "pnpm install", + "assert-command": "pnpm test:assert:tunnel-dev" + } + ] } } diff --git a/dev-packages/e2e-tests/test-applications/solidstart-2/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/solidstart-2/playwright.config.mjs index b52ff06a5105..2d40cab81f7e 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-2/playwright.config.mjs +++ b/dev-packages/e2e-tests/test-applications/solidstart-2/playwright.config.mjs @@ -1,7 +1,7 @@ import { getPlaywrightConfig } from '@sentry-internal/test-utils'; const config = getPlaywrightConfig({ - startCommand: 'pnpm start', + startCommand: process.env.TEST_ENV === 'development' ? 'pnpm exec vite dev --port 3030' : 'pnpm start', port: 3030, }); diff --git a/dev-packages/e2e-tests/test-applications/solidstart-2/server/plugins/sentry.ts b/dev-packages/e2e-tests/test-applications/solidstart-2/server/plugins/sentry.ts index 812abe8255fb..b28336bff817 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-2/server/plugins/sentry.ts +++ b/dev-packages/e2e-tests/test-applications/solidstart-2/server/plugins/sentry.ts @@ -7,7 +7,7 @@ export default definePlugin(() => { dsn: process.env.E2E_TEST_DSN, environment: 'qa', // dynamic sampling bias to keep transactions tracesSampleRate: 1.0, // Capture 100% of the transactions - tunnel: 'http://localhost:3031/', // proxy server + tunnel: process.env.E2E_TEST_TUNNEL ? true : 'http://localhost:3031/', // proxy server debug: !!process.env.DEBUG, }); }); diff --git a/dev-packages/e2e-tests/test-applications/solidstart-2/src/entry-client.tsx b/dev-packages/e2e-tests/test-applications/solidstart-2/src/entry-client.tsx index 11087fbb5918..ce51cefc79f3 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-2/src/entry-client.tsx +++ b/dev-packages/e2e-tests/test-applications/solidstart-2/src/entry-client.tsx @@ -3,13 +3,16 @@ import * as Sentry from '@sentry/solidstart'; import { solidRouterBrowserTracingIntegration } from '@sentry/solidstart/solidrouter'; import { StartClient, mount } from '@solidjs/start/client'; +declare const __E2E_TEST_TUNNEL__: boolean; + Sentry.init({ // We can't use env variables here, seems like they are stripped // out in production builds. - dsn: 'https://public@dsn.ingest.sentry.io/1337', + // The tunnel variant forwards to the test proxy, so the DSN has to point there. + dsn: __E2E_TEST_TUNNEL__ ? 'http://public@localhost:3031/1337' : 'https://public@dsn.ingest.sentry.io/1337', environment: 'qa', // dynamic sampling bias to keep transactions integrations: [solidRouterBrowserTracingIntegration()], - tunnel: 'http://localhost:3031/', // proxy server + tunnel: __E2E_TEST_TUNNEL__ ? true : 'http://localhost:3031/', // proxy server // Performance Monitoring tracesSampleRate: 1.0, // Capture 100% of the transactions debug: !!import.meta.env.DEBUG, diff --git a/dev-packages/e2e-tests/test-applications/solidstart-2/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/solidstart-2/tests/tunnel.test.ts new file mode 100644 index 000000000000..92b0892e5925 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solidstart-2/tests/tunnel.test.ts @@ -0,0 +1,37 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +test.skip(!process.env.E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); + +const APP_NAME = 'solidstart-2'; +const TUNNEL_PATH = /^\/[a-z0-9]{1,7}$/; + +test('answers the managed tunnel and drops its server span', async ({ page }) => { + const tunnelServerSpanPromise = waitForStreamedSpan( + APP_NAME, + span => getSpanOp(span) === 'http.server' && TUNNEL_PATH.test(String(span.attributes['url.path']?.value ?? '')), + ); + const tunnelResponsePromise = page.waitForResponse( + response => response.request().method() === 'POST' && TUNNEL_PATH.test(new URL(response.url()).pathname), + ); + + await page.goto('/'); + const tunnelResponse = await tunnelResponsePromise; + + expect(tunnelResponse.status()).toBe(200); + expect(new URL(tunnelResponse.url()).origin).toBe(new URL(page.url()).origin); + + // Anchor on a regular server span requested after the tunnel traffic: spans flush in order, so a tunnel span + // would arrive first. + const anchorSpanPromise = waitForStreamedSpan( + APP_NAME, + span => getSpanOp(span) === 'http.server' && String(span.attributes['url.path']?.value ?? '') === '/users/6', + ); + await page.request.get('/users/6'); + + const winner = await Promise.race([ + tunnelServerSpanPromise.then(() => 'tunnel' as const), + anchorSpanPromise.then(() => 'anchor' as const), + ]); + expect(winner).toBe('anchor'); +}); diff --git a/dev-packages/e2e-tests/test-applications/solidstart-2/vite.config.ts b/dev-packages/e2e-tests/test-applications/solidstart-2/vite.config.ts index d3ffd97997a9..28c43c71e6e1 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-2/vite.config.ts +++ b/dev-packages/e2e-tests/test-applications/solidstart-2/vite.config.ts @@ -4,6 +4,9 @@ import { nitro } from 'nitro/vite'; import { defineConfig } from 'vite'; export default defineConfig({ + define: { + __E2E_TEST_TUNNEL__: JSON.stringify(!!process.env.E2E_TEST_TUNNEL), + }, plugins: [ solidStart({ appRoot: './src', diff --git a/dev-packages/e2e-tests/test-applications/solidstart/app.config.ts b/dev-packages/e2e-tests/test-applications/solidstart/app.config.ts index 71061cf25d96..7486f6ecf58c 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart/app.config.ts +++ b/dev-packages/e2e-tests/test-applications/solidstart/app.config.ts @@ -4,5 +4,12 @@ import { defineConfig } from '@solidjs/start/config'; export default defineConfig( withSentry({ middleware: './src/middleware.ts', + // The base-path variant serves the app, and so the tunnel, under `/app`. + ...(process.env.E2E_TEST_BASE_PATH && { server: { baseURL: `${process.env.E2E_TEST_BASE_PATH}/` } }), + vite: { + define: { + __E2E_TEST_TUNNEL__: JSON.stringify(!!process.env.E2E_TEST_TUNNEL), + }, + }, }), ); diff --git a/dev-packages/e2e-tests/test-applications/solidstart/instrument.server.mjs b/dev-packages/e2e-tests/test-applications/solidstart/instrument.server.mjs index f75de066068a..c543b273ad5d 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart/instrument.server.mjs +++ b/dev-packages/e2e-tests/test-applications/solidstart/instrument.server.mjs @@ -6,6 +6,6 @@ Sentry.init({ dsn: process.env.E2E_TEST_DSN, environment: 'qa', // dynamic sampling bias to keep transactions tracesSampleRate: 1.0, // Capture 100% of the transactions - tunnel: 'http://localhost:3031/', // proxy server + tunnel: process.env.E2E_TEST_TUNNEL ? true : 'http://localhost:3031/', // proxy server debug: !!process.env.DEBUG, }); diff --git a/dev-packages/e2e-tests/test-applications/solidstart/package.json b/dev-packages/e2e-tests/test-applications/solidstart/package.json index ac2b8c393432..118efe66bc77 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart/package.json +++ b/dev-packages/e2e-tests/test-applications/solidstart/package.json @@ -10,7 +10,13 @@ "test:prod": "TEST_ENV=production playwright test", "test:build": "pnpm install && pnpm build", "test:assert": "pnpm test:prod && pnpm test:dev", - "test:dev": "TEST_ENV=development playwright test db" + "test:dev": "TEST_ENV=development playwright test db", + "test:build:tunnel": "pnpm install && E2E_TEST_TUNNEL=1 pnpm build", + "test:build:tunnel-basepath": "pnpm install && E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app pnpm build", + "test:assert:tunnel": "E2E_TEST_TUNNEL=1 E2E_TEST_DSN=http://public@localhost:3031/1337 TEST_ENV=production playwright test tunnel", + "test:assert:tunnel-basepath": "E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app E2E_TEST_DSN=http://public@localhost:3031/1337 TEST_ENV=production playwright test tunnel", + "test:assert:tunnel-dev": "E2E_TEST_TUNNEL=1 E2E_TEST_DSN=http://public@localhost:3031/1337 TEST_ENV=development playwright test tunnel", + "test:assert:tunnel-basepath-dev": "E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app E2E_TEST_DSN=http://public@localhost:3031/1337 TEST_ENV=development playwright test tunnel" }, "type": "module", "dependencies": { @@ -30,11 +36,35 @@ "jsdom": "^24.0.0", "solid-js": "1.9.5", "typescript": "^5.4.5", - "vinxi": "^0.4.0", - "vite": "^5.4.11", + "vinxi": "^0.5.11", + "vite": "^6.4.4", "vite-plugin-solid": "^2.11.6" }, "volta": { "extends": "../../package.json" + }, + "sentryTest": { + "variants": [ + { + "build-command": "pnpm test:build:tunnel", + "assert-command": "pnpm test:assert:tunnel", + "label": "solidstart (tunnel)" + }, + { + "build-command": "pnpm test:build:tunnel-basepath", + "assert-command": "pnpm test:assert:tunnel-basepath", + "label": "solidstart (tunnel-basepath)" + }, + { + "build-command": "pnpm install", + "assert-command": "pnpm test:assert:tunnel-dev", + "label": "solidstart (tunnel-dev)" + }, + { + "build-command": "pnpm install", + "assert-command": "pnpm test:assert:tunnel-basepath-dev", + "label": "solidstart (tunnel-basepath-dev)" + } + ] } } diff --git a/dev-packages/e2e-tests/test-applications/solidstart/src/entry-client.tsx b/dev-packages/e2e-tests/test-applications/solidstart/src/entry-client.tsx index 11087fbb5918..a92e54089aed 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart/src/entry-client.tsx +++ b/dev-packages/e2e-tests/test-applications/solidstart/src/entry-client.tsx @@ -3,13 +3,15 @@ import * as Sentry from '@sentry/solidstart'; import { solidRouterBrowserTracingIntegration } from '@sentry/solidstart/solidrouter'; import { StartClient, mount } from '@solidjs/start/client'; +declare const __E2E_TEST_TUNNEL__: boolean; + Sentry.init({ // We can't use env variables here, seems like they are stripped // out in production builds. - dsn: 'https://public@dsn.ingest.sentry.io/1337', + dsn: __E2E_TEST_TUNNEL__ ? 'http://public@localhost:3031/1337' : 'https://public@dsn.ingest.sentry.io/1337', environment: 'qa', // dynamic sampling bias to keep transactions integrations: [solidRouterBrowserTracingIntegration()], - tunnel: 'http://localhost:3031/', // proxy server + tunnel: __E2E_TEST_TUNNEL__ ? true : 'http://localhost:3031/', // proxy server // Performance Monitoring tracesSampleRate: 1.0, // Capture 100% of the transactions debug: !!import.meta.env.DEBUG, diff --git a/dev-packages/e2e-tests/test-applications/solidstart/src/instrument.server.ts b/dev-packages/e2e-tests/test-applications/solidstart/src/instrument.server.ts index 3dd5d8933b7b..1e1ef4a28d61 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart/src/instrument.server.ts +++ b/dev-packages/e2e-tests/test-applications/solidstart/src/instrument.server.ts @@ -4,6 +4,6 @@ Sentry.init({ dsn: process.env.E2E_TEST_DSN, environment: 'qa', // dynamic sampling bias to keep transactions tracesSampleRate: 1.0, // Capture 100% of the transactions - tunnel: 'http://localhost:3031/', // proxy server + tunnel: process.env.E2E_TEST_TUNNEL ? true : 'http://localhost:3031/', // proxy server debug: !!process.env.DEBUG, }); diff --git a/dev-packages/e2e-tests/test-applications/solidstart/src/middleware.ts b/dev-packages/e2e-tests/test-applications/solidstart/src/middleware.ts index 88123a035fb6..9dee5a5d3eb8 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart/src/middleware.ts +++ b/dev-packages/e2e-tests/test-applications/solidstart/src/middleware.ts @@ -2,5 +2,14 @@ import { sentryBeforeResponseMiddleware } from '@sentry/solidstart'; import { createMiddleware } from '@solidjs/start/middleware'; export default createMiddleware({ + onRequest: [ + // The tunnel variant checks that the managed tunnel answers before an auth middleware like this one. + event => { + const request = event.request; + if (process.env.E2E_TEST_TUNNEL && request.method === 'POST' && request.headers.get('x-auth') !== 'ok') { + return new Response('blocked by user auth', { status: 401 }); + } + }, + ], onBeforeResponse: [sentryBeforeResponseMiddleware()], }); diff --git a/dev-packages/e2e-tests/test-applications/solidstart/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/solidstart/tests/tunnel.test.ts new file mode 100644 index 000000000000..3ff1081fff07 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solidstart/tests/tunnel.test.ts @@ -0,0 +1,42 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +test.skip(!process.env.E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); + +const APP_NAME = 'solidstart'; +const BASE_PATH = process.env.E2E_TEST_BASE_PATH ?? ''; +const TUNNEL_PATH = new RegExp(`^${BASE_PATH}/[a-z0-9]{1,7}$`); + +test('answers the managed tunnel before the app middleware', async ({ page }) => { + // The variant's auth middleware rejects every other POST. + expect((await page.request.post(`${BASE_PATH}/`, { data: 'x' })).status()).toBe(401); + + const tunnelServerSpanPromise = waitForStreamedSpan( + APP_NAME, + span => getSpanOp(span) === 'http.server' && TUNNEL_PATH.test(String(span.attributes['url.path']?.value ?? '')), + ); + const tunnelResponsePromise = page.waitForResponse( + response => response.request().method() === 'POST' && TUNNEL_PATH.test(new URL(response.url()).pathname), + ); + + await page.goto(`${BASE_PATH}/`); + const tunnelResponse = await tunnelResponsePromise; + + expect(tunnelResponse.status()).toBe(200); + expect(new URL(tunnelResponse.url()).origin).toBe(new URL(page.url()).origin); + + // Anchor on a regular server span requested after the tunnel traffic: spans flush in order, so a tunnel span + // would arrive first. + const anchorSpanPromise = waitForStreamedSpan( + APP_NAME, + span => + getSpanOp(span) === 'http.server' && String(span.attributes['url.path']?.value ?? '') === `${BASE_PATH}/users/6`, + ); + await page.request.get(`${BASE_PATH}/users/6`); + + const winner = await Promise.race([ + tunnelServerSpanPromise.then(() => 'tunnel' as const), + anchorSpanPromise.then(() => 'anchor' as const), + ]); + expect(winner).toBe('anchor'); +}); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/package.json b/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/package.json index 1844e0e7b5cd..6f8a4665e604 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/package.json +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/package.json @@ -11,7 +11,9 @@ "clean": "npx rimraf node_modules pnpm-lock.yaml", "test:e2e": "playwright test", "test:build": "pnpm install && pnpm build", - "test:assert": "pnpm test:e2e" + "test:assert": "pnpm test:e2e", + "test:build:tunnel": "pnpm install && E2E_TEST_TUNNEL=1 E2E_TEST_DSN=http://public@localhost:3031/1337 PUBLIC_E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm build", + "test:assert:tunnel": "E2E_TEST_TUNNEL=1 E2E_TEST_DSN=http://public@localhost:3031/1337 PUBLIC_E2E_TEST_DSN=http://public@localhost:3031/1337 playwright test tunnel" }, "dependencies": { "@sentry/sveltekit": "file:../../packed/sentry-sveltekit-packed.tgz" @@ -30,5 +32,14 @@ }, "volta": { "extends": "../../package.json" + }, + "sentryTest": { + "variants": [ + { + "label": "sveltekit-3-cloudflare-workers (tunnel)", + "build-command": "pnpm test:build:tunnel", + "assert-command": "pnpm test:assert:tunnel" + } + ] } } diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/src/app.d.ts b/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/src/app.d.ts index 4fc6bc229fa5..ea92ff23f5d3 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/src/app.d.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/src/app.d.ts @@ -1,6 +1,8 @@ // See https://svelte.dev/docs/kit/types#app.d.ts // `App.Platform` (including `platform.ctx`) is declared by `@sveltejs/adapter-cloudflare`. declare global { + const __E2E_TEST_TUNNEL__: boolean; + namespace App { // interface Error {} // interface Locals {} diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/src/hooks.client.ts b/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/src/hooks.client.ts index ba1d3dfa2be0..2c640017c085 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/src/hooks.client.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/src/hooks.client.ts @@ -4,7 +4,7 @@ import * as Sentry from '@sentry/sveltekit'; Sentry.init({ environment: 'qa', dsn: PUBLIC_E2E_TEST_DSN, - tunnel: 'http://localhost:3031/', // proxy server + tunnel: __E2E_TEST_TUNNEL__ ? true : 'http://localhost:3031/', // proxy server tracesSampleRate: 1.0, }); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/src/hooks.server.ts b/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/src/hooks.server.ts index 1b8dd4528c36..5fd10e38d8ab 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/src/hooks.server.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/src/hooks.server.ts @@ -10,7 +10,7 @@ export const handle = sequence( environment: 'qa', // dynamic sampling bias to keep spans dsn: E2E_TEST_DSN, debug: !!process.env.DEBUG, - tunnel: 'http://localhost:3031/', // proxy server + tunnel: __E2E_TEST_TUNNEL__ ? true : 'http://localhost:3031/', // proxy server tracesSampleRate: 1.0, }), sentryHandle(), diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/tests/tunnel.test.ts new file mode 100644 index 000000000000..b81ae9c2b575 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/tests/tunnel.test.ts @@ -0,0 +1,43 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +test.skip(!process.env.E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); + +const TUNNEL_PATH = /^\/app\/[a-z0-9]{1,7}$/; + +test('serves the managed tunnel from the Worker under the base path', async ({ page }) => { + const pageloadSpanPromise = waitForStreamedSpan( + 'sveltekit-3-cloudflare-workers', + span => getSpanOp(span) === 'pageload' && span.is_segment, + ); + const tunnelServerSpanPromise = waitForStreamedSpan( + 'sveltekit-3-cloudflare-workers', + span => getSpanOp(span) === 'http.server' && TUNNEL_PATH.test(String(span.attributes['url.path']?.value ?? '')), + ); + const tunnelResponsePromise = page.waitForResponse( + response => response.request().method() === 'POST' && TUNNEL_PATH.test(new URL(response.url()).pathname), + ); + + await page.goto('/app/'); + const tunnelResponse = await tunnelResponsePromise; + + expect(tunnelResponse.status()).toBe(200); + expect(new URL(tunnelResponse.url()).origin).toBe(new URL(page.url()).origin); + await expect(pageloadSpanPromise).resolves.toBeDefined(); + + // Anchor on a regular server span requested after the tunnel traffic: spans flush in order, so a tunnel span + // would arrive first. + const anchorSpanPromise = waitForStreamedSpan( + 'sveltekit-3-cloudflare-workers', + span => + getSpanOp(span) === 'http.server' && + String(span.attributes['url.path']?.value ?? '').startsWith('/app/server-load-error'), + ); + await page.request.get('/app/server-load-error'); + + const winner = await Promise.race([ + tunnelServerSpanPromise.then(() => 'tunnel' as const), + anchorSpanPromise.then(() => 'anchor' as const), + ]); + expect(winner).toBe('anchor'); +}); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/vite.config.js b/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/vite.config.js index f83d55aa81e8..4f950627ee7e 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/vite.config.js +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/vite.config.js @@ -4,7 +4,13 @@ import { sveltekit } from '@sveltejs/kit/vite'; import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; import { defineConfig } from 'vite'; +// The tunnel variant serves the app under a base path to cover that the managed tunnel follows it. +const tunnelVariant = !!process.env.E2E_TEST_TUNNEL; + export default defineConfig({ + define: { + __E2E_TEST_TUNNEL__: JSON.stringify(tunnelVariant), + }, plugins: [ sentrySvelteKit({ autoUploadSourceMaps: false, @@ -17,6 +23,7 @@ export default defineConfig({ prerender: { handleHttpError: 'ignore', }, + paths: tunnelVariant ? { base: '/app' } : undefined, }), ], }); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3/package.json b/dev-packages/e2e-tests/test-applications/sveltekit-3/package.json index 7bf8da3e4e00..b57949e2b20b 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3/package.json +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3/package.json @@ -12,7 +12,10 @@ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "test:prod": "TEST_ENV=production playwright test", "test:build": "pnpm install && pnpm build", - "test:assert": "pnpm test:prod" + "test:assert": "pnpm test:prod", + "test:build:tunnel": "pnpm install && E2E_TEST_TUNNEL=1 E2E_TEST_DSN=http://public@localhost:3031/1337 PUBLIC_E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm build", + "test:assert:tunnel": "E2E_TEST_TUNNEL=1 E2E_TEST_DSN=http://public@localhost:3031/1337 PUBLIC_E2E_TEST_DSN=http://public@localhost:3031/1337 TEST_ENV=production playwright test tunnel", + "test:assert:tunnel-dev": "E2E_TEST_TUNNEL=1 E2E_TEST_DSN=http://public@localhost:3031/1337 PUBLIC_E2E_TEST_DSN=http://public@localhost:3031/1337 TEST_ENV=development playwright test tunnel" }, "dependencies": { "@sentry/sveltekit": "file:../../packed/sentry-sveltekit-packed.tgz" @@ -31,5 +34,19 @@ "volta": { "extends": "../../package.json" }, - "type": "module" + "type": "module", + "sentryTest": { + "variants": [ + { + "label": "sveltekit-3 (tunnel)", + "build-command": "pnpm test:build:tunnel", + "assert-command": "pnpm test:assert:tunnel" + }, + { + "label": "sveltekit-3 (tunnel-dev)", + "build-command": "pnpm install", + "assert-command": "pnpm test:assert:tunnel-dev" + } + ] + } } diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3/src/app.d.ts b/dev-packages/e2e-tests/test-applications/sveltekit-3/src/app.d.ts index ede601ab93e2..bcf9f139abc1 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3/src/app.d.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3/src/app.d.ts @@ -1,6 +1,8 @@ // See https://kit.svelte.dev/docs/types#app // for information about these interfaces declare global { + const __E2E_TEST_TUNNEL__: boolean; + namespace App { // interface Error {} // interface Locals {} diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3/src/hooks.client.ts b/dev-packages/e2e-tests/test-applications/sveltekit-3/src/hooks.client.ts index 4d4723a527d3..1c9774fda5d2 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3/src/hooks.client.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3/src/hooks.client.ts @@ -4,7 +4,7 @@ import * as Sentry from '@sentry/sveltekit'; Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions dsn: PUBLIC_E2E_TEST_DSN, - tunnel: `http://localhost:3031/`, // proxy server + tunnel: __E2E_TEST_TUNNEL__ ? true : `http://localhost:3031/`, // proxy server tracesSampleRate: 1.0, }); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3/src/instrumentation.server.ts b/dev-packages/e2e-tests/test-applications/sveltekit-3/src/instrumentation.server.ts index 18c0b65b6db7..c0041a8ffe8e 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3/src/instrumentation.server.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3/src/instrumentation.server.ts @@ -7,6 +7,6 @@ Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions dsn: E2E_TEST_DSN, debug: !!process.env.DEBUG, - tunnel: `http://localhost:3031/`, // proxy server + tunnel: __E2E_TEST_TUNNEL__ ? true : `http://localhost:3031/`, // proxy server tracesSampleRate: 1.0, }); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/tunnel.test.ts new file mode 100644 index 000000000000..ea8d4e673c90 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/tunnel.test.ts @@ -0,0 +1,73 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +test.skip(!process.env.E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); + +const TUNNEL_PATH = /^\/app\/[a-z0-9]{1,7}$/; + +test('sends client events through the managed tunnel under the base path', async ({ page }) => { + const errorEventPromise = waitForError( + 'sveltekit-3', + event => event?.exception?.values?.[0]?.value === 'Click Error', + ); + const tunnelServerSpanPromise = waitForStreamedSpan( + 'sveltekit-3', + span => getSpanOp(span) === 'http.server' && TUNNEL_PATH.test(String(span.attributes['url.path']?.value ?? '')), + ); + + // The pageload envelope is the first tunnel request, and it's only sent once the app hydrated. + const tunnelResponsePromise = page.waitForResponse( + response => response.request().method() === 'POST' && TUNNEL_PATH.test(new URL(response.url()).pathname), + ); + await page.goto('/app/client-error'); + const tunnelResponse = await tunnelResponsePromise; + + expect(tunnelResponse.status()).toBe(200); + expect(new URL(tunnelResponse.url()).origin).toBe(new URL(page.url()).origin); + + await page.getByText('Throw error').click(); + await expect(errorEventPromise).resolves.toBeDefined(); + + // Anchor on a regular server span requested after the tunnel traffic: spans flush in order, so a tunnel span + // would arrive first. + const anchorSpanPromise = waitForStreamedSpan( + 'sveltekit-3', + span => + getSpanOp(span) === 'http.server' && String(span.attributes['url.path']?.value ?? '').startsWith('/app/users'), + ); + await page.request.get('/app/users/123'); + + const winner = await Promise.race([ + tunnelServerSpanPromise.then(() => 'tunnel' as const), + anchorSpanPromise.then(() => 'anchor' as const), + ]); + expect(winner).toBe('anchor'); +}); + +test('does not send spans of tunnel requests with Kit tracing on', async ({ page }) => { + // Kit's own `sveltekit.handle.root` span starts with only `http.url`, no `url.path`. + const tunnelSpanPromise = waitForStreamedSpan('sveltekit-3', span => { + const url = span.attributes['http.url']?.value ?? span.attributes['url.path']?.value; + return typeof url === 'string' && TUNNEL_PATH.test(url.startsWith('http') ? new URL(url).pathname : url); + }); + const tunnelResponsePromise = page.waitForResponse( + response => response.request().method() === 'POST' && TUNNEL_PATH.test(new URL(response.url()).pathname), + ); + + await page.goto('/app/'); + await tunnelResponsePromise; + + // Anchor on a regular page span requested after the tunnel traffic: spans flush in order, so a tunnel span + // would arrive first. + const anchorSpanPromise = waitForStreamedSpan('sveltekit-3', span => { + const url = span.attributes['http.url']?.value ?? span.attributes['url.path']?.value; + return typeof url === 'string' && url.endsWith('/app/users/456'); + }); + await page.request.get('/app/users/456'); + + const winner = await Promise.race([ + tunnelSpanPromise.then(() => 'tunnel' as const), + anchorSpanPromise.then(() => 'anchor' as const), + ]); + expect(winner).toBe('anchor'); +}); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3/vite.config.js b/dev-packages/e2e-tests/test-applications/sveltekit-3/vite.config.js index 3e789da133e5..19109a90b019 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3/vite.config.js +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3/vite.config.js @@ -4,7 +4,13 @@ import { sveltekit } from '@sveltejs/kit/vite'; import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; import { defineConfig } from 'vite'; +// The tunnel variant serves the app under a base path to cover that the managed tunnel follows it. +const tunnelVariant = !!process.env.E2E_TEST_TUNNEL; + export default defineConfig({ + define: { + __E2E_TEST_TUNNEL__: JSON.stringify(tunnelVariant), + }, plugins: [ sentrySvelteKit({ autoUploadSourceMaps: false, @@ -22,6 +28,7 @@ export default defineConfig({ tracing: { server: true, }, + paths: tunnelVariant ? { base: '/app' } : undefined, }), ], }); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/src/server.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/src/server.ts index b35026939d88..1d2f09c0b10c 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/src/server.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/src/server.ts @@ -2,6 +2,8 @@ import * as Sentry from '@sentry/cloudflare'; import { wrapFetchWithSentry } from '@sentry/tanstackstart-react'; import handler from '@tanstack/react-start/server-entry'; +const app = wrapFetchWithSentry(handler); + export default Sentry.withSentry( (env: Env) => ({ dsn: env.E2E_TEST_DSN, @@ -9,5 +11,14 @@ export default Sentry.withSentry( tracesSampleRate: 1.0, environment: 'qa', }), - wrapFetchWithSentry(handler), + { + fetch(request, env, ctx) { + // Lets a test check that the Worker's `ctx` survives the Sentry Vite plugin wrapping the server entry. + if (new URL(request.url).pathname === '/test-execution-context') { + return new Response(typeof ctx?.waitUntil); + } + + return (app.fetch as ExportedHandlerFetchHandler)(request, env, ctx); + }, + } satisfies ExportedHandler, ); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/tests/tunnel.test.ts new file mode 100644 index 000000000000..82060b30bd13 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/tests/tunnel.test.ts @@ -0,0 +1,19 @@ +import { expect, test } from '@playwright/test'; + +test("keeps the Worker's ExecutionContext when the Sentry Vite plugin wraps the server entry", async ({ request }) => { + const response = await request.get('/test-execution-context'); + + expect(await response.text()).toBe('function'); +}); + +test('answers a fixed tunnelRoute with the DSN that `withSentry` initializes', async ({ request }) => { + // A DSN the server doesn't allow, so the tunnel answers without forwarding anywhere. + const envelopeHeader = { dsn: 'https://other@o1.ingest.sentry.io/42', sent_at: new Date().toISOString() }; + const response = await request.post('/monitoring', { + data: `${JSON.stringify(envelopeHeader)}\n`, + headers: { 'Content-Type': 'application/x-sentry-envelope' }, + }); + + expect(response.headers()['x-sentry-tunnel']).toBe('1'); + expect(response.status()).toBe(403); +}); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/vite.config.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/vite.config.ts index 8e749133c7d8..b9657a989669 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/vite.config.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/vite.config.ts @@ -3,10 +3,17 @@ import tsConfigPaths from 'vite-tsconfig-paths'; import { tanstackStart } from '@tanstack/react-start/plugin/vite'; import viteReact from '@vitejs/plugin-react'; import { cloudflare } from '@cloudflare/vite-plugin'; +import { sentryTanstackStart } from '@sentry/tanstackstart-react/vite'; export default defineConfig({ server: { port: 3030, }, - plugins: [cloudflare({ viteEnvironment: { name: 'ssr' } }), tsConfigPaths(), tanstackStart(), viteReact()], + plugins: [ + cloudflare({ viteEnvironment: { name: 'ssr' } }), + tsConfigPaths(), + tanstackStart(), + viteReact(), + sentryTanstackStart({ tunnelRoute: '/monitoring', sourcemaps: { disable: true } }), + ], }); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/tests/tunnel.test.ts index 76a39f44be81..966441835367 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/tests/tunnel.test.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/tests/tunnel.test.ts @@ -10,7 +10,7 @@ const expectedTunnelPathMatcher = ? '/custom-monitor' : tunnelRouteMode === 'object' ? '/object-monitor' - : /^\/[a-z0-9]{8}$/; + : /^\/[a-z0-9]{1,7}$/; test.skip(tunnelRouteMode === 'off', 'Tunnel assertions only run in the tunnel-route variants'); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json b/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json index 555271be7beb..9b0bb01e60f0 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json @@ -14,13 +14,17 @@ "test:build:tunnel-static": "pnpm install && E2E_TEST_TUNNEL_ROUTE_MODE=static E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm build", "test:build:tunnel-custom": "pnpm install && E2E_TEST_CUSTOM_TUNNEL_ROUTE=1 pnpm build", "test:build:tunnel-object": "pnpm install && E2E_TEST_TUNNEL_ROUTE_MODE=object E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm build", + "test:build:tunnel-basepath": "pnpm install && E2E_TEST_TUNNEL_ROUTE_MODE=dynamic E2E_TEST_BASE_PATH=/app E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm build", "test:build-latest": "pnpm add @tanstack/react-start@latest @tanstack/react-router@latest nitro@latest vite@latest @vitejs/plugin-react-swc@latest vite-tsconfig-paths@latest && pnpm install && pnpm build", "test:assert:proxy": "pnpm test && TEST_ENV=development pnpm playwright test db-drivers", "test:assert": "pnpm test:assert:proxy", "test:assert:tunnel-generated": "E2E_TEST_TUNNEL_ROUTE_MODE=dynamic E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm test", "test:assert:tunnel-static": "E2E_TEST_TUNNEL_ROUTE_MODE=static E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm test", "test:assert:tunnel-custom": "E2E_TEST_CUSTOM_TUNNEL_ROUTE=1 pnpm test", - "test:assert:tunnel-object": "E2E_TEST_TUNNEL_ROUTE_MODE=object E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm test" + "test:assert:tunnel-object": "E2E_TEST_TUNNEL_ROUTE_MODE=object E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm test", + "test:assert:tunnel-basepath": "E2E_TEST_TUNNEL_ROUTE_MODE=dynamic E2E_TEST_BASE_PATH=/app E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm test tunnel", + "test:assert:tunnel-dev": "E2E_TEST_TUNNEL_ROUTE_MODE=dynamic E2E_TEST_DSN=http://public@localhost:3031/1337 TEST_ENV=development pnpm test tunnel", + "test:assert:tunnel-basepath-dev": "E2E_TEST_TUNNEL_ROUTE_MODE=dynamic E2E_TEST_BASE_PATH=/app E2E_TEST_DSN=http://public@localhost:3031/1337 TEST_ENV=development pnpm test tunnel" }, "//": "Need to use ioredis 5.10.1 because that's the last version before they support tracing channels", "dependencies": { @@ -70,6 +74,21 @@ "label": "tanstackstart-react (tunnel-object)", "build-command": "pnpm test:build:tunnel-object", "assert-command": "pnpm test:assert:tunnel-object" + }, + { + "label": "tanstackstart-react (tunnel-basepath)", + "build-command": "pnpm test:build:tunnel-basepath", + "assert-command": "pnpm test:assert:tunnel-basepath" + }, + { + "label": "tanstackstart-react (tunnel-dev)", + "build-command": "pnpm install", + "assert-command": "pnpm test:assert:tunnel-dev" + }, + { + "label": "tanstackstart-react (tunnel-basepath-dev)", + "build-command": "pnpm install", + "assert-command": "pnpm test:assert:tunnel-basepath-dev" } ] } diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/tunnel.test.ts index 9857d951b2d8..e14011b4a557 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/tunnel.test.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/tunnel.test.ts @@ -3,6 +3,7 @@ import { getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/t const tunnelRouteMode = process.env.E2E_TEST_TUNNEL_ROUTE_MODE ?? (process.env.E2E_TEST_CUSTOM_TUNNEL_ROUTE === '1' ? 'custom' : 'off'); +const basePath = process.env.E2E_TEST_BASE_PATH ?? ''; const expectedTunnelPathMatcher = tunnelRouteMode === 'static' ? '/monitor' @@ -10,7 +11,8 @@ const expectedTunnelPathMatcher = ? '/custom-monitor' : tunnelRouteMode === 'object' ? '/object-monitor' - : /^\/[a-z0-9]{8}$/; + : // The path `tunnelRoute: true` derives from the DSN. + new RegExp(`^${basePath}/[a-z0-9]{1,7}$`); test.skip(tunnelRouteMode === 'off', 'Tunnel assertions only run in the tunnel-route variants'); @@ -19,7 +21,7 @@ test('Sends client-side errors through the configured tunnel route', async ({ pa return errorEvent?.exception?.values?.[0]?.value === 'Sentry Client Test Error'; }); - await page.goto('/'); + await page.goto(`${basePath}/`); const pageOrigin = new URL(page.url()).origin; // Wait for React to hydrate (see __root.tsx) before clicking — the SSR HTML @@ -54,20 +56,19 @@ test('Sends client-side errors through the configured tunnel route', async ({ pa expect(managedTunnelUrl.pathname).toBe(expectedTunnelPathMatcher); } else { expect(managedTunnelUrl.pathname).toMatch(expectedTunnelPathMatcher); - expect(managedTunnelUrl.pathname).not.toBe('/monitor'); + expect(managedTunnelUrl.pathname).not.toBe(`${basePath}/monitor`); } expect(errorEvent.exception?.values?.[0]?.value).toBe('Sentry Client Test Error'); - expect(errorEvent.transaction).toBe('/'); + expect(errorEvent.transaction).toBe(`${basePath}/`); }); -test('Rejects non-POST requests to the managed tunnel route instead of rendering the app', async ({ request }) => { +test('Only answers POSTs on the managed tunnel path and leaves other methods to the app', async ({ request }) => { test.skip(tunnelRouteMode !== 'static', 'Requires a known managed tunnel path'); const response = await request.get('/monitor'); - expect(response.status()).toBe(405); - expect(response.headers()['allow']).toBe('POST'); + expect(response.status()).toBe(404); }); function pathnameMatchesTunnelRoute(pathname: string): boolean { @@ -101,7 +102,7 @@ test('Does not create a server transaction for the tunnel route', async ({ page // transaction by the server SDK — tunnel traffic is plumbing, not application requests. const tunnelServerEventPromise = waitForServerHttpSpan(pathnameMatchesTunnelRoute); - await page.goto('/'); + await page.goto(`${basePath}/`); const pageOrigin = new URL(page.url()).origin; await page.locator('html[data-hydrated="true"]').waitFor(); @@ -127,7 +128,7 @@ test('Does not create a server transaction for the tunnel route', async ({ page // this anchor. Racing the two lets us assert the absence of a tunnel transaction without idling on a timeout. const anchorServerEventPromise = waitForServerHttpSpan(pathname => pathname.includes('/api/user/')); - await page.request.get('/api/user/456'); + await page.request.get(`${basePath}/api/user/456`); const winner = await Promise.race([ tunnelServerEventPromise.then(() => 'tunnel' as const), diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts index 2385d8aa5e93..9e0813a8d701 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts @@ -45,7 +45,10 @@ export default defineConfig({ }, plugins: [ tsConfigPaths(), - tanstackStart(), + // Start routes the whole app under the base path, so the tunnel has to be served under it too. + tanstackStart( + process.env.E2E_TEST_BASE_PATH ? { router: { basepath: process.env.E2E_TEST_BASE_PATH } } : undefined, + ), nitro(), // react's vite plugin must come after start's vite plugin viteReact(), diff --git a/dev-packages/node-integration-tests/suites/tracing/managed-tunnel/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/managed-tunnel/instrument.mjs new file mode 100644 index 000000000000..df798c3e0249 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/managed-tunnel/instrument.mjs @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + // A meta-framework answers the tunnel requests, Node only drops their server spans. + tunnel: true, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/managed-tunnel/server.mjs b/dev-packages/node-integration-tests/suites/tracing/managed-tunnel/server.mjs new file mode 100644 index 000000000000..ee4a7683d045 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/managed-tunnel/server.mjs @@ -0,0 +1,10 @@ +import { sendPortToRunner } from '@sentry-internal/node-integration-tests'; +import http from 'http'; + +const server = http.createServer((_request, response) => { + response.end('app'); +}); + +server.listen(0, () => { + sendPortToRunner(server.address().port); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/managed-tunnel/test.ts b/dev-packages/node-integration-tests/suites/tracing/managed-tunnel/test.ts new file mode 100644 index 000000000000..1504ba345677 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/managed-tunnel/test.ts @@ -0,0 +1,29 @@ +import { URL_PATH } from '@sentry/conventions/attributes'; +import { getTunnelPath } from '@sentry/core'; +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +describe('managed tunnel', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests(__dirname, 'server.mjs', 'instrument.mjs', (createRunner, test) => { + test('drops the server span of tunnel requests', async () => { + const runner = createRunner() + .expect({ + span: container => { + // Spans flush in order, so a span for the tunnel request would arrive in this container or before it. + const segmentPaths = container.items.filter(item => item.is_segment).map(item => item.attributes[URL_PATH]); + expect(segmentPaths).toEqual([{ type: 'string', value: '/after-tunnel' }]); + }, + }) + .start(); + + await runner.makeRequest('post', getTunnelPath('https://public@dsn.ingest.sentry.io/1337')!); + await runner.makeRequest('get', '/after-tunnel'); + + await runner.completed(); + }); + }); +}); diff --git a/packages/astro/src/client/sdk.ts b/packages/astro/src/client/sdk.ts index 14effd00cacb..bd05cbd24579 100644 --- a/packages/astro/src/client/sdk.ts +++ b/packages/astro/src/client/sdk.ts @@ -2,6 +2,7 @@ import type { BrowserOptions } from '@sentry/browser'; import { getDefaultIntegrations as getBrowserDefaultIntegrations, init as initBrowserSdk } from '@sentry/browser'; import type { Client, Integration } from '@sentry/core'; import { applySdkMetadata } from '@sentry/core'; +import { applyAstroTunnelOptions } from '../tunnel'; import { browserTracingIntegration } from './browserTracingIntegration'; import { createAstroRouteProvider } from './routeProvider'; @@ -23,6 +24,7 @@ export function init(options: BrowserOptions): Client | undefined { }; applySdkMetadata(opts, 'astro', ['astro', 'browser']); + applyAstroTunnelOptions(opts); return initBrowserSdk(opts); } diff --git a/packages/astro/src/integration/index.ts b/packages/astro/src/integration/index.ts index 396413b64939..390d28ed92cc 100644 --- a/packages/astro/src/integration/index.ts +++ b/packages/astro/src/integration/index.ts @@ -8,6 +8,7 @@ import * as path from 'path'; import type { VitePlugin } from './cloudflare'; import { sentryCloudflareNodeWarningPlugin, sentryCloudflareVitePlugin } from './cloudflare'; import { buildClientSnippet, buildSdkInitFileImportSnippet, buildServerSnippet } from './snippets'; +import { getAstroTunnelOptions, makeTunnelOptionsVitePlugin, TUNNEL_OPTIONS_IMPORT } from './tunnel'; import type { SentryOptions } from './types'; const PKG_NAME = '@sentry/astro'; @@ -128,15 +129,21 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => { }); } + const tunnelOptions = getAstroTunnelOptions(config); + if (tunnelOptions) { + updateConfig({ vite: { plugins: [makeTunnelOptionsVitePlugin(tunnelOptions)] } }); + } + const tunnelOptionsImport = tunnelOptions ? TUNNEL_OPTIONS_IMPORT : ''; + if (sdkEnabled.client) { const pathToClientInit = clientInitPath ? path.resolve(clientInitPath) : findDefaultSdkInitFile('client'); if (pathToClientInit) { debug && logger.info(`Using ${pathToClientInit} for client init.`); - injectScript('page', buildSdkInitFileImportSnippet(pathToClientInit)); + injectScript('page', tunnelOptionsImport + buildSdkInitFileImportSnippet(pathToClientInit)); } else { debug && logger.info('Using default client init.'); - injectScript('page', buildClientSnippet(options || {})); + injectScript('page', tunnelOptionsImport + buildClientSnippet(options || {})); } } @@ -182,10 +189,10 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => { // Always inject the server config via `injectScript('page-ssr')`. // This ensures Sentry.init() runs in dev mode (where the Vite plugin doesn't fire) // and also serves as the fallback for non-Cloudflare adapters in production. - injectScript('page-ssr', buildSdkInitFileImportSnippet(pathToServerInit)); + injectScript('page-ssr', tunnelOptionsImport + buildSdkInitFileImportSnippet(pathToServerInit)); } else if (!usesCloudflareVitePlugin) { debug && logger.info('Using default server init.'); - injectScript('page-ssr', buildServerSnippet(options || {})); + injectScript('page-ssr', tunnelOptionsImport + buildServerSnippet(options || {})); } if (isCloudflareWorkers && command !== 'dev') { diff --git a/packages/astro/src/integration/tunnel.ts b/packages/astro/src/integration/tunnel.ts new file mode 100644 index 000000000000..d3f0bc2a3d5b --- /dev/null +++ b/packages/astro/src/integration/tunnel.ts @@ -0,0 +1,32 @@ +import type { TunnelOptions } from '@sentry/core'; +import type { AstroConfig } from 'astro'; +import { TUNNEL_OPTIONS_GLOBAL } from '../tunnel'; +import type { VitePlugin } from './cloudflare'; + +const TUNNEL_OPTIONS_MODULE = 'virtual:@sentry/astro/tunnel-options'; +const RESOLVED_TUNNEL_OPTIONS_MODULE = `\0${TUNNEL_OPTIONS_MODULE}`; + +/** The tunnel options Astro's routing requires, or `undefined` if the defaults work. */ +export function getAstroTunnelOptions(config: Pick): TunnelOptions | undefined { + const basePath = config.base?.replace(/\/$/, ''); + const trailingSlash = config.trailingSlash === 'always'; + + return basePath || trailingSlash + ? { ...(basePath && { basePath }), ...(trailingSlash && { trailingSlash }) } + : undefined; +} + +/** Imported first in the injected init scripts, so the options are set before `Sentry.init` runs. */ +export const TUNNEL_OPTIONS_IMPORT = `import "${TUNNEL_OPTIONS_MODULE}";\n`; + +/** Serves the module that hands the tunnel options to the SDKs. */ +export function makeTunnelOptionsVitePlugin(tunnelOptions: TunnelOptions): VitePlugin { + return { + name: 'sentry-astro-tunnel-options', + resolveId: id => (id === TUNNEL_OPTIONS_MODULE ? RESOLVED_TUNNEL_OPTIONS_MODULE : null), + load: id => + id === RESOLVED_TUNNEL_OPTIONS_MODULE + ? `globalThis[${JSON.stringify(TUNNEL_OPTIONS_GLOBAL)}] = ${JSON.stringify(tunnelOptions)};` + : null, + }; +} diff --git a/packages/astro/src/server/middleware.ts b/packages/astro/src/server/middleware.ts index 2e88b7fc160a..a0f99392679e 100644 --- a/packages/astro/src/server/middleware.ts +++ b/packages/astro/src/server/middleware.ts @@ -26,7 +26,7 @@ import { filterCollectedUrl, filterCollectedUrlQuery, } from '@sentry/core'; -import { flushIfServerless } from '@sentry/core/server'; +import { flushIfServerless, handleTunnelRequestIfMatched } from '@sentry/core/server'; import { captureException, continueTrace, @@ -94,6 +94,11 @@ export const handleRequest: (options?: MiddlewareOptions) => MiddlewareHandler = return next(); } + const tunnelResponse = handleTunnelRequestIfMatched(ctx.request); + if (tunnelResponse) { + return tunnelResponse; + } + const isDynamicPageRequest = checkIsDynamicPageRequest(ctx); // For static (prerendered) routes, we only want to inject the parametrized route meta tags diff --git a/packages/astro/src/server/sdk.ts b/packages/astro/src/server/sdk.ts index 6ba2703bc994..aea532c3eaa5 100644 --- a/packages/astro/src/server/sdk.ts +++ b/packages/astro/src/server/sdk.ts @@ -2,6 +2,7 @@ import { HTTP_SERVER } from '@sentry/conventions/op'; import { applySdkMetadata } from '@sentry/core'; import type { NodeClient, NodeOptions } from '@sentry/node'; import { init as initNodeSdk } from '@sentry/node'; +import { applyAstroTunnelOptions } from '../tunnel'; /** * @@ -13,6 +14,7 @@ export function init(options: NodeOptions): NodeClient | undefined { }; applySdkMetadata(opts, 'astro', ['astro', 'node']); + applyAstroTunnelOptions(opts); opts.ignoreSpans = [ ...(opts.ignoreSpans || []), diff --git a/packages/astro/src/tunnel.ts b/packages/astro/src/tunnel.ts new file mode 100644 index 000000000000..e24da0785fdf --- /dev/null +++ b/packages/astro/src/tunnel.ts @@ -0,0 +1,14 @@ +import type { Options, TunnelOptions } from '@sentry/core'; +import { applyTunnelDefaults, GLOBAL_OBJ } from '@sentry/core'; + +/** Set by the Sentry Astro integration from the `base` and `trailingSlash` config. */ +export const TUNNEL_OPTIONS_GLOBAL = '__sentry_astro_tunnel_options'; + +/** + * Serves the managed tunnel under the app's base path and with Astro's trailing slash policy, which Astro enforces + * before any middleware runs. Options the user set win. + */ +export function applyAstroTunnelOptions(options: Pick): void { + const astroTunnelOptions = (GLOBAL_OBJ as { [TUNNEL_OPTIONS_GLOBAL]?: TunnelOptions })[TUNNEL_OPTIONS_GLOBAL]; + options.tunnel = applyTunnelDefaults(options.tunnel, astroTunnelOptions); +} diff --git a/packages/astro/test/server/middleware.test.ts b/packages/astro/test/server/middleware.test.ts index 88a21e303f10..573ef640e460 100644 --- a/packages/astro/test/server/middleware.test.ts +++ b/packages/astro/test/server/middleware.test.ts @@ -2,6 +2,7 @@ import { HTTP_REQUEST_METHOD, SENTRY_SEGMENT_NAME_SOURCE, URL_FULL, URL_PATH } f import type { Client, Span } from '@sentry/core'; import * as SentryCore from '@sentry/core'; +import * as SentryCoreServer from '@sentry/core/server'; import * as SentryNode from '@sentry/node'; import type { APIContext } from 'astro'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -33,6 +34,27 @@ const STATIC_REQUEST_CONTEXT = { url: new URL('https://myDomain.io/users/'), }; +describe('sentryMiddleware with the managed tunnel', () => { + it('answers tunnel requests without calling the rest of the middleware chain', async () => { + const getClientSpy = vi.spyOn(SentryNode, 'getClient').mockReturnValue({} as Client); + const tunnelResponse = new Response('forwarded'); + const matcherSpy = vi + .spyOn(SentryCoreServer, 'handleTunnelRequestIfMatched') + .mockReturnValue(Promise.resolve(tunnelResponse)); + const next = vi.fn(); + const request = new Request('https://myDomain.io/abc123', { method: 'POST' }); + + const response = await handleRequest()({ ...DYNAMIC_REQUEST_CONTEXT, request } as unknown as APIContext, next); + + expect(response).toBe(tunnelResponse); + expect(matcherSpy).toHaveBeenCalledWith(request); + expect(next).not.toHaveBeenCalled(); + + getClientSpy.mockRestore(); + matcherSpy.mockRestore(); + }); +}); + describe('sentryMiddleware', () => { const startSpanSpy = vi.spyOn(SentryNode, 'startSpan'); diff --git a/packages/astro/test/tunnel.test.ts b/packages/astro/test/tunnel.test.ts new file mode 100644 index 000000000000..9aaf293c3e11 --- /dev/null +++ b/packages/astro/test/tunnel.test.ts @@ -0,0 +1,72 @@ +import { GLOBAL_OBJ } from '@sentry/core'; +import { afterEach, describe, expect, it } from 'vitest'; +import { getAstroTunnelOptions, makeTunnelOptionsVitePlugin } from '../src/integration/tunnel'; +import { applyAstroTunnelOptions, TUNNEL_OPTIONS_GLOBAL } from '../src/tunnel'; + +const globalWithOptions = GLOBAL_OBJ as { __sentry_astro_tunnel_options?: unknown }; + +describe('getAstroTunnelOptions', () => { + it('maps `base` and `trailingSlash: always` onto tunnel options', () => { + expect(getAstroTunnelOptions({ base: '/docs/', trailingSlash: 'always' })).toEqual({ + basePath: '/docs', + trailingSlash: true, + }); + expect(getAstroTunnelOptions({ base: '/docs', trailingSlash: 'ignore' })).toEqual({ basePath: '/docs' }); + }); + + it('returns nothing for the defaults', () => { + expect(getAstroTunnelOptions({ base: '/', trailingSlash: 'ignore' })).toBeUndefined(); + }); +}); + +describe('makeTunnelOptionsVitePlugin', () => { + it('serves a module that sets the tunnel options global', () => { + const plugin = makeTunnelOptionsVitePlugin({ basePath: '/docs' }); + const resolved = (plugin.resolveId as unknown as (id: string) => string | null)( + 'virtual:@sentry/astro/tunnel-options', + ); + + expect((plugin.load as unknown as (id: string) => string | null)(resolved!)).toBe( + `globalThis[${JSON.stringify(TUNNEL_OPTIONS_GLOBAL)}] = {"basePath":"/docs"};`, + ); + }); +}); + +describe('applyAstroTunnelOptions', () => { + afterEach(() => { + delete globalWithOptions.__sentry_astro_tunnel_options; + }); + + it('adds the Astro routing options to the managed tunnel, letting user options win', () => { + globalWithOptions.__sentry_astro_tunnel_options = { basePath: '/docs', trailingSlash: true }; + const fromTrue: Parameters[0] = { tunnel: true }; + const fromOptions: Parameters[0] = { tunnel: { basePath: '/other' } }; + + applyAstroTunnelOptions(fromTrue); + applyAstroTunnelOptions(fromOptions); + + expect(fromTrue.tunnel).toEqual({ basePath: '/docs', trailingSlash: true }); + expect(fromOptions.tunnel).toEqual({ basePath: '/other', trailingSlash: true }); + }); + + it('serves a relative tunnel path with the Astro routing options too', () => { + globalWithOptions.__sentry_astro_tunnel_options = { basePath: '/docs', trailingSlash: true }; + const options: Parameters[0] = { tunnel: '/monitor' }; + + applyAstroTunnelOptions(options); + + expect(options.tunnel).toEqual({ basePath: '/docs', trailingSlash: true, path: '/monitor' }); + }); + + it('leaves URL tunnels and a disabled tunnel alone', () => { + globalWithOptions.__sentry_astro_tunnel_options = { basePath: '/docs' }; + const url: Parameters[0] = { tunnel: 'https://example.com/tunnel' }; + const off: Parameters[0] = {}; + + applyAstroTunnelOptions(url); + applyAstroTunnelOptions(off); + + expect(url.tunnel).toBe('https://example.com/tunnel'); + expect(off.tunnel).toBeUndefined(); + }); +}); diff --git a/packages/aws-serverless/src/init.ts b/packages/aws-serverless/src/init.ts index 8c7c238265fa..48bbceef2f96 100644 --- a/packages/aws-serverless/src/init.ts +++ b/packages/aws-serverless/src/init.ts @@ -1,5 +1,6 @@ import type { Integration, Options } from '@sentry/core'; import { applySdkMetadata, debug, envToBool, getSDKSource } from '@sentry/core'; +import { resolveServerTunnelOption } from '@sentry/core/server'; import type { NodeClient, NodeOptions } from '@sentry/node'; import { getDefaultIntegrationsWithoutPerformance, initWithoutDefaultIntegrations } from '@sentry/node'; import { awsIntegration } from '@sentry/server-utils'; @@ -76,7 +77,8 @@ export function init(options: AwsServerlessOptions = {}): NodeClient | undefined // 2. Environment variable SENTRY_LAYER_EXTENSION (if set) // 3. Default logic based on sdkSource, tunnel, and proxy settings const useLayerExtensionFromEnv = envToBool(process.env.SENTRY_LAYER_EXTENSION, { strict: true }); - const defaultUseLayerExtension = sdkSource === 'aws-lambda-layer' && !options.tunnel && !proxyWouldInterfere; + const defaultUseLayerExtension = + sdkSource === 'aws-lambda-layer' && typeof options.tunnel !== 'string' && !proxyWouldInterfere; const useLayerExtension = options.useLayerExtension ?? useLayerExtensionFromEnv ?? defaultUseLayerExtension; const opts = { @@ -88,8 +90,8 @@ export function init(options: AwsServerlessOptions = {}): NodeClient | undefined if (opts.useLayerExtension) { if (sdkSource !== 'aws-lambda-layer') { DEBUG_BUILD && debug.warn('The Sentry Lambda extension is only supported when using the AWS Lambda layer.'); - } else if (opts.tunnel || proxyWouldInterfere) { - if (opts.tunnel) { + } else if (typeof opts.tunnel === 'string' || proxyWouldInterfere) { + if (typeof opts.tunnel === 'string') { DEBUG_BUILD && debug.warn( `Using a custom tunnel with the Sentry Lambda extension is not supported. Events will be tunnelled to ${opts.tunnel} and not through the extension.`, @@ -104,6 +106,8 @@ export function init(options: AwsServerlessOptions = {}): NodeClient | undefined } } else { DEBUG_BUILD && debug.log('Proxying Sentry events through the Sentry Lambda extension'); + // The extension takes over the transport, but `tunnel: true` still has to serve the managed tunnel. + opts._managedTunnel = resolveServerTunnelOption(opts.tunnel)._managedTunnel; opts.tunnel = 'http://localhost:9000/envelope'; } } diff --git a/packages/aws-serverless/test/init.test.ts b/packages/aws-serverless/test/init.test.ts index 500338dc7144..a7eda1bcfe84 100644 --- a/packages/aws-serverless/test/init.test.ts +++ b/packages/aws-serverless/test/init.test.ts @@ -60,6 +60,19 @@ describe('init', () => { ); }); + test('keeps the managed tunnel when the Lambda extension takes over the transport', () => { + mockGetSDKSource.mockReturnValue('aws-lambda-layer'); + + init({ tunnel: true, useLayerExtension: true }); + + expect(mockInitWithoutDefaultIntegrations).toHaveBeenCalledWith( + expect.objectContaining({ + tunnel: 'http://localhost:9000/envelope', + _managedTunnel: {}, + }), + ); + }); + test('should not set tunnel when Lambda extension is disabled', () => { mockGetSDKSource.mockReturnValue('aws-lambda-layer'); const options: AwsServerlessOptions = { diff --git a/packages/browser/src/sdk.ts b/packages/browser/src/sdk.ts index c058144432e1..8c0046d69e1b 100644 --- a/packages/browser/src/sdk.ts +++ b/packages/browser/src/sdk.ts @@ -24,6 +24,7 @@ import { defaultStackParser } from './stack-parsers'; import { makeFetchTransport } from './transports/fetch'; import { normalizeStringifyValue } from './normalizeStringifyValue'; import { checkAndWarnIfIsEmbeddedBrowserExtension } from './utils/detectBrowserExtension'; +import { makeManagedTunnelTransport, resolveBrowserTunnelOption } from './utils/managedTunnel'; /** Get the default integrations for the browser SDK. */ export function getDefaultIntegrations(_options: Options): Integration[] { @@ -115,12 +116,18 @@ export function init(options: BrowserOptions = {}): Client | undefined { defaultIntegrations, }); + const tunnelOptions = resolveBrowserTunnelOption(options); + const transport = options.transport || makeFetchTransport; + const clientOptions: BrowserClientOptions = { ...options, + ...tunnelOptions, enabled: shouldDisableBecauseIsBrowserExtenstion ? false : options.enabled, stackParser: stackParserFromStackParserOptions(options.stackParser || defaultStackParser), integrations, - transport: options.transport || makeFetchTransport, + transport: tunnelOptions._managedTunnel + ? makeManagedTunnelTransport(transport, options.transportOptions?.headers) + : transport, }; setNormalizeStringifier(normalizeStringifyValue); diff --git a/packages/browser/src/transports/fetch.ts b/packages/browser/src/transports/fetch.ts index 83e3905de35c..cafaac5ea23f 100644 --- a/packages/browser/src/transports/fetch.ts +++ b/packages/browser/src/transports/fetch.ts @@ -50,6 +50,8 @@ export function makeFetchTransport( headers: { 'x-sentry-rate-limits': response.headers.get('X-Sentry-Rate-Limits'), 'retry-after': response.headers.get('Retry-After'), + // Tells a managed tunnel's own responses apart from whatever else answers its path. + 'x-sentry-tunnel': response.headers.get('X-Sentry-Tunnel'), }, }; } catch (e) { diff --git a/packages/browser/src/utils/managedTunnel.ts b/packages/browser/src/utils/managedTunnel.ts new file mode 100644 index 000000000000..317ab2acb426 --- /dev/null +++ b/packages/browser/src/utils/managedTunnel.ts @@ -0,0 +1,124 @@ +import type { Envelope, Transport, TransportMakeRequestResponse, TunnelOptions } from '@sentry/core'; +import { + debug, + getEnvelopeEndpointWithUrlEncodedAuth, + getTunnelPath, + makeDsn, + normalizeTunnelBasePath, +} from '@sentry/core'; +import { DEBUG_BUILD } from '../debug-build'; +import type { BrowserOptions } from '../client'; +import type { BrowserTransportOptions } from '../transports/types'; + +/** + * Resolves the `tunnel` option `init` received. `true` or {@link TunnelOptions} point the tunnel at the same-origin + * path the server SDK derives from the same DSN. + */ +export function resolveBrowserTunnelOption( + options: BrowserOptions, +): Pick & { tunnel: string | undefined } { + const { tunnel, dsn, transportOptions } = options; + if (typeof tunnel === 'string' || !tunnel) { + return { tunnel, transportOptions }; + } + + const tunnelOptions: TunnelOptions = tunnel === true ? {} : tunnel; + const tunnelPath = tunnelOptions.path ?? (dsn && getTunnelPath(dsn)); + if (!tunnelPath) { + return { tunnel: undefined, transportOptions }; + } + + const basePath = normalizeTunnelBasePath(tunnelOptions.basePath); + const resolvedTunnel = `${basePath}${tunnelPath}${tunnelOptions.trailingSlash ? '/' : ''}`; + + DEBUG_BUILD && debug.log(`Tunneling events to "${resolvedTunnel}"`); + + return { + tunnel: resolvedTunnel, + // Lets the client explain a tunnel request that nothing on the server answers. + _managedTunnel: tunnelOptions, + // The fetch transport sends `text/plain` by default to avoid a CORS preflight. A same-origin request has no + // preflight, and frameworks with CSRF protection (SvelteKit, Astro) reject a `text/plain` POST as a form submission. + transportOptions: { + ...transportOptions, + headers: { 'Content-Type': 'application/x-sentry-envelope', ...transportOptions?.headers }, + }, + }; +} + +let hasWarnedAboutRejectedDsn = false; + +/** + * Sends to Sentry directly once it's clear nothing on this origin serves the managed tunnel, e.g. in a static build, + * on an SPA host, or when the server SDK isn't set up. An envelope is only sent again when the tunnel's response + * proves it was never ingested, so nothing reaches Sentry twice. + */ +export function makeManagedTunnelTransport( + makeTransport: (options: BrowserTransportOptions) => Transport, + directHeaders: BrowserTransportOptions['headers'], +): (options: BrowserTransportOptions) => Transport { + return options => { + const tunnelTransport = makeTransport(options); + let directTransport: Transport | undefined; + + const switchToDirect = (envelope: Envelope, reason: string): Transport | undefined => { + const { dsn, sdk } = envelope[0]; + const components = dsn && makeDsn(dsn); + if (components && !directTransport) { + DEBUG_BUILD && debug.warn(`Sending to Sentry directly: ${reason}`); + directTransport = makeTransport({ + ...options, + url: getEnvelopeEndpointWithUrlEncodedAuth(components, undefined, sdk), + headers: directHeaders, + }); + } + + return directTransport; + }; + + return { + async send(envelope: Envelope): Promise { + if (directTransport) { + return directTransport.send(envelope); + } + + const response = await tunnelTransport.send(envelope); + const { statusCode } = response; + + // The tunnel handler answered, so whatever status it passes on is Sentry's. + if (response.headers?.['x-sentry-tunnel']) { + if (DEBUG_BUILD && statusCode === 403 && !hasWarnedAboutRejectedDsn) { + hasWarnedAboutRejectedDsn = true; + debug.warn( + `The managed tunnel at "${options.url}" rejected this DSN, so events sent through it are lost. Add it to the server's \`allowedDsns\`.`, + ); + } + + return response; + } + + // Something other than the tunnel handler rejected the request, e.g. nothing serves the path (404) or an auth + // middleware answered first (401). Only the handler forwards, so the envelope never reached Sentry and is safe + // to send once more. + if (statusCode !== undefined && statusCode >= 400 && statusCode < 500) { + const transport = switchToDirect(envelope, `"${options.url}" answered ${statusCode} instead of the tunnel.`); + + return transport ? transport.send(envelope) : response; + } + + // Something other than the tunnel handler answered, like a static host serving `index.html`. This envelope + // may have been delivered all the same, so it isn't sent again, only the ones after it go directly. + if (statusCode !== undefined && statusCode >= 200 && statusCode < 300) { + switchToDirect(envelope, `the response from "${options.url}" didn't come from a Sentry tunnel handler.`); + } + + return response; + }, + async flush(timeout?: number): Promise { + const results = await Promise.all([tunnelTransport.flush(timeout), directTransport?.flush(timeout) ?? true]); + + return results.every(Boolean); + }, + }; + }; +} diff --git a/packages/browser/test/transports/fetch.test.ts b/packages/browser/test/transports/fetch.test.ts index 1c1dee2c7f90..52098701347d 100644 --- a/packages/browser/test/transports/fetch.test.ts +++ b/packages/browser/test/transports/fetch.test.ts @@ -73,9 +73,10 @@ describe('fetchTransport', () => { expect(headers.get).toHaveBeenCalledTimes(0); await transport.send(ERROR_ENVELOPE); - expect(headers.get).toHaveBeenCalledTimes(2); + expect(headers.get).toHaveBeenCalledTimes(3); expect(headers.get).toHaveBeenCalledWith('X-Sentry-Rate-Limits'); expect(headers.get).toHaveBeenCalledWith('Retry-After'); + expect(headers.get).toHaveBeenCalledWith('X-Sentry-Tunnel'); }); it('allows for custom options to be passed in', async () => { diff --git a/packages/browser/test/utils/managedTunnel.test.ts b/packages/browser/test/utils/managedTunnel.test.ts new file mode 100644 index 000000000000..dc366af408d5 --- /dev/null +++ b/packages/browser/test/utils/managedTunnel.test.ts @@ -0,0 +1,239 @@ +import type { EventEnvelope, EventItem, Transport, TransportMakeRequestResponse } from '@sentry/core'; +import { createEnvelope, getEnvelopeEndpointWithUrlEncodedAuth, getTunnelPath, makeDsn } from '@sentry/core'; +import { isTunnelRequest } from '@sentry/core/server'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { getClient, getCurrentScope, init } from '../../src'; +import { makeFetchTransport } from '../../src/transports/fetch'; +import { makeManagedTunnelTransport, resolveBrowserTunnelOption } from '../../src/utils/managedTunnel'; +import type { BrowserTransportOptions } from '../../src/transports/types'; + +const DSN = 'https://public@o123.ingest.sentry.io/1337'; + +describe('resolveBrowserTunnelOption', () => { + it('resolves `tunnel: true` to the path the server derives from the same DSN', () => { + const { tunnel } = resolveBrowserTunnelOption({ dsn: DSN, tunnel: true }); + + expect(tunnel).toBe(getTunnelPath(DSN)); + expect(isTunnelRequest({ method: 'POST', url: tunnel! }, { allowedDsns: [DSN] })).toBe(true); + }); + + it('marks the tunnel as managed so the client can explain a server that does not answer it', () => { + expect(resolveBrowserTunnelOption({ dsn: DSN, tunnel: { basePath: '/docs' } })._managedTunnel).toEqual({ + basePath: '/docs', + }); + expect(resolveBrowserTunnelOption({ dsn: DSN, tunnel: '/my-tunnel' })._managedTunnel).toBeUndefined(); + }); + + it('prefixes the base path and appends a trailing slash when asked', () => { + const { tunnel } = resolveBrowserTunnelOption({ dsn: DSN, tunnel: { basePath: '/docs/', trailingSlash: true } }); + + expect(tunnel).toBe(`/docs${getTunnelPath(DSN)}/`); + expect(isTunnelRequest({ method: 'POST', url: tunnel! }, { allowedDsns: [DSN], basePath: '/docs' })).toBe(true); + }); + + it('normalizes the base path like the server does, so the tunnel URL is never page-relative', () => { + expect(resolveBrowserTunnelOption({ dsn: DSN, tunnel: { basePath: 'docs' } }).tunnel).toBe( + `/docs${getTunnelPath(DSN)}`, + ); + expect(resolveBrowserTunnelOption({ dsn: DSN, tunnel: { basePath: './docs/' } }).tunnel).toBe( + `/docs${getTunnelPath(DSN)}`, + ); + expect(resolveBrowserTunnelOption({ dsn: DSN, tunnel: { basePath: './' } }).tunnel).toBe(getTunnelPath(DSN)); + }); + + it('uses a fixed path instead of the DSN-derived one', () => { + const { tunnel, transportOptions } = resolveBrowserTunnelOption({ dsn: DSN, tunnel: { path: '/monitor' } }); + + expect(tunnel).toBe('/monitor'); + expect(transportOptions?.headers).toEqual({ 'Content-Type': 'application/x-sentry-envelope' }); + }); + + it('keeps a URL tunnel and its transport options untouched', () => { + const transportOptions = { headers: { 'x-custom': '1' } }; + + expect(resolveBrowserTunnelOption({ dsn: DSN, tunnel: '/my-tunnel', transportOptions })).toEqual({ + tunnel: '/my-tunnel', + transportOptions, + }); + }); + + it('turns the tunnel off without a valid DSN', () => { + expect(resolveBrowserTunnelOption({ tunnel: true }).tunnel).toBeUndefined(); + expect(resolveBrowserTunnelOption({ dsn: 'not-a-dsn', tunnel: true }).tunnel).toBeUndefined(); + }); + + it('sends envelopes as application/x-sentry-envelope so CSRF checks do not treat them as form posts', async () => { + const { tunnel, transportOptions } = resolveBrowserTunnelOption({ + dsn: DSN, + tunnel: true, + transportOptions: { headers: { 'x-custom': '1' } }, + }); + const nativeFetch = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); + + const transport = makeFetchTransport( + { ...transportOptions, url: tunnel!, recordDroppedEvent: () => undefined }, + nativeFetch, + ); + await transport.send( + createEnvelope({ event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2', sent_at: '123' }, [ + [{ type: 'event' }, { event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2' }] as EventItem, + ]), + ); + + expect(nativeFetch).toHaveBeenCalledWith( + getTunnelPath(DSN), + expect.objectContaining({ + headers: { 'Content-Type': 'application/x-sentry-envelope', 'x-custom': '1' }, + }), + ); + }); + + it('lets a user-provided Content-Type header win', () => { + const { transportOptions } = resolveBrowserTunnelOption({ + dsn: DSN, + tunnel: true, + transportOptions: { headers: { 'Content-Type': 'text/plain' } }, + }); + + expect(transportOptions?.headers).toEqual({ 'Content-Type': 'text/plain' }); + }); +}); + +describe('init with `tunnel: true`', () => { + afterEach(() => { + getCurrentScope().setClient(undefined); + }); + + it('gives the client a resolved tunnel URL', () => { + init({ dsn: DSN, tunnel: true, defaultIntegrations: false }); + + expect(getClient()?.getOptions().tunnel).toBe(getTunnelPath(DSN)); + }); +}); + +describe('makeManagedTunnelTransport', () => { + const TUNNEL_URL = `https://example.com${getTunnelPath(DSN)}`; + const DIRECT_URL = getEnvelopeEndpointWithUrlEncodedAuth(makeDsn(DSN)!); + + function makeEnvelope(id: string): EventEnvelope { + return createEnvelope({ dsn: DSN, event_id: id }, [ + [{ type: 'event' }, { event_id: id }] as EventItem, + ]); + } + + // Each transport answers with the next response queued for its URL and records what it sent. + function setup(responses: Record): { + transport: Transport; + sent: Array<{ url: string; id: string; headers: BrowserTransportOptions['headers'] }>; + } { + const sent: Array<{ url: string; id: string; headers: BrowserTransportOptions['headers'] }> = []; + const makeTransport = (options: BrowserTransportOptions): Transport => ({ + send: envelope => { + sent.push({ url: options.url, id: String(envelope[0].event_id), headers: options.headers }); + + return Promise.resolve(responses[options.url]?.shift() ?? { statusCode: 200 }); + }, + flush: () => Promise.resolve(true), + }); + const transport = makeManagedTunnelTransport(makeTransport, { 'X-Custom': '1' })({ + url: TUNNEL_URL, + headers: { 'Content-Type': 'application/x-sentry-envelope', 'X-Custom': '1' }, + recordDroppedEvent: () => undefined, + }); + + return { transport, sent }; + } + + const tunnelResponse = { statusCode: 200, headers: { 'x-sentry-tunnel': '1' } } as TransportMakeRequestResponse; + + it('keeps sending through the tunnel while its handler answers', async () => { + const { transport, sent } = setup({ [TUNNEL_URL]: [tunnelResponse, tunnelResponse] }); + + await transport.send(makeEnvelope('a')); + await transport.send(makeEnvelope('b')); + + expect(sent.map(({ url, id }) => [url, id])).toEqual([ + [TUNNEL_URL, 'a'], + [TUNNEL_URL, 'b'], + ]); + }); + + it.each([400, 401, 403, 404, 405, 413, 429])( + 'sends an envelope directly once, and everything after it, when something other than the tunnel answers %s', + async statusCode => { + const { transport, sent } = setup({ [TUNNEL_URL]: [{ statusCode }] }); + + expect(await transport.send(makeEnvelope('a'))).toEqual({ statusCode: 200 }); + await transport.send(makeEnvelope('b')); + + expect(sent.map(({ url, id }) => [url, id])).toEqual([ + [TUNNEL_URL, 'a'], + [DIRECT_URL, 'a'], + [DIRECT_URL, 'b'], + ]); + // Sentry takes the default `text/plain` without a CORS preflight, so the tunnel's content type is dropped. + expect(sent[1]?.headers).toEqual({ 'X-Custom': '1' }); + }, + ); + + it.each([403, 404, 405, 429])( + 'keeps using the tunnel when Sentry answers %s through its handler', + async statusCode => { + const sentryResponse = { statusCode, headers: { 'x-sentry-tunnel': '1' } } as TransportMakeRequestResponse; + const { transport, sent } = setup({ [TUNNEL_URL]: [sentryResponse, tunnelResponse] }); + + expect(await transport.send(makeEnvelope('a'))).toBe(sentryResponse); + await transport.send(makeEnvelope('b')); + + expect(sent.map(({ url, id }) => [url, id])).toEqual([ + [TUNNEL_URL, 'a'], + [TUNNEL_URL, 'b'], + ]); + }, + ); + + it('never sends an envelope again after a 2xx the tunnel handler did not produce, only the ones after it', async () => { + const { transport, sent } = setup({ + [TUNNEL_URL]: [{ statusCode: 200, headers: {} } as TransportMakeRequestResponse], + }); + + await transport.send(makeEnvelope('a')); + await transport.send(makeEnvelope('b')); + + expect(sent.map(({ url, id }) => [url, id])).toEqual([ + [TUNNEL_URL, 'a'], + [DIRECT_URL, 'b'], + ]); + }); + + it.each([500, 503])('never sends an envelope again after a %s', async statusCode => { + const { transport, sent } = setup({ [TUNNEL_URL]: [{ statusCode }] }); + + await transport.send(makeEnvelope('a')); + await transport.send(makeEnvelope('b')); + + expect(sent.map(({ url, id }) => [url, id])).toEqual([ + [TUNNEL_URL, 'a'], + [TUNNEL_URL, 'b'], + ]); + }); + + it('only sends each envelope directly once when several were already on their way to the tunnel', async () => { + const { transport, sent } = setup({ [TUNNEL_URL]: [{ statusCode: 404 }, { statusCode: 404 }] }); + + await Promise.all([transport.send(makeEnvelope('a')), transport.send(makeEnvelope('b'))]); + + expect(sent.filter(({ url }) => url === DIRECT_URL).map(({ id }) => id)).toEqual(['a', 'b']); + }); + + it('is used by `init` for a managed tunnel only', () => { + const transport = vi.fn(makeFetchTransport); + + init({ dsn: DSN, tunnel: true, transport }); + const managedTransportOptions = transport.mock.calls[0]?.[0]; + init({ dsn: DSN, tunnel: 'https://proxy.example.com', transport }); + + expect(managedTransportOptions?.url).toBe(getTunnelPath(DSN)); + expect(transport).toHaveBeenLastCalledWith(expect.objectContaining({ url: 'https://proxy.example.com' })); + }); +}); diff --git a/packages/bun/src/client.ts b/packages/bun/src/client.ts index 03d301f05f95..b4670612e1e0 100644 --- a/packages/bun/src/client.ts +++ b/packages/bun/src/client.ts @@ -1,5 +1,5 @@ import type { ServerRuntimeClientOptions } from '@sentry/core/server'; -import { ServerRuntimeClient } from '@sentry/core/server'; +import { resolveServerTunnelOption, ServerRuntimeClient } from '@sentry/core/server'; import { applySdkMetadata } from '@sentry/core'; import * as os from 'os'; import type { BunClientOptions } from './types'; @@ -17,6 +17,7 @@ export class BunClient extends ServerRuntimeClient { const clientOptions: ServerRuntimeClientOptions = { ...options, + ...resolveServerTunnelOption(options.tunnel), platform: 'javascript', runtime: { name: 'bun', version: Bun.version }, serverName: options.serverName || global.process.env.SENTRY_NAME || os.hostname(), diff --git a/packages/bun/test/managedTunnel.test.ts b/packages/bun/test/managedTunnel.test.ts new file mode 100644 index 000000000000..7523cf0a711c --- /dev/null +++ b/packages/bun/test/managedTunnel.test.ts @@ -0,0 +1,44 @@ +import type { TransactionEvent } from '@sentry/core'; +import * as SentryCore from '@sentry/core'; +import { afterEach, describe, expect, test } from 'bun:test'; +import { init } from '../src'; + +const DSN = 'https://username@domain/123'; + +describe('managed tunnel', () => { + afterEach(() => { + SentryCore.getCurrentScope().setClient(undefined); + }); + + test('Bun.serve drops the server span of tunnel requests', async () => { + const transactions: string[] = []; + init({ + dsn: DSN, + tracesSampleRate: 1, + traceLifecycle: 'static', + // A meta-framework answers the tunnel requests, Bun only drops their server spans. + tunnel: true, + beforeSendTransaction: (event: TransactionEvent) => { + transactions.push(event.request?.url ?? ''); + return null; + }, + transport: () => + SentryCore.createTransport({ recordDroppedEvent: () => undefined }, () => SentryCore.resolvedSyncPromise({})), + }); + + const server = Bun.serve({ port: 0, fetch: () => new Response('app') }); + const baseUrl = `http://localhost:${server.port}`; + + const tunnelResponse = await fetch(`${baseUrl}${SentryCore.getTunnelPath(DSN)}`, { + method: 'POST', + body: 'envelope', + }); + expect(await tunnelResponse.text()).toBe('app'); + await (await fetch(`${baseUrl}/after-tunnel`)).text(); + await SentryCore.flush(); + server.stop(); + + expect(transactions).toHaveLength(1); + expect(transactions[0]?.endsWith('/after-tunnel')).toBe(true); + }); +}); diff --git a/packages/cloudflare/src/baseSdk.ts b/packages/cloudflare/src/baseSdk.ts index 0375e7f94601..fdd0f3c89a81 100644 --- a/packages/cloudflare/src/baseSdk.ts +++ b/packages/cloudflare/src/baseSdk.ts @@ -1,5 +1,6 @@ import type { Integration } from '@sentry/core'; import { _INTERNAL_isClientClosed, debug, getCurrentScope, setCurrentClient } from '@sentry/core'; +import { resolveServerTunnelOption } from '@sentry/core/server'; import { consoleIntegration, conversationIdIntegration, @@ -120,6 +121,7 @@ export function initWithDefaultIntegrations( const clientOptions: CloudflareClientOptions = { ...options, + ...resolveServerTunnelOption(options.tunnel), cacheClient: cacheEnabled, stackParser: stackParserFromStackParserOptions(options.stackParser || defaultStackParser), integrations: getIntegrationsToSetup(options), diff --git a/packages/cloudflare/test/request.test.ts b/packages/cloudflare/test/request.test.ts index ae30155c3c6a..b4118c0a8c3d 100644 --- a/packages/cloudflare/test/request.test.ts +++ b/packages/cloudflare/test/request.test.ts @@ -63,6 +63,46 @@ describe('withSentry', () => { }); }); + describe('managed tunnel', () => { + async function transactionsFor(url: string, tunnel?: true): Promise { + const transactions: string[] = []; + await wrapRequestHandler( + { + options: { + ...MOCK_OPTIONS, + tunnel, + tracesSampleRate: 1, + beforeSendTransaction(event) { + transactions.push(event.transaction ?? ''); + return null; + }, + }, + request: new Request(url, { method: 'POST' }), + context: createMockExecutionContext(), + }, + () => new Response('app'), + ).then(response => response.text()); + + // Wait for async span end and transaction capture + await new Promise(resolve => setTimeout(resolve, 50)); + + return transactions; + } + + test('leaves tunnel requests to the app and drops their server span', async () => { + const tunnelUrl = `https://example.com${SentryCore.getTunnelPath(MOCK_OPTIONS.dsn!)}`; + + expect(await transactionsFor(tunnelUrl, true)).toEqual([]); + expect(await transactionsFor('https://example.com/api', true)).toEqual(['POST /api']); + }); + + test('keeps the span of the tunnel path when the managed tunnel is off', async () => { + const tunnelUrl = `https://example.com${SentryCore.getTunnelPath(MOCK_OPTIONS.dsn!)}`; + + expect(await transactionsFor(tunnelUrl)).toHaveLength(1); + }); + }); + test('passes through the response from the handler', async () => { const response = new Response('test'); const result = await wrapRequestHandler( diff --git a/packages/cloudflare/test/sdk.test.ts b/packages/cloudflare/test/sdk.test.ts index d7820ecc343e..c631eb6268e6 100644 --- a/packages/cloudflare/test/sdk.test.ts +++ b/packages/cloudflare/test/sdk.test.ts @@ -23,6 +23,15 @@ describe('init', () => { expect(client).toBeInstanceOf(CloudflareClient); }); + test('enables the managed tunnel for `tunnel: { allowedDsns }`', () => { + const browserDsn = 'https://browser@o1.ingest.sentry.io/42'; + init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', tunnel: { allowedDsns: [browserDsn] } }); + + const options = getClient()?.getOptions(); + expect(options?.tunnel).toBeUndefined(); + expect(options?._managedTunnel).toEqual({ allowedDsns: [browserDsn] }); + }); + test('installs SpanStreaming integration by default', () => { init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index c61b3975d41b..a07e32769da6 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -1317,6 +1317,7 @@ export abstract class Client { try { const result = await this._transport.send(envelope); this.emit('afterEnvelope', envelope); + return result; } catch (reason) { DEBUG_BUILD && debug.error('Error while sending envelope:', reason); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index aae98061c25d..b7172593df2c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -102,7 +102,13 @@ export { handleCallbackErrors } from './utils/handleCallbackErrors'; export { safeCallback } from './utils/safeCallback'; export { parameterize, fmt } from './utils/parameterize'; export type { HandleTunnelRequestOptions } from './utils/tunnel'; -export { handleTunnelRequest } from './utils/tunnel'; +export { + applyTunnelDefaults, + getTunnelPath, + handleTunnelRequest, + normalizeTunnelBasePath, + TUNNEL_RESPONSE_HEADER, +} from './utils/tunnel'; export { addAutoIpAddressToSession } from './utils/ipAddress'; export { convertSpanLinksForEnvelope, @@ -415,7 +421,7 @@ export type { HttpHeadersCollection, ResolvedDataCollection, } from './types/datacollection'; -export type { ClientOptions, CoreOptions as Options } from './types/options'; +export type { ClientOptions, CoreOptions as Options, TunnelOptions } from './types/options'; export type { Package } from './types/package'; export type { PolymorphicEvent, PolymorphicRequest } from './types/polymorphics'; export type { diff --git a/packages/core/src/integrations/fetch.ts b/packages/core/src/integrations/fetch.ts index 5cf7a0489719..dd5fe7e16ced 100644 --- a/packages/core/src/integrations/fetch.ts +++ b/packages/core/src/integrations/fetch.ts @@ -5,6 +5,7 @@ import { getClient } from '../currentScopes'; import { instrumentFetchRequest } from '../fetch'; import { defineIntegration } from '../integration'; import { addFetchInstrumentationHandler } from '../instrument/fetch'; +import { isTracingSuppressed } from '../tracing/trace'; import type { FetchBreadcrumbData, FetchBreadcrumbHint } from '../types/breadcrumb'; import type { HandlerDataFetch } from '../types/instrument'; import type { Integration, IntegrationFn } from '../types/integration'; @@ -92,7 +93,8 @@ export function createFetchIntegration({ return; } - if (isSentryRequestUrl(handlerData.fetchData.url, client)) { + // Matches Node's http and undici instrumentation, so SDK-internal requests (e.g. a tunnel forward) leave no breadcrumb. + if (isTracingSuppressed() || isSentryRequestUrl(handlerData.fetchData.url, client)) { return; } diff --git a/packages/core/src/server-runtime-client.ts b/packages/core/src/server-runtime-client.ts index 159c4aeb3b93..9dcc4512bea4 100644 --- a/packages/core/src/server-runtime-client.ts +++ b/packages/core/src/server-runtime-client.ts @@ -21,6 +21,7 @@ import { uuid4 } from './utils/misc'; import { makePromiseBuffer } from './utils/promisebuffer'; import { resolvedSyncPromise } from './utils/syncpromise'; import { _getTraceInfoFromScope } from './utils/trace-info'; +import { applyManagedTunnelDefaults } from './utils/managedTunnelDefaults'; export interface ServerRuntimeClientOptions extends ClientOptions { platform?: string; @@ -58,6 +59,7 @@ export class ServerRuntimeClient< super(options); + applyManagedTunnelDefaults(this); this._setUpMetricsProcessing(); } diff --git a/packages/core/src/server.ts b/packages/core/src/server.ts index e44bb9c2df8b..c1fe19f4ed7b 100644 --- a/packages/core/src/server.ts +++ b/packages/core/src/server.ts @@ -6,6 +6,14 @@ export type { ServerRuntimeClientOptions } from './server-runtime-client'; export { ServerRuntimeClient } from './server-runtime-client'; +export type { TunnelRequestLike } from './utils/tunnel'; +export { + getTunnelPaths, + handleTunnelRequestIfMatched, + isTunnelRequest, + resolveServerTunnelOption, +} from './utils/tunnel'; +export { _INTERNAL_resetManagedTunnelDefaults, setManagedTunnelDefaults } from './utils/managedTunnelDefaults'; export type { ServerRuntimeOptions } from './types/options'; export { trpcMiddleware } from './trpc'; export { wrapMcpServerWithSentry } from './integrations/mcp-server'; diff --git a/packages/core/src/types/options.ts b/packages/core/src/types/options.ts index 8ea3944f1613..dfdf56788773 100644 --- a/packages/core/src/types/options.ts +++ b/packages/core/src/types/options.ts @@ -96,7 +96,7 @@ export type IgnoreSpanAttributeValue = string | boolean | number | string[] | bo * At least one of the properties (`name`, `op`, or `attributes`) must be set. * If multiple are set, all must match for the span to be ignored. */ -type IgnoreSpanFilter = +export type IgnoreSpanFilter = | { /** * Spans with a name matching this pattern will be ignored. @@ -373,6 +373,14 @@ export interface ClientOptions Streame */ export type BeforeSendStaticSpanCallback = (span: SpanJSON) => SpanJSON; +/** Options for the managed tunnel, see `tunnel`. */ +export interface TunnelOptions { + /** + * Server only. DSNs whose envelopes the tunnel forwards, in addition to the server's own DSN. List the browser DSN + * here when the browser uses a different one. + */ + allowedDsns?: string[]; + + /** A fixed tunnel path instead of the one derived from the DSN. A relative `tunnel` string sets this. */ + path?: string; + + /** Path prefix the app is served under, e.g. `/docs`. Set the same value in the browser and the server `init`. */ + basePath?: string; + + /** Browser only. Append a trailing slash to the tunnel path, for servers that redirect paths without one. */ + trailingSlash?: boolean; +} + /** Base configuration options for every SDK. */ export interface CoreOptions extends Omit< Partial>, - 'integrations' | 'transport' | 'stackParser' + 'integrations' | 'transport' | 'stackParser' | 'tunnel' > { + /** + * Where to send envelopes instead of the Sentry ingest URL. + * + * - A URL sends envelopes to your own tunnel endpoint. On the server, a relative path such as `/monitor` instead + * serves the managed tunnel at that path, so `tunnel: '/monitor'` on both sides gives a fixed path. + * - `true` (or {@link TunnelOptions}) enables the managed tunnel. Set it in both the browser and the server `init`: + * the browser sends envelopes to a same-origin path derived from its DSN, and the server SDK serves that path and + * forwards the envelopes to Sentry. This keeps ad-blockers from dropping events. + * + * @default undefined + */ + tunnel?: string | true | TunnelOptions; + /** * If this is set to false, default integrations will not be added, otherwise this will internally be set to the * recommended default integrations. diff --git a/packages/core/src/utils/managedTunnelDefaults.ts b/packages/core/src/utils/managedTunnelDefaults.ts new file mode 100644 index 000000000000..df84bd3be34f --- /dev/null +++ b/packages/core/src/utils/managedTunnelDefaults.ts @@ -0,0 +1,72 @@ +import type { Client } from '../client'; +import { getClient } from '../currentScopes'; +import type { IgnoreSpanFilter, TunnelOptions } from '../types/options'; +import { getTunnelIgnoreSpanFilters, normalizeTunnelBasePath, withPathBelowBasePath } from './tunnel'; + +/** + * Managed tunnel settings a framework SDK knows outside `init`. + */ +interface ManagedTunnelDefaults { + /** Enables the managed tunnel, e.g. from TanStack Start's `tunnelRoute` build option. */ + tunnel?: TunnelOptions; + /** The path prefix the app is served under. */ + basePath?: string; +} + +let managedTunnelDefaults: ManagedTunnelDefaults | undefined; + +// The span filters each client got for its managed tunnel, so applying new defaults replaces them. +const installedSpanFilters = new WeakMap(); + +/** + * Sets the managed tunnel settings a framework SDK knows outside `init`, for every server client created afterwards + * and for the current one. Clients created per request, like on Cloudflare, then start with the tunnel and its span + * filters, so not even their first tunnel request's span gets through. A managed tunnel or `basePath` that `init` + * set wins. + * + * Framework SDKs call this once, when their server code loads. + */ +export function setManagedTunnelDefaults(defaults: ManagedTunnelDefaults): void { + managedTunnelDefaults = { + ...managedTunnelDefaults, + ...(defaults.tunnel && { tunnel: defaults.tunnel }), + ...(defaults.basePath !== undefined && { basePath: defaults.basePath }), + }; + applyManagedTunnelDefaults(getClient()); +} + +/** + * Settles a server client's managed tunnel: the one its `init` enabled, or else the defaults, under the default base + * path, and installs the span filters for it. Server clients call this when they're created, and + * {@link setManagedTunnelDefaults} calls it again for a client that already exists, replacing the filters it added. + */ +export function applyManagedTunnelDefaults(client: Client | undefined): void { + const options = client?.getOptions(); + const tunnelOptions = options?._managedTunnel ?? managedTunnelDefaults?.tunnel; + if (!client || !options || !tunnelOptions) { + return; + } + + const basePath = normalizeTunnelBasePath(managedTunnelDefaults?.basePath); + const resolved = withPathBelowBasePath( + tunnelOptions.basePath === undefined && basePath ? { ...tunnelOptions, basePath } : tunnelOptions, + ); + options._managedTunnel = resolved; + + const previousFilters = installedSpanFilters.get(client) ?? []; + const spanFilters = getTunnelIgnoreSpanFilters(resolved, client.getDsn()); + options.ignoreSpans = [ + ...(options.ignoreSpans ?? []).filter(filter => !(previousFilters as unknown[]).includes(filter)), + ...spanFilters, + ]; + installedSpanFilters.set(client, spanFilters); +} + +/** + * Resets {@link setManagedTunnelDefaults}, for tests. + * + * @internal + */ +export function _INTERNAL_resetManagedTunnelDefaults(): void { + managedTunnelDefaults = undefined; +} diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 5645633ff45e..ae45bd4e3958 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -1,13 +1,197 @@ import { getEnvelopeEndpointWithUrlEncodedAuth } from '../api'; +import { getClient } from '../currentScopes'; +import { suppressTracing } from '../tracing/trace'; +import { URL_PATH } from '@sentry/conventions/attributes'; +import type { DsnComponents, DsnLike } from '../types/dsn'; +import type { IgnoreSpanFilter, TunnelOptions } from '../types/options'; +import { escapeStringForRegex } from '../vendor/escapeStringForRegex'; import { debug } from './debug-logger'; -import { makeDsn } from './dsn'; +import { dsnToString, makeDsn } from './dsn'; import { parseEnvelope } from './envelope'; +import { parseUrl } from './url'; export interface HandleTunnelRequestOptions { /** Incoming request containing the Sentry envelope as its body */ request: Request; - /** Pre-parsed array of allowed DSN strings */ - allowedDsns: Array; + /** DSNs whose envelopes may be forwarded, in addition to the DSN of the active client. */ + allowedDsns?: Array; +} + +// Servers derive the paths on every POST, so parse and hash each DSN once. This also keeps an invalid DSN in +// `allowedDsns` from logging on every request. Keys come from config, never from requests, so the map stays small. +const tunnelPathCache = new Map(); + +/** + * Splits the `tunnel` option a server SDK's `init` received. A URL stays the transport tunnel for the server's own + * envelopes. `true` or {@link TunnelOptions} enables serving the managed tunnel instead. + */ +export function resolveServerTunnelOption(tunnel: string | true | TunnelOptions | undefined): { + tunnel: string | undefined; + _managedTunnel?: TunnelOptions; +} { + if (!tunnel) { + return { tunnel }; + } + + if (typeof tunnel === 'string') { + // A relative path can't be a transport URL on the server, so it names the path to serve instead. + return tunnel.startsWith('/') ? { tunnel: undefined, _managedTunnel: { path: tunnel } } : { tunnel }; + } + + return { tunnel: undefined, _managedTunnel: tunnel === true ? {} : tunnel }; +} + +/** + * Fills in the defaults a framework SDK knows about its app, like the base path, for a managed tunnel. Options the + * user set win. A relative `tunnel` path is a managed tunnel with that `path`, so it gets the defaults too, while a + * URL and an unset `tunnel` are returned as they are. + */ +export function applyTunnelDefaults( + tunnel: string | true | TunnelOptions | undefined, + defaults: TunnelOptions | undefined, +): string | true | TunnelOptions | undefined { + if (!tunnel || !defaults || !Object.keys(defaults).length) { + return tunnel; + } + + if (typeof tunnel === 'string') { + return tunnel.startsWith('/') ? withPathBelowBasePath({ ...defaults, path: tunnel }) : tunnel; + } + + return withPathBelowBasePath({ ...defaults, ...(tunnel === true ? {} : tunnel) }); +} + +/** + * Returns the same-origin tunnel path for a DSN, e.g. `/k3x9q2a`. + * + * Browser and server SDKs derive the path independently from the DSN, so no build step has to pass it + * between them. The path is a hash so it carries nothing an ad-blocker filter list could match on. + */ +export function getTunnelPath(dsn: DsnLike): string | undefined { + if (typeof dsn === 'string' && tunnelPathCache.has(dsn)) { + return tunnelPathCache.get(dsn); + } + + const components = makeDsn(dsn); + const path = components ? `/${fnv1a(dsnToString(components)).toString(36)}` : undefined; + if (typeof dsn === 'string') { + tunnelPathCache.set(dsn, path); + } + + return path; +} + +/** + * The parts of an incoming request the tunnel decision may look at. A fetch `Request` satisfies it, and Node SDKs pass + * an `IncomingMessage` directly. Callers always pass the whole request so the decision can change without touching them. + */ +export interface TunnelRequestLike { + method?: string; + /** Absolute URL or path with query, e.g. Node's `IncomingMessage.url`. */ + url?: string; + headers?: Headers | Record; +} + +/** + * Returns the tunnel paths the server accepts, relative to its base path. Frameworks that can only register routes + * use these, everything else should call {@link isTunnelRequest} or {@link handleTunnelRequestIfMatched}. + * + * Without `options`, this uses the managed tunnel config of the active client. + */ +export function getTunnelPaths(options?: TunnelOptions): string[] { + const tunnelOptions = options ?? getClient()?.getOptions()._managedTunnel; + if (!tunnelOptions) { + return []; + } + + return tunnelPathsFor(tunnelOptions, resolveAllowedDsns(tunnelOptions.allowedDsns)); +} + +/** + * The `ignoreSpans` filters a server client installs for the server spans of tunnel requests, which server SDKs start + * before a framework hook gets to answer the request. Besides `url.path`, they match the legacy + * `http.url`, the only URL attribute some framework spans start with, like SvelteKit's `sveltekit.handle.root`. + */ +export function getTunnelIgnoreSpanFilters( + tunnelOptions: TunnelOptions, + dsn: DsnComponents | undefined, +): IgnoreSpanFilter[] { + const pathPattern = getTunnelPathPattern(tunnelOptions, dsn); + if (!pathPattern) { + return []; + } + + return [ + // oxlint-disable-next-line sdk/no-regexp-constructor -- built from escaped config values, not end user input + { attributes: { [URL_PATH]: new RegExp(`^${pathPattern}$`) } }, + // oxlint-disable-next-line sdk/no-regexp-constructor -- built from escaped config values, not end user input + { attributes: { 'http.url': new RegExp(`^[a-z][a-z\\d+.-]*://[^/]+${pathPattern}(?:[?#].*)?$`) } }, + ]; +} + +/** + * The tunnel paths under the base path, as a regex source matching a pathname with an optional trailing slash. + */ +function getTunnelPathPattern(tunnelOptions: TunnelOptions, dsn: DsnComponents | undefined): string | undefined { + const paths = tunnelPathsFor(tunnelOptions, resolveAllowedDsns(tunnelOptions.allowedDsns, dsn)).map( + escapeStringForRegex, + ); + if (!paths.length) { + return undefined; + } + + const basePath = escapeStringForRegex(normalizeTunnelBasePath(tunnelOptions.basePath)); + + return `${basePath}(?:${paths.join('|')})/?`; +} + +/** + * Normalizes a framework's base path to the `/app` form the managed tunnel is served under, or `''` for the root. + */ +export function normalizeTunnelBasePath(basePath: string | undefined): string { + const path = (basePath?.startsWith('./') ? basePath.slice(1) : (basePath ?? '')).replace(/\/$/, ''); + + return !path || path.startsWith('/') ? path : `/${path}`; +} + +/** + * Returns true if the request targets the managed tunnel. + * + * Without `options`, this uses the managed tunnel config of the active client and matches nothing unless its + * `init` was called with `tunnel: true`. + */ +export function isTunnelRequest(request: TunnelRequestLike, options?: TunnelOptions): boolean { + if (request.method !== 'POST' || !request.url) { + return false; + } + + const tunnelOptions = options ?? getClient()?.getOptions()._managedTunnel; + if (!tunnelOptions) { + return false; + } + + const path = stripBasePath(parseUrl(request.url).path || '/', tunnelOptions.basePath); + + return path !== undefined && getTunnelPaths(tunnelOptions).includes(path.replace(/\/$/, '')); +} + +/** + * Forwards the request to Sentry if it targets the managed tunnel, otherwise returns `undefined` so the caller + * can hand the request to the app. + */ +export function handleTunnelRequestIfMatched(request: Request): Promise | undefined { + if (!isTunnelRequest(request)) { + return undefined; + } + + const allowedDsns = getClient()?.getOptions()._managedTunnel?.allowedDsns; + // A fixed path matches without a DSN, but with nowhere to forward to the request is left to the app. Its 404 makes + // the browser send directly, instead of losing every envelope to an error response. + if (!resolveAllowedDsns(allowedDsns).length) { + return undefined; + } + + return handleTunnelRequest({ request, allowedDsns }); } /** @@ -19,17 +203,73 @@ export interface HandleTunnelRequestOptions { * @returns A `Response` — either the upstream Sentry response on success, or an error response. */ export async function handleTunnelRequest(options: HandleTunnelRequestOptions): Promise { - const { request, allowedDsns } = options; + return markTunnelResponse(await forwardTunnelRequest(options)); +} + +/** + * Set on every response the tunnel handler returns, so a browser can tell it apart from whatever else answers the + * tunnel path, e.g. a static host serving `index.html` for every path. + */ +export const TUNNEL_RESPONSE_HEADER = 'X-Sentry-Tunnel'; + +function markTunnelResponse(response: Response): Response { + const headers = new Headers(response.headers); + // `fetch` already decoded the body, so its original encoding and length no longer describe it. + headers.delete('content-encoding'); + headers.delete('content-length'); + headers.set(TUNNEL_RESPONSE_HEADER, '1'); + + return new Response(response.body, { status: response.status, statusText: response.statusText, headers }); +} + +async function forwardTunnelRequest(options: HandleTunnelRequestOptions): Promise { + const { request } = options; + const allowedDsns = resolveAllowedDsns(options.allowedDsns); if (allowedDsns.length === 0) { return new Response('Tunnel not configured', { status: 500 }); } - const body = new Uint8Array(await request.arrayBuffer()); + const reader = request.body?.getReader(); + // A client that drops the connection mid-upload makes the read throw. That is the client going away, not an app + // error, so it gets the same answer as any other unreadable envelope. + const head = reader && (await readEnvelopeHeaderLine(reader).catch(() => undefined)); + if (!reader || !head) { + return new Response('Invalid envelope', { status: 400 }); + } + const sentryIngestUrl = resolveIngestUrl(head.line, allowedDsns); + if (typeof sentryIngestUrl !== 'string') { + // Nothing past the header gets forwarded, so the runtime can stop receiving the body. + reader.cancel().catch(() => undefined); + + return sentryIngestUrl; + } + + try { + // The forward is SDK plumbing, not app work, so it must not show up as a span. + return await suppressTracing(() => + fetch(sentryIngestUrl, { + method: 'POST', + headers: forwardHeaders(request), + // The body streams through without being buffered, so its size is left for Sentry to judge. + body: prependToStream(head.read, reader), + duplex: 'half', + } as RequestInit), + ); + } catch (error) { + debug.error('Sentry tunnel: failed to forward envelope', error); + return new Response('Failed to forward envelope to Sentry', { status: 500 }); + } +} + +/** + * Returns the Sentry ingest URL for an envelope header line, or the response that rejects it. + */ +function resolveIngestUrl(headerLine: Uint8Array, allowedDsns: Array): string | Response { let envelopeHeader; try { - [envelopeHeader] = parseEnvelope(body); + [envelopeHeader] = parseEnvelope(headerLine); } catch { return new Response('Invalid envelope', { status: 400 }); } @@ -59,18 +299,153 @@ export async function handleTunnelRequest(options: HandleTunnelRequestOptions): return new Response('Invalid DSN', { status: 403 }); } - const sentryIngestUrl = getEnvelopeEndpointWithUrlEncodedAuth(dsnComponents); + return getEnvelopeEndpointWithUrlEncodedAuth(dsnComponents); +} - try { - return await fetch(sentryIngestUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-sentry-envelope', - }, - body, - }); - } catch (error) { - debug.error('Sentry tunnel: failed to forward envelope', error); - return new Response('Failed to forward envelope to Sentry', { status: 500 }); +/** + * The header is a small JSON object, so a line longer than this is not an envelope. + */ +const MAX_ENVELOPE_HEADER_BYTES = 64 * 1024; + +/** + * Reads the request body only up to the end of the envelope header line. + * Returns the line and every byte read so far, or `undefined` when no header line fits the limit. + */ +async function readEnvelopeHeaderLine( + reader: ReadableStreamDefaultReader, +): Promise<{ line: Uint8Array; read: Uint8Array } | undefined> { + let read: Uint8Array = new Uint8Array(0); + + while (read.length <= MAX_ENVELOPE_HEADER_BYTES) { + const { done, value } = await reader.read(); + if (done) { + return read.length ? { line: read, read } : undefined; + } + + const searchFrom = read.length; + read = concatBytes(read, value); + + const newline = read.indexOf(0x0a, searchFrom); + if (newline !== -1) { + return { line: read.subarray(0, newline), read }; + } + } + + await reader.cancel(); + + return undefined; +} + +function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array { + const merged = new Uint8Array(a.length + b.length); + merged.set(a); + merged.set(b, a.length); + + return merged; +} + +function prependToStream( + prefix: Uint8Array, + reader: ReadableStreamDefaultReader, +): ReadableStream { + return new ReadableStream({ + start(controller) { + controller.enqueue(prefix); + }, + async pull(controller) { + const { done, value } = await reader.read(); + if (done) { + controller.close(); + return; + } + + controller.enqueue(value); + }, + cancel(reason) { + return reader.cancel(reason); + }, + }); +} + +function forwardHeaders(request: Request): Record { + const headers: Record = { 'Content-Type': 'application/x-sentry-envelope' }; + // Keeps the upload fixed-length where the runtime allows it (Node, Bun), instead of chunked. + const contentLength = request.headers.get('content-length'); + if (contentLength) { + headers['Content-Length'] = contentLength; } + + // Relay infers the user's IP and geo from the sender, which is now this server. Proxies and CDNs put the + // visitor's IP in these headers, in the order Relay itself reads them. + const clientIp = + request.headers.get('cf-connecting-ip') || + request.headers.get('x-vercel-forwarded-for') || + request.headers.get('x-forwarded-for'); + if (clientIp) { + headers['X-Sentry-Forwarded-For'] = clientIp; + } + + return headers; +} + +/** + * `allowedDsns` plus the server's own DSN. The own DSN is always trusted, so listing only the browser DSN can't lock + * out a browser that shares it. + */ +function resolveAllowedDsns( + allowedDsns: Array | undefined, + ownDsn: DsnComponents | undefined = getClient()?.getDsn(), +): Array { + const dsns = [...(allowedDsns ?? []), ...(ownDsn ? [dsnToString(ownDsn)] : [])]; + + return [...new Set(dsns)]; +} + +/** + * Makes a fixed `path` relative to `basePath`. A path the user wrote with the base path in it, as a `tunnel` had to + * be before the SDK added the base path, would otherwise be served under the base path twice. + */ +export function withPathBelowBasePath(tunnelOptions: TunnelOptions): TunnelOptions { + const path = tunnelOptions.path && stripBasePath(tunnelOptions.path, tunnelOptions.basePath); + + return path ? { ...tunnelOptions, path } : tunnelOptions; +} + +/** + * Returns the path below `basePath`, or `undefined` for a path outside it, which the tunnel doesn't serve. + */ +function stripBasePath(pathname: string, basePath: string | undefined): string | undefined { + const base = normalizeTunnelBasePath(basePath); + if (!base) { + return pathname; + } + + return pathname === base || pathname.startsWith(`${base}/`) ? pathname.slice(base.length) : undefined; +} + +/** + * 32-bit FNV-1a, because the browser needs the path synchronously in `init` (`crypto.subtle.digest` is async) and it + * runs in every runtime without dependencies. It needn't be cryptographic since the DSN is public. + * + * Browser and server SDKs compute the path separately, so changing the algorithm breaks mixed SDK versions. + */ +function fnv1a(input: string): number { + let hash = 0x811c9dc5; + for (let i = 0; i < input.length; i++) { + // eslint-disable-next-line no-bitwise + hash ^= input.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); + } + + // eslint-disable-next-line no-bitwise + return hash >>> 0; +} + +function tunnelPathsFor(tunnelOptions: TunnelOptions, allowedDsns: Array): string[] { + if (tunnelOptions.path) { + return [tunnelOptions.path.replace(/\/$/, '')]; + } + + // A DSN can be listed in another form than the own one, e.g. with a trailing slash, and still give the same path. + return [...new Set(allowedDsns.map(dsn => getTunnelPath(dsn)).filter((path): path is string => !!path))]; } diff --git a/packages/core/test/lib/integrations/fetch.test.ts b/packages/core/test/lib/integrations/fetch.test.ts index 4c332b479fa0..4362c15dd2fd 100644 --- a/packages/core/test/lib/integrations/fetch.test.ts +++ b/packages/core/test/lib/integrations/fetch.test.ts @@ -4,6 +4,7 @@ import * as currentScopesModule from '../../../src/currentScopes'; import * as fetchModule from '../../../src/fetch'; import { createFetchIntegration } from '../../../src/integrations/fetch'; import * as instrumentFetchModule from '../../../src/instrument/fetch'; +import { suppressTracing } from '../../../src/tracing/trace'; import type { HandlerDataFetch } from '../../../src/types/instrument'; import type { Integration } from '../../../src/types/integration'; import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; @@ -136,6 +137,23 @@ describe('createFetchIntegration', () => { expect(addBreadcrumbSpy).not.toHaveBeenCalled(); }); + it('does not instrument requests made while tracing is suppressed', () => { + const handler = setupIntegration(fetchIntegration(), client); + + suppressTracing(() => + handler({ + fetchData: { url: 'https://o1.ingest.us.sentry.io/api/2/envelope/', method: 'POST' }, + args: ['https://o1.ingest.us.sentry.io/api/2/envelope/'], + startTimestamp: Date.now(), + endTimestamp: Date.now() + 100, + response: { status: 200 } as Response, + }), + ); + + expect(instrumentFetchRequestSpy).not.toHaveBeenCalled(); + expect(addBreadcrumbSpy).not.toHaveBeenCalled(); + }); + it('applies the `shouldCreateSpanForRequest` option', () => { const handler = setupIntegration( fetchIntegration({ shouldCreateSpanForRequest: url => url === 'http://only-this-one.com/' }), diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index 2b499ce754ae..ff7e16995dc9 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -1,15 +1,63 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { getEnvelopeEndpointWithUrlEncodedAuth } from '../../../src/api'; +import { getCurrentScope, setCurrentClient } from '../../../src'; +import { isTracingSuppressed } from '../../../src/tracing/trace'; import { makeDsn } from '../../../src/utils/dsn'; import { createEnvelope, serializeEnvelope } from '../../../src/utils/envelope'; -import { handleTunnelRequest } from '../../../src/utils/tunnel'; +import { + applyTunnelDefaults, + getTunnelPath, + getTunnelIgnoreSpanFilters, + getTunnelPaths, + handleTunnelRequest, + handleTunnelRequestIfMatched, + isTunnelRequest, + normalizeTunnelBasePath, + resolveServerTunnelOption, +} from '../../../src/utils/tunnel'; +import { + _INTERNAL_resetManagedTunnelDefaults, + applyManagedTunnelDefaults, + setManagedTunnelDefaults, +} from '../../../src/utils/managedTunnelDefaults'; +import type { IgnoreSpanFilter, TunnelOptions } from '../../../src/types/options'; +import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; +import { ServerRuntimeClient } from '../../../src/server-runtime-client'; const TEST_DSN = 'https://public@dsn.ingest.sentry.io/1337'; -function makeEnvelopeRequest(envelopeHeader: Record): Request { +/** The `url.path` filter, the first of the tunnel span filters. */ +function getTunnelIgnoreSpanFilter( + ...args: Parameters +): IgnoreSpanFilter | undefined { + return getTunnelIgnoreSpanFilters(...args)[0]; +} + +function makeEnvelopeRequest(envelopeHeader: Record, url = 'http://localhost/tunnel'): Request { const envelope = createEnvelope(envelopeHeader, []); const body = serializeEnvelope(envelope); - return new Request('http://localhost/tunnel', { method: 'POST', body }); + + return new Request(url, { method: 'POST', body }); +} + +/** + * A request whose body starts with `head` and then never ends, so buffering it would hang the test. + */ +function makeEndlessRequest(head: string, onCancel?: () => void): Request { + const filler = new Uint8Array(16 * 1024).fill(0x61); + let sentHead = false; + + return new Request('http://localhost/tunnel', { + method: 'POST', + duplex: 'half', + body: new ReadableStream({ + pull(controller) { + controller.enqueue(sentHead ? filler : new TextEncoder().encode(head)); + sentHead = true; + }, + cancel: onCancel, + }), + } as RequestInit); } describe('handleTunnelRequest', () => { @@ -22,6 +70,7 @@ describe('handleTunnelRequest', () => { afterEach(() => { vi.restoreAllMocks(); + getCurrentScope().setClient(undefined); }); it('forwards the envelope to Sentry and returns the upstream response', async () => { @@ -38,9 +87,120 @@ describe('handleTunnelRequest', () => { expect(url).toBe(getEnvelopeEndpointWithUrlEncodedAuth(makeDsn(TEST_DSN)!)); expect(init.method).toBe('POST'); expect(init.headers).toEqual({ 'Content-Type': 'application/x-sentry-envelope' }); - expect(init.body).toBeInstanceOf(Uint8Array); + expect(await new Response(init.body).text()).toBe(serializeEnvelope(createEnvelope({ dsn: TEST_DSN }, []))); + + expect(result.status).toBe(200); + expect(await result.text()).toBe('ok'); + }); + + it("marks every response it returns as the tunnel's, so the browser can tell it from a static host", async () => { + fetchMock.mockResolvedValueOnce( + new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json', 'Content-Encoding': 'gzip' } }), + ); + + const forwarded = await handleTunnelRequest({ + request: makeEnvelopeRequest({ dsn: TEST_DSN }), + allowedDsns: [TEST_DSN], + }); + const rejected = await handleTunnelRequest({ + request: makeEnvelopeRequest({ dsn: 'https://public@other.ingest.sentry.io/1' }), + allowedDsns: [TEST_DSN], + }); + + expect(forwarded.headers.get('X-Sentry-Tunnel')).toBe('1'); + expect(forwarded.headers.get('Content-Type')).toBe('application/json'); + // `fetch` already decoded the body, so the original encoding would make the browser decode it twice. + expect(forwarded.headers.get('Content-Encoding')).toBeNull(); + expect(rejected.status).toBe(403); + expect(rejected.headers.get('X-Sentry-Tunnel')).toBe('1'); + }); + + it('forwards the content length of the incoming request', async () => { + fetchMock.mockResolvedValueOnce(new Response('ok')); + const body = `${JSON.stringify({ dsn: TEST_DSN })}\n`; + + await handleTunnelRequest({ + request: new Request('http://localhost/tunnel', { + method: 'POST', + body, + headers: { 'content-length': String(body.length) }, + }), + allowedDsns: [TEST_DSN], + }); + + expect(fetchMock).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: { 'Content-Type': 'application/x-sentry-envelope', 'Content-Length': String(body.length) }, + }), + ); + }); + + it.each([ + ['cf-connecting-ip', { 'cf-connecting-ip': '1.1.1.1', 'x-forwarded-for': '2.2.2.2' }, '1.1.1.1'], + ['x-vercel-forwarded-for', { 'x-vercel-forwarded-for': '3.3.3.3', 'x-forwarded-for': '2.2.2.2' }, '3.3.3.3'], + ['x-forwarded-for', { 'x-forwarded-for': '2.2.2.2, 10.0.0.1' }, '2.2.2.2, 10.0.0.1'], + ])('forwards the client IP from %s', async (_, incomingHeaders, forwardedFor) => { + fetchMock.mockResolvedValueOnce(new Response('ok')); + + await handleTunnelRequest({ + request: new Request('http://localhost/tunnel', { + method: 'POST', + body: `${JSON.stringify({ dsn: TEST_DSN })}\n`, + headers: incomingHeaders, + }), + allowedDsns: [TEST_DSN], + }); + + expect(fetchMock).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ 'X-Sentry-Forwarded-For': forwardedFor }), + }), + ); + }); + + it('rejects a disallowed DSN after reading only the envelope header', async () => { + const result = await handleTunnelRequest({ + request: makeEndlessRequest(`${JSON.stringify({ dsn: 'https://other@example.com/9999' })}\n`), + allowedDsns: [TEST_DSN], + }); + + expect(result.status).toBe(403); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('cancels the body of a rejected request', async () => { + const onCancel = vi.fn(); + + await handleTunnelRequest({ + request: makeEndlessRequest(`${JSON.stringify({ dsn: 'https://other@example.com/9999' })}\n`, onCancel), + allowedDsns: [TEST_DSN], + }); + + expect(onCancel).toHaveBeenCalledOnce(); + }); + + it('streams an allowed envelope to Sentry without reading the rest of the body', async () => { + const upstreamResponse = new Response('ok'); + fetchMock.mockResolvedValueOnce(upstreamResponse); - expect(result).toBe(upstreamResponse); + const result = await handleTunnelRequest({ + request: makeEndlessRequest(`${JSON.stringify({ dsn: TEST_DSN })}\n`), + allowedDsns: [TEST_DSN], + }); + + expect(await result.text()).toBe('ok'); + }); + + it('returns 400 when the envelope header line exceeds the limit', async () => { + const result = await handleTunnelRequest({ + request: makeEndlessRequest(''), + allowedDsns: [TEST_DSN], + }); + + expect(result.status).toBe(400); + expect(fetchMock).not.toHaveBeenCalled(); }); it('returns 500 when allowedDsns is empty', async () => { @@ -118,7 +278,7 @@ describe('handleTunnelRequest', () => { expect(fetchMock).toHaveBeenCalledOnce(); const [url] = fetchMock.mock.calls[0]!; expect(url).toBe(getEnvelopeEndpointWithUrlEncodedAuth(makeDsn(TEST_DSN)!)); - expect(result).toBe(upstreamResponse); + expect(await result.text()).toBe('ok'); }); it('returns 500 when fetch throws a network error', async () => { @@ -133,4 +293,478 @@ describe('handleTunnelRequest', () => { expect(result.status).toBe(500); expect(await result.text()).toBe('Failed to forward envelope to Sentry'); }); + + it('forwards with tracing suppressed so the forward does not create a span', async () => { + let suppressed: boolean | undefined; + fetchMock.mockImplementationOnce(() => { + suppressed = isTracingSuppressed(); + + return Promise.resolve(new Response('ok')); + }); + + await handleTunnelRequest({ request: makeEnvelopeRequest({ dsn: TEST_DSN }), allowedDsns: [TEST_DSN] }); + + expect(suppressed).toBe(true); + }); + + it('accepts the DSN of the active client on top of allowedDsns', async () => { + setTestClient(TEST_DSN); + fetchMock.mockResolvedValue(new Response('ok')); + + const ownDsnResult = await handleTunnelRequest({ + request: makeEnvelopeRequest({ dsn: TEST_DSN }), + allowedDsns: ['https://browser@o2.ingest.sentry.io/42'], + }); + const unlistedDsnResult = await handleTunnelRequest({ + request: makeEnvelopeRequest({ dsn: 'https://other@o3.ingest.sentry.io/7' }), + allowedDsns: ['https://browser@o2.ingest.sentry.io/42'], + }); + + expect(ownDsnResult.status).toBe(200); + expect(unlistedDsnResult.status).toBe(403); + }); + + it('defaults allowedDsns to the DSN of the active client', async () => { + setTestClient(TEST_DSN); + fetchMock.mockResolvedValueOnce(new Response('ok')); + + const result = await handleTunnelRequest({ request: makeEnvelopeRequest({ dsn: TEST_DSN }) }); + + expect(result.status).toBe(200); + expect(fetchMock).toHaveBeenCalledWith( + getEnvelopeEndpointWithUrlEncodedAuth(makeDsn(TEST_DSN)!), + expect.objectContaining({ method: 'POST' }), + ); + }); + + it('returns 500 when allowedDsns is omitted and there is no client', async () => { + const result = await handleTunnelRequest({ request: makeEnvelopeRequest({ dsn: TEST_DSN }) }); + + expect(result.status).toBe(500); + expect(fetchMock).not.toHaveBeenCalled(); + }); }); + +describe('getTunnelPath', () => { + it('returns a short opaque path that does not leak the DSN', () => { + const path = getTunnelPath(TEST_DSN); + + expect(path).toMatch(/^\/[0-9a-z]{1,7}$/); + expect(path).not.toContain('sentry'); + }); + + // Browser and server SDKs of different versions must derive the same path, so the hash must never change. + it('derives a fixed path for a known DSN', () => { + expect(getTunnelPath(TEST_DSN)).toBe('/fdv680'); + }); + + it('is stable for the same DSN and differs between DSNs', () => { + expect(getTunnelPath(TEST_DSN)).toBe(getTunnelPath(TEST_DSN)); + expect(getTunnelPath(TEST_DSN)).not.toBe(getTunnelPath('https://public@dsn.ingest.sentry.io/1338')); + }); + + it('ignores DSN formatting differences so browser and server derive the same path', () => { + expect(getTunnelPath('https://public:secret@dsn.ingest.sentry.io/1337')).toBe(getTunnelPath(TEST_DSN)); + expect(getTunnelPath(makeDsn(TEST_DSN)!)).toBe(getTunnelPath(TEST_DSN)); + }); + + it('returns undefined for an invalid DSN', () => { + expect(getTunnelPath('not-a-dsn')).toBeUndefined(); + }); + + it('parses each DSN string once, so an invalid DSN logs once', () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + getTunnelPath('also-not-a-dsn'); + getTunnelPath('also-not-a-dsn'); + + expect(consoleErrorSpy).toHaveBeenCalledOnce(); + consoleErrorSpy.mockRestore(); + }); +}); + +describe('isTunnelRequest', () => { + const tunnelPath = getTunnelPath(TEST_DSN)!; + + afterEach(() => { + getCurrentScope().setClient(undefined); + }); + + it('matches a POST to the tunnel path of an allowed DSN', () => { + expect(isTunnelRequest({ method: 'POST', url: tunnelPath }, { allowedDsns: [TEST_DSN] })).toBe(true); + expect(isTunnelRequest({ method: 'POST', url: `${tunnelPath}/` }, { allowedDsns: [TEST_DSN] })).toBe(true); + }); + + it('does not match other methods, other paths, or path prefixes', () => { + expect(isTunnelRequest({ method: 'GET', url: tunnelPath }, { allowedDsns: [TEST_DSN] })).toBe(false); + expect(isTunnelRequest({ method: 'POST', url: '/somewhere' }, { allowedDsns: [TEST_DSN] })).toBe(false); + expect(isTunnelRequest({ method: 'POST', url: `${tunnelPath}x` }, { allowedDsns: [TEST_DSN] })).toBe(false); + }); + + it('does not match the tunnel path of a DSN that is not allowed', () => { + const otherDsn = 'https://other@example.com/9999'; + + expect(isTunnelRequest({ method: 'POST', url: getTunnelPath(otherDsn)! }, { allowedDsns: [TEST_DSN] })).toBe(false); + }); + + it('uses the managed tunnel config of the active client when no options are passed', () => { + setTestClient(TEST_DSN); + expect(isTunnelRequest({ method: 'POST', url: tunnelPath })).toBe(false); + + setTestClient(TEST_DSN, {}); + expect(isTunnelRequest({ method: 'POST', url: tunnelPath })).toBe(true); + }); + + it('matches nothing without a client', () => { + expect(isTunnelRequest({ method: 'POST', url: tunnelPath })).toBe(false); + expect(isTunnelRequest({ method: 'POST', url: tunnelPath }, {})).toBe(false); + }); + + it('matches under a base path', () => { + expect( + isTunnelRequest({ method: 'POST', url: `/docs${tunnelPath}` }, { allowedDsns: [TEST_DSN], basePath: '/docs/' }), + ).toBe(true); + expect( + isTunnelRequest({ method: 'POST', url: `/docsx${tunnelPath}` }, { allowedDsns: [TEST_DSN], basePath: '/docs' }), + ).toBe(false); + }); + + it('matches nothing outside the base path', () => { + expect(isTunnelRequest({ method: 'POST', url: tunnelPath }, { allowedDsns: [TEST_DSN], basePath: '/docs' })).toBe( + false, + ); + expect(isTunnelRequest({ method: 'POST', url: '/monitor' }, { path: '/monitor', basePath: '/docs' })).toBe(false); + }); +}); + +describe('isTunnelRequest request shapes', () => { + const tunnelPath = getTunnelPath(TEST_DSN)!; + + it('accepts a fetch Request with an absolute URL and query', () => { + const request = new Request(`https://example.com${tunnelPath}?o=1`, { method: 'POST' }); + + expect(isTunnelRequest(request, { allowedDsns: [TEST_DSN] })).toBe(true); + }); + + it('accepts a Node IncomingMessage-like object', () => { + const incomingMessage = { method: 'POST', url: `${tunnelPath}?o=1`, headers: { host: 'example.com' } }; + + expect(isTunnelRequest(incomingMessage, { allowedDsns: [TEST_DSN] })).toBe(true); + }); + + it('does not match without a URL', () => { + expect(isTunnelRequest({ method: 'POST' }, { allowedDsns: [TEST_DSN] })).toBe(false); + }); +}); + +describe('getTunnelPaths', () => { + afterEach(() => { + getCurrentScope().setClient(undefined); + }); + + it('returns one path per allowed DSN and skips invalid ones', () => { + const otherDsn = 'https://other@o2.ingest.sentry.io/42'; + + expect(getTunnelPaths({ allowedDsns: [TEST_DSN, otherDsn, 'not-a-dsn'] })).toEqual([ + getTunnelPath(TEST_DSN), + getTunnelPath(otherDsn), + ]); + }); + + it('returns a fixed path instead of the DSN-derived ones', () => { + expect(getTunnelPaths({ path: '/monitor', allowedDsns: [TEST_DSN] })).toEqual(['/monitor']); + expect(isTunnelRequest({ method: 'POST', url: '/monitor' }, { path: '/monitor', allowedDsns: [TEST_DSN] })).toBe( + true, + ); + }); + + it('matches a fixed path that ends with a slash', () => { + expect(isTunnelRequest({ method: 'POST', url: '/monitor' }, { path: '/monitor/' })).toBe(true); + expect(isTunnelRequest({ method: 'POST', url: '/monitor/' }, { path: '/monitor/' })).toBe(true); + }); + + it('serves the path of the active client DSN on top of allowedDsns, once', () => { + const browserDsn = 'https://browser@o2.ingest.sentry.io/42'; + setTestClient(TEST_DSN, { allowedDsns: [browserDsn, TEST_DSN] }); + + expect(getTunnelPaths()).toEqual([getTunnelPath(browserDsn), getTunnelPath(TEST_DSN)]); + }); + + it('uses the managed tunnel config of the active client', () => { + expect(getTunnelPaths()).toEqual([]); + + setTestClient(TEST_DSN); + expect(getTunnelPaths()).toEqual([]); + + setTestClient(TEST_DSN, {}); + expect(getTunnelPaths()).toEqual([getTunnelPath(TEST_DSN)]); + }); +}); + +describe('handleTunnelRequestIfMatched', () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + vi.restoreAllMocks(); + getCurrentScope().setClient(undefined); + }); + + it('forwards a matching request', async () => { + fetchMock.mockResolvedValueOnce(new Response('ok')); + setTestClient(TEST_DSN, {}); + const request = makeEnvelopeRequest({ dsn: TEST_DSN }, `http://localhost${getTunnelPath(TEST_DSN)}`); + + const result = await handleTunnelRequestIfMatched(request); + + expect(result?.status).toBe(200); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it('forwards using the allowedDsns of the active client managed tunnel config', async () => { + const browserDsn = 'https://browser@o1.ingest.sentry.io/42'; + setTestClient(TEST_DSN, { allowedDsns: [browserDsn] }); + fetchMock.mockResolvedValueOnce(new Response('ok')); + const request = makeEnvelopeRequest({ dsn: browserDsn }, `http://localhost${getTunnelPath(browserDsn)}`); + + const result = await handleTunnelRequestIfMatched(request); + + expect(result?.status).toBe(200); + expect(fetchMock).toHaveBeenCalledWith( + getEnvelopeEndpointWithUrlEncodedAuth(makeDsn(browserDsn)!), + expect.objectContaining({ method: 'POST' }), + ); + }); + + it('leaves a fixed path to the app when there is no DSN to forward to', () => { + setTestClient(undefined, { path: '/monitor' }); + const request = makeEnvelopeRequest({ dsn: TEST_DSN }, 'http://localhost/monitor'); + + expect(handleTunnelRequestIfMatched(request)).toBeUndefined(); + expect(request.bodyUsed).toBe(false); + }); + + it('returns undefined without reading the body when the request does not match', () => { + setTestClient(TEST_DSN, {}); + const request = makeEnvelopeRequest({ dsn: TEST_DSN }, 'http://localhost/api/users'); + + expect(handleTunnelRequestIfMatched(request)).toBeUndefined(); + expect(request.bodyUsed).toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe('getTunnelIgnoreSpanFilter', () => { + function urlPathMatcher(filter: ReturnType): RegExp { + return (filter as { attributes: { 'url.path': RegExp } }).attributes['url.path']; + } + + it("matches the tunnel path of the client's DSN, with or without a trailing slash", () => { + const matcher = urlPathMatcher(getTunnelIgnoreSpanFilter({}, makeDsn(TEST_DSN))); + const tunnelPath = getTunnelPath(TEST_DSN)!; + + expect(matcher.test(tunnelPath)).toBe(true); + expect(matcher.test(`${tunnelPath}/`)).toBe(true); + expect(matcher.test(`${tunnelPath}x`)).toBe(false); + expect(matcher.test('/api/users')).toBe(false); + }); + + it('follows allowedDsns, a fixed path and the base path', () => { + const otherDsn = 'https://other@o2.ingest.sentry.io/42'; + + expect( + urlPathMatcher(getTunnelIgnoreSpanFilter({ allowedDsns: [otherDsn] }, makeDsn(TEST_DSN))).test( + getTunnelPath(otherDsn)!, + ), + ).toBe(true); + expect( + urlPathMatcher(getTunnelIgnoreSpanFilter({ allowedDsns: [otherDsn] }, makeDsn(TEST_DSN))).test( + getTunnelPath(TEST_DSN)!, + ), + ).toBe(true); + expect(urlPathMatcher(getTunnelIgnoreSpanFilter({ path: '/monitor' }, undefined)).test('/monitor')).toBe(true); + expect(urlPathMatcher(getTunnelIgnoreSpanFilter({ path: '/monitor/' }, undefined)).test('/monitor')).toBe(true); + expect( + urlPathMatcher(getTunnelIgnoreSpanFilter({ basePath: '/app/' }, makeDsn(TEST_DSN))).test( + `/app${getTunnelPath(TEST_DSN)}`, + ), + ).toBe(true); + expect( + urlPathMatcher(getTunnelIgnoreSpanFilter({ basePath: '/app' }, makeDsn(TEST_DSN))).test(getTunnelPath(TEST_DSN)!), + ).toBe(false); + }); + + it('returns nothing when there is no path to match', () => { + expect(getTunnelIgnoreSpanFilter({}, undefined)).toBeUndefined(); + }); +}); + +describe('server client with the managed tunnel enabled', () => { + it('ignores the server spans of tunnel requests and keeps user filters', () => { + const client = new ServerRuntimeClient( + getDefaultTestClientOptions({ dsn: TEST_DSN, _managedTunnel: {}, ignoreSpans: ['user-filter'] }), + ); + const ignoreSpans = client.getOptions().ignoreSpans!; + + expect(ignoreSpans[0]).toBe('user-filter'); + expect( + (ignoreSpans[1] as { attributes: { 'url.path': RegExp } }).attributes['url.path'].test(getTunnelPath(TEST_DSN)!), + ).toBe(true); + }); + + it('leaves ignoreSpans alone without the managed tunnel, and in non-server clients', () => { + expect( + new ServerRuntimeClient(getDefaultTestClientOptions({ dsn: TEST_DSN })).getOptions().ignoreSpans, + ).toBeUndefined(); + expect( + new TestClient(getDefaultTestClientOptions({ dsn: TEST_DSN, _managedTunnel: {} })).getOptions().ignoreSpans, + ).toBeUndefined(); + }); +}); + +describe('resolveServerTunnelOption', () => { + it('keeps a URL as the transport tunnel', () => { + expect(resolveServerTunnelOption('https://example.com/tunnel')).toEqual({ tunnel: 'https://example.com/tunnel' }); + }); + + it('enables the managed tunnel for `true` and options, and leaves the transport untunneled', () => { + expect(resolveServerTunnelOption(true)).toEqual({ tunnel: undefined, _managedTunnel: {} }); + expect(resolveServerTunnelOption({ allowedDsns: [TEST_DSN] })).toEqual({ + tunnel: undefined, + _managedTunnel: { allowedDsns: [TEST_DSN] }, + }); + }); + + it('serves a relative path instead of using it as the transport tunnel', () => { + expect(resolveServerTunnelOption('/monitor')).toEqual({ tunnel: undefined, _managedTunnel: { path: '/monitor' } }); + }); + + it('leaves everything off when unset', () => { + expect(resolveServerTunnelOption(undefined)).toEqual({ tunnel: undefined }); + }); +}); + +describe('applyTunnelDefaults', () => { + it('fills in the defaults for `true` and options, and lets options the user set win', () => { + expect(applyTunnelDefaults(true, { basePath: '/app' })).toEqual({ basePath: '/app' }); + expect(applyTunnelDefaults({ allowedDsns: [TEST_DSN] }, { basePath: '/app' })).toEqual({ + basePath: '/app', + allowedDsns: [TEST_DSN], + }); + expect(applyTunnelDefaults({ basePath: '/custom' }, { basePath: '/app' })).toEqual({ basePath: '/custom' }); + }); + + it('treats a relative path as a managed tunnel with that path', () => { + expect(applyTunnelDefaults('/monitor', { basePath: '/app' })).toEqual({ basePath: '/app', path: '/monitor' }); + }); + + it('does not add the base path again to a path that already starts with it', () => { + expect(applyTunnelDefaults('/app/monitor', { basePath: '/app' })).toEqual({ basePath: '/app', path: '/monitor' }); + expect(applyTunnelDefaults({ path: '/app/monitor' }, { basePath: '/app' })).toEqual({ + basePath: '/app', + path: '/monitor', + }); + expect(applyTunnelDefaults('/application/monitor', { basePath: '/app' })).toEqual({ + basePath: '/app', + path: '/application/monitor', + }); + }); + + it('leaves a URL, an unset tunnel and empty defaults alone', () => { + expect(applyTunnelDefaults('https://example.com/tunnel', { basePath: '/app' })).toBe('https://example.com/tunnel'); + expect(applyTunnelDefaults(undefined, { basePath: '/app' })).toBeUndefined(); + expect(applyTunnelDefaults('/monitor', {})).toBe('/monitor'); + expect(applyTunnelDefaults(true, undefined)).toBe(true); + }); +}); + +describe('normalizeTunnelBasePath', () => { + it.each([ + ['/app', '/app'], + ['/app/', '/app'], + ['app', '/app'], + ['./app/', '/app'], + ['./', ''], + ['/', ''], + ['', ''], + [undefined, ''], + ])('normalizes %j to %j', (input, expected) => { + expect(normalizeTunnelBasePath(input)).toBe(expected); + }); +}); + +describe('setManagedTunnelDefaults', () => { + afterEach(() => { + getCurrentScope().setClient(undefined); + _INTERNAL_resetManagedTunnelDefaults(); + }); + + it('serves the tunnel under the base path and drops its spans there', () => { + setTestClient(TEST_DSN, {}); + const client = getCurrentScope().getClient()!; + + setManagedTunnelDefaults({ basePath: '/app/' }); + + expect(client.getOptions()._managedTunnel).toEqual({ basePath: '/app' }); + expect(isTunnelRequest({ method: 'POST', url: `/app${getTunnelPath(TEST_DSN)}` })).toBe(true); + expect(isTunnelRequest({ method: 'POST', url: getTunnelPath(TEST_DSN) })).toBe(false); + expect(client.getOptions().ignoreSpans).toContainEqual( + getTunnelIgnoreSpanFilter({ basePath: '/app' }, makeDsn(TEST_DSN)), + ); + }); + + it('does not add the base path again to a fixed path that already starts with it', () => { + setTestClient(TEST_DSN, { path: '/app/monitor' }); + const client = getCurrentScope().getClient()!; + + setManagedTunnelDefaults({ basePath: '/app' }); + + expect(client.getOptions()._managedTunnel).toEqual({ basePath: '/app', path: '/monitor' }); + expect(isTunnelRequest({ method: 'POST', url: '/app/monitor' })).toBe(true); + expect(isTunnelRequest({ method: 'POST', url: '/app/app/monitor' })).toBe(false); + }); + + it('keeps a base path the user set, and does nothing without a managed tunnel or a base path', () => { + setTestClient(TEST_DSN, { basePath: '/custom' }); + const client = getCurrentScope().getClient()!; + setManagedTunnelDefaults({ basePath: '/app' }); + expect(client.getOptions()._managedTunnel).toEqual({ basePath: '/custom' }); + _INTERNAL_resetManagedTunnelDefaults(); + + setTestClient(TEST_DSN, {}); + const rootClient = getCurrentScope().getClient()!; + setManagedTunnelDefaults({ basePath: '/' }); + expect(rootClient.getOptions()._managedTunnel).toEqual({}); + _INTERNAL_resetManagedTunnelDefaults(); + + setTestClient(TEST_DSN); + const offClient = getCurrentScope().getClient()!; + setManagedTunnelDefaults({ basePath: '/app' }); + expect(offClient.getOptions()._managedTunnel).toBeUndefined(); + }); + + it('enables the tunnel on clients created later, unless their `init` set one', () => { + setManagedTunnelDefaults({ tunnel: { path: '/monitor' }, basePath: '/app' }); + + setTestClient(TEST_DSN); + const laterClient = getCurrentScope().getClient()!; + applyManagedTunnelDefaults(laterClient); + expect(laterClient.getOptions()._managedTunnel).toEqual({ path: '/monitor', basePath: '/app' }); + expect(laterClient.getOptions().ignoreSpans).toContainEqual( + getTunnelIgnoreSpanFilter({ path: '/monitor', basePath: '/app' }, makeDsn(TEST_DSN)), + ); + + setTestClient(TEST_DSN, { path: '/from-init' }); + const initClient = getCurrentScope().getClient()!; + applyManagedTunnelDefaults(initClient); + expect(initClient.getOptions()._managedTunnel).toEqual({ path: '/from-init', basePath: '/app' }); + }); +}); + +function setTestClient(dsn: string | undefined, managedTunnel?: TunnelOptions): void { + setCurrentClient(new TestClient(getDefaultTestClientOptions({ dsn, _managedTunnel: managedTunnel }))); +} diff --git a/packages/deno/src/sdk.ts b/packages/deno/src/sdk.ts index 93869d8face1..37ee1af30098 100644 --- a/packages/deno/src/sdk.ts +++ b/packages/deno/src/sdk.ts @@ -14,9 +14,9 @@ import { requestDataIntegration, stackParserFromStackParserOptions, } from '@sentry/core'; +import { nodeStackLineParser, resolveServerTunnelOption } from '@sentry/core/server'; import { getTracingIntegrations, getErrorIntegrations } from '@sentry/server-utils'; import { DenoClient } from './client'; -import { nodeStackLineParser } from '@sentry/core/server'; import { breadcrumbsIntegration } from './integrations/breadcrumbs'; import { denoContextIntegration } from './integrations/context'; import { contextLinesIntegration } from './integrations/contextlines'; @@ -110,6 +110,7 @@ export function init(options: DenoOptions = {}): Client { const clientOptions: ServerRuntimeClientOptions = { ...options, + ...resolveServerTunnelOption(options.tunnel), stackParser: stackParserFromStackParserOptions(options.stackParser || defaultStackParser), integrations: getIntegrationsToSetup({ integrations: options.integrations, defaultIntegrations }), transport: options.transport || makeFetchTransport, diff --git a/packages/deno/test/deno-serve-tunnel.test.ts b/packages/deno/test/deno-serve-tunnel.test.ts new file mode 100644 index 000000000000..67cd1e61c989 --- /dev/null +++ b/packages/deno/test/deno-serve-tunnel.test.ts @@ -0,0 +1,42 @@ +// + +import type { TransactionEvent } from '@sentry/core'; +import { getMainCarrier, getTunnelPath } from '@sentry/core'; +import { assertEquals } from 'https://deno.land/std@0.212.0/assert/mod.ts'; +import { init } from '../build/esm/index.js'; + +const DSN = 'https://username@domain/123'; + +Deno.test('Deno.serve drops the server span of managed tunnel requests', async () => { + getMainCarrier().__SENTRY__ = undefined; + const transactions: string[] = []; + + init({ + dsn: DSN, + tracesSampleRate: 1, + traceLifecycle: 'static', + // A meta-framework answers the tunnel requests, Deno only drops their server spans. + tunnel: true, + beforeSendTransaction: (event: TransactionEvent) => { + transactions.push(event.request?.url ?? ''); + return null; + }, + }); + + const abortController = new AbortController(); + let onListen: ((_: unknown) => void) | undefined = undefined; + const listening = new Promise(resolve => (onListen = resolve)); + const server = Deno.serve({ port: 0, signal: abortController.signal, onListen }, () => new Response('app')); + await listening; + + const baseUrl = `http://localhost:${server.addr.port}`; + const tunnelResponse = await fetch(`${baseUrl}${getTunnelPath(DSN)}`, { method: 'POST', body: 'envelope' }); + assertEquals(await tunnelResponse.text(), 'app'); + await (await fetch(`${baseUrl}/after-tunnel`)).text(); + + abortController.abort(); + await server.finished; + + assertEquals(transactions.length, 1); + assertEquals(transactions[0]?.endsWith('/after-tunnel'), true); +}); diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index b3ce9965ea31..738b0e894e96 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -55,6 +55,11 @@ "types": "./build/types/config/index.d.ts", "default": "./build/cjs/config/index.js" }, + "./tunnel": { + "types": "./build/types/tunnel/index.d.ts", + "import": "./build/esm/tunnel/index.js", + "require": "./build/cjs/tunnel/index.js" + }, "./async-storage-shim": { "import": { "default": "./build/esm/config/templates/requestAsyncStorageShim.js" @@ -73,6 +78,13 @@ "default": "./build/orchestrion-runtime/*.js" } }, + "typesVersions": { + "*": { + "tunnel": [ + "build/types/tunnel/index.d.ts" + ] + } + }, "publishConfig": { "access": "public" }, diff --git a/packages/nextjs/rollup.npm.config.mjs b/packages/nextjs/rollup.npm.config.mjs index bef1bd0f92d3..925a49507c0f 100644 --- a/packages/nextjs/rollup.npm.config.mjs +++ b/packages/nextjs/rollup.npm.config.mjs @@ -13,6 +13,7 @@ export default [ 'src/edge/index.ts', 'src/cloudflare/index.ts', 'src/config/index.ts', + 'src/tunnel/index.ts', ], // prevent this internal nextjs code from ending up in our built package (this doesn't happen automatically because diff --git a/packages/nextjs/src/client/tunnelRoute.ts b/packages/nextjs/src/client/tunnelRoute.ts index 64025fdd5f9d..ba6b187ad7fe 100644 --- a/packages/nextjs/src/client/tunnelRoute.ts +++ b/packages/nextjs/src/client/tunnelRoute.ts @@ -1,33 +1,12 @@ -import { debug, dsnFromString, GLOBAL_OBJ } from '@sentry/core'; import type { BrowserOptions } from '@sentry/react'; -import { DEBUG_BUILD } from '../common/debug-build'; - -const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { - _sentryRewritesTunnelPath?: string; -}; +import { getInjectedManagedTunnelOptions } from '../common/managedTunnelRoute'; /** * Applies the `tunnel` option to the Next.js SDK options based on `withSentryConfig`'s `tunnelRoute` option. */ export function applyTunnelRouteOption(options: BrowserOptions): void { - const tunnelRouteOption = process.env._sentryRewritesTunnelPath || globalWithInjectedValues._sentryRewritesTunnelPath; - if (tunnelRouteOption && options.dsn) { - const dsnComponents = dsnFromString(options.dsn); - if (!dsnComponents) { - return; - } - const sentrySaasDsnMatch = dsnComponents.host.match(/^o(\d+)\.ingest(?:\.([a-z]{2}))?\.sentry\.io$/); - if (sentrySaasDsnMatch) { - const orgId = sentrySaasDsnMatch[1]; - const regionCode = sentrySaasDsnMatch[2]; - let tunnelPath = `${tunnelRouteOption}?o=${orgId}&p=${dsnComponents.projectId}`; - if (regionCode) { - tunnelPath += `&r=${regionCode}`; - } - options.tunnel = tunnelPath; - DEBUG_BUILD && debug.log(`Tunneling events to "${tunnelPath}"`); - } else { - DEBUG_BUILD && debug.warn('Provided DSN is not a Sentry SaaS DSN. Will not tunnel events.'); - } + const tunnelOptions = options.tunnel === undefined && getInjectedManagedTunnelOptions(); + if (tunnelOptions) { + options.tunnel = tunnelOptions; } } diff --git a/packages/nextjs/src/cloudflare/index.ts b/packages/nextjs/src/cloudflare/index.ts index c97b7298a143..283d01afcfbb 100644 --- a/packages/nextjs/src/cloudflare/index.ts +++ b/packages/nextjs/src/cloudflare/index.ts @@ -11,6 +11,7 @@ import { setOpenTelemetryContextAsyncContextStrategy, } from '@sentry/opentelemetry'; import { ATTR_NEXT_SPAN_TYPE } from '../common/nextSpanAttributes'; +import { getInjectedManagedTunnelOptions, getManagedTunnelSpanFilter } from '../common/managedTunnelRoute'; import { isTunnelRouteSpan } from '../common/utils/dropMiddlewareTunnelRequests'; import { dropReactControlFlowErrorsEventProcessor } from '../common/utils/dropReactControlFlowErrors'; import { markAsyncContextOwnedByNextjsCloudflare } from '../common/utils/responseEnd'; @@ -68,9 +69,16 @@ export const withSentry: typeof withSentryCloudflare = (optionsCallback, handler return withSentryCloudflare(env => { const options: CloudflareOptions = { enableOpenTelemetrySetup: true, ...optionsCallback(env) }; + if (options.tunnel === undefined) { + options.tunnel = getInjectedManagedTunnelOptions(); + } + + const managedTunnelSpanFilter = + typeof options.tunnel === 'object' && getManagedTunnelSpanFilter(options.tunnel, options.dsn); options.ignoreSpans = [ ...(options.ignoreSpans || []), ...NEXTJS_SERVER_IGNORE_SPANS, + ...(managedTunnelSpanFilter ? [managedTunnelSpanFilter] : []), // The request spans of Next.js repeat the request span of `withSentry`. An ignored span does not become active, // so the other Next.js spans become children of the request span of `withSentry`, which then gets the route // from their `next.route` and its status from the response. diff --git a/packages/nextjs/src/common/index.ts b/packages/nextjs/src/common/index.ts index b9a652522349..bfe258d111af 100644 --- a/packages/nextjs/src/common/index.ts +++ b/packages/nextjs/src/common/index.ts @@ -12,3 +12,4 @@ export { wrapPageComponentWithSentry } from './pages-router-instrumentation/wrap export { wrapGenerationFunctionWithSentry } from './wrapGenerationFunctionWithSentry'; export { withServerActionInstrumentation } from './withServerActionInstrumentation'; export { captureRequestError } from './captureRequestError'; +export { isTunnelRequest } from './isTunnelRequest'; diff --git a/packages/nextjs/src/common/isTunnelRequest.ts b/packages/nextjs/src/common/isTunnelRequest.ts new file mode 100644 index 000000000000..f5c1ced22ba7 --- /dev/null +++ b/packages/nextjs/src/common/isTunnelRequest.ts @@ -0,0 +1,21 @@ +import type { TunnelRequestLike } from '@sentry/core/server'; +import { getClient } from '@sentry/core'; +import { isTunnelRequest as isManagedTunnelRequest } from '@sentry/core/server'; +import { getInjectedManagedTunnelOptions } from './managedTunnelRoute'; + +/** + * Returns true if the request targets the tunnel `tunnelRoute` serves, so middleware that blocks unauthenticated + * requests can let it through: + * + * ```ts + * if (Sentry.isTunnelRequest(request)) { + * return NextResponse.next(); + * } + * ``` + */ +export function isTunnelRequest(request: TunnelRequestLike): boolean { + // On OpenNext the client is set up before the injected options are, so they are read here as well. + const tunnelOptions = getClient()?.getOptions()._managedTunnel ?? getInjectedManagedTunnelOptions(); + + return !!tunnelOptions && isManagedTunnelRequest(request, tunnelOptions); +} diff --git a/packages/nextjs/src/common/managedTunnelRoute.ts b/packages/nextjs/src/common/managedTunnelRoute.ts new file mode 100644 index 000000000000..f64ede2ee616 --- /dev/null +++ b/packages/nextjs/src/common/managedTunnelRoute.ts @@ -0,0 +1,66 @@ +import { HTTP_TARGET } from '@sentry/conventions/attributes'; +import type { Options, TunnelOptions } from '@sentry/core'; +import { escapeStringForRegex, GLOBAL_OBJ } from '@sentry/core'; +import { getTunnelPaths } from '@sentry/core/server'; + +/** + * The route the managed tunnel rewrite points at. The user's `app/sentry-tunnel/route.ts` answers it. + */ +export const MANAGED_TUNNEL_ROUTE = '/sentry-tunnel'; + +/** + * The query parameter that carries the original request path through the rewrite. + */ +export const MANAGED_TUNNEL_PATH_PARAM = 'p'; + +/** + * The tunnel options `withSentryConfig` injects for `tunnelRoute`. + */ +export type ManagedTunnelOptions = Pick & { basePath: string }; + +const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { + _sentryManagedTunnel?: ManagedTunnelOptions; +}; + +/** + * The tunnel options `withSentryConfig` injected, if `tunnelRoute` is set. + * + * The client bundle reads them from `env`. The server keeps `@sentry/nextjs` external, where `env` isn't inlined, + * so it reads the injected global instead. + */ +export function getInjectedManagedTunnelOptions(): ManagedTunnelOptions | undefined { + if (globalWithInjectedValues._sentryManagedTunnel) { + return globalWithInjectedValues._sentryManagedTunnel; + } + + const injected = process.env._sentryManagedTunnel; + + return injected ? (JSON.parse(injected) as ManagedTunnelOptions) : undefined; +} + +/** + * Drops Next.js' own spans for managed tunnel requests and for the tunnel route. + * + * Next.js sets the request path as `http.target`, not `url.path`, and streamed spans are only matched against + * `ignoreSpans` when they start, so core's `url.path` filter never sees them. + */ +export function getManagedTunnelSpanFilter( + tunnelOptions: TunnelOptions, + dsn: string | undefined, +): NonNullable[number] | undefined { + // `init` runs this before the client exists, so the own DSN is added here. + const paths = getTunnelPaths({ + ...tunnelOptions, + allowedDsns: [...(dsn ? [dsn] : []), ...(tunnelOptions.allowedDsns ?? [])], + }); + if (!paths.length) { + return undefined; + } + + const basePath = escapeStringForRegex(tunnelOptions.basePath?.replace(/\/$/, '') ?? ''); + const routes = [...paths, MANAGED_TUNNEL_ROUTE].map(escapeStringForRegex).join('|'); + + // Next.js still emits the old semantic conventions. + // oxlint-disable-next-line sdk/no-regexp-constructor, typescript/no-deprecated -- escaped config values, see above + return { attributes: { [HTTP_TARGET]: new RegExp(`^${basePath}(?:${routes})/?(?:\\?|$)`) } }; +} diff --git a/packages/nextjs/src/common/sentryTunnelRouteHandler.ts b/packages/nextjs/src/common/sentryTunnelRouteHandler.ts new file mode 100644 index 000000000000..5ff2d153ec37 --- /dev/null +++ b/packages/nextjs/src/common/sentryTunnelRouteHandler.ts @@ -0,0 +1,31 @@ +import type { TunnelOptions } from '@sentry/core'; +import { getClient, handleTunnelRequest } from '@sentry/core'; +import { isTunnelRequest } from '@sentry/core/server'; +import { getInjectedManagedTunnelOptions, MANAGED_TUNNEL_PATH_PARAM, MANAGED_TUNNEL_ROUTE } from './managedTunnelRoute'; + +/** + * Answers managed tunnel requests in `app/sentry-tunnel/route.ts`: + * + * ```ts + * export { POST } from '@sentry/nextjs/tunnel'; + * ``` + */ +export function sentryTunnelRouteHandler(request: Request): Promise { + // On OpenNext the client is set up before the injected options are, so they are read here as well. + const tunnelOptions: TunnelOptions | undefined = + getClient()?.getOptions()._managedTunnel ?? getInjectedManagedTunnelOptions(); + const basePath = tunnelOptions?.basePath?.replace(/\/$/, '') ?? ''; + const url = new URL(request.url); + + // The rewrite matches any envelope POST with a single path segment, so the path must still match the DSN. + const requestedPath = + url.pathname === `${basePath}${MANAGED_TUNNEL_ROUTE}` + ? `${basePath}/${url.searchParams.get(MANAGED_TUNNEL_PATH_PARAM) ?? ''}` + : url.pathname; + + if (!tunnelOptions || !isTunnelRequest({ method: request.method, url: requestedPath }, tunnelOptions)) { + return Promise.resolve(new Response(null, { status: 404 })); + } + + return handleTunnelRequest({ request, allowedDsns: tunnelOptions.allowedDsns }); +} diff --git a/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts b/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts index 31064377e4ba..f6a004bd7794 100644 --- a/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts +++ b/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts @@ -1,14 +1,11 @@ import { HTTP_TARGET, URL_FULL, URL_PATH, SENTRY_ORIGIN } from '@sentry/conventions/attributes'; import type { RawAttributes } from '@sentry/core'; -import { getClient, GLOBAL_OBJ, isSentryRequestUrl, type Span } from '@sentry/core'; +import { getClient, isSentryRequestUrl, type Span } from '@sentry/core'; +import { isTunnelRequest } from '@sentry/core/server'; +import { getInjectedManagedTunnelOptions, MANAGED_TUNNEL_ROUTE } from '../managedTunnelRoute'; import { ATTR_NEXT_SPAN_TYPE } from '../nextSpanAttributes'; -import { isPathnameUnderSentryTunnelRoute } from './tunnelPathnameMatch'; import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION } from '../span-attributes-with-logic-attached'; -const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { - _sentryRewritesTunnelPath?: string; -}; - /** * Drops spans for tunnel requests from middleware, fetch instrumentation, or BaseServer.handleRequest. * This catches: @@ -63,22 +60,26 @@ function isSentryRequestSpan(attrs: RawAttributes>): boo * Checks if a span's HTTP target matches the tunnel route. */ export function isTunnelRouteSpan(spanAttributes: Record): boolean { - const tunnelPath = globalWithInjectedValues._sentryRewritesTunnelPath || process.env._sentryRewritesTunnelPath; - if (!tunnelPath) { - return false; - } - // `http.target` is only read for spans from a user's own OpenTelemetry instrumentation, which // still emits the old semantic conventions; the SDK sets `url.path`. // eslint-disable-next-line typescript/no-deprecated const target = spanAttributes[URL_PATH] ?? spanAttributes[HTTP_TARGET]; + if (typeof target !== 'string') { + return false; + } - if (typeof target === 'string') { - // Extract pathname from the target (e.g., "/tunnel?o=123&p=456" -> "/tunnel") - const pathname = target.split('?')[0] || ''; + // Extract pathname from the target (e.g., "/tunnel?o=123&p=456" -> "/tunnel") + const pathname = target.split('?')[0] || ''; - return isPathnameUnderSentryTunnelRoute(pathname, tunnelPath); + const managedTunnel = getClient()?.getOptions()._managedTunnel ?? getInjectedManagedTunnelOptions(); + if (!managedTunnel) { + return false; } - return false; + const basePath = managedTunnel.basePath?.replace(/\/$/, '') ?? ''; + + return ( + pathname === `${basePath}${MANAGED_TUNNEL_ROUTE}` || + isTunnelRequest({ method: 'POST', url: pathname }, managedTunnel) + ); } diff --git a/packages/nextjs/src/common/utils/tunnelPathnameMatch.ts b/packages/nextjs/src/common/utils/tunnelPathnameMatch.ts deleted file mode 100644 index 9f107d33636c..000000000000 --- a/packages/nextjs/src/common/utils/tunnelPathnameMatch.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Returns true when `pathname` is exactly the Sentry tunnel route or a sub-path - * (`tunnelPath` + `/...`). A plain `startsWith(tunnelPath)` is unsafe: e.g. tunnel - * `/api/t` must not match `/api/things`. - */ -export function isPathnameUnderSentryTunnelRoute(pathname: string, tunnelPath: string): boolean { - return pathname === tunnelPath || pathname.startsWith(`${tunnelPath}/`); -} diff --git a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts index 743f4abc35a8..8f87e7750b30 100644 --- a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts +++ b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts @@ -18,6 +18,7 @@ import type { TurbopackOptions, } from '../types'; import { supportsNativeDebugIds, supportsTurbopackRuleCondition } from '../util'; +import { getManagedTunnelOptions } from '../withSentryConfig/tunnel'; import { generateValueInjectionRules } from './generateValueInjectionRules'; /** @@ -53,17 +54,10 @@ export function constructTurbopackConfig({ ...(shouldEnableNativeDebugIds ? { debugIds: true } : {}), }; - const tunnelPath = - userSentryOptions?.tunnelRoute !== undefined && - userNextConfig.output !== 'export' && - typeof userSentryOptions.tunnelRoute === 'string' - ? `${userNextConfig.basePath ?? ''}${userSentryOptions.tunnelRoute}` - : undefined; - const valueInjectionRules = generateValueInjectionRules({ routeManifest, nextJsVersion, - tunnelPath, + managedTunnel: getManagedTunnelOptions(userNextConfig, userSentryOptions?.tunnelRoute), vercelCronsConfig, }); diff --git a/packages/nextjs/src/config/turbopack/generateValueInjectionRules.ts b/packages/nextjs/src/config/turbopack/generateValueInjectionRules.ts index 8bca01cb2ce4..5bc3821dad27 100644 --- a/packages/nextjs/src/config/turbopack/generateValueInjectionRules.ts +++ b/packages/nextjs/src/config/turbopack/generateValueInjectionRules.ts @@ -1,4 +1,5 @@ import * as path from 'path'; +import type { ManagedTunnelOptions } from '../../common/managedTunnelRoute'; import type { VercelCronsConfig } from '../../common/types'; import type { RouteManifest } from '../manifest/types'; import type { JSONValue, TurbopackMatcherWithRule } from '../types'; @@ -10,12 +11,12 @@ import { getPackageModules, supportsTurbopackRuleCondition } from '../util'; export function generateValueInjectionRules({ routeManifest, nextJsVersion, - tunnelPath, + managedTunnel, vercelCronsConfig, }: { routeManifest?: RouteManifest; nextJsVersion?: string; - tunnelPath?: string; + managedTunnel?: ManagedTunnelOptions; vercelCronsConfig?: VercelCronsConfig; }): TurbopackMatcherWithRule[] { const rules: TurbopackMatcherWithRule[] = []; @@ -32,9 +33,8 @@ export function generateValueInjectionRules({ clientValues._sentryRouteManifest = JSON.stringify(routeManifest); } - // Inject tunnel route path for both client and server - if (tunnelPath) { - isomorphicValues._sentryRewritesTunnelPath = tunnelPath; + if (managedTunnel) { + isomorphicValues._sentryManagedTunnel = managedTunnel; } // Inject Vercel crons config for server-side cron auto-instrumentation diff --git a/packages/nextjs/src/config/types.ts b/packages/nextjs/src/config/types.ts index f1d3a8441bcb..a9149cad1621 100644 --- a/packages/nextjs/src/config/types.ts +++ b/packages/nextjs/src/config/types.ts @@ -4,6 +4,7 @@ import type { ModuleMetadata, ModuleMetadataCallback, ReactComponentAnnotationOptions, + TunnelOptions, } from '@sentry/core'; // The first argument to `withSentryConfig` (which is the user's next config). @@ -266,19 +267,27 @@ export type SentryBuildOptions = Omit< widenClientFileUpload?: boolean; /** - * Tunnel Sentry requests through this route on the Next.js server, to circumvent ad-blockers blocking Sentry events - * from being sent. This option should be a path (for example: '/error-monitoring'). + * Tunnel Sentry requests through your Next.js server, to circumvent ad-blockers blocking Sentry events from being + * sent. Your server forwards only the envelope, so cookies and auth headers never leave it. * - * - Pass `true` to auto-generate a random, ad-blocker-resistant route for each build - * - Pass a string path (e.g., '/monitoring') to use a custom route + * - Pass `true` to derive an ad-blocker-resistant path from the DSN, the same on the server and in the browser + * - Pass a string path (e.g., '/monitoring') to use a fixed route + * - Pass `{ path, allowedDsns }` to also forward envelopes for other DSNs, e.g. when the browser uses a different + * DSN than the server. The server's own DSN is always forwarded. + * + * The tunnel is answered by `app/sentry-tunnel/route.ts`, which the build creates if it is missing: + * + * ```ts + * export { POST } from '@sentry/nextjs/tunnel'; + * ``` * * Tunnel requests go through your middleware (`proxy.ts` / `middleware.ts`) like any other request. If your - * middleware redirects or blocks unauthenticated requests, exclude the tunnel route in its `matcher` so events - * can reach Sentry. Matchers have to be static, so use a fixed string route in that case rather than `true`. + * middleware redirects or blocks unauthenticated requests, let them through with `isTunnelRequest(request)` from + * `@sentry/nextjs`. Don't exempt requests by their `content-type` header, since any client can set it. * * NOTE: This feature only works with Next.js 11+ */ - tunnelRoute?: string | boolean; + tunnelRoute?: string | boolean | Pick; /** * Suppress the warning about the `onRouterTransitionStart` hook. diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts index 833965b69518..6cf567b300df 100644 --- a/packages/nextjs/src/config/webpack.ts +++ b/packages/nextjs/src/config/webpack.ts @@ -28,6 +28,7 @@ import type { import { sentryOrchestrionWebpackPlugin } from '@sentry/server-utils/orchestrion/webpack'; import { getNextjsVersion, getPackageModules } from './util'; import type { VercelCronsConfigResult } from './withSentryConfig/getFinalConfigObjectUtils'; +import { getManagedTunnelOptions } from './withSentryConfig/tunnel'; // Next.js runs webpack 3 times, once for the client, the server, and for edge. Because we don't want to print certain // warnings 3 times, we keep track of them here. @@ -719,19 +720,12 @@ function addValueInjectionLoader({ const releaseToInject = releaseName && shouldCreateRelease ? releaseName : undefined; const isomorphicValues = { - // `rewritesTunnel` set by the user in Next.js config - _sentryRewritesTunnelPath: - userSentryOptions.tunnelRoute !== undefined && - userNextConfig.output !== 'export' && - typeof userSentryOptions.tunnelRoute === 'string' - ? `${userNextConfig.basePath ?? ''}${userSentryOptions.tunnelRoute}` - : undefined, - // The webpack plugin's release injection breaks the `app` directory so we inject the release manually here instead. // Having a release defined in dev-mode spams releases in Sentry so we only set one in non-dev mode // Only inject if release creation is not explicitly disabled (to maintain build determinism) SENTRY_RELEASE: releaseToInject && !buildContext.dev ? { id: releaseToInject } : undefined, _sentryBasePath: buildContext.dev ? userNextConfig.basePath : undefined, + _sentryManagedTunnel: getManagedTunnelOptions(userNextConfig, userSentryOptions.tunnelRoute), // This is used to determine version-based dev-symbolication behavior _sentryNextJsVersion: nextJsVersion, }; diff --git a/packages/nextjs/src/config/withSentryConfig/buildTime.ts b/packages/nextjs/src/config/withSentryConfig/buildTime.ts index b93d9ea2fc9c..fbfddce845d4 100644 --- a/packages/nextjs/src/config/withSentryConfig/buildTime.ts +++ b/packages/nextjs/src/config/withSentryConfig/buildTime.ts @@ -3,6 +3,7 @@ import * as fs from 'fs'; import * as path from 'path'; import type { NextConfigObject, SentryBuildOptions } from '../types'; import { hasOnlyAppRouterPages } from './pagesRouterDetection'; +import { getManagedTunnelOptions } from './tunnel'; /** * Adds Sentry-related build-time variables to `nextConfig.env`. @@ -23,12 +24,7 @@ export function setUpBuildTimeVariables( const assetPrefix = userNextConfig.assetPrefix || userNextConfig.basePath || ''; const basePath = userNextConfig.basePath ?? ''; - const rewritesTunnelPath = - userSentryOptions.tunnelRoute !== undefined && - userNextConfig.output !== 'export' && - typeof userSentryOptions.tunnelRoute === 'string' - ? `${basePath}${userSentryOptions.tunnelRoute}` - : undefined; + const managedTunnel = getManagedTunnelOptions(userNextConfig, userSentryOptions.tunnelRoute); const buildTimeVariables: Record = { // Make sure that if we have a windows path, the backslashes are interpreted as such (rather than as escape @@ -49,8 +45,8 @@ export function setUpBuildTimeVariables( buildTimeVariables._experimentalThirdPartyOriginStackFrames = 'true'; } - if (rewritesTunnelPath) { - buildTimeVariables._sentryRewritesTunnelPath = rewritesTunnelPath; + if (managedTunnel) { + buildTimeVariables._sentryManagedTunnel = JSON.stringify(managedTunnel); } if (basePath) { diff --git a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts index c457cb75aaf0..b50ea78c9c85 100644 --- a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts +++ b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts @@ -2,6 +2,7 @@ import { debug, isMatchingPattern, parseSemver } from '@sentry/core'; import { getSentryRelease } from '@sentry/node'; import * as fs from 'fs'; import * as path from 'path'; +import { MANAGED_TUNNEL_ROUTE } from '../../common/managedTunnelRoute'; import type { VercelCronsConfig } from '../../common/types'; import { getBuildLogger } from '../buildLogger'; import { createRouteManifest } from '../manifest/createRouteManifest'; @@ -9,7 +10,7 @@ import type { RouteManifest } from '../manifest/types'; import type { NextConfigObject, SentryBuildOptions } from '../types'; import { requiresInstrumentationHook } from '../util'; import { getGitRevision, getInstrumentationClientFileContents } from './buildTime'; -import { resolveTunnelRoute, setUpTunnelRewriteRules } from './tunnel'; +import { ensureTunnelRouteFile, getManagedTunnelOptions, setUpTunnelRewriteRules } from './tunnel'; let showedExportModeTunnelWarning = false; let showedExperimentalBuildModeWarning = false; @@ -29,7 +30,7 @@ export function resolveReleaseName(userSentryOptions: SentryBuildOptions): strin /** * Applies tunnel-route rewrites, if configured. * - * Note: this mutates `userSentryOptions` (to store the resolved tunnel route) and `incomingUserNextConfigObject`. + * Note: this mutates `incomingUserNextConfigObject`. */ export function maybeSetUpTunnelRouteRewriteRules( incomingUserNextConfigObject: NextConfigObject, @@ -49,11 +50,26 @@ export function maybeSetUpTunnelRouteRewriteRules( return; } - // Update the global options object to use the resolved value everywhere - const resolvedTunnelRoute = resolveTunnelRoute(userSentryOptions.tunnelRoute); - userSentryOptions.tunnelRoute = resolvedTunnelRoute || undefined; + const tunnelOptions = getManagedTunnelOptions(incomingUserNextConfigObject, userSentryOptions.tunnelRoute); + if (!tunnelOptions) { + return; + } + + try { + const createdRouteFile = ensureTunnelRouteFile(); + if (createdRouteFile) { + getBuildLogger(userSentryOptions.silent).log( + `[@sentry/nextjs] Created ${path.relative(process.cwd(), createdRouteFile)} to answer \`tunnelRoute\`. Commit it with your app.`, + ); + } + } catch (error) { + getBuildLogger(userSentryOptions.silent).warn( + `[@sentry/nextjs] Could not create the tunnel route. Add \`app${MANAGED_TUNNEL_ROUTE}/route.ts\` with \`export { POST } from '@sentry/nextjs/tunnel';\`.`, + error, + ); + } - setUpTunnelRewriteRules(incomingUserNextConfigObject, resolvedTunnelRoute); + setUpTunnelRewriteRules(incomingUserNextConfigObject, tunnelOptions); } /** diff --git a/packages/nextjs/src/config/withSentryConfig/tunnel.ts b/packages/nextjs/src/config/withSentryConfig/tunnel.ts index 78b050a525bd..6840aca84611 100644 --- a/packages/nextjs/src/config/withSentryConfig/tunnel.ts +++ b/packages/nextjs/src/config/withSentryConfig/tunnel.ts @@ -1,117 +1,115 @@ -import { _INTERNAL_safeMathRandom } from '@sentry/core'; -import type { NextConfigObject } from '../types'; +import type { ManagedTunnelOptions } from '../../common/managedTunnelRoute'; +import { MANAGED_TUNNEL_PATH_PARAM, MANAGED_TUNNEL_ROUTE } from '../../common/managedTunnelRoute'; +import * as fs from 'fs'; +import * as path from 'path'; +import type { NextConfigObject, SentryBuildOptions } from '../types'; /** - * Generates a random tunnel route path that's less likely to be blocked by ad-blockers - */ -function generateRandomTunnelRoute(): string { - // Generate a random 8-character alphanumeric string - const randomString = _INTERNAL_safeMathRandom().toString(36).substring(2, 10); - return `/${randomString}`; -} - -/** - * Resolves the tunnel route based on the user's configuration and the environment. - * @param tunnelRoute - The user-provided tunnel route option + * The managed tunnel options for `tunnelRoute`, or `undefined` when the tunnel is off. + * + * `true` derives the path from the DSN at runtime. A string keeps that fixed path. */ -export function resolveTunnelRoute(tunnelRoute: string | true): string { - if (process.env.__SENTRY_TUNNEL_ROUTE__) { - // Reuse cached value from previous build (server/client) - return process.env.__SENTRY_TUNNEL_ROUTE__; +export function getManagedTunnelOptions( + userNextConfig: NextConfigObject, + tunnelRoute: SentryBuildOptions['tunnelRoute'], +): ManagedTunnelOptions | undefined { + if (!tunnelRoute || userNextConfig.output === 'export') { + return undefined; } - const resolvedTunnelRoute = typeof tunnelRoute === 'string' ? tunnelRoute : generateRandomTunnelRoute(); + const { path, allowedDsns } = + typeof tunnelRoute === 'object' ? tunnelRoute : { path: typeof tunnelRoute === 'string' ? tunnelRoute : undefined }; - // Cache for subsequent builds (only during build time) - // Turbopack runs the config twice, so we need a shared context to avoid generating a new tunnel route for each build. - // env works well here - // https://linear.app/getsentry/issue/JS-549/adblock-plus-blocking-requests-to-sentry-and-monitoring-tunnel - if (resolvedTunnelRoute) { - process.env.__SENTRY_TUNNEL_ROUTE__ = resolvedTunnelRoute; - } - - return resolvedTunnelRoute; + return { + basePath: userNextConfig.basePath ?? '', + ...(path && { path }), + ...(allowedDsns && { allowedDsns }), + }; } /** - * Injects rewrite rules into the Next.js config provided by the user to tunnel - * requests from the `tunnelPath` to Sentry. + * Rewrites tunnel requests to the app's own tunnel route, in the same process. * - * See https://nextjs.org/docs/api-reference/next.config.js/rewrites. + * With a DSN-derived path the config doesn't know the path, since it doesn't know the DSN. The rewrite matches any + * single-segment envelope POST instead, and the route handler checks the path against the DSN. */ -export function setUpTunnelRewriteRules(userNextConfig: NextConfigObject, tunnelPath: string): void { +export function setUpTunnelRewriteRules(userNextConfig: NextConfigObject, tunnelOptions: ManagedTunnelOptions): void { const originalRewrites = userNextConfig.rewrites; - // Allow overriding the tunnel destination for E2E tests via environment variable - const destinationOverride = process.env._SENTRY_TUNNEL_DESTINATION_OVERRIDE; - // Make sure destinations are statically defined at build time - const destination = destinationOverride || 'https://o:orgid.ingest.sentry.io/api/:projectid/envelope/?hsts=0'; - const destinationWithRegion = - destinationOverride || 'https://o:orgid.ingest.:region.sentry.io/api/:projectid/envelope/?hsts=0'; + const tunnelRewrite = tunnelOptions.path + ? { + source: `${tunnelOptions.path}(/?)`, + destination: `${MANAGED_TUNNEL_ROUTE}?${MANAGED_TUNNEL_PATH_PARAM}=${encodeURIComponent(tunnelOptions.path.replace(/^\//, ''))}`, + } + : { + source: '/:sentryTunnelPath', + has: [{ type: 'header', key: 'content-type', value: 'application/x-sentry-envelope' }], + destination: `${MANAGED_TUNNEL_ROUTE}?${MANAGED_TUNNEL_PATH_PARAM}=:sentryTunnelPath`, + }; // This function doesn't take any arguments at the time of writing but we future-proof // here in case Next.js ever decides to pass some userNextConfig.rewrites = async (...args: unknown[]) => { - const tunnelRouteRewrite = { - // Matched rewrite routes will look like the following: `[tunnelPath]?o=[orgid]&p=[projectid]` - // Nextjs will automatically convert `source` into a regex for us - source: `${tunnelPath}(/?)`, - has: [ - { - type: 'query', - key: 'o', // short for orgId - we keep it short so matching is harder for ad-blockers - value: '(?\\d*)', - }, - { - type: 'query', - key: 'p', // short for projectId - we keep it short so matching is harder for ad-blockers - value: '(?\\d*)', - }, - ], - destination, - }; - - const tunnelRouteRewriteWithRegion = { - // Matched rewrite routes will look like the following: `[tunnelPath]?o=[orgid]&p=[projectid]?r=[region]` - // Nextjs will automatically convert `source` into a regex for us - source: `${tunnelPath}(/?)`, - has: [ - { - type: 'query', - key: 'o', // short for orgId - we keep it short so matching is harder for ad-blockers - value: '(?\\d*)', - }, - { - type: 'query', - key: 'p', // short for projectId - we keep it short so matching is harder for ad-blockers - value: '(?\\d*)', - }, - { - type: 'query', - key: 'r', // short for region - we keep it short so matching is harder for ad-blockers - value: '(?[a-z]{2})', - }, - ], - destination: destinationWithRegion, - }; - - // Order of these is important, they get applied first to last. - const newRewrites = [tunnelRouteRewriteWithRegion, tunnelRouteRewrite]; - if (typeof originalRewrites !== 'function') { - return newRewrites; + return { beforeFiles: [tunnelRewrite], afterFiles: [], fallback: [] }; } // @ts-expect-error Expected 0 arguments but got 1 - this is from the future-proofing mentioned above, so we don't care about it const originalRewritesResult = await originalRewrites(...args); if (Array.isArray(originalRewritesResult)) { - return [...newRewrites, ...originalRewritesResult]; - } else { - return { - ...originalRewritesResult, - beforeFiles: [...newRewrites, ...(originalRewritesResult.beforeFiles || [])], - }; + return { beforeFiles: [tunnelRewrite], afterFiles: originalRewritesResult, fallback: [] }; } + + return { + ...originalRewritesResult, + beforeFiles: [tunnelRewrite, ...(originalRewritesResult.beforeFiles || [])], + }; }; } + +const MANAGED_TUNNEL_ROUTE_FILE_CONTENTS = `// Answers Sentry's tunnel (\`tunnelRoute\`). Created by \`withSentryConfig\`, commit it with your app. +export { POST } from '@sentry/nextjs/tunnel'; +`; + +/** + * Creates the route the tunnel rewrite points at, unless the app already has it. + * + * Next.js only routes files it finds in the app directory, and a route created after it scanned them is missed, + * so the file is written before the build or dev server starts and kept in place. + * + * @returns The path of the file it created, if any. + */ +export function ensureTunnelRouteFile(projectDir: string = process.cwd()): string | undefined { + const appDir = resolveAppDir(projectDir); + const routeDir = path.join(appDir, MANAGED_TUNNEL_ROUTE); + const hasRouteFile = ['ts', 'tsx', 'js', 'jsx', 'mjs'].some(extension => + fs.existsSync(path.join(routeDir, `route.${extension}`)), + ); + if (hasRouteFile) { + return undefined; + } + + const extension = fs.existsSync(path.join(projectDir, 'tsconfig.json')) ? 'ts' : 'js'; + const routeFile = path.join(routeDir, `route.${extension}`); + fs.mkdirSync(routeDir, { recursive: true }); + fs.writeFileSync(routeFile, MANAGED_TUNNEL_ROUTE_FILE_CONTENTS); + + return routeFile; +} + +/** + * Next.js only reads `src/app` and `src/pages` when neither `app` nor `pages` exists in the project root. + */ +function resolveAppDir(projectDir: string): string { + const appDir = path.join(projectDir, 'app'); + if (isDirectory(appDir) || isDirectory(path.join(projectDir, 'pages'))) { + return appDir; + } + + return isDirectory(path.join(projectDir, 'src')) ? path.join(projectDir, 'src', 'app') : appDir; +} + +function isDirectory(dir: string): boolean { + return fs.existsSync(dir) && fs.lstatSync(dir).isDirectory(); +} diff --git a/packages/nextjs/src/edge/index.ts b/packages/nextjs/src/edge/index.ts index b073b4822453..fa5969d31501 100644 --- a/packages/nextjs/src/edge/index.ts +++ b/packages/nextjs/src/edge/index.ts @@ -16,6 +16,7 @@ import { import type { VercelEdgeOptions } from '@sentry/vercel-edge'; import { getDefaultIntegrations, init as vercelEdgeInit } from '@sentry/vercel-edge'; import { DEBUG_BUILD } from '../common/debug-build'; +import { getInjectedManagedTunnelOptions, getManagedTunnelSpanFilter } from '../common/managedTunnelRoute'; import { ATTR_NEXT_SPAN_NAME, ATTR_NEXT_SPAN_TYPE } from '../common/nextSpanAttributes'; import { ATTR_NEXT_PAGES_API_ROUTE_TYPE, @@ -66,7 +67,6 @@ export type EdgeOptions = VercelEdgeOptions; const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryRewriteFramesDistDir?: string; _sentryRelease?: string; - _sentryRewritesTunnelPath?: string; }; /** Inits the Sentry NextJS SDK on the Edge Runtime. */ @@ -107,6 +107,15 @@ export function init(options: VercelEdgeOptions = {}): void { ...(isRunningOnCloudflare && { runtime: { name: 'cloudflare' } }), }; + if (opts.tunnel === undefined) { + opts.tunnel = getInjectedManagedTunnelOptions(); + } + + const managedTunnelSpanFilter = typeof opts.tunnel === 'object' && getManagedTunnelSpanFilter(opts.tunnel, opts.dsn); + if (managedTunnelSpanFilter) { + opts.ignoreSpans = [...(opts.ignoreSpans || []), managedTunnelSpanFilter]; + } + const nextjsIgnoreSpans: NonNullable = [ // (set in `dropMiddlewareTunnelRequests` during `spanStart`) { attributes: { [TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION]: true } }, diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index 6dea488a7d9d..9a949548f645 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -13,6 +13,7 @@ import { import type { NodeClient, NodeOptions } from '@sentry/node'; import { getDefaultIntegrations, httpIntegration, init as nodeInit } from '@sentry/node'; import { DEBUG_BUILD } from '../common/debug-build'; +import { getInjectedManagedTunnelOptions, getManagedTunnelSpanFilter } from '../common/managedTunnelRoute'; import { devErrorSymbolicationEventProcessor } from '../common/devErrorSymbolicationEventProcessor'; import { dropReactControlFlowErrorsEventProcessor } from '../common/utils/dropReactControlFlowErrors'; import { isBuild } from '../common/utils/isBuild'; @@ -155,6 +156,15 @@ export function init(options: NodeOptions): NodeClient | undefined { ...cloudflareConfig, }; + if (opts.tunnel === undefined) { + opts.tunnel = getInjectedManagedTunnelOptions(); + } + + const managedTunnelSpanFilter = typeof opts.tunnel === 'object' && getManagedTunnelSpanFilter(opts.tunnel, opts.dsn); + if (managedTunnelSpanFilter) { + opts.ignoreSpans = [...(opts.ignoreSpans || []), managedTunnelSpanFilter]; + } + opts.ignoreSpans = [...(opts.ignoreSpans || []), ...NEXTJS_SERVER_IGNORE_SPANS]; if (DEBUG_BUILD && opts.debug) { diff --git a/packages/nextjs/src/tunnel/index.ts b/packages/nextjs/src/tunnel/index.ts new file mode 100644 index 000000000000..3589068f33a5 --- /dev/null +++ b/packages/nextjs/src/tunnel/index.ts @@ -0,0 +1 @@ +export { sentryTunnelRouteHandler as POST } from '../common/sentryTunnelRouteHandler'; diff --git a/packages/nextjs/test/cloudflare/withSentry.test.ts b/packages/nextjs/test/cloudflare/withSentry.test.ts index ff3395f62b4e..9a85d91fc282 100644 --- a/packages/nextjs/test/cloudflare/withSentry.test.ts +++ b/packages/nextjs/test/cloudflare/withSentry.test.ts @@ -28,7 +28,7 @@ describe('withSentry', () => { getMainCarrier().__SENTRY__ = undefined; (GLOBAL_OBJ as typeof GLOBAL_OBJ & { __SENTRY_CLOUDFLARE_CLIENT__?: unknown }).__SENTRY_CLOUDFLARE_CLIENT__ = undefined; - delete (GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryRewritesTunnelPath?: string })._sentryRewritesTunnelPath; + delete (GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryManagedTunnel?: unknown })._sentryManagedTunnel; delete (GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryRelease?: string })._sentryRelease; vi.restoreAllMocks(); trace.disable(); @@ -186,8 +186,10 @@ describe('withSentry', () => { }); it('does not sample the request span of a request to the tunnel route', async () => { - (GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryRewritesTunnelPath?: string })._sentryRewritesTunnelPath = - '/monitoring'; + (GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryManagedTunnel?: unknown })._sentryManagedTunnel = { + basePath: '', + path: '/monitoring', + }; const sampledByPath: Record = {}; const handler = withSentry(() => ({ dsn: DSN, tracesSampleRate: 1 }), { fetch: (request: Request) => { @@ -197,7 +199,7 @@ describe('withSentry', () => { }, }); - await handler.fetch?.(new Request('https://example.com/monitoring?o=1&p=2') as never, {}, { + await handler.fetch?.(new Request('https://example.com/monitoring', { method: 'POST' }) as never, {}, { waitUntil: vi.fn(), passThroughOnException: vi.fn(), props: {}, @@ -212,8 +214,10 @@ describe('withSentry', () => { }); it('does not sample the request span of a request to the tunnel route when a `tracesSampler` samples every span', async () => { - (GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryRewritesTunnelPath?: string })._sentryRewritesTunnelPath = - '/monitoring'; + (GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryManagedTunnel?: unknown })._sentryManagedTunnel = { + basePath: '', + path: '/monitoring', + }; const sampledByPath: Record = {}; const handler = withSentry(() => ({ dsn: DSN, tracesSampler: () => 1 }), { fetch: (request: Request) => { @@ -223,7 +227,7 @@ describe('withSentry', () => { }, }); - await handler.fetch?.(new Request('https://example.com/monitoring?o=1&p=2') as never, {}, { + await handler.fetch?.(new Request('https://example.com/monitoring', { method: 'POST' }) as never, {}, { waitUntil: vi.fn(), passThroughOnException: vi.fn(), props: {}, diff --git a/packages/nextjs/test/config/withSentryConfig.test.ts b/packages/nextjs/test/config/withSentryConfig.test.ts index cc749732eb35..c31e139a2777 100644 --- a/packages/nextjs/test/config/withSentryConfig.test.ts +++ b/packages/nextjs/test/config/withSentryConfig.test.ts @@ -6,10 +6,17 @@ import { } from '../../src/config/diagnosticsChannelInjection'; import type { SentryBuildOptions } from '../../src/config/types'; import * as util from '../../src/config/util'; +import type * as tunnelModule from '../../src/config/withSentryConfig/tunnel'; import { DEFAULT_SERVER_EXTERNAL_PACKAGES } from '../../src/config/withSentryConfig'; import { defaultRuntimePhase, defaultsObject, exportedNextConfig, userNextConfig } from './fixtures'; import { materializeFinalNextConfig } from './testUtils'; +// `tunnelRoute` creates the app's tunnel route file, which must not land in this package. +vi.mock('../../src/config/withSentryConfig/tunnel', async importOriginal => ({ + ...(await importOriginal()), + ensureTunnelRouteFile: vi.fn(), +})); + // Build-time instrumentation is on by default, so the bundle-safe packages are deliberately dropped // from the externals defaults (the loader transforms them) and the orchestrion runtime is added. // Asserted by exact equality, so a regression that left them external fails here. diff --git a/packages/nextjs/test/utils/dropMiddlewareTunnelRequests.test.ts b/packages/nextjs/test/utils/dropMiddlewareTunnelRequests.test.ts index a635d5d65be5..7a887a57fa10 100644 --- a/packages/nextjs/test/utils/dropMiddlewareTunnelRequests.test.ts +++ b/packages/nextjs/test/utils/dropMiddlewareTunnelRequests.test.ts @@ -2,15 +2,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { dropMiddlewareTunnelRequests } from '../../src/common/utils/dropMiddlewareTunnelRequests'; import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION } from '../../src/common/span-attributes-with-logic-attached'; -const globalWithInjectedValues = global as typeof global & { - _sentryRewritesTunnelPath?: string; -}; +let managedTunnel: { basePath: string; path?: string } | undefined; vi.mock('@sentry/core', async requireActual => { return { ...(await requireActual()), getClient: () => ({ - getOptions: () => ({}), + getOptions: () => ({ _managedTunnel: managedTunnel }), }), }; }); @@ -26,25 +24,25 @@ function createMockSpan(): { setAttribute: ReturnType; attributes: } beforeEach(() => { - globalWithInjectedValues._sentryRewritesTunnelPath = undefined; + managedTunnel = undefined; }); describe('dropMiddlewareTunnelRequests', () => { describe('BaseServer.handleRequest spans', () => { it('marks BaseServer.handleRequest span for dropping when http.target matches tunnel path', () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + managedTunnel = { basePath: '', path: '/monitoring' }; const span = createMockSpan(); dropMiddlewareTunnelRequests(span as any, { 'next.span_type': 'BaseServer.handleRequest', - 'http.target': '/monitoring?o=123&p=456', + 'http.target': '/monitoring?o=123', }); expect(span.setAttribute).toHaveBeenCalledWith(TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION, true); }); it('marks BaseServer.handleRequest span for dropping when http.target exactly matches tunnel path', () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + managedTunnel = { basePath: '', path: '/monitoring' }; const span = createMockSpan(); dropMiddlewareTunnelRequests(span as any, { @@ -56,7 +54,7 @@ describe('dropMiddlewareTunnelRequests', () => { }); it('does not mark BaseServer.handleRequest span for dropping when http.target does not match tunnel path', () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + managedTunnel = { basePath: '', path: '/monitoring' }; const span = createMockSpan(); dropMiddlewareTunnelRequests(span as any, { @@ -67,8 +65,20 @@ describe('dropMiddlewareTunnelRequests', () => { expect(span.setAttribute).not.toHaveBeenCalled(); }); + it('marks the span of the tunnel route the rewrite points at', () => { + managedTunnel = { basePath: '' }; + const span = createMockSpan(); + + dropMiddlewareTunnelRequests(span as any, { + 'next.span_type': 'BaseServer.handleRequest', + 'http.target': '/sentry-tunnel?p=abc123', + }); + + expect(span.setAttribute).toHaveBeenCalledWith(TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION, true); + }); + it('does not mark BaseServer.handleRequest span for dropping when http.target shares tunnel path prefix', () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + managedTunnel = { basePath: '', path: '/monitoring' }; const span = createMockSpan(); dropMiddlewareTunnelRequests(span as any, { @@ -91,12 +101,12 @@ describe('dropMiddlewareTunnelRequests', () => { }); it('handles BaseServer.handleRequest span with basePath prefix in http.target', () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/basepath/monitoring'; + managedTunnel = { basePath: '/basepath', path: '/monitoring' }; const span = createMockSpan(); dropMiddlewareTunnelRequests(span as any, { 'next.span_type': 'BaseServer.handleRequest', - 'http.target': '/basepath/monitoring?o=123&p=456', + 'http.target': '/basepath/monitoring', }); expect(span.setAttribute).toHaveBeenCalledWith(TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION, true); @@ -105,12 +115,12 @@ describe('dropMiddlewareTunnelRequests', () => { describe('Middleware.execute spans', () => { it('marks middleware span for dropping when http.target matches tunnel path', () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + managedTunnel = { basePath: '', path: '/monitoring' }; const span = createMockSpan(); dropMiddlewareTunnelRequests(span as any, { 'next.span_type': 'Middleware.execute', - 'http.target': '/monitoring?o=123&p=456', + 'http.target': '/monitoring?o=123', }); expect(span.setAttribute).toHaveBeenCalledWith(TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION, true); @@ -119,7 +129,7 @@ describe('dropMiddlewareTunnelRequests', () => { describe('unrelated spans', () => { it('does not process spans without matching span type or origin', () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + managedTunnel = { basePath: '', path: '/monitoring' }; const span = createMockSpan(); dropMiddlewareTunnelRequests(span as any, { @@ -138,7 +148,7 @@ describe('dropMiddlewareTunnelRequests', () => { getOptions: () => ({ enableOpenTelemetrySetup: false }), } as any); - globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + managedTunnel = { basePath: '', path: '/monitoring' }; const span = createMockSpan(); dropMiddlewareTunnelRequests(span as any, { diff --git a/packages/nextjs/test/utils/tunnelRoute.test.ts b/packages/nextjs/test/utils/tunnelRoute.test.ts index 8382e66ca7d4..73353c9d0da3 100644 --- a/packages/nextjs/test/utils/tunnelRoute.test.ts +++ b/packages/nextjs/test/utils/tunnelRoute.test.ts @@ -1,120 +1,198 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; import type { BrowserOptions } from '@sentry/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { getTunnelPath } from '@sentry/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { applyTunnelRouteOption } from '../../src/client/tunnelRoute'; +import { isTunnelRequest } from '../../src/common/isTunnelRequest'; +import { sentryTunnelRouteHandler } from '../../src/common/sentryTunnelRouteHandler'; +import { + ensureTunnelRouteFile, + getManagedTunnelOptions, + setUpTunnelRewriteRules, +} from '../../src/config/withSentryConfig/tunnel'; +import type { NextConfigObject } from '../../src/config/types'; const globalWithInjectedValues = global as typeof global & { - _sentryRewritesTunnelPath?: string; + _sentryManagedTunnel?: unknown; }; -beforeEach(() => { - globalWithInjectedValues._sentryRewritesTunnelPath = undefined; +afterEach(() => { + delete globalWithInjectedValues._sentryManagedTunnel; }); describe('applyTunnelRouteOption()', () => { - it('Correctly applies `tunnelRoute` option when conditions are met', () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/my-error-monitoring-route'; - const options: any = { - dsn: 'https://11111111111111111111111111111111@o2222222.ingest.sentry.io/3333333', - } as BrowserOptions; + it('applies the injected tunnel options', () => { + globalWithInjectedValues._sentryManagedTunnel = { basePath: '/base' }; + const options: BrowserOptions = { dsn: 'https://public@o1.ingest.sentry.io/1' }; applyTunnelRouteOption(options); - expect(options.tunnel).toBe('/my-error-monitoring-route?o=2222222&p=3333333'); + expect(options.tunnel).toEqual({ basePath: '/base' }); }); - it("Doesn't apply `tunnelRoute` when DSN is missing", () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/my-error-monitoring-route'; - const options: any = { - // no dsn - } as BrowserOptions; + it('keeps a `tunnel` the user set', () => { + globalWithInjectedValues._sentryManagedTunnel = { basePath: '' }; + const options: BrowserOptions = { tunnel: '/my-tunnel' }; applyTunnelRouteOption(options); - expect(options.tunnel).toBeUndefined(); + expect(options.tunnel).toBe('/my-tunnel'); }); - it("Doesn't apply `tunnelRoute` when DSN is invalid", () => { - // Avoid polluting the test output with error messages - const mockConsoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); - - globalWithInjectedValues._sentryRewritesTunnelPath = '/my-error-monitoring-route'; - const options: any = { - dsn: 'invalidDsn', - } as BrowserOptions; + it('leaves `tunnel` unset without `tunnelRoute`', () => { + const options: BrowserOptions = {}; applyTunnelRouteOption(options); expect(options.tunnel).toBeUndefined(); + }); +}); - mockConsoleError.mockRestore(); +describe('getManagedTunnelOptions()', () => { + it('derives the path from the DSN for `true` and keeps a string path', () => { + expect(getManagedTunnelOptions({ basePath: '/base' }, true)).toEqual({ basePath: '/base' }); + expect(getManagedTunnelOptions({}, '/monitoring')).toEqual({ basePath: '', path: '/monitoring' }); }); - it("Doesn't apply `tunnelRoute` option when `tunnelRoute` option wasn't injected", () => { - const options: any = { - dsn: 'https://11111111111111111111111111111111@o2222222.ingest.sentry.io/3333333', - } as BrowserOptions; + it('keeps the path and allowed DSNs of an options object', () => { + const allowedDsns = ['https://browser@o1.ingest.sentry.io/1']; - applyTunnelRouteOption(options); + expect(getManagedTunnelOptions({}, { allowedDsns })).toEqual({ basePath: '', allowedDsns }); + expect(getManagedTunnelOptions({}, { path: '/monitoring', allowedDsns })).toEqual({ + basePath: '', + path: '/monitoring', + allowedDsns, + }); + }); - expect(options.tunnel).toBeUndefined(); + it('is off without `tunnelRoute` and for static exports', () => { + expect(getManagedTunnelOptions({}, undefined)).toBeUndefined(); + expect(getManagedTunnelOptions({}, false)).toBeUndefined(); + expect(getManagedTunnelOptions({ output: 'export' }, true)).toBeUndefined(); }); +}); - it("Doesn't `tunnelRoute` option when DSN is not a SaaS DSN", () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/my-error-monitoring-route'; - const options: any = { - dsn: 'https://11111111111111111111111111111111@example.com/3333333', - } as BrowserOptions; +describe('sentryTunnelRouteHandler()', () => { + const BROWSER_DSN = 'https://browser@o1.ingest.sentry.io/1'; - applyTunnelRouteOption(options); + afterEach(() => { + vi.unstubAllGlobals(); + }); - expect(options.tunnel).toBeUndefined(); + function tunnelRequest(dsn: string): Request { + return new Request(`http://localhost/sentry-tunnel?p=${getTunnelPath(dsn)!.slice(1)}`, { + method: 'POST', + body: `${JSON.stringify({ dsn })}\n`, + }); + } + + it('forwards envelopes for the allowed DSNs of `tunnelRoute`', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response('ok')); + vi.stubGlobal('fetch', fetchMock); + globalWithInjectedValues._sentryManagedTunnel = { basePath: '', allowedDsns: [BROWSER_DSN] }; + + const response = await sentryTunnelRouteHandler(tunnelRequest(BROWSER_DSN)); + + expect(response.status).toBe(200); + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining('https://o1.ingest.sentry.io/api/1/envelope/'), + expect.anything(), + ); }); - it('Correctly applies `tunnelRoute` option to region DSNs', () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/my-error-monitoring-route'; - const options: any = { - dsn: 'https://11111111111111111111111111111111@o2222222.ingest.us.sentry.io/3333333', - } as BrowserOptions; + it('rejects envelopes for a DSN that is not allowed', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + globalWithInjectedValues._sentryManagedTunnel = { basePath: '', allowedDsns: [BROWSER_DSN] }; - applyTunnelRouteOption(options); + const response = await sentryTunnelRouteHandler(tunnelRequest('https://other@o1.ingest.sentry.io/2')); - expect(options.tunnel).toBe('/my-error-monitoring-route?o=2222222&p=3333333&r=us'); + expect(response.status).toBe(404); + expect(fetchMock).not.toHaveBeenCalled(); }); }); -describe('Random tunnel route generation', () => { - it('Works when tunnelRoute is true and generates random-looking paths', () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/abc123def'; // Simulated random path - const options: any = { - dsn: 'https://11111111111111111111111111111111@o2222222.ingest.sentry.io/3333333', - } as BrowserOptions; +describe('isTunnelRequest()', () => { + it('matches POSTs to the tunnel of the injected options', () => { + globalWithInjectedValues._sentryManagedTunnel = { basePath: '/base', path: '/monitoring' }; - applyTunnelRouteOption(options); + expect(isTunnelRequest({ method: 'POST', url: 'http://localhost/base/monitoring' })).toBe(true); + expect(isTunnelRequest({ method: 'GET', url: 'http://localhost/base/monitoring' })).toBe(false); + expect(isTunnelRequest({ method: 'POST', url: 'http://localhost/base/admin' })).toBe(false); + }); + + it('matches nothing without `tunnelRoute`', () => { + expect(isTunnelRequest({ method: 'POST', url: 'http://localhost/monitoring' })).toBe(false); + }); +}); - expect(options.tunnel).toBe('/abc123def?o=2222222&p=3333333'); - expect(options.tunnel).toMatch(/^\/[a-z0-9]+\?o=2222222&p=3333333$/); +describe('setUpTunnelRewriteRules()', () => { + it('rewrites envelope POSTs on a single segment to the tunnel route for a DSN-derived path', async () => { + const config: NextConfigObject = {}; + setUpTunnelRewriteRules(config, { basePath: '' }); + + expect(await config.rewrites!()).toEqual({ + beforeFiles: [ + { + source: '/:sentryTunnelPath', + has: [{ type: 'header', key: 'content-type', value: 'application/x-sentry-envelope' }], + destination: '/sentry-tunnel?p=:sentryTunnelPath', + }, + ], + afterFiles: [], + fallback: [], + }); }); - it('Works with region DSNs when tunnelRoute is true', () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/x7h9k2m'; // Simulated random path - const options: any = { - dsn: 'https://11111111111111111111111111111111@o2222222.ingest.eu.sentry.io/3333333', - } as BrowserOptions; + it('rewrites a fixed path and keeps the user rewrites', async () => { + const userRewrite = { source: '/old', destination: '/new' }; + const config: NextConfigObject = { rewrites: async () => [userRewrite] }; + setUpTunnelRewriteRules(config, { basePath: '', path: '/api/monitor' }); - applyTunnelRouteOption(options); + expect(await config.rewrites!()).toEqual({ + beforeFiles: [{ source: '/api/monitor(/?)', destination: '/sentry-tunnel?p=api%2Fmonitor' }], + afterFiles: [userRewrite], + fallback: [], + }); + }); +}); + +describe('ensureTunnelRouteFile()', () => { + const projectDirs: string[] = []; - expect(options.tunnel).toBe('/x7h9k2m?o=2222222&p=3333333&r=eu'); - expect(options.tunnel).toMatch(/^\/[a-z0-9]+\?o=2222222&p=3333333&r=eu$/); + function makeProjectDir(...dirs: string[]): string { + const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sentry-nextjs-tunnel-')); + projectDirs.push(projectDir); + dirs.forEach(dir => fs.mkdirSync(path.join(projectDir, dir), { recursive: true })); + return projectDir; + } + + afterEach(() => { + projectDirs.splice(0).forEach(dir => fs.rmSync(dir, { recursive: true, force: true })); }); - it('Does not apply tunnel when tunnelRoute is false', () => { - globalWithInjectedValues._sentryRewritesTunnelPath = undefined; - const options: any = { - dsn: 'https://11111111111111111111111111111111@o2222222.ingest.sentry.io/3333333', - } as BrowserOptions; + it('creates the route in `app` and leaves an existing one alone', () => { + const projectDir = makeProjectDir('app'); + fs.writeFileSync(path.join(projectDir, 'tsconfig.json'), '{}'); - applyTunnelRouteOption(options); + const routeFile = ensureTunnelRouteFile(projectDir); - expect(options.tunnel).toBeUndefined(); + expect(routeFile).toBe(path.join(projectDir, 'app', 'sentry-tunnel', 'route.ts')); + expect(fs.readFileSync(routeFile!, 'utf8')).toContain("export { POST } from '@sentry/nextjs/tunnel';"); + expect(ensureTunnelRouteFile(projectDir)).toBeUndefined(); + }); + + it('uses `src/app` in a `src` layout and JavaScript without a tsconfig', () => { + const projectDir = makeProjectDir('src/pages'); + + expect(ensureTunnelRouteFile(projectDir)).toBe(path.join(projectDir, 'src', 'app', 'sentry-tunnel', 'route.js')); + }); + + it('uses the root `app` when the root has `pages`, as Next.js then ignores `src`', () => { + const projectDir = makeProjectDir('pages', 'src/utils'); + + expect(ensureTunnelRouteFile(projectDir)).toBe(path.join(projectDir, 'app', 'sentry-tunnel', 'route.js')); }); }); diff --git a/packages/nitro/src/module.ts b/packages/nitro/src/module.ts index 1a4e5b0478d1..51f76c39eae3 100644 --- a/packages/nitro/src/module.ts +++ b/packages/nitro/src/module.ts @@ -1,3 +1,4 @@ +import { setManagedTunnelDefaults } from '@sentry/core/server'; import type { NitroModule } from 'nitro/types'; import type { SentryNitroOptions } from './config'; import { instrumentServer } from './instruments/instrumentServer'; @@ -12,6 +13,12 @@ export function createNitroModule(sentryOptions?: SentryNitroOptions, sentryEnab setup: nitro => { instrumentServer(nitro); setupSourceMaps(nitro, sentryOptions, sentryEnabledSourcemaps); + + // In dev, Vite's server takes each request in this process before Nitro handles it, and its `http.server` span + // comes from the SDK `--import` set up here. The runtime plugin only reaches the client where Nitro runs. + if (nitro.options.dev) { + setManagedTunnelDefaults({ basePath: nitro.options.baseURL }); + } }, }; } diff --git a/packages/nitro/src/runtime/plugins/server.ts b/packages/nitro/src/runtime/plugins/server.ts index 9fd3f93a6f40..2df6b959c4c2 100644 --- a/packages/nitro/src/runtime/plugins/server.ts +++ b/packages/nitro/src/runtime/plugins/server.ts @@ -1,4 +1,6 @@ +import { handleTunnelRequestIfMatched, setManagedTunnelDefaults } from '@sentry/core/server'; import { definePlugin } from 'nitro'; +import { useRuntimeConfig } from 'nitro/runtime-config'; import { captureErrorHook } from '../hooks/captureErrorHook'; import { captureStorageEvents } from '../hooks/captureStorageEvents'; import { captureTracingEvents } from '../hooks/captureTracingEvents'; @@ -6,6 +8,13 @@ import { captureTracingEvents } from '../hooks/captureTracingEvents'; export default definePlugin(nitroApp => { nitroApp.hooks.hook('error', captureErrorHook); + // Nitro runs the app's `middleware/` before anything a module registers, so an auth middleware there would block + // the tunnel. Every preset hands requests to `nitroApp.fetch` after the plugins ran, so wrapping it goes first, + // before route rules and middleware. Requests still carry Nitro's `baseURL` here, so the tunnel is served under it. + setManagedTunnelDefaults({ basePath: (useRuntimeConfig() as { app?: { baseURL?: string } }).app?.baseURL }); + const appFetch = nitroApp.fetch; + nitroApp.fetch = request => handleTunnelRequestIfMatched(request) ?? appFetch(request); + captureTracingEvents(); captureStorageEvents(); }); diff --git a/packages/nitro/test/module.test.ts b/packages/nitro/test/module.test.ts new file mode 100644 index 000000000000..f967e66da9bb --- /dev/null +++ b/packages/nitro/test/module.test.ts @@ -0,0 +1,42 @@ +import type { Client } from '@sentry/core'; +import { getCurrentScope, makeDsn } from '@sentry/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createNitroModule } from '../src/module'; + +vi.mock('../src/instruments/instrumentServer', () => ({ instrumentServer: vi.fn() })); +vi.mock('../src/sourceMaps', () => ({ setupSourceMaps: vi.fn() })); + +const DSN = 'https://public@o1.ingest.sentry.io/1337'; + +function setClient(managedTunnel: object | undefined): Record { + const options = { dsn: DSN, _managedTunnel: managedTunnel }; + getCurrentScope().setClient({ getDsn: () => makeDsn(DSN), getOptions: () => options } as unknown as Client); + + return options; +} + +function setup(options: { dev: boolean; baseURL: string }): void { + (createNitroModule().setup as (nitro: unknown) => void)({ options }); +} + +describe('createNitroModule', () => { + afterEach(() => { + getCurrentScope().setClient(undefined); + }); + + it("serves the dev server's managed tunnel under `baseURL`", () => { + const options = setClient({}); + + setup({ dev: true, baseURL: '/app/' }); + + expect(options._managedTunnel).toEqual({ basePath: '/app' }); + }); + + it('leaves the client alone in a build, where the runtime plugin applies the base path', () => { + const options = setClient({}); + + setup({ dev: false, baseURL: '/app/' }); + + expect(options._managedTunnel).toEqual({}); + }); +}); diff --git a/packages/node/src/sdk/index.ts b/packages/node/src/sdk/index.ts index 57cf3871256a..3367653fb076 100644 --- a/packages/node/src/sdk/index.ts +++ b/packages/node/src/sdk/index.ts @@ -1,6 +1,5 @@ import type { Integration, Options } from '@sentry/core'; import { - _INTERNAL_warnIfClientIsActive, applySdkMetadata, consoleSandbox, conversationIdIntegration, @@ -12,11 +11,13 @@ import { getCurrentScope, getIntegrationsToSetup, hasSpansEnabled, + _INTERNAL_warnIfClientIsActive, linkedErrorsIntegration, propagationContextFromHeaders, requestDataIntegration, stackParserFromStackParserOptions, } from '@sentry/core'; +import { resolveServerTunnelOption } from '@sentry/core/server'; import { isMainThread, parentPort } from 'node:worker_threads'; import { detectOrchestrionSetup, getErrorIntegrations, getTracingIntegrations } from '@sentry/server-utils'; import { DEBUG_BUILD } from '../debug-build'; @@ -249,6 +250,7 @@ function getClientOptions( return { ...mergedOptions, + ...resolveServerTunnelOption(mergedOptions.tunnel), integrations: resolvedIntegrations, }; } diff --git a/packages/node/test/sdk/init.test.ts b/packages/node/test/sdk/init.test.ts index 9ebfd0d05e31..7b92ef2d00af 100644 --- a/packages/node/test/sdk/init.test.ts +++ b/packages/node/test/sdk/init.test.ts @@ -40,6 +40,24 @@ describe('init()', () => { vi.clearAllMocks(); }); + describe('tunnel', () => { + it('enables the managed tunnel for `tunnel: true` and sends its own envelopes directly', () => { + init({ dsn: PUBLIC_DSN, tunnel: true }); + + const options = getClient()?.getOptions(); + expect(options?.tunnel).toBeUndefined(); + expect(options?._managedTunnel).toEqual({}); + }); + + it('keeps a URL tunnel for its own envelopes', () => { + init({ dsn: PUBLIC_DSN, tunnel: 'https://example.com/tunnel' }); + + const options = getClient()?.getOptions(); + expect(options?.tunnel).toBe('https://example.com/tunnel'); + expect(options?._managedTunnel).toBeUndefined(); + }); + }); + describe('metadata', () => { it('has the correct metadata', () => { init({ dsn: PUBLIC_DSN }); diff --git a/packages/nuxt/src/client/sdk.ts b/packages/nuxt/src/client/sdk.ts index 76428543a716..4aa7891645b8 100644 --- a/packages/nuxt/src/client/sdk.ts +++ b/packages/nuxt/src/client/sdk.ts @@ -1,6 +1,7 @@ import { getDefaultIntegrations as getBrowserDefaultIntegrations, init as initBrowser } from '@sentry/browser'; import type { Client } from '@sentry/core'; import { applySdkMetadata, DEFAULT_ENVIRONMENT, DEV_ENVIRONMENT } from '@sentry/core'; +import { applyNuxtTunnelBasePath } from '../common/tunnel'; import type { SentryNuxtClientOptions } from '../common/types'; /** @@ -17,6 +18,7 @@ export function init(options: SentryNuxtClientOptions): Client | undefined { ...options, }; + applyNuxtTunnelBasePath(sentryOptions); applySdkMetadata(sentryOptions, 'nuxt', ['nuxt', 'vue']); return initBrowser(sentryOptions); diff --git a/packages/nuxt/src/common/tunnel.ts b/packages/nuxt/src/common/tunnel.ts new file mode 100644 index 000000000000..58b23d06b854 --- /dev/null +++ b/packages/nuxt/src/common/tunnel.ts @@ -0,0 +1,16 @@ +import type { Options } from '@sentry/core'; +import { applyTunnelDefaults, GLOBAL_OBJ, normalizeTunnelBasePath } from '@sentry/core'; + +/** Set by the generated client config plugin to the app's runtime `app.baseURL`. */ +export const NUXT_TUNNEL_BASE_PATH_GLOBAL = '__sentry_nuxt_base_path'; + +/** + * Serves the managed tunnel under the app's `app.baseURL`, which a proxy in front of the app may be the only path + * forwarded to it. It's read from runtime config, so a `NUXT_APP_BASE_URL` set at runtime is honoured too. + * A `basePath` the user set wins. + */ +export function applyNuxtTunnelBasePath(options: Pick): void { + const baseURL = (GLOBAL_OBJ as { [NUXT_TUNNEL_BASE_PATH_GLOBAL]?: string })[NUXT_TUNNEL_BASE_PATH_GLOBAL]; + const basePath = normalizeTunnelBasePath(baseURL); + options.tunnel = applyTunnelDefaults(options.tunnel, basePath ? { basePath } : undefined); +} diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index 10f22891ac2d..5d23d675f3fc 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -3,8 +3,9 @@ import { addPlugin, addPluginTemplate, addTemplate, addVitePlugin, createResolve import type {} from '@nuxt/nitro-server'; import * as path from 'path'; +import { NUXT_TUNNEL_BASE_PATH_GLOBAL } from './common/tunnel'; import type { SentryNuxtModuleOptions } from './common/types'; -import { setupNitroServer } from './vite/nitroServer'; +import { addTunnelPlugin, setupNitroServer } from './vite/nitroServer'; import { setupOrchestrion } from './vite/orchestrion'; import { setupSourceMaps } from './vite/sourceMaps'; import { findDefaultSdkInitFile, resolveServerApi } from './vite/utils'; @@ -55,11 +56,12 @@ export default defineNuxtModule({ // Dynamic import of config file to wrap it within a Nuxt context (here: defineNuxtPlugin) // Makes it possible to call useRuntimeConfig() in the user-defined sentry config file getContents: () => ` - import { defineNuxtPlugin } from "#imports"; + import { defineNuxtPlugin, useRuntimeConfig } from "#imports"; export default defineNuxtPlugin({ name: 'sentry-client-config', async setup() { + globalThis.${NUXT_TUNNEL_BASE_PATH_GLOBAL} = useRuntimeConfig().app.baseURL; await import("${buildDirResolver.resolve(`/${clientConfigFile}`)}") } });`, @@ -91,6 +93,9 @@ export default defineNuxtModule({ } else { addPlugin({ src: moduleDirResolver.resolve('./runtime/plugins/route-detector-legacy.server'), mode: 'server' }); } + } else { + // The Cloudflare setup initializes the SDK from `sentryCloudflareNitroPlugin`, so it needs the tunnel too. + addTunnelPlugin(serverApi); } if (clientConfigFile || serverConfigFile) { diff --git a/packages/nuxt/src/runtime/plugins/tunnel-legacy.server.ts b/packages/nuxt/src/runtime/plugins/tunnel-legacy.server.ts new file mode 100644 index 000000000000..fca620bf1a3d --- /dev/null +++ b/packages/nuxt/src/runtime/plugins/tunnel-legacy.server.ts @@ -0,0 +1,29 @@ +import { handleTunnelRequestIfMatched, isTunnelRequest, setManagedTunnelDefaults } from '@sentry/core/server'; +import { eventHandler, toWebRequest } from 'h3'; +import type { NitroAppPlugin } from 'nitropack'; +import { useRuntimeConfig } from 'nitropack/runtime'; + +/** + * Answers managed tunnel requests on Nuxt v3 and v4 (Nitro v2), from the first layer of the h3 app. That runs + * before route rules and the app's `server/middleware`. + */ +export default (nitroApp => { + // The first layer sees the path before Nitro strips `app.baseURL`, so the tunnel is served under it. + // `useRuntimeConfig` also picks up a `NUXT_APP_BASE_URL` set at runtime, the same value the browser gets. + setManagedTunnelDefaults({ basePath: useRuntimeConfig().app?.baseURL }); + + // Not the `request` hook: h3 still runs the first layer after a hook answered, and with a `headers` route rule + // that layer throws for writing headers on the sent response. + nitroApp.h3App.stack.unshift({ + route: '', + handler: eventHandler(event => { + // Checked on the method and path first, so no other request's body gets wrapped in a web `Request`. + if (!isTunnelRequest({ method: event.method, url: event.path })) { + return undefined; + } + + // A returned `Response` is sent and ends the request, while `undefined` hands it to the next layer. + return handleTunnelRequestIfMatched(toWebRequest(event)); + }), + }); +}) satisfies NitroAppPlugin; diff --git a/packages/nuxt/src/runtime/plugins/tunnel.server.ts b/packages/nuxt/src/runtime/plugins/tunnel.server.ts new file mode 100644 index 000000000000..f84440a06781 --- /dev/null +++ b/packages/nuxt/src/runtime/plugins/tunnel.server.ts @@ -0,0 +1,17 @@ +import { handleTunnelRequestIfMatched, setManagedTunnelDefaults } from '@sentry/core/server'; +import { useRuntimeConfig } from 'nitro/runtime-config'; +import type { NitroAppPlugin, NitroApp } from 'nitro/types'; + +/** + * Answers managed tunnel requests on Nuxt v5+ (Nitro v3+). + */ +export default ((nitroApp: NitroApp) => { + // Nitro runs the app's `server/middleware` before anything a module registers, so an auth middleware there would + // block the tunnel. Every preset hands requests to `nitroApp.fetch` after the plugins ran, so wrapping it goes + // first, before route rules and middleware. + // Requests still carry Nuxt's `app.baseURL` here, so the tunnel is served under it. `useRuntimeConfig` also picks + // up a `NUXT_APP_BASE_URL` set at runtime. + setManagedTunnelDefaults({ basePath: (useRuntimeConfig() as { app?: { baseURL?: string } }).app?.baseURL }); + const appFetch = nitroApp.fetch; + nitroApp.fetch = request => handleTunnelRequestIfMatched(request) ?? appFetch(request); +}) satisfies NitroAppPlugin; diff --git a/packages/nuxt/src/vite/nitroServer.ts b/packages/nuxt/src/vite/nitroServer.ts index 500e94f1f8f6..270576ab49d7 100644 --- a/packages/nuxt/src/vite/nitroServer.ts +++ b/packages/nuxt/src/vite/nitroServer.ts @@ -55,6 +55,8 @@ export function setupNitroServer( addNitroPlugin(moduleDirResolver.resolve('./runtime/plugins/update-route-name-legacy.server')); } + addTunnelPlugin(serverApi); + addNitroPlugin(moduleDirResolver.resolve('./runtime/plugins/sentry.server')); // Preps the middleware instrumentation module. @@ -123,3 +125,16 @@ function setupDeprecatedInjectMode( } } } + +/** + * Adds the plugin that answers the managed tunnel. The Cloudflare setup has no server config file, so it skips + * `setupNitroServer` and adds this on its own. + */ +export function addTunnelPlugin(serverApi: ServerApi): void { + const moduleDirResolver = createResolver(import.meta.url); + addNitroPlugin( + moduleDirResolver.resolve( + serverApi === 'nitro3' ? './runtime/plugins/tunnel.server' : './runtime/plugins/tunnel-legacy.server', + ), + ); +} diff --git a/packages/nuxt/test/common/tunnel.test.ts b/packages/nuxt/test/common/tunnel.test.ts new file mode 100644 index 000000000000..ed491b077fdd --- /dev/null +++ b/packages/nuxt/test/common/tunnel.test.ts @@ -0,0 +1,40 @@ +import { GLOBAL_OBJ } from '@sentry/core'; +import { afterEach, describe, expect, it } from 'vitest'; +import { applyNuxtTunnelBasePath, NUXT_TUNNEL_BASE_PATH_GLOBAL } from '../../src/common/tunnel'; + +const globalWithBasePath = GLOBAL_OBJ as { [NUXT_TUNNEL_BASE_PATH_GLOBAL]?: string }; + +describe('applyNuxtTunnelBasePath', () => { + afterEach(() => { + delete globalWithBasePath.__sentry_nuxt_base_path; + }); + + it('serves the managed tunnel under `app.baseURL`', () => { + globalWithBasePath[NUXT_TUNNEL_BASE_PATH_GLOBAL] = '/app/'; + const options: Parameters[0] = { tunnel: true }; + + applyNuxtTunnelBasePath(options); + + expect(options.tunnel).toEqual({ basePath: '/app' }); + }); + + it('keeps a base path the user set', () => { + globalWithBasePath[NUXT_TUNNEL_BASE_PATH_GLOBAL] = '/app/'; + const options: Parameters[0] = { tunnel: { basePath: '/custom' } }; + + applyNuxtTunnelBasePath(options); + + expect(options.tunnel).toEqual({ basePath: '/custom' }); + }); + + it('serves from the root for the default and relative base URLs', () => { + for (const baseURL of ['/', './']) { + globalWithBasePath[NUXT_TUNNEL_BASE_PATH_GLOBAL] = baseURL; + const options: Parameters[0] = { tunnel: true }; + + applyNuxtTunnelBasePath(options); + + expect(options.tunnel).toBe(true); + } + }); +}); diff --git a/packages/nuxt/test/vite/nitroServer.test.ts b/packages/nuxt/test/vite/nitroServer.test.ts index a1ab7e7d40e2..44649c1c536f 100644 --- a/packages/nuxt/test/vite/nitroServer.test.ts +++ b/packages/nuxt/test/vite/nitroServer.test.ts @@ -65,26 +65,32 @@ describe('setupNitroServer', () => { serverApi: 'nitro2', handler: 'handler-legacy.server', updateRouteName: 'update-route-name-legacy.server', + tunnel: 'tunnel-legacy.server', storage: 'storage-legacy.server', }, { serverApi: 'nitro3', handler: 'handler.server', updateRouteName: 'update-route-name.server', + tunnel: 'tunnel.server', storage: 'storage.server', }, - ] as const)('registers the $handler plugins on $serverApi', ({ serverApi, handler, updateRouteName, storage }) => { - const { nuxt } = createFakeNuxt(); - - setupNitroServer(nuxt, serverApi, SERVER_CONFIG, MODULE_OPTIONS); - - expect(addNitroPluginMock).toHaveBeenCalledTimes(5); - expect(addNitroPluginMock).toHaveBeenNthCalledWith(1, `${APP_ROOT}/.nuxt/sentry-server-config-plugin.mjs`); - expect(addNitroPluginMock).toHaveBeenNthCalledWith(2, `./runtime/plugins/${handler}`); - expect(addNitroPluginMock).toHaveBeenNthCalledWith(3, `./runtime/plugins/${updateRouteName}`); - expect(addNitroPluginMock).toHaveBeenNthCalledWith(4, './runtime/plugins/sentry.server'); - expect(addNitroPluginMock).toHaveBeenNthCalledWith(5, `./runtime/plugins/${storage}`); - }); + ] as const)( + 'registers the $handler plugins on $serverApi', + ({ serverApi, handler, updateRouteName, tunnel, storage }) => { + const { nuxt } = createFakeNuxt(); + + setupNitroServer(nuxt, serverApi, SERVER_CONFIG, MODULE_OPTIONS); + + expect(addNitroPluginMock).toHaveBeenCalledTimes(6); + expect(addNitroPluginMock).toHaveBeenNthCalledWith(1, `${APP_ROOT}/.nuxt/sentry-server-config-plugin.mjs`); + expect(addNitroPluginMock).toHaveBeenNthCalledWith(2, `./runtime/plugins/${handler}`); + expect(addNitroPluginMock).toHaveBeenNthCalledWith(3, `./runtime/plugins/${updateRouteName}`); + expect(addNitroPluginMock).toHaveBeenNthCalledWith(4, `./runtime/plugins/${tunnel}`); + expect(addNitroPluginMock).toHaveBeenNthCalledWith(5, './runtime/plugins/sentry.server'); + expect(addNitroPluginMock).toHaveBeenNthCalledWith(6, `./runtime/plugins/${storage}`); + }, + ); it('instruments the middleware and adds the config shim once Nitro initializes', () => { const { nuxt, callHook } = createFakeNuxt(); diff --git a/packages/react-router/src/client/sdk.ts b/packages/react-router/src/client/sdk.ts index a9b13f2e7cd0..5c140ca27f36 100644 --- a/packages/react-router/src/client/sdk.ts +++ b/packages/react-router/src/client/sdk.ts @@ -1,10 +1,22 @@ import type { BrowserOptions } from '@sentry/browser'; import { init as browserInit } from '@sentry/browser'; import type { Client } from '@sentry/core'; -import { applySdkMetadata, consoleSandbox, setTag } from '@sentry/core'; +import { + applySdkMetadata, + applyTunnelDefaults, + consoleSandbox, + GLOBAL_OBJ, + normalizeTunnelBasePath, + setTag, +} from '@sentry/core'; const BROWSER_TRACING_INTEGRATION_ID = 'BrowserTracing'; +// React Router inlines this context before the client entry runs, so it's there before `init`. +const GLOBAL_OBJ_WITH_ROUTER_CONTEXT = GLOBAL_OBJ as typeof GLOBAL_OBJ & { + __reactRouterContext?: { basename?: string }; +}; + /** * Initializes the client side of the React Router SDK. */ @@ -25,6 +37,10 @@ export function init(options: BrowserOptions): Client | undefined { } } + // The managed tunnel is served under the app's `basename`, like every other route. A `basePath` the user set wins. + const basePath = normalizeTunnelBasePath(GLOBAL_OBJ_WITH_ROUTER_CONTEXT.__reactRouterContext?.basename); + options.tunnel = applyTunnelDefaults(options.tunnel, basePath ? { basePath } : undefined); + applySdkMetadata(options, 'react-router', ['react-router', 'browser']); const client = browserInit(options); diff --git a/packages/react-router/src/cloudflare/index.ts b/packages/react-router/src/cloudflare/index.ts index b94430728125..23dc670a803e 100644 --- a/packages/react-router/src/cloudflare/index.ts +++ b/packages/react-router/src/cloudflare/index.ts @@ -4,6 +4,7 @@ import { isPrerenderRequest } from '../server/serverBuild'; export * from '../client'; export { wrapSentryHandleRequest } from '../server/wrapSentryHandleRequest'; +export { createSentryTunnelRoutes } from '../server/tunnelRoute'; export { createSentryServerInstrumentation, type CreateSentryServerInstrumentationOptions, diff --git a/packages/react-router/src/server/index.ts b/packages/react-router/src/server/index.ts index 8245169f01a7..aaa6bfcb4e14 100644 --- a/packages/react-router/src/server/index.ts +++ b/packages/react-router/src/server/index.ts @@ -8,6 +8,7 @@ export { wrapSentryHandleRequest } from './wrapSentryHandleRequest'; export { createSentryHandleRequest, type SentryHandleRequestOptions } from './createSentryHandleRequest'; export { createSentryHandleError, type SentryHandleErrorOptions } from './createSentryHandleError'; export { getMetaTagTransformer } from './getMetaTagTransformer'; +export { createSentryTunnelRoutes } from './tunnelRoute'; // React Router instrumentation API support export { diff --git a/packages/react-router/src/server/tunnelRoute.ts b/packages/react-router/src/server/tunnelRoute.ts new file mode 100644 index 000000000000..f891a06d9e22 --- /dev/null +++ b/packages/react-router/src/server/tunnelRoute.ts @@ -0,0 +1,66 @@ +import { debug, getClient } from '@sentry/core'; +import { getTunnelPaths, handleTunnelRequestIfMatched, setManagedTunnelDefaults } from '@sentry/core/server'; +import { DEBUG_BUILD } from '../common/debug-build'; + +// Never matches a request, for when the managed tunnel is off. +const DISABLED_TUNNEL_PATH = '/__sentry-tunnel-disabled__'; + +/** + * The resource routes that serve the managed tunnel, one per tunnel path. `sentryReactRouter()` adds them to the + * server build, since React Router has no request hook to answer from and a route matches only one path. Not meant + * to be used directly. + * + * @param basename The app's `basename`, which React Router matches the routes under but leaves in the request URL. + */ +export function createSentryTunnelRoutes(basename?: string): Record> { + // Runs when the server build loads. Clients created later, e.g. per request on Cloudflare, start with the basename. + setManagedTunnelDefaults({ basePath: basename }); + + // `init` runs from `--import` before the server build loads, so the client and its tunnel paths are usually known. + if (!getClient()) { + return { 'sentry-tunnel': makeTunnelRoute('sentry-tunnel', getLazyTunnelPath) }; + } + + return Object.fromEntries( + getTunnelPaths().map((path, index) => { + const id = `sentry-tunnel-${index}`; + + return [id, makeTunnelRoute(id, () => path)]; + }), + ); +} + +/** + * The path of the single route added when `init` runs after the server build loads, e.g. per request on Cloudflare. + * React Router reads it on its first request and keeps it until the server restarts. + */ +function getLazyTunnelPath(): string { + if (!getClient()) { + DEBUG_BUILD && debug.warn('The managed tunnel stays off until restart: a request came in before `Sentry.init`.'); + return DISABLED_TUNNEL_PATH; + } + + const [path, ...otherPaths] = getTunnelPaths(); + if (otherPaths.length) { + DEBUG_BUILD && debug.warn(`Sentry.init ran after the server build loaded, so only ${path} is served.`); + } + + return path ?? DISABLED_TUNNEL_PATH; +} + +function makeTunnelRoute(id: string, getPath: () => string): Record { + return { + id, + parentId: undefined, + index: undefined, + caseSensitive: undefined, + get path(): string { + return getPath(); + }, + module: { + action: ({ request }: { request: Request }) => + handleTunnelRequestIfMatched(request) ?? new Response(null, { status: 404 }), + loader: () => new Response(null, { status: 405, headers: { Allow: 'POST' } }), + }, + }; +} diff --git a/packages/react-router/src/vite/makeTunnelRoutePlugin.ts b/packages/react-router/src/vite/makeTunnelRoutePlugin.ts new file mode 100644 index 000000000000..4c03163913d9 --- /dev/null +++ b/packages/react-router/src/vite/makeTunnelRoutePlugin.ts @@ -0,0 +1,28 @@ +import type { Plugin } from 'vite'; + +const SERVER_BUILD_MODULE_ID = 'virtual:react-router/server-build'; + +/** + * Adds the managed tunnel routes to the server build. They only serve requests when `Sentry.init` enables the + * managed tunnel. The server build exports the app's `basename`, which the route needs to match the request URL. + */ +export function makeTunnelRoutePlugin(): Plugin { + return { + name: 'sentry-react-router-tunnel-route', + enforce: 'post', + + transform(code, id, options) { + if (!options?.ssr || !id.includes(SERVER_BUILD_MODULE_ID)) { + return null; + } + + return { + code: `${code} +import { createSentryTunnelRoutes as __sentryCreateTunnelRoutes } from '@sentry/react-router'; +Object.assign(routes, __sentryCreateTunnelRoutes(typeof basename === 'string' ? basename : undefined)); +`, + map: null, + }; + }, + }; +} diff --git a/packages/react-router/src/vite/plugin.ts b/packages/react-router/src/vite/plugin.ts index 602ca6f4c412..2992b9a5bbc0 100644 --- a/packages/react-router/src/vite/plugin.ts +++ b/packages/react-router/src/vite/plugin.ts @@ -4,6 +4,7 @@ import { makeConfigInjectorPlugin } from './makeConfigInjectorPlugin'; import { makeCustomSentryVitePlugins } from './makeCustomSentryVitePlugins'; import { makeEnableSourceMapsPlugin } from './makeEnableSourceMapsPlugin'; import { makeServerBuildCapturePlugin } from './makeServerBuildCapturePlugin'; +import { makeTunnelRoutePlugin } from './makeTunnelRoutePlugin'; import type { SentryReactRouterBuildOptions } from './types'; /** @@ -21,6 +22,7 @@ export async function sentryReactRouter( plugins.push(makeConfigInjectorPlugin(options)); plugins.push(makeServerBuildCapturePlugin()); + plugins.push(makeTunnelRoutePlugin()); if (process.env.NODE_ENV !== 'development' && viteConfig.command === 'build' && viteConfig.mode !== 'development') { plugins.push(sentryOrchestrionPlugin({ buildTimeInstrumentation: options.buildTimeInstrumentation })); diff --git a/packages/react-router/test/client/sdk.test.ts b/packages/react-router/test/client/sdk.test.ts index c932fec91064..4cb7a16fa9d3 100644 --- a/packages/react-router/test/client/sdk.test.ts +++ b/packages/react-router/test/client/sdk.test.ts @@ -41,6 +41,18 @@ describe('React Router client SDK', () => { expect(browserInit).toHaveBeenCalledWith(expect.objectContaining(expectedMetadata)); }); + it("serves the managed tunnel under the app's `basename`, unless the user set a base path", () => { + vi.stubGlobal('__reactRouterContext', { basename: '/app/' }); + + reactRouterInit({ tunnel: true }); + expect(browserInit).toHaveBeenLastCalledWith(expect.objectContaining({ tunnel: { basePath: '/app' } })); + + reactRouterInit({ tunnel: { basePath: '/custom' } }); + expect(browserInit).toHaveBeenLastCalledWith(expect.objectContaining({ tunnel: { basePath: '/custom' } })); + + vi.unstubAllGlobals(); + }); + it('returns client from init', () => { expect(reactRouterInit({})).not.toBeUndefined(); }); diff --git a/packages/react-router/test/server/tunnelRoute.test.ts b/packages/react-router/test/server/tunnelRoute.test.ts new file mode 100644 index 000000000000..cc27a2b3e385 --- /dev/null +++ b/packages/react-router/test/server/tunnelRoute.test.ts @@ -0,0 +1,79 @@ +import type { Client } from '@sentry/core'; +import { createEnvelope, getCurrentScope, getTunnelPath, makeDsn, serializeEnvelope } from '@sentry/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createSentryTunnelRoutes } from '../../src/server/tunnelRoute'; + +const DSN = 'https://public@o1.ingest.sentry.io/1337'; + +type TunnelRoute = { + path: string; + parentId: undefined; + module: { action: (args: { request: Request }) => Promise | Response; loader: () => Response }; +}; + +function firstRoute(basename?: string): TunnelRoute { + return Object.values(createSentryTunnelRoutes(basename))[0] as TunnelRoute; +} + +function setClient(managedTunnel: object | undefined): void { + const options = { dsn: DSN, _managedTunnel: managedTunnel }; + getCurrentScope().setClient({ getDsn: () => makeDsn(DSN), getOptions: () => options } as unknown as Client); +} + +describe('createSentryTunnelRoutes', () => { + afterEach(() => { + getCurrentScope().setClient(undefined); + vi.unstubAllGlobals(); + }); + + it('is a top-level route at the tunnel path when `init` enabled the managed tunnel', () => { + setClient({}); + const route = firstRoute(); + + expect(route.parentId).toBeUndefined(); + expect(route.path).toBe(getTunnelPath(DSN)); + }); + + it('adds no route when the tunnel is off, and a path that never matches when Sentry is not initialized yet', () => { + const route = firstRoute(); + const pathWithoutClient = route.path; + setClient(undefined); + + expect(pathWithoutClient).toBe('/__sentry-tunnel-disabled__'); + expect(route.path).toBe('/__sentry-tunnel-disabled__'); + expect(createSentryTunnelRoutes()).toEqual({}); + }); + + it('forwards tunnel POSTs and rejects other methods', async () => { + setClient({}); + vi.stubGlobal( + 'fetch', + vi.fn(() => Promise.resolve(new Response('forwarded'))), + ); + const route = firstRoute(); + const request = new Request(`http://localhost${getTunnelPath(DSN)}`, { + method: 'POST', + body: serializeEnvelope(createEnvelope({ dsn: DSN }, [])) as BodyInit, + }); + + expect(await (await route.module.action({ request })).text()).toBe('forwarded'); + expect(route.module.loader().status).toBe(405); + }); + + it('serves the tunnel under `basename`, which React Router leaves in the request URL', async () => { + setClient({}); + vi.stubGlobal( + 'fetch', + vi.fn(() => Promise.resolve(new Response('forwarded'))), + ); + const route = firstRoute('/app/'); + const request = new Request(`http://localhost/app${getTunnelPath(DSN)}`, { + method: 'POST', + body: serializeEnvelope(createEnvelope({ dsn: DSN }, [])) as BodyInit, + }); + + // React Router matches the route under `basename` itself, so the path stays relative to it. + expect(route.path).toBe(getTunnelPath(DSN)); + expect(await (await route.module.action({ request })).text()).toBe('forwarded'); + }); +}); diff --git a/packages/react-router/test/vite/makeTunnelRoutePlugin.test.ts b/packages/react-router/test/vite/makeTunnelRoutePlugin.test.ts new file mode 100644 index 000000000000..4982ed40acc1 --- /dev/null +++ b/packages/react-router/test/vite/makeTunnelRoutePlugin.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { makeTunnelRoutePlugin } from '../../src/vite/makeTunnelRoutePlugin'; + +type Transform = (code: string, id: string, options?: { ssr?: boolean }) => { code: string } | null; + +describe('makeTunnelRoutePlugin', () => { + const transform = makeTunnelRoutePlugin().transform as unknown as Transform; + + it('adds the tunnel route to the SSR server build', () => { + const result = transform('export const routes = {};', '\0virtual:react-router/server-build', { ssr: true }); + + expect(result?.code).toContain( + "Object.assign(routes, __sentryCreateTunnelRoutes(typeof basename === 'string' ? basename : undefined));", + ); + expect(result?.code).toContain("from '@sentry/react-router'"); + }); + + it('leaves other modules and the client build alone', () => { + expect(transform('', '\0virtual:react-router/server-build', { ssr: false })).toBeNull(); + expect(transform('', '/app/root.tsx', { ssr: true })).toBeNull(); + }); +}); diff --git a/packages/react-router/test/vite/plugin.test.ts b/packages/react-router/test/vite/plugin.test.ts index 52cbecceb9a9..5c33619821df 100644 --- a/packages/react-router/test/vite/plugin.test.ts +++ b/packages/react-router/test/vite/plugin.test.ts @@ -3,6 +3,7 @@ import { makeConfigInjectorPlugin } from '../../src/vite/makeConfigInjectorPlugi import { makeCustomSentryVitePlugins } from '../../src/vite/makeCustomSentryVitePlugins'; import { makeEnableSourceMapsPlugin } from '../../src/vite/makeEnableSourceMapsPlugin'; import { makeServerBuildCapturePlugin } from '../../src/vite/makeServerBuildCapturePlugin'; +import { makeTunnelRoutePlugin } from '../../src/vite/makeTunnelRoutePlugin'; import { sentryReactRouter } from '../../src/vite/plugin'; vi.spyOn(console, 'log').mockImplementation(() => { @@ -16,6 +17,7 @@ vi.mock('../../src/vite/makeCustomSentryVitePlugins'); vi.mock('../../src/vite/makeEnableSourceMapsPlugin'); vi.mock('../../src/vite/makeConfigInjectorPlugin'); vi.mock('../../src/vite/makeServerBuildCapturePlugin'); +vi.mock('../../src/vite/makeTunnelRoutePlugin'); // Stub the orchestrion plugin so these stay pure wiring tests (no apm code transformer pulled in). // Mirror the real plugin's contract: `buildTimeInstrumentation: false` yields the inert variant. @@ -31,6 +33,7 @@ describe('sentryReactRouter', () => { const mockSourceMapsPlugin = { name: 'source-maps-plugin' }; const mockConfigInjectorPlugin = { name: 'sentry-config-injector' }; const mockServerBuildCapturePlugin = { name: 'sentry-react-router-server-build-capture' }; + const mockTunnelRoutePlugin = { name: 'sentry-react-router-tunnel-route' }; const mockOrchestrionPlugin = { name: 'sentry-orchestrion-vite' }; beforeEach(() => { @@ -39,6 +42,7 @@ describe('sentryReactRouter', () => { vi.mocked(makeEnableSourceMapsPlugin).mockReturnValue(mockSourceMapsPlugin); vi.mocked(makeConfigInjectorPlugin).mockReturnValue(mockConfigInjectorPlugin); vi.mocked(makeServerBuildCapturePlugin).mockReturnValue(mockServerBuildCapturePlugin); + vi.mocked(makeTunnelRoutePlugin).mockReturnValue(mockTunnelRoutePlugin); }); afterEach(() => { @@ -51,7 +55,7 @@ describe('sentryReactRouter', () => { const result = await sentryReactRouter({}, { command: 'build', mode: 'production' }); - expect(result).toEqual([mockConfigInjectorPlugin, mockServerBuildCapturePlugin]); + expect(result).toEqual([mockConfigInjectorPlugin, mockServerBuildCapturePlugin, mockTunnelRoutePlugin]); expect(makeCustomSentryVitePlugins).not.toHaveBeenCalled(); expect(makeEnableSourceMapsPlugin).not.toHaveBeenCalled(); @@ -61,7 +65,7 @@ describe('sentryReactRouter', () => { it('should return config injector plugin when not in build mode', async () => { const result = await sentryReactRouter({}, { command: 'serve', mode: 'production' }); - expect(result).toEqual([mockConfigInjectorPlugin, mockServerBuildCapturePlugin]); + expect(result).toEqual([mockConfigInjectorPlugin, mockServerBuildCapturePlugin, mockTunnelRoutePlugin]); expect(makeCustomSentryVitePlugins).not.toHaveBeenCalled(); expect(makeEnableSourceMapsPlugin).not.toHaveBeenCalled(); }); @@ -69,7 +73,7 @@ describe('sentryReactRouter', () => { it('should return config injector plugin in development build mode', async () => { const result = await sentryReactRouter({}, { command: 'build', mode: 'development' }); - expect(result).toEqual([mockConfigInjectorPlugin, mockServerBuildCapturePlugin]); + expect(result).toEqual([mockConfigInjectorPlugin, mockServerBuildCapturePlugin, mockTunnelRoutePlugin]); expect(makeCustomSentryVitePlugins).not.toHaveBeenCalled(); expect(makeEnableSourceMapsPlugin).not.toHaveBeenCalled(); }); @@ -83,6 +87,7 @@ describe('sentryReactRouter', () => { expect(result).toEqual([ mockConfigInjectorPlugin, mockServerBuildCapturePlugin, + mockTunnelRoutePlugin, mockOrchestrionPlugin, mockSourceMapsPlugin, ...mockPlugins, diff --git a/packages/remix/src/v3/server/middleware.ts b/packages/remix/src/v3/server/middleware.ts index f2ce59c48f01..b5803af44dcb 100644 --- a/packages/remix/src/v3/server/middleware.ts +++ b/packages/remix/src/v3/server/middleware.ts @@ -10,6 +10,7 @@ import { updateSpanName, winterCGRequestToRequestData, } from '@sentry/core'; +import { handleTunnelRequestIfMatched } from '@sentry/core/server'; import { formatRouteTiming } from '../routeTiming'; import type { MatcherLike, MiddlewareLike, NextFunctionLike, RequestContextLike } from '../types'; @@ -25,6 +26,11 @@ import { resolveRoutePattern } from './route'; */ export function sentryRemixMiddleware(matcher: MatcherLike): MiddlewareLike { return async function sentryMiddleware(context: RequestContextLike, next: NextFunctionLike): Promise { + const tunnelResponse = handleTunnelRequestIfMatched(context.request); + if (tunnelResponse) { + return tunnelResponse; + } + const isolationScope = getIsolationScope(); isolationScope.setSDKProcessingMetadata({ normalizedRequest: winterCGRequestToRequestData(context.request) }); diff --git a/packages/remix/test/v3/middleware.test.ts b/packages/remix/test/v3/middleware.test.ts index 3bc7031300bf..c86d426793e9 100644 --- a/packages/remix/test/v3/middleware.test.ts +++ b/packages/remix/test/v3/middleware.test.ts @@ -1,14 +1,24 @@ import type * as SentryCore from '@sentry/core'; +import type * as SentryCoreServer from '@sentry/core/server'; import { beforeEach, describe, expect, it, vi } from 'vitest'; // Trace data as the SDK would produce it for the active request; empty when nothing is active. const getTraceData = vi.hoisted(() => vi.fn<() => { 'sentry-trace'?: string; baggage?: string }>(() => ({}))); +const handleTunnelRequestIfMatched = vi.hoisted(() => + vi.fn<(request: Request) => Promise | undefined>(() => undefined), +); + vi.mock('@sentry/core', async importOriginal => ({ ...(await importOriginal()), getTraceData, })); +vi.mock('@sentry/core/server', async importOriginal => ({ + ...(await importOriginal()), + handleTunnelRequestIfMatched, +})); + import { sentryRemixMiddleware } from '../../src/v3/server/middleware'; import type { MatcherLike, RequestContextLike } from '../../src/v3/types'; @@ -27,6 +37,20 @@ function contextFor(url: string, headers: Record = {}): RequestC describe('sentryRemixMiddleware', () => { beforeEach(() => { getTraceData.mockReturnValue({}); + handleTunnelRequestIfMatched.mockReturnValue(undefined); + }); + + it('answers tunnel requests before route matching and the rest of the chain', async () => { + const tunnelResponse = new Response('forwarded'); + handleTunnelRequestIfMatched.mockReturnValue(Promise.resolve(tunnelResponse)); + const next = vi.fn(); + const context = contextFor('http://x/abc123'); + + const response = await sentryRemixMiddleware(matcherFor('/'))(context, next); + + expect(response).toBe(tunnelResponse); + expect(handleTunnelRequestIfMatched).toHaveBeenCalledWith(context.request); + expect(next).not.toHaveBeenCalled(); }); it('propagates the trace to HTML responses through Server-Timing, ahead of the route', async () => { diff --git a/packages/solidstart/rollup.npm.config.mjs b/packages/solidstart/rollup.npm.config.mjs index d28b29896b10..994c3cbe8fa7 100644 --- a/packages/solidstart/rollup.npm.config.mjs +++ b/packages/solidstart/rollup.npm.config.mjs @@ -13,11 +13,12 @@ export default makeNPMConfigVariants( 'src/client/solidrouter.ts', 'src/server/solidrouter.ts', 'src/vite/index.ts', + 'src/nitro/tunnelPlugin.ts', ], // prevent this internal code from ending up in our built package (this doesn't happen automatically because // the name doesn't match an SDK dependency) packageSpecificConfig: { - external: ['solid-js/web', 'solid-js', '@sentry/solid', '@sentry/solid/solidrouter'], + external: ['solid-js/web', 'solid-js', '@sentry/solid', '@sentry/solid/solidrouter', 'h3'], output: { dynamicImportInCjs: true, }, diff --git a/packages/solidstart/src/client/sdk.ts b/packages/solidstart/src/client/sdk.ts index 06ee8f092094..7473f329d11c 100644 --- a/packages/solidstart/src/client/sdk.ts +++ b/packages/solidstart/src/client/sdk.ts @@ -1,6 +1,7 @@ import type { Client, Integration } from '@sentry/core'; import { applySdkMetadata } from '@sentry/core'; import type { BrowserOptions } from '@sentry/solid'; +import { applyTunnelBasePath } from '../common/tunnel'; import { browserTracingIntegration, getDefaultIntegrations as getDefaultSolidIntegrations, @@ -19,6 +20,7 @@ export function init(options: BrowserOptions): Client | undefined { ...options, }; + applyTunnelBasePath(opts); applySdkMetadata(opts, 'solidstart', ['solidstart', 'solid']); return initSolidSDK(opts); diff --git a/packages/solidstart/src/common/tunnel.ts b/packages/solidstart/src/common/tunnel.ts new file mode 100644 index 000000000000..09fbf034b1eb --- /dev/null +++ b/packages/solidstart/src/common/tunnel.ts @@ -0,0 +1,17 @@ +import type { Options } from '@sentry/core'; +import { applyTunnelDefaults } from '@sentry/core'; + +/** Set on the server by `withSentry` to the app's `server.baseURL`. */ +export const TUNNEL_BASE_PATH_GLOBAL = '__sentry_solidstart_base_path'; + +declare const __SENTRY_SOLIDSTART_TUNNEL_BASE_PATH__: string | undefined; + +/** + * Serves the browser's managed tunnel under the app's `server.baseURL`, which `withSentry` defines at build time. + * A `basePath` the user set wins. + */ +export function applyTunnelBasePath(options: Pick): void { + const basePath = + typeof __SENTRY_SOLIDSTART_TUNNEL_BASE_PATH__ !== 'undefined' ? __SENTRY_SOLIDSTART_TUNNEL_BASE_PATH__ : ''; + options.tunnel = applyTunnelDefaults(options.tunnel, basePath ? { basePath } : undefined); +} diff --git a/packages/solidstart/src/config/types.ts b/packages/solidstart/src/config/types.ts index 14e814c78195..d9c13ae76ebb 100644 --- a/packages/solidstart/src/config/types.ts +++ b/packages/solidstart/src/config/types.ts @@ -9,6 +9,7 @@ export type RollupConfig = { export type SolidStartInlineConfig = NonNullable[0]>; export type SolidStartInlineServerConfig = { + plugins?: string[]; hooks?: { close?: () => unknown; 'rollup:before'?: (nitro: Nitro) => unknown; diff --git a/packages/solidstart/src/config/withSentry.ts b/packages/solidstart/src/config/withSentry.ts index e4b0f6d08b49..973bf6c78666 100644 --- a/packages/solidstart/src/config/withSentry.ts +++ b/packages/solidstart/src/config/withSentry.ts @@ -1,4 +1,5 @@ -import { debug } from '@sentry/core'; +import { fileURLToPath } from 'node:url'; +import { debug, GLOBAL_OBJ, normalizeTunnelBasePath } from '@sentry/core'; import { INSTRUMENTED_MODULE_NAMES } from '@sentry/server-utils/orchestrion/config'; import { commonJSInteropOptions, @@ -6,6 +7,7 @@ import { sentryOrchestrionPlugin, } from '@sentry/server-utils/orchestrion/rollup'; import type { Nitro, NitroConfig } from 'nitropack'; +import { TUNNEL_BASE_PATH_GLOBAL } from '../common/tunnel'; import { addSentryPluginToVite } from '../vite/sentrySolidStartVite'; import type { SentrySolidStartPluginOptions } from '../vite/types'; import { @@ -46,11 +48,21 @@ export function withSentry( }; const server = (solidStartConfig.server || {}) as SolidStartInlineServerConfig; + // Requests reach the tunnel with `server.baseURL` still in front, so both sides serve it under that. + const tunnelBasePath = normalizeTunnelBasePath( + (server as SolidStartInlineServerConfig & { baseURL?: string }).baseURL, + ); + // vinxi's dev server runs Nitro in this process, so the tunnel plugin can read it from here in dev. + if (tunnelBasePath) { + (GLOBAL_OBJ as { [TUNNEL_BASE_PATH_GLOBAL]?: string })[TUNNEL_BASE_PATH_GLOBAL] = tunnelBasePath; + } + const viteConfig = solidStartConfig.vite; const vite = typeof viteConfig === 'function' - ? (...args: Parameters) => addSentryPluginToVite(viteConfig(...args), sentryPluginOptions) - : addSentryPluginToVite(viteConfig, sentryPluginOptions); + ? (...args: Parameters) => + addSentryPluginToVite(viteConfig(...args), sentryPluginOptions, tunnelBasePath) + : addSentryPluginToVite(viteConfig, sentryPluginOptions, tunnelBasePath); const addBuildTimeInstrumentation = sentryPluginOptions.buildTimeInstrumentation !== false; @@ -61,6 +73,14 @@ export function withSentry( // but at runtime it holds resolved plugin objects we can push onto, so narrow to our own shape. const sentryRollupConfig = rollupConfig as unknown as RollupConfig; + if (tunnelBasePath) { + // The built server runs in another process, so the tunnel plugin reads the base path from the bundle. + sentryRollupConfig.plugins.push({ + name: 'sentry-solidstart-tunnel-base-path', + banner: `globalThis.${TUNNEL_BASE_PATH_GLOBAL} = ${JSON.stringify(tunnelBasePath)};`, + }); + } + if (addBuildTimeInstrumentation) { sentryRollupConfig.plugins.push( sentryOrchestrionPlugin({ buildTimeInstrumentation: sentryPluginOptions.buildTimeInstrumentation }), @@ -116,6 +136,15 @@ export function withSentry( externals, commonJS, modules: [...existingModules, sentryNitroModule], + // Through `server.plugins`, since vinxi's dev server ignores plugins that Nitro modules add. + plugins: [...(server.plugins || []), getTunnelPluginPath()], }, }; } + +function getTunnelPluginPath(): string { + const url = new URL('../nitro/tunnelPlugin.js', import.meta.url); + + // `import.meta.url` is a file URL in the built package, but not in every test environment. + return url.protocol === 'file:' ? fileURLToPath(url) : url.pathname; +} diff --git a/packages/solidstart/src/nitro/tunnelPlugin.ts b/packages/solidstart/src/nitro/tunnelPlugin.ts new file mode 100644 index 000000000000..017fd3004e78 --- /dev/null +++ b/packages/solidstart/src/nitro/tunnelPlugin.ts @@ -0,0 +1,33 @@ +import { GLOBAL_OBJ } from '@sentry/core'; +import { handleTunnelRequestIfMatched, isTunnelRequest, setManagedTunnelDefaults } from '@sentry/core/server'; +import { eventHandler, toWebRequest } from 'h3'; +import type { NitroAppPlugin } from 'nitropack'; +import { TUNNEL_BASE_PATH_GLOBAL } from '../common/tunnel'; + +/** + * Answers managed tunnel requests on SolidStart v1 (Nitro v2), from the first layer of the h3 app. That runs before + * route rules, the app's middleware and routing. `withSentry` registers it. + */ +export default (nitroApp => { + // The first layer sees the path before Nitro strips `server.baseURL`, so the tunnel is served under it. The server + // bundle's banner sets the global before this plugin runs. + setManagedTunnelDefaults({ + basePath: (GLOBAL_OBJ as { [TUNNEL_BASE_PATH_GLOBAL]?: string })[TUNNEL_BASE_PATH_GLOBAL], + }); + + // Not the `request` hook: h3 still runs the first layer after a hook answered, so route rules would act on the + // sent response. + nitroApp.h3App.stack.unshift({ + route: '', + handler: eventHandler(event => { + // Checked on the method and path first, so no other request's body gets wrapped in a web `Request`. + if (!isTunnelRequest({ method: event.method, url: event.path })) { + return undefined; + } + + // `toWebRequest` also reads the body of the mock requests serverless presets pass through `localFetch`, which + // aren't Node streams. A returned `Response` is sent and ends the request, `undefined` hands it on. + return handleTunnelRequestIfMatched(toWebRequest(event)); + }), + }); +}) satisfies NitroAppPlugin; diff --git a/packages/solidstart/src/vite/sentrySolidStartVite.ts b/packages/solidstart/src/vite/sentrySolidStartVite.ts index 8bb0a55741d1..61b884c29d2e 100644 --- a/packages/solidstart/src/vite/sentrySolidStartVite.ts +++ b/packages/solidstart/src/vite/sentrySolidStartVite.ts @@ -7,10 +7,22 @@ import type { SentrySolidStartPluginOptions } from './types'; /** * Various Sentry vite plugins to be used for SolidStart. */ -export const sentrySolidStartVite = (options: SentrySolidStartPluginOptions = {}, viteConfig: UserConfig): Plugin[] => { +export const sentrySolidStartVite = ( + options: SentrySolidStartPluginOptions = {}, + viteConfig: UserConfig, + tunnelBasePath = '', +): Plugin[] => { warnOnRemovedBuildOptions(options, ['unstable_sentryVitePluginOptions']); - const sentryPlugins: Plugin[] = []; + const sentryPlugins: Plugin[] = [ + { + name: 'sentry-solidstart-tunnel-base-path', + // Serves the browser's managed tunnel under `server.baseURL`, like every other route of the app. + config: () => ({ + define: { __SENTRY_SOLIDSTART_TUNNEL_BASE_PATH__: JSON.stringify(tunnelBasePath) }, + }), + }, + ]; if (options.autoInjectServerSentry !== 'experimental_dynamic-import') { sentryPlugins.push(makeBuildInstrumentationFilePlugin(options)); @@ -31,9 +43,13 @@ export const sentrySolidStartVite = (options: SentrySolidStartPluginOptions = {} /** * Helper to add the Sentry SolidStart vite plugin to a vite config. */ -export const addSentryPluginToVite = (config: UserConfig = {}, options: SentrySolidStartPluginOptions): UserConfig => { +export const addSentryPluginToVite = ( + config: UserConfig = {}, + options: SentrySolidStartPluginOptions, + tunnelBasePath?: string, +): UserConfig => { const plugins = Array.isArray(config.plugins) ? [...config.plugins] : []; - plugins.unshift(sentrySolidStartVite(options, config)); + plugins.unshift(sentrySolidStartVite(options, config, tunnelBasePath)); return { ...config, diff --git a/packages/solidstart/test/common/tunnel.test.ts b/packages/solidstart/test/common/tunnel.test.ts new file mode 100644 index 000000000000..a3d7168b0958 --- /dev/null +++ b/packages/solidstart/test/common/tunnel.test.ts @@ -0,0 +1,28 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { applyTunnelBasePath } from '../../src/common/tunnel'; + +describe('applyTunnelBasePath', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('serves the managed tunnel under the defined base path, unless the user set one', () => { + vi.stubGlobal('__SENTRY_SOLIDSTART_TUNNEL_BASE_PATH__', '/app'); + const managed: Parameters[0] = { tunnel: true }; + const own: Parameters[0] = { tunnel: { basePath: '/custom' } }; + + applyTunnelBasePath(managed); + applyTunnelBasePath(own); + + expect(managed.tunnel).toEqual({ basePath: '/app' }); + expect(own.tunnel).toEqual({ basePath: '/custom' }); + }); + + it('leaves the tunnel alone without a base path', () => { + const options: Parameters[0] = { tunnel: true }; + + applyTunnelBasePath(options); + + expect(options.tunnel).toBe(true); + }); +}); diff --git a/packages/solidstart/test/config/withSentry.test.ts b/packages/solidstart/test/config/withSentry.test.ts index 3afba804811d..c4c056319b55 100644 --- a/packages/solidstart/test/config/withSentry.test.ts +++ b/packages/solidstart/test/config/withSentry.test.ts @@ -78,6 +78,16 @@ describe('withSentry()', () => { expect(addInstrumentationFileToBuildMock).toHaveBeenCalledWith(nitroOptions); }); + it('registers the managed tunnel plugin through server.plugins and keeps user plugins', () => { + const config = withSentry({ server: { plugins: ['./user-plugin.ts'] } }, {}); + + expect((config?.server as { plugins?: string[] }).plugins).toEqual([ + './user-plugin.ts', + // Vitest serves the source file, the built package the transpiled one. + expect.stringMatching(/\/nitro\/tunnelPlugin\.[jt]s$/), + ]); + }); + it('does not override user-defined hooks in server.hooks', () => { const config = withSentry(solidStartConfig, {}); expect(config?.server.hooks?.['rollup:before']).toBe(userDefinedNitroRollupBeforeHookMock); @@ -116,6 +126,7 @@ describe('withSentry()', () => { }); const names = config?.vite.plugins.flat().map((plugin: Plugin) => plugin.name); expect(names).toEqual([ + 'sentry-solidstart-tunnel-base-path', 'sentry-solidstart-build-instrumentation-file', 'sentry-vite-plugin', 'sentry-solidstart-update-source-map-setting', @@ -139,6 +150,7 @@ describe('withSentry()', () => { const names = config?.vite.plugins.flat().map((plugin: Plugin) => plugin.name); expect(names).toEqual([ + 'sentry-solidstart-tunnel-base-path', 'sentry-solidstart-build-instrumentation-file', 'sentry-vite-plugin', 'sentry-solidstart-update-source-map-setting', @@ -166,6 +178,7 @@ describe('withSentry()', () => { .plugins.flat() .map((plugin: Plugin) => plugin.name); expect(names).toEqual([ + 'sentry-solidstart-tunnel-base-path', 'sentry-solidstart-build-instrumentation-file', 'sentry-vite-plugin', 'sentry-solidstart-update-source-map-setting', diff --git a/packages/solidstart/test/nitro/tunnelPlugin.test.ts b/packages/solidstart/test/nitro/tunnelPlugin.test.ts new file mode 100644 index 000000000000..c1eb6307ef8a --- /dev/null +++ b/packages/solidstart/test/nitro/tunnelPlugin.test.ts @@ -0,0 +1,68 @@ +import type { Client } from '@sentry/core'; +import { createEnvelope, getCurrentScope, getTunnelPath, makeDsn, serializeEnvelope } from '@sentry/core'; +import { createApp, eventHandler, setHeaders, toNodeListener } from 'h3'; +import { fetchNodeRequestHandler } from 'node-mock-http'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import tunnelPlugin from '../../src/nitro/tunnelPlugin'; + +const DSN = 'https://public@o1.ingest.sentry.io/1337'; + +type RequestHook = (event: unknown) => Promise; + +/** + * Calls an h3 app wired like Nitro v2's: the `request` hook runs in `onRequest`, and the route rules layer, here + * with a `headers` rule, comes before the app. Requests come in the way serverless presets pass them through + * `localFetch`, with node-mock-http's request, which isn't a Node stream. + */ +function fetchApp(url: string, init: RequestInit): Promise { + const requestHooks: RequestHook[] = []; + const app = createApp({ + onRequest: async event => { + for (const hook of requestHooks) { + await hook(event); + } + }, + onError: error => onError(error), + }); + app.use(eventHandler(event => setHeaders(event, { 'x-route-rule': '1' }))); + app.use(eventHandler(() => 'app')); + (tunnelPlugin as (nitroApp: unknown) => void)({ + h3App: app, + hooks: { hook: (name: string, fn: RequestHook) => name === 'request' && requestHooks.push(fn) }, + }); + + return fetchNodeRequestHandler(toNodeListener(app), `http://localhost${url}`, init); +} + +const onError = vi.fn(); + +describe('SolidStart v1 tunnel plugin', () => { + afterEach(() => { + getCurrentScope().setClient(undefined); + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it('answers tunnel requests before route rules and the app when `init` enabled the managed tunnel', async () => { + const options = { dsn: DSN, _managedTunnel: {} }; + getCurrentScope().setClient({ getDsn: () => makeDsn(DSN), getOptions: () => options } as unknown as Client); + const sentryFetch = vi.fn((_url: string, _init: RequestInit) => Promise.resolve(new Response('forwarded'))); + vi.stubGlobal('fetch', sentryFetch); + const envelope = serializeEnvelope(createEnvelope({ dsn: DSN }, [])) as string; + + const response = await fetchApp(getTunnelPath(DSN)!, { method: 'POST', body: envelope }); + + expect(await response.text()).toBe('forwarded'); + expect(await new Response(sentryFetch.mock.lastCall![1].body).text()).toBe(envelope); + // A route rule that ran after the answer would fail to set headers on the sent response. + expect(response.headers.get('x-route-rule')).toBeNull(); + expect(onError).not.toHaveBeenCalled(); + }); + + it('leaves every other request to the app', async () => { + const response = await fetchApp('/api/users', { method: 'POST', body: 'x' }); + + expect(await response.text()).toBe('app'); + expect(response.headers.get('x-route-rule')).toBe('1'); + }); +}); diff --git a/packages/solidstart/test/vite/sentrySolidStartVite.test.ts b/packages/solidstart/test/vite/sentrySolidStartVite.test.ts index 545638f9e3dc..db2382efdc45 100644 --- a/packages/solidstart/test/vite/sentrySolidStartVite.test.ts +++ b/packages/solidstart/test/vite/sentrySolidStartVite.test.ts @@ -26,6 +26,7 @@ describe('sentrySolidStartVite()', () => { const plugins = getSentrySolidStartVitePlugins(); const names = plugins.map(plugin => plugin.name); expect(names).toEqual([ + 'sentry-solidstart-tunnel-base-path', 'sentry-solidstart-build-instrumentation-file', 'sentry-vite-plugin', 'sentry-solidstart-update-source-map-setting', @@ -35,7 +36,7 @@ describe('sentrySolidStartVite()', () => { it("returns only build-instrumentation-file plugin if source maps upload isn't enabled", () => { const plugins = getSentrySolidStartVitePlugins({ sourcemaps: { disable: true } }); const names = plugins.map(plugin => plugin.name); - expect(names).toEqual(['sentry-solidstart-build-instrumentation-file']); + expect(names).toEqual(['sentry-solidstart-tunnel-base-path', 'sentry-solidstart-build-instrumentation-file']); }); it('returns only build-instrumentation-file plugin if `NODE_ENV` is development', async () => { @@ -44,7 +45,7 @@ describe('sentrySolidStartVite()', () => { const plugins = getSentrySolidStartVitePlugins({ sourcemaps: { disable: false } }); const names = plugins.map(plugin => plugin.name); - expect(names).toEqual(['sentry-solidstart-build-instrumentation-file']); + expect(names).toEqual(['sentry-solidstart-tunnel-base-path', 'sentry-solidstart-build-instrumentation-file']); process.env.NODE_ENV = previousEnv; }); diff --git a/packages/sveltekit/src/client/sdk.ts b/packages/sveltekit/src/client/sdk.ts index a6294ad25977..4c1dc3c2edc6 100644 --- a/packages/sveltekit/src/client/sdk.ts +++ b/packages/sveltekit/src/client/sdk.ts @@ -2,6 +2,7 @@ import type { Client, Integration } from '@sentry/core'; import { applySdkMetadata } from '@sentry/core'; import type { BrowserOptions } from '@sentry/svelte'; import { getDefaultIntegrations as getDefaultSvelteIntegrations, init as initSvelteSdk, WINDOW } from '@sentry/svelte'; +import { applyTunnelBasePath } from '../common/tunnel'; import { browserTracingIntegration as svelteKitBrowserTracingIntegration } from './browserTracingIntegration'; type WindowWithSentryFetchProxy = typeof WINDOW & { @@ -23,6 +24,7 @@ export function init(options: BrowserOptions): Client | undefined { }; applySdkMetadata(opts, 'sveltekit', ['sveltekit', 'svelte']); + applyTunnelBasePath(opts); // 1. Switch window.fetch to our fetch proxy we injected earlier const actualFetch = switchToFetchProxy(); diff --git a/packages/sveltekit/src/common/tunnel.ts b/packages/sveltekit/src/common/tunnel.ts new file mode 100644 index 000000000000..a7e6a0ff57e8 --- /dev/null +++ b/packages/sveltekit/src/common/tunnel.ts @@ -0,0 +1,14 @@ +import type { Options } from '@sentry/core'; +import { applyTunnelDefaults, GLOBAL_OBJ } from '@sentry/core'; + +/** Set by `sentrySvelteKit()` to the app's `paths.base`. */ +export const TUNNEL_BASE_PATH_GLOBAL = '__sentry_sveltekit_base_path'; + +/** + * Serves the managed tunnel under the app's base path, which SvelteKit requires for every request it handles. + * A `basePath` the user set wins. + */ +export function applyTunnelBasePath(options: Pick): void { + const basePath = (GLOBAL_OBJ as { [TUNNEL_BASE_PATH_GLOBAL]?: string })[TUNNEL_BASE_PATH_GLOBAL]; + options.tunnel = applyTunnelDefaults(options.tunnel, basePath ? { basePath } : undefined); +} diff --git a/packages/sveltekit/src/server-common/handle.ts b/packages/sveltekit/src/server-common/handle.ts index aa78ab6da7b8..1b38a0acc3a4 100644 --- a/packages/sveltekit/src/server-common/handle.ts +++ b/packages/sveltekit/src/server-common/handle.ts @@ -19,7 +19,7 @@ import { withIsolationScope, filterCollectedUrl, } from '@sentry/core'; -import { flushIfServerless } from '@sentry/core/server'; +import { flushIfServerless, handleTunnelRequestIfMatched } from '@sentry/core/server'; import type { Handle, ResolveOptions } from '@sveltejs/kit'; import { DEBUG_BUILD } from '../common/debug-build'; import { getTracePropagationData, sendErrorToSentry } from './utils'; @@ -291,6 +291,11 @@ export function sentryHandle(handlerOptions?: SentryHandleOptions): Handle { }; const sentryRequestHandler: Handle = input => { + const tunnelResponse = handleTunnelRequestIfMatched(input.event.request); + if (tunnelResponse) { + return tunnelResponse; + } + const backwardsForwardsCompatibleEvent = input.event as typeof input.event & BackwardsForwardsCompatibleEvent; // Escape hatch to suppress request isolation and trace continuation (see initCloudflareSentryHandle) diff --git a/packages/sveltekit/src/server/sdk.ts b/packages/sveltekit/src/server/sdk.ts index 75fe3fcc9021..8500511815a3 100644 --- a/packages/sveltekit/src/server/sdk.ts +++ b/packages/sveltekit/src/server/sdk.ts @@ -1,6 +1,7 @@ import { applySdkMetadata } from '@sentry/core'; import type { NodeClient, NodeOptions } from '@sentry/node'; import { getDefaultIntegrations as getDefaultNodeIntegrations, init as initNodeSdk } from '@sentry/node'; +import { applyTunnelBasePath } from '../common/tunnel'; import { rewriteFramesIntegration } from '../server-common/integrations/rewriteFramesIntegration'; import { svelteKitSpansIntegration } from '../server-common/integrations/svelteKitSpans'; import { httpIntegration } from './integrations/http'; @@ -26,6 +27,7 @@ export function init(options: NodeOptions): NodeClient | undefined { }; applySdkMetadata(opts, 'sveltekit', ['sveltekit', 'node']); + applyTunnelBasePath(opts); return initNodeSdk(opts); } diff --git a/packages/sveltekit/src/vite/sentryVitePlugins.ts b/packages/sveltekit/src/vite/sentryVitePlugins.ts index d4bf6ead91ec..b0fe5739d283 100644 --- a/packages/sveltekit/src/vite/sentryVitePlugins.ts +++ b/packages/sveltekit/src/vite/sentryVitePlugins.ts @@ -11,6 +11,7 @@ import { makeGlobalValuesInjectionPlugin } from './injectGlobalValues'; import { createKitConfigResolver } from './kitConfig'; import { makeCustomSentryVitePlugins } from './sourceMaps'; import { getAdapterOutputDir } from './svelteConfig'; +import { makeTunnelBasePathPlugin } from './tunnelBasePath'; import type { CustomSentryVitePluginOptions, SentrySvelteKitPluginOptions } from './types'; const DEFAULT_PLUGIN_OPTIONS: SentrySvelteKitPluginOptions = { @@ -56,6 +57,7 @@ export async function sentrySvelteKit(options: SentrySvelteKitPluginOptions = {} kitConfigResolver.plugin, makeBrowserTracingVariantResolverPlugin(), makeOpenTelemetryApiResolverPlugin(), + makeTunnelBasePathPlugin(getKitConfig), ]; if (mergedOptions.autoInstrument) { diff --git a/packages/sveltekit/src/vite/tunnelBasePath.ts b/packages/sveltekit/src/vite/tunnelBasePath.ts new file mode 100644 index 000000000000..c8197d8d74a2 --- /dev/null +++ b/packages/sveltekit/src/vite/tunnelBasePath.ts @@ -0,0 +1,50 @@ +import { escapeStringForRegex } from '@sentry/core'; +import MagicString from 'magic-string'; +import type { Plugin } from 'vite'; +import { TUNNEL_BASE_PATH_GLOBAL } from '../common/tunnel'; +import type { ResolvedKitConfig } from './kitConfig'; +import { getHooksFileName } from './svelteConfig'; + +const VIRTUAL_BASE_PATH_FILE = '\0sentry-sveltekit-tunnel-base-path'; + +/** + * Hands the app's `paths.base` to the client and server SDKs so `tunnel: true` works under a base path without + * the user repeating it. It's imported first in the hooks files, so it's set before any `init` they import. + */ +export function makeTunnelBasePathPlugin(getKitConfig: () => Promise): Plugin { + let basePath = ''; + let entryFileRegexp: RegExp | undefined; + + return { + name: 'sentry-sveltekit-tunnel-base-path', + + configResolved: async () => { + const kitConfig = await getKitConfig(); + basePath = kitConfig.paths?.base || ''; + + const hooksFiles = [getHooksFileName(kitConfig, 'server'), getHooksFileName(kitConfig, 'client')] + .map(escapeStringForRegex) + .join('|'); + // oxlint-disable-next-line sdk/no-regexp-constructor -- not end user input + escaped anyway + entryFileRegexp = new RegExp(`/(?:${hooksFiles}|src/instrumentation\\.server)\\.(?:js|ts|mjs|mts)$`); + }, + + resolveId: id => (id === VIRTUAL_BASE_PATH_FILE ? id : null), + + load: id => + id === VIRTUAL_BASE_PATH_FILE + ? `globalThis[${JSON.stringify(TUNNEL_BASE_PATH_GLOBAL)}] = ${JSON.stringify(basePath)};` + : null, + + transform: (code, id) => { + if (!basePath || !entryFileRegexp?.test(id)) { + return null; + } + + const ms = new MagicString(code); + ms.prepend(`import ${JSON.stringify(VIRTUAL_BASE_PATH_FILE)};\n`); + + return { code: ms.toString(), map: ms.generateMap({ hires: true }) }; + }, + }; +} diff --git a/packages/sveltekit/src/worker/cloudflare.ts b/packages/sveltekit/src/worker/cloudflare.ts index 651cc6af6f37..92e72386d69b 100644 --- a/packages/sveltekit/src/worker/cloudflare.ts +++ b/packages/sveltekit/src/worker/cloudflare.ts @@ -6,6 +6,7 @@ import { } from '@sentry/cloudflare'; import { addNonEnumerableProperty } from '@sentry/core'; import type { Handle } from '@sveltejs/kit'; +import { applyTunnelBasePath } from '../common/tunnel'; import { rewriteFramesIntegration } from '../server-common/integrations/rewriteFramesIntegration'; import { svelteKitSpansIntegration } from '../server-common/integrations/svelteKitSpans'; import { getCloudflareExecutionContext } from '../server-common/utils'; @@ -31,6 +32,7 @@ export function initCloudflareSentryHandle(options: CloudflareOptions): Handle { ...options, }; + applyTunnelBasePath(opts); setAsyncLocalStorageAsyncContextStrategy(); const handleInitSentry: Handle = ({ event, resolve }) => { diff --git a/packages/sveltekit/test/common/tunnel.test.ts b/packages/sveltekit/test/common/tunnel.test.ts new file mode 100644 index 000000000000..0f1038c81733 --- /dev/null +++ b/packages/sveltekit/test/common/tunnel.test.ts @@ -0,0 +1,57 @@ +import { GLOBAL_OBJ } from '@sentry/core'; +import { afterEach, describe, expect, it } from 'vitest'; +import { applyTunnelBasePath, TUNNEL_BASE_PATH_GLOBAL } from '../../src/common/tunnel'; + +const globalWithBasePath = GLOBAL_OBJ as { [TUNNEL_BASE_PATH_GLOBAL]?: string }; + +describe('applyTunnelBasePath', () => { + afterEach(() => { + delete globalWithBasePath.__sentry_sveltekit_base_path; + }); + + it('adds the injected base path to the managed tunnel', () => { + globalWithBasePath[TUNNEL_BASE_PATH_GLOBAL] = '/app'; + const fromTrue: Parameters[0] = { tunnel: true }; + const fromOptions: Parameters[0] = { tunnel: { allowedDsns: ['dsn'] } }; + + applyTunnelBasePath(fromTrue); + applyTunnelBasePath(fromOptions); + + expect(fromTrue.tunnel).toEqual({ basePath: '/app' }); + expect(fromOptions.tunnel).toEqual({ basePath: '/app', allowedDsns: ['dsn'] }); + }); + + it('keeps a base path the user set', () => { + globalWithBasePath[TUNNEL_BASE_PATH_GLOBAL] = '/app'; + const options: Parameters[0] = { tunnel: { basePath: '/other' } }; + + applyTunnelBasePath(options); + + expect(options.tunnel).toEqual({ basePath: '/other' }); + }); + + it('serves a relative tunnel path under the base path too', () => { + globalWithBasePath[TUNNEL_BASE_PATH_GLOBAL] = '/app'; + const options: Parameters[0] = { tunnel: '/monitor' }; + + applyTunnelBasePath(options); + + expect(options.tunnel).toEqual({ basePath: '/app', path: '/monitor' }); + }); + + it('leaves URL tunnels, a disabled tunnel, and apps without a base path alone', () => { + globalWithBasePath[TUNNEL_BASE_PATH_GLOBAL] = '/app'; + const url: Parameters[0] = { tunnel: 'https://example.com/tunnel' }; + const off: Parameters[0] = {}; + applyTunnelBasePath(url); + applyTunnelBasePath(off); + + delete globalWithBasePath.__sentry_sveltekit_base_path; + const noBase: Parameters[0] = { tunnel: true }; + applyTunnelBasePath(noBase); + + expect(url.tunnel).toBe('https://example.com/tunnel'); + expect(off.tunnel).toBeUndefined(); + expect(noBase.tunnel).toBe(true); + }); +}); diff --git a/packages/sveltekit/test/server-common/handle.test.ts b/packages/sveltekit/test/server-common/handle.test.ts index 8843e63ed1e6..3d081a2df4a0 100644 --- a/packages/sveltekit/test/server-common/handle.test.ts +++ b/packages/sveltekit/test/server-common/handle.test.ts @@ -141,6 +141,44 @@ describe('sentryHandle with span streaming', () => { }); }); +describe('sentryHandle with the managed tunnel', () => { + const DSN = 'https://public@o1.ingest.sentry.io/1337'; + + beforeEach(() => { + vi.stubGlobal( + 'fetch', + vi.fn(() => Promise.resolve(new Response('forwarded'))), + ); + setCurrentClient(new NodeClient(getDefaultNodeClientOptions({ dsn: DSN, _managedTunnel: {} }))); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('answers tunnel requests without resolving the route', async () => { + const tunnelPath = SentryCore.getTunnelPath(DSN)!; + const request = new Request(`http://localhost:3000${tunnelPath}`, { + method: 'POST', + body: SentryCore.serializeEnvelope(SentryCore.createEnvelope({ dsn: DSN }, [])) as BodyInit, + }); + const mockResolve = vi.fn(); + + const response = await sentryHandle()({ event: mockEvent({ request, route: { id: null } }), resolve: mockResolve }); + + expect(await response.text()).toBe('forwarded'); + expect(mockResolve).not.toHaveBeenCalled(); + }); + + it('resolves every other request', async () => { + const mockResolve = vi.fn(() => Promise.resolve(new Response('app'))); + + const response = await sentryHandle()({ event: mockEvent(), resolve: mockResolve }); + + expect(await response.text()).toBe('app'); + }); +}); + describe('sentryHandle', () => { describe.each([ // isSync, isError, expectedResponse diff --git a/packages/sveltekit/test/vite/sentrySvelteKitPlugins.test.ts b/packages/sveltekit/test/vite/sentrySvelteKitPlugins.test.ts index c78099729998..7cae5e1097c4 100644 --- a/packages/sveltekit/test/vite/sentrySvelteKitPlugins.test.ts +++ b/packages/sveltekit/test/vite/sentrySvelteKitPlugins.test.ts @@ -68,10 +68,10 @@ describe('sentrySvelteKit()', () => { expect(plugins).toBeInstanceOf(Array); // 1 kit config resolver + 1 browser-tracing variant resolver + 1 OpenTelemetry API resolver - // + 1 auto instrument plugin + // + 1 tunnel base path plugin + 1 auto instrument plugin // + 1 orchestrion plugin + 1 build flag plugin + 1 global values injection plugin + 1 modified main plugin // + 3 custom plugins - expect(plugins).toHaveLength(11); + expect(plugins).toHaveLength(12); }); it('returns the custom sentry source maps upload plugin, unmodified sourcemaps plugins and the auto-instrument plugin by default', async () => { @@ -84,6 +84,8 @@ describe('sentrySvelteKit()', () => { 'sentry-sveltekit-browser-tracing-variant', // OpenTelemetry API resolver: 'sentry-sveltekit-opentelemetry-api', + // tunnel base path plugin: + 'sentry-sveltekit-tunnel-base-path', // auto instrument plugin: 'sentry-auto-instrumentation', // orchestrion build-time instrumentation plugin: @@ -103,7 +105,7 @@ describe('sentrySvelteKit()', () => { it("doesn't return the sentry source maps plugins if autoUploadSourcemaps is `false`", async () => { const plugins = await getSentrySvelteKitPlugins({ autoUploadSourceMaps: false }); - expect(plugins).toHaveLength(6); // kit config resolver + browser-tracing variant resolver + OpenTelemetry API resolver + auto instrument + orchestrion + build flag + expect(plugins).toHaveLength(7); // kit config resolver + browser-tracing variant resolver + OpenTelemetry API resolver + tunnel base path + auto instrument + orchestrion + build flag }); it("doesn't return the sentry source maps plugins if `NODE_ENV` is development", async () => { @@ -111,9 +113,9 @@ describe('sentrySvelteKit()', () => { process.env.NODE_ENV = 'development'; const plugins = await getSentrySvelteKitPlugins({ autoUploadSourceMaps: true, autoInstrument: true }); - const instrumentPlugin = plugins[3]; + const instrumentPlugin = plugins[4]; - expect(plugins).toHaveLength(7); // kit config resolver + browser-tracing variant resolver + OpenTelemetry API resolver + auto instrument + orchestrion + build flag + global values injection + expect(plugins).toHaveLength(8); // kit config resolver + browser-tracing variant resolver + OpenTelemetry API resolver + tunnel base path + auto instrument + orchestrion + build flag + global values injection expect(instrumentPlugin?.name).toEqual('sentry-auto-instrumentation'); process.env.NODE_ENV = previousEnv; @@ -122,7 +124,7 @@ describe('sentrySvelteKit()', () => { it("doesn't return the auto instrument plugin if autoInstrument is `false`", async () => { const plugins = await getSentrySvelteKitPlugins({ autoInstrument: false }); const pluginNames = plugins.map(plugin => plugin.name); - expect(plugins).toHaveLength(10); // kit config resolver + browser-tracing variant resolver + OpenTelemetry API resolver + orchestrion + build flag + global values injection + 1 modified main plugin + 3 custom plugins + expect(plugins).toHaveLength(11); // kit config resolver + browser-tracing variant resolver + OpenTelemetry API resolver + tunnel base path + orchestrion + build flag + global values injection + 1 modified main plugin + 3 custom plugins expect(pluginNames).not.toContain('sentry-auto-instrumentation'); }); @@ -234,7 +236,7 @@ describe('sentrySvelteKit()', () => { // just to ignore the source maps plugin: autoUploadSourceMaps: false, }); - const plugin = plugins[3]!; + const plugin = plugins[4]!; expect(plugin.name).toEqual('sentry-auto-instrumentation'); expect(makePluginSpy).toHaveBeenCalledWith({ diff --git a/packages/sveltekit/test/vite/tunnelBasePath.test.ts b/packages/sveltekit/test/vite/tunnelBasePath.test.ts new file mode 100644 index 000000000000..a2c7d04a7a63 --- /dev/null +++ b/packages/sveltekit/test/vite/tunnelBasePath.test.ts @@ -0,0 +1,44 @@ +import type { Plugin } from 'vite'; +import { describe, expect, it } from 'vitest'; +import type { ResolvedKitConfig } from '../../src/vite/kitConfig'; +import { makeTunnelBasePathPlugin } from '../../src/vite/tunnelBasePath'; + +type Hook = (...args: unknown[]) => unknown; + +async function setup(kitConfig: ResolvedKitConfig): Promise { + const plugin = makeTunnelBasePathPlugin(() => Promise.resolve(kitConfig)); + await (plugin.configResolved as Hook)({}); + + return plugin; +} + +describe('makeTunnelBasePathPlugin', () => { + it('imports the base path first in the hooks and instrumentation files', async () => { + const plugin = await setup({ paths: { base: '/app' } }); + const transform = plugin.transform as Hook; + + for (const id of ['/p/src/hooks.server.ts', '/p/src/hooks.client.js', '/p/src/instrumentation.server.ts']) { + expect(transform('const a = 1;', id)).toEqual({ + code: `import ${JSON.stringify('\0sentry-sveltekit-tunnel-base-path')};\nconst a = 1;`, + map: expect.objectContaining({ mappings: expect.any(String) }), + }); + } + expect(transform('const a = 1;', '/p/src/routes/+page.svelte')).toBeNull(); + expect((plugin.load as Hook)('\0sentry-sveltekit-tunnel-base-path')).toBe( + 'globalThis["__sentry_sveltekit_base_path"] = "/app";', + ); + }); + + it('follows custom hooks file locations', async () => { + const plugin = await setup({ paths: { base: '/app' }, files: { hooks: { server: 'src/server/hooks' } } }); + + expect((plugin.transform as Hook)('', '/p/src/server/hooks.ts')).not.toBeNull(); + expect((plugin.transform as Hook)('', '/p/src/hooks.server.ts')).toBeNull(); + }); + + it('does nothing without a base path', async () => { + const plugin = await setup({}); + + expect((plugin.transform as Hook)('', '/p/src/hooks.server.ts')).toBeNull(); + }); +}); diff --git a/packages/tanstackstart-react/src/client/index.ts b/packages/tanstackstart-react/src/client/index.ts index 73c7e4cf7b24..8e9def78a531 100644 --- a/packages/tanstackstart-react/src/client/index.ts +++ b/packages/tanstackstart-react/src/client/index.ts @@ -62,3 +62,10 @@ export function createSentryTunnelRoute(_options: CreateSentryTunnelRouteOptions * can statically import it (the call is server-guarded and tree-shaken from the client bundle). */ export function registerSentryServerTunnelRoute(_path: string): void {} + +/** + * No-op stub for client-side builds. The server entry the Vite plugin wraps only runs on the server. + */ +export function wrapServerEntryWithSentryTunnel(entry: T, _options?: unknown): T { + return entry; +} diff --git a/packages/tanstackstart-react/src/client/tunnelRoute.ts b/packages/tanstackstart-react/src/client/tunnelRoute.ts index b22612c123d0..d5f92d76f435 100644 --- a/packages/tanstackstart-react/src/client/tunnelRoute.ts +++ b/packages/tanstackstart-react/src/client/tunnelRoute.ts @@ -1,15 +1,25 @@ -import { consoleSandbox } from '@sentry/core'; +import { applyTunnelDefaults, consoleSandbox } from '@sentry/core'; import type { BrowserOptions as ReactBrowserOptions } from '@sentry/react'; -declare const __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__: string | undefined; +declare const __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__: string | true | undefined; +declare const __SENTRY_TANSTACKSTART_TUNNEL_BASE_PATH__: string | undefined; let hasWarnedAboutManagedTunnelRouteOverride = false; /** * Applies the managed tunnel route from `sentryTanstackStart({ tunnelRoute: ... })` unless the user already - * configured an explicit runtime `tunnel` option in `Sentry.init()`. + * configured an explicit runtime `tunnel` option in `Sentry.init()`, then serves the tunnel under the base path + * Start routes on. A `basePath` the user set wins. */ export function applyTunnelRouteOption(options: ReactBrowserOptions): void { + applyManagedTunnelRoute(options); + + const basePath = + typeof __SENTRY_TANSTACKSTART_TUNNEL_BASE_PATH__ !== 'undefined' ? __SENTRY_TANSTACKSTART_TUNNEL_BASE_PATH__ : ''; + options.tunnel = applyTunnelDefaults(options.tunnel, basePath ? { basePath } : undefined); +} + +function applyManagedTunnelRoute(options: ReactBrowserOptions): void { const managedTunnelRoute = typeof __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__ !== 'undefined' ? __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__ : undefined; @@ -18,7 +28,7 @@ export function applyTunnelRouteOption(options: ReactBrowserOptions): void { } if (options.tunnel) { - if (!hasWarnedAboutManagedTunnelRouteOverride) { + if (options.tunnel !== managedTunnelRoute && !hasWarnedAboutManagedTunnelRouteOverride) { hasWarnedAboutManagedTunnelRouteOverride = true; consoleSandbox(() => { // eslint-disable-next-line no-console diff --git a/packages/tanstackstart-react/src/index.types.ts b/packages/tanstackstart-react/src/index.types.ts index a5464d731373..3e3e8fe690ef 100644 --- a/packages/tanstackstart-react/src/index.types.ts +++ b/packages/tanstackstart-react/src/index.types.ts @@ -49,3 +49,4 @@ export declare const sentryGlobalRequestMiddleware: typeof serverSdk.sentryGloba export declare const sentryGlobalFunctionMiddleware: typeof serverSdk.sentryGlobalFunctionMiddleware; export declare const createSentryTunnelRoute: typeof serverSdk.createSentryTunnelRoute; export declare const registerSentryServerTunnelRoute: typeof serverSdk.registerSentryServerTunnelRoute; +export declare const wrapServerEntryWithSentryTunnel: typeof serverSdk.wrapServerEntryWithSentryTunnel; diff --git a/packages/tanstackstart-react/src/server/index.ts b/packages/tanstackstart-react/src/server/index.ts index 2d2b1bb85d26..2d96ca364495 100644 --- a/packages/tanstackstart-react/src/server/index.ts +++ b/packages/tanstackstart-react/src/server/index.ts @@ -9,7 +9,11 @@ export { init } from './sdk'; export { wrapFetchWithSentry } from './wrapFetchWithSentry'; export { wrapMiddlewaresWithSentry } from './middleware'; export { sentryGlobalRequestMiddleware, sentryGlobalFunctionMiddleware } from './globalMiddleware'; -export { createSentryTunnelRoute, registerSentryServerTunnelRoute } from './tunnelRoute'; +export { + createSentryTunnelRoute, + registerSentryServerTunnelRoute, + wrapServerEntryWithSentryTunnel, +} from './tunnelRoute'; /** * A no-op stub of the replay integration for the server. Router setup code is shared between client and server, diff --git a/packages/tanstackstart-react/src/server/tunnelRoute.ts b/packages/tanstackstart-react/src/server/tunnelRoute.ts index 89501d8a2da4..dc5cdb82ee58 100644 --- a/packages/tanstackstart-react/src/server/tunnelRoute.ts +++ b/packages/tanstackstart-react/src/server/tunnelRoute.ts @@ -1,5 +1,8 @@ import { URL_PATH } from '@sentry/conventions/attributes'; +import type { TunnelOptions } from '@sentry/core'; import { dsnToString, escapeStringForRegex, getClient, handleTunnelRequest } from '@sentry/core'; +import { handleTunnelRequestIfMatched, setManagedTunnelDefaults } from '@sentry/core/server'; +import type { ServerEntry } from './wrapFetchWithSentry'; const registeredTunnelRoutePaths = new Set(); @@ -92,3 +95,34 @@ export function createSentryTunnelRoute(options: CreateSentryTunnelRouteOptions) }, }; } + +/** + * Answers managed tunnel requests before the server entry runs. The `sentryTanstackStart` Vite plugin applies this + * to the app's server entry automatically, with its `tunnelRoute` and the base path Start routes on. + * + * `tunnelRoute` enables the managed tunnel on every server client the same way `Sentry.init({ tunnel })` does, unless + * that `init` already enabled one, and the tunnel is then served from the client's config like in every other SDK. + */ +export function wrapServerEntryWithSentryTunnel( + entry: T, + tunnelRoute?: TunnelOptions, + basePath?: string, +): T { + // Runs when the server entry loads, before any request. Requests still carry Start's base path here, so the tunnel + // is served under it. + setManagedTunnelDefaults({ tunnel: tunnelRoute, basePath }); + + // A class entry, like Cloudflare's `WorkerEntrypoint`, can't be spread into an object. The tunnel is then served + // by the wrapped default server entry it calls. + if (typeof entry === 'function') { + return entry; + } + + return { + ...entry, + // Cloudflare Workers pass `env` and `ctx` after the request, and `withSentry` needs `ctx` to flush. + fetch(request: Request, ...rest: unknown[]) { + return handleTunnelRequestIfMatched(request) ?? entry.fetch(request, ...rest); + }, + }; +} diff --git a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts index c3359c077d1b..507b9069c7c5 100644 --- a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts +++ b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts @@ -5,7 +5,7 @@ import { makeAutoInstrumentMiddlewarePlugin } from './autoInstrumentMiddleware'; import { makeRoutePatternPlugin } from './routePatterns'; import { makeAddSentryVitePlugin, makeEnableSourceMapsVitePlugin } from './sourceMaps'; import type { TunnelRouteOptions } from './tunnelRoute'; -import { makeTunnelRoutePlugin } from './tunnelRoute'; +import { makeServerEntryTunnelPlugin, makeTunnelBasePathPlugin, makeTunnelRoutePlugin } from './tunnelRoute'; /** * Build-time options for the Sentry TanStack Start SDK. @@ -36,14 +36,13 @@ export interface SentryTanstackStartOptions extends BuildTimeOptionsBase { /** * Configures a framework-managed same-origin tunnel route for Sentry envelopes. * - * This creates a TanStack Start server route backed by `createSentryTunnelRoute()` and applies the resulting path - * as the default `tunnel` option on the client. + * The server entry answers the tunnel before Start's request middleware and routing, and the resulting path is + * applied as the default `tunnel` option on the client. * * You can pass: - * - `true` to generate an opaque route path per dev session or production build. + * - `true` to serve the path derived from the DSN, the same one `Sentry.init({ tunnel: true })` uses. * - `'/custom-path'` to use a fixed static route path. - * - `{ allowedDsns, path }` for full control. If `allowedDsns` is omitted or empty, the tunnel route derives the DSN - * from the active server Sentry client at runtime. + * - `{ allowedDsns, path }` for full control. The DSN of the active server Sentry client is always allowed too. * * If you also pass `tunnel` to `Sentry.init()`, that explicit runtime option wins and a warning is emitted because * the managed tunnel route is being bypassed. @@ -76,7 +75,12 @@ export interface SentryTanstackStartOptions extends BuildTimeOptionsBase { * @returns An array of Vite plugins */ export function sentryTanstackStart(options: SentryTanstackStartOptions = {}): Plugin[] { - const plugins: Plugin[] = [makeRoutePatternPlugin()]; + const tunnelBasePath = makeTunnelBasePathPlugin(); + const plugins: Plugin[] = [ + makeRoutePatternPlugin(), + tunnelBasePath.plugin, + makeServerEntryTunnelPlugin(options.tunnelRoute, tunnelBasePath.getBasePath), + ]; if (options.tunnelRoute) { plugins.push(makeTunnelRoutePlugin(options.tunnelRoute, options.debug)); diff --git a/packages/tanstackstart-react/src/vite/tunnelRoute.ts b/packages/tanstackstart-react/src/vite/tunnelRoute.ts index 06f3337b4e5e..56cb12a90cc5 100644 --- a/packages/tanstackstart-react/src/vite/tunnelRoute.ts +++ b/packages/tanstackstart-react/src/vite/tunnelRoute.ts @@ -1,3 +1,6 @@ +import type { TunnelOptions } from '@sentry/core'; +import { normalizeTunnelBasePath } from '@sentry/core'; +import { setManagedTunnelDefaults } from '@sentry/core/server'; import type { Plugin } from 'vite'; export type TunnelRouteOptions = @@ -14,43 +17,11 @@ export type TunnelRouteOptions = /** * Controls the public route path used by the managed tunnel route. * - * If omitted, an opaque path is generated once per dev session or production build. + * If omitted, the path is derived from the DSN at runtime, the same way `Sentry.init({ tunnel: true })` does. */ path?: string; }; -const MANAGED_TUNNEL_ROUTE_IMPORT = 'SentryManagedTunnelRouteImport'; -const MANAGED_TUNNEL_ROUTE_NAME = 'SentryManagedTunnelRoute'; -const MANAGED_TUNNEL_ROUTE_PATH_ENV_KEY = '__SENTRY_INTERNAL_TANSTACKSTART_TUNNEL_ROUTE__'; - -const VIRTUAL_TUNNEL_ROUTE_ID = 'virtual:sentry-tanstackstart-react/tunnel-route'; -const RESOLVED_VIRTUAL_TUNNEL_ROUTE_ID = `\0${VIRTUAL_TUNNEL_ROUTE_ID}`; - -function generateRandomTunnelRoute(): string { - const randomPath = Array.from({ length: 8 }, () => Math.floor(Math.random() * 36).toString(36)).join(''); - - return `/${randomPath}`; -} - -export function resolveTunnelRoute(tunnel: true | string): string { - if (typeof tunnel === 'string') { - return tunnel; - } - - if (process.env[MANAGED_TUNNEL_ROUTE_PATH_ENV_KEY]) { - return process.env[MANAGED_TUNNEL_ROUTE_PATH_ENV_KEY]; - } - - const resolvedTunnelRoute = generateRandomTunnelRoute(); - process.env[MANAGED_TUNNEL_ROUTE_PATH_ENV_KEY] = resolvedTunnelRoute; - return resolvedTunnelRoute; -} - -type NormalizedTunnelRouteOptions = { - resolvedPath: string; - allowedDsns: string[] | undefined; -}; - function validateStaticPath(path: string): void { if (!path.startsWith('/') || path.includes('?') || path.includes('#')) { throw new Error( @@ -59,156 +30,160 @@ function validateStaticPath(path: string): void { } } -function normalizeTunnelRouteOptions(options: TunnelRouteOptions): NormalizedTunnelRouteOptions { +/** + * Maps `tunnelRoute` onto the options `Sentry.init({ tunnel })` takes, so both are served the same way. + */ +export function normalizeTunnelRouteOptions(options: TunnelRouteOptions): TunnelOptions { if (options === true) { - return { resolvedPath: resolveTunnelRoute(true), allowedDsns: undefined }; + return {}; } if (typeof options === 'string') { validateStaticPath(options); - return { - resolvedPath: resolveTunnelRoute(options), - allowedDsns: undefined, - }; - } - const allowedDsns = options.allowedDsns && options.allowedDsns.length > 0 ? options.allowedDsns : undefined; - const path = options.path; + return { path: options }; + } - if (path) { - validateStaticPath(path); + if (options.path) { + validateStaticPath(options.path); } - return { resolvedPath: resolveTunnelRoute(path || true), allowedDsns }; + return { + path: options.path || undefined, + allowedDsns: options.allowedDsns?.length ? options.allowedDsns : undefined, + }; } // `routeTree.gen.ts` quote style follows `tsr.config.json#quoteStyle` (`single` | `double`), // so we check both forms for each route-identifying key. const ROUTE_CONFLICT_KEYS = ['fullPath', 'path', 'id'] as const; -function hasRouteConflict(source: string, resolvedTunnelRoute: string): boolean { - const literals = [`'${resolvedTunnelRoute}'`, `"${resolvedTunnelRoute}"`]; +function hasRouteConflict(source: string, tunnelPath: string): boolean { + const literals = [`'${tunnelPath}'`, `"${tunnelPath}"`]; return ROUTE_CONFLICT_KEYS.some(key => literals.some(literal => source.includes(`${key}: ${literal}`))); } -function injectAfterLastImport(source: string, statement: string): string { - const importMatches = [...source.matchAll(/^import .+$/gm)]; - const lastImport = importMatches.at(-1); +/** + * Tells the client SDK which tunnel to use. The server entry wrapper serves it. + */ +export function makeTunnelRoutePlugin(options: TunnelRouteOptions, debug?: boolean): Plugin { + const { path } = normalizeTunnelRouteOptions(options); - if (lastImport?.index === undefined) { - throw new Error( - '[@sentry/tanstackstart-react] Failed to inject the managed tunnel route because `routeTree.gen.ts` imports could not be located.', - ); + if (debug) { + // eslint-disable-next-line no-console + console.log(`[@sentry/tanstackstart-react] Tunnel route: ${path ?? 'derived from the DSN'}`); } - const insertIndex = lastImport.index + lastImport[0].length; - return `${source.slice(0, insertIndex)}\n${statement}${source.slice(insertIndex)}`; -} - -export function injectManagedTunnelRoute(source: string, resolvedTunnelRoute: string): string { - if (source.includes(VIRTUAL_TUNNEL_ROUTE_ID)) { - return source; - } + return { + name: 'sentry-tanstackstart-tunnel-route', + config() { + return { define: { __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__: path ? JSON.stringify(path) : 'true' } }; + }, + // The server entry wrapper answers POSTs to a fixed path before Start's routing, so an app route on that path + // would silently lose them. + transform(source, id) { + if (path && /[\\/]routeTree\.gen\.ts$/.test(id) && hasRouteConflict(source, path)) { + throw new Error( + `[@sentry/tanstackstart-react] Cannot register managed tunnel route "${path}" because an existing TanStack Start route already uses that path.`, + ); + } - if (hasRouteConflict(source, resolvedTunnelRoute)) { - throw new Error( - `[@sentry/tanstackstart-react] Cannot register managed tunnel route "${resolvedTunnelRoute}" because an existing TanStack Start route already uses that path.`, - ); - } + return null; + }, + }; +} - const serializedTunnelRoute = JSON.stringify(resolvedTunnelRoute); +const START_BASE_PATH_DEFINE = 'process.env.TSS_ROUTER_BASEPATH'; + +/** + * Reads the base path TanStack Start routes on and hands it to the client SDK and the server entry wrapper, so the + * managed tunnel is served under it without the user repeating it. Start resolves it from `router.basepath`, or from + * Vite's `base`, and defines it for every environment. This runs after Start's own `config` hook to see that define. + */ +export function makeTunnelBasePathPlugin(): { plugin: Plugin; getBasePath: () => string } { + let basePath = ''; + + const plugin: Plugin = { + name: 'sentry-tanstackstart-tunnel-base-path', + enforce: 'post', + config(config) { + const startBasePath = config.define?.[START_BASE_PATH_DEFINE]; + basePath = normalizeTunnelBasePath( + typeof startBasePath === 'string' ? parseDefine(startBasePath) : getRouterBasePathFromViteBase(config.base), + ); + + return { define: { __SENTRY_TANSTACKSTART_TUNNEL_BASE_PATH__: JSON.stringify(basePath) } }; + }, + }; - let transformedSource = injectAfterLastImport( - source, - `import { Route as ${MANAGED_TUNNEL_ROUTE_IMPORT} } from '${VIRTUAL_TUNNEL_ROUTE_ID}'`, - ); + return { plugin, getBasePath: () => basePath }; +} - const rootRouteChildrenMatch = transformedSource.match( - /const rootRouteChildren(?:\s*:\s*RootRouteChildren)?\s*=\s*\{/, - ); +function parseDefine(value: string): string | undefined { + try { + const parsed: unknown = JSON.parse(value); - if (rootRouteChildrenMatch?.index === undefined) { - throw new Error( - '[@sentry/tanstackstart-react] Failed to inject the managed tunnel route because the generated TanStack route tree did not contain `rootRouteChildren`.', - ); + return typeof parsed === 'string' ? parsed : undefined; + } catch { + return undefined; } - - const injectedRootRouteChildrenDeclaration = `const ${MANAGED_TUNNEL_ROUTE_NAME} = ${MANAGED_TUNNEL_ROUTE_IMPORT}.update({ - id: ${serializedTunnelRoute}, - path: ${serializedTunnelRoute}, - getParentRoute: () => rootRouteImport, -} as any) - -${rootRouteChildrenMatch[0]} - ${MANAGED_TUNNEL_ROUTE_NAME}: ${MANAGED_TUNNEL_ROUTE_NAME}, -`; - - transformedSource = `${transformedSource.slice(0, rootRouteChildrenMatch.index)}${injectedRootRouteChildrenDeclaration}${transformedSource.slice(rootRouteChildrenMatch.index + rootRouteChildrenMatch[0].length)}`; - - return transformedSource; } -export function makeTunnelRoutePlugin(options: TunnelRouteOptions, debug?: boolean): Plugin { - const normalized = normalizeTunnelRouteOptions(options); - const resolvedTunnelRoute = normalized.resolvedPath; - const serializedTunnelRoute = JSON.stringify(resolvedTunnelRoute); - const serializedAllowedDsns = normalized.allowedDsns ? JSON.stringify(normalized.allowedDsns) : undefined; +// Start's own fallback when `router.basepath` isn't set: an absolute (CDN) `base` doesn't move the app's routes. +function getRouterBasePathFromViteBase(base: string | undefined): string | undefined { + return base && !/^[a-z][a-z\d+.-]*:/i.test(base) && !base.startsWith('//') ? base : undefined; +} - if (debug) { - // eslint-disable-next-line no-console - console.log(`[@sentry/tanstackstart-react] Registered tunnel route: ${resolvedTunnelRoute}`); - } +const SERVER_ENTRY_ALIAS = 'virtual:tanstack-start-server-entry'; +const ORIGINAL_SERVER_ENTRY_QUERY = '?sentry-tunnel-original'; +// The default entry is used directly when the app has no `src/server.ts`, and by Cloudflare's `main`. +const DEFAULT_SERVER_ENTRY = /\/react-start\/dist\/(?:plugin\/)?default-entry\/(?:esm\/)?server\.[jt]s$/; + +/** + * Wraps the TanStack Start server entry so tunnel requests are answered before Start's own request middleware and + * routing. It does nothing unless the managed tunnel is enabled, either here via `tunnelRoute` or at runtime via + * `Sentry.init({ tunnel: true })`. + */ +export function makeServerEntryTunnelPlugin( + tunnelRoute: TunnelRouteOptions | undefined, + getBasePath: () => string = () => '', +): Plugin { + const normalizedTunnelOptions = tunnelRoute ? normalizeTunnelRouteOptions(tunnelRoute) : undefined; + const tunnelOptions = normalizedTunnelOptions ? JSON.stringify(normalizedTunnelOptions) : 'undefined'; + const serverEntryIds = new Set(); return { - name: 'sentry-tanstackstart-tunnel-route', + name: 'sentry-tanstackstart-server-entry-tunnel', enforce: 'pre', - config() { - return { - define: { - __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__: serializedTunnelRoute, - }, - }; + configResolved(config) { + const aliases = Array.isArray(config.resolve.alias) ? config.resolve.alias : []; + const serverEntry = aliases.find(alias => alias.find === SERVER_ENTRY_ALIAS); + if (serverEntry) { + serverEntryIds.add(normalizePath(serverEntry.replacement)); + } }, - resolveId(source) { - return source === VIRTUAL_TUNNEL_ROUTE_ID ? RESOLVED_VIRTUAL_TUNNEL_ROUTE_ID : null; + // `vite dev` takes each request in this process and passes it on to where the server entry runs, so its + // `http.server` span comes from the SDK `--import` set up here, which the server entry never reaches. + configureServer() { + setManagedTunnelDefaults({ tunnel: normalizedTunnelOptions, basePath: getBasePath() }); }, load(id) { - if (id !== RESOLVED_VIRTUAL_TUNNEL_ROUTE_ID) { + const normalizedId = normalizePath(id); + if (!serverEntryIds.has(normalizedId) && !DEFAULT_SERVER_ENTRY.test(normalizedId)) { return null; } - return `import { createFileRoute } from '@tanstack/react-router'; -import { registerSentryServerTunnelRoute } from '@sentry/tanstackstart-react'; - -// Server-only: drop the tunnel route's own transaction. Registered synchronously at module evaluation -// (before any request) so streamed spans are filtered from the first request. The SSR guard tree-shakes -// it (and the import) out of the client bundle. -if (import.meta.env.SSR) { - registerSentryServerTunnelRoute(${serializedTunnelRoute}); -} + const original = JSON.stringify(`${id}${ORIGINAL_SERVER_ENTRY_QUERY}`); -export const Route = createFileRoute(${serializedTunnelRoute})({ - server: { - handlers: { - async POST({ request }) { - const Sentry = await import('@sentry/tanstackstart-react'); - return Sentry.createSentryTunnelRoute(${serializedAllowedDsns ? `{ allowedDsns: ${serializedAllowedDsns} }` : `{}`}).handlers.POST({ request }); - }, - // Without a handler for a method, TanStack Start falls back to SSR-rendering the app (running root loaders). - ANY() { - return new Response(null, { status: 405, headers: { Allow: 'POST' } }); - }, - }, - }, -}); + return `import entry from ${original}; +import { wrapServerEntryWithSentryTunnel } from '@sentry/tanstackstart-react'; +export * from ${original}; +export default wrapServerEntryWithSentryTunnel(entry, ${tunnelOptions}, ${JSON.stringify(getBasePath())}); `; }, - transform(source, id) { - if (!id.endsWith('/routeTree.gen.ts') && !id.endsWith('\\routeTree.gen.ts')) { - return null; - } - - return injectManagedTunnelRoute(source, resolvedTunnelRoute); - }, }; } + +function normalizePath(id: string): string { + return id.replace(/\\/g, '/'); +} diff --git a/packages/tanstackstart-react/test/client/tunnelRoute.test.ts b/packages/tanstackstart-react/test/client/tunnelRoute.test.ts index 9dca196f072d..f1f01669c999 100644 --- a/packages/tanstackstart-react/test/client/tunnelRoute.test.ts +++ b/packages/tanstackstart-react/test/client/tunnelRoute.test.ts @@ -50,6 +50,30 @@ describe('applyTunnelRouteOption()', () => { ); }); + it('turns on the DSN-derived tunnel when the plugin derives the path from the DSN', async () => { + vi.stubGlobal('__SENTRY_TANSTACKSTART_TUNNEL_ROUTE__', true); + + const { applyTunnelRouteOption } = await import('../../src/client/tunnelRoute'); + + const options: BrowserOptions = { dsn: 'https://public@dsn.ingest.sentry.io/1337' }; + applyTunnelRouteOption(options); + + expect(options.tunnel).toBe(true); + }); + + it('does not warn when the runtime option already matches the managed tunnel', async () => { + vi.stubGlobal('__SENTRY_TANSTACKSTART_TUNNEL_ROUTE__', true); + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const { applyTunnelRouteOption } = await import('../../src/client/tunnelRoute'); + + const options: BrowserOptions = { dsn: 'https://public@dsn.ingest.sentry.io/1337', tunnel: true }; + applyTunnelRouteOption(options); + + expect(options.tunnel).toBe(true); + expect(warnSpy).not.toHaveBeenCalled(); + }); + it('does nothing when no managed tunnel route was injected', async () => { const { applyTunnelRouteOption } = await import('../../src/client/tunnelRoute'); @@ -61,4 +85,34 @@ describe('applyTunnelRouteOption()', () => { expect(options.tunnel).toBeUndefined(); }); + + it('serves the tunnel under the base path Start routes on, unless the user set one', async () => { + vi.stubGlobal('__SENTRY_TANSTACKSTART_TUNNEL_BASE_PATH__', '/app'); + + const { applyTunnelRouteOption } = await import('../../src/client/tunnelRoute'); + + const managed: BrowserOptions = { tunnel: true }; + applyTunnelRouteOption(managed); + expect(managed.tunnel).toEqual({ basePath: '/app' }); + + const own: BrowserOptions = { tunnel: { basePath: '/custom' } }; + applyTunnelRouteOption(own); + expect(own.tunnel).toEqual({ basePath: '/custom' }); + + const url: BrowserOptions = { tunnel: 'https://example.com/tunnel' }; + applyTunnelRouteOption(url); + expect(url.tunnel).toBe('https://example.com/tunnel'); + }); + + it('serves a static `tunnelRoute` under the base path too', async () => { + vi.stubGlobal('__SENTRY_TANSTACKSTART_TUNNEL_ROUTE__', '/managed-tunnel'); + vi.stubGlobal('__SENTRY_TANSTACKSTART_TUNNEL_BASE_PATH__', '/app'); + + const { applyTunnelRouteOption } = await import('../../src/client/tunnelRoute'); + + const options: BrowserOptions = {}; + applyTunnelRouteOption(options); + + expect(options.tunnel).toEqual({ basePath: '/app', path: '/managed-tunnel' }); + }); }); diff --git a/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts b/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts new file mode 100644 index 000000000000..1c299a135705 --- /dev/null +++ b/packages/tanstackstart-react/test/server/wrapServerEntryWithSentryTunnel.test.ts @@ -0,0 +1,136 @@ +import type { Client } from '@sentry/core'; +import { createEnvelope, getCurrentScope, getTunnelPath, makeDsn, serializeEnvelope } from '@sentry/core'; +import { _INTERNAL_resetManagedTunnelDefaults } from '@sentry/core/server'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { wrapServerEntryWithSentryTunnel } from '../../src/server/tunnelRoute'; + +const DSN = 'https://public@o1.ingest.sentry.io/1337'; +const TUNNEL_URL = `http://localhost:3000${getTunnelPath(DSN)}`; + +function makeEnvelopeRequest(url: string): Request { + return new Request(url, { method: 'POST', body: serializeEnvelope(createEnvelope({ dsn: DSN }, [])) as BodyInit }); +} + +function setClient(managedTunnel: object | undefined): void { + const options = { dsn: DSN, _managedTunnel: managedTunnel, ignoreSpans: [] }; + getCurrentScope().setClient({ getDsn: () => makeDsn(DSN), getOptions: () => options } as unknown as Client); +} + +describe('wrapServerEntryWithSentryTunnel', () => { + const appFetch = vi.fn(() => new Response('app')); + const sentryFetch = vi.fn(() => Promise.resolve(new Response('forwarded'))); + + beforeEach(() => { + vi.stubGlobal('fetch', sentryFetch); + }); + + afterEach(() => { + getCurrentScope().setClient(undefined); + _INTERNAL_resetManagedTunnelDefaults(); + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it('answers the tunnel path when `init` enabled the managed tunnel, without calling the app', async () => { + setClient({}); + + const response = await wrapServerEntryWithSentryTunnel({ fetch: appFetch }).fetch(makeEnvelopeRequest(TUNNEL_URL)); + + expect(await response.text()).toBe('forwarded'); + expect(appFetch).not.toHaveBeenCalled(); + }); + + it('hands every request to the app when the managed tunnel is off', async () => { + setClient(undefined); + + const response = await wrapServerEntryWithSentryTunnel({ fetch: appFetch }).fetch(makeEnvelopeRequest(TUNNEL_URL)); + + expect(await response.text()).toBe('app'); + expect(sentryFetch).not.toHaveBeenCalled(); + }); + + it('serves the tunnel with build-time options even when `init` did not enable it', async () => { + setClient(undefined); + + const response = await wrapServerEntryWithSentryTunnel({ fetch: appFetch }, {}).fetch( + makeEnvelopeRequest(TUNNEL_URL), + ); + + expect(await response.text()).toBe('forwarded'); + }); + + it('passes other requests and their options to the app', async () => { + setClient({}); + const request = makeEnvelopeRequest('http://localhost:3000/api/users'); + + await wrapServerEntryWithSentryTunnel({ fetch: appFetch }).fetch(request, { context: 1 }); + + expect(appFetch).toHaveBeenCalledWith(request, { context: 1 }); + }); + + it("passes every argument to the app, like a Cloudflare Worker's `env` and `ctx`", async () => { + setClient({}); + const request = makeEnvelopeRequest('http://localhost:3000/api/users'); + const env = { E2E_TEST_DSN: DSN }; + const ctx = { waitUntil: vi.fn(), passThroughOnException: vi.fn() }; + + await wrapServerEntryWithSentryTunnel({ fetch: appFetch }).fetch(request, env, ctx); + + expect(appFetch).toHaveBeenCalledWith(request, env, ctx); + }); + + it('returns a class entry, like a `WorkerEntrypoint`, unchanged', () => { + class Worker { + public fetch(): Response { + return new Response('app'); + } + } + + expect(wrapServerEntryWithSentryTunnel(Worker as unknown as { fetch: () => Response })).toBe(Worker); + }); + + it('enables `tunnelRoute` on the client like `init` would, dropping its spans from the start', () => { + setClient(undefined); + const options = getCurrentScope().getClient()!.getOptions(); + + wrapServerEntryWithSentryTunnel({ fetch: appFetch }, {}); + + expect(options._managedTunnel).toEqual({}); + expect( + (options.ignoreSpans![0] as { attributes: { 'url.path': RegExp } }).attributes['url.path'].test( + getTunnelPath(DSN)!, + ), + ).toBe(true); + }); + + it('keeps the managed tunnel `init` enabled over `tunnelRoute`', async () => { + setClient({ path: '/from-init' }); + const options = getCurrentScope().getClient()!.getOptions(); + + const response = await wrapServerEntryWithSentryTunnel({ fetch: appFetch }, { path: '/from-build' }).fetch( + makeEnvelopeRequest('http://localhost:3000/from-init'), + ); + + expect(options._managedTunnel).toEqual({ path: '/from-init' }); + expect(await response.text()).toBe('forwarded'); + }); + + it('answers the tunnel under the base path Start routes on, and only there', async () => { + setClient({}); + const wrapped = wrapServerEntryWithSentryTunnel({ fetch: appFetch }, undefined, '/app'); + + expect( + await (await wrapped.fetch(makeEnvelopeRequest(`http://localhost:3000/app${getTunnelPath(DSN)}`))).text(), + ).toBe('forwarded'); + expect(await (await wrapped.fetch(makeEnvelopeRequest(TUNNEL_URL))).text()).toBe('app'); + }); + + it('keeps a base path the server `init` set', async () => { + setClient({ basePath: '/custom' }); + const wrapped = wrapServerEntryWithSentryTunnel({ fetch: appFetch }, undefined, '/app'); + + expect( + await (await wrapped.fetch(makeEnvelopeRequest(`http://localhost:3000/custom${getTunnelPath(DSN)}`))).text(), + ).toBe('forwarded'); + }); +}); diff --git a/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts b/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts index 0715873cdfa5..02ffa14e8dd1 100644 --- a/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts +++ b/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts @@ -2,7 +2,7 @@ import type { Plugin } from 'vite'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { makeAutoInstrumentMiddlewarePlugin } from '../../src/vite/autoInstrumentMiddleware'; import { sentryTanstackStart, type SentryTanstackStartOptions } from '../../src/vite/sentryTanstackStart'; -import { makeTunnelRoutePlugin } from '../../src/vite/tunnelRoute'; +import { makeServerEntryTunnelPlugin, makeTunnelRoutePlugin } from '../../src/vite/tunnelRoute'; const mockSourceMapsConfigPlugin: Plugin = { name: 'sentry-tanstackstart-files-to-delete-after-upload-plugin', @@ -35,6 +35,16 @@ const mockTunnelRoutePlugin: Plugin = { transform: vi.fn(), }; +const mockServerEntryTunnelPlugin: Plugin = { + name: 'sentry-tanstackstart-server-entry-tunnel', + enforce: 'pre', + load: vi.fn(), +}; + +const mockTunnelBasePathPlugin: Plugin = { + name: 'sentry-tanstackstart-tunnel-base-path', +}; + const mockRoutePatternPlugin: Plugin = { name: 'sentry-tanstackstart-route-patterns', enforce: 'pre', @@ -67,6 +77,8 @@ vi.mock('../../src/vite/autoInstrumentMiddleware', () => ({ vi.mock('../../src/vite/tunnelRoute', () => ({ makeTunnelRoutePlugin: vi.fn(() => mockTunnelRoutePlugin), + makeServerEntryTunnelPlugin: vi.fn(() => mockServerEntryTunnelPlugin), + makeTunnelBasePathPlugin: vi.fn(() => ({ plugin: mockTunnelBasePathPlugin, getBasePath: () => '' })), })); describe('sentryTanstackStart()', () => { @@ -85,6 +97,8 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockTunnelBasePathPlugin, + mockServerEntryTunnelPlugin, mockOrchestrionPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, @@ -97,7 +111,7 @@ describe('sentryTanstackStart()', () => { const plugins = sentryTanstackStart({ autoInstrumentMiddleware: false }); - expect(plugins).toEqual([mockRoutePatternPlugin]); + expect(plugins).toEqual([mockRoutePatternPlugin, mockTunnelBasePathPlugin, mockServerEntryTunnelPlugin]); }); it('returns only the tunnel route plugin in development mode when tunnelRoute is configured', () => { @@ -108,7 +122,12 @@ describe('sentryTanstackStart()', () => { tunnelRoute: { allowedDsns: ['https://public@o0.ingest.sentry.io/0'] }, }); - expect(plugins).toEqual([mockRoutePatternPlugin, mockTunnelRoutePlugin]); + expect(plugins).toEqual([ + mockRoutePatternPlugin, + mockTunnelBasePathPlugin, + mockServerEntryTunnelPlugin, + mockTunnelRoutePlugin, + ]); }); it('returns Sentry Vite plugins but not enable source maps plugin when sourcemaps.disable is true', () => { @@ -119,6 +138,8 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockTunnelBasePathPlugin, + mockServerEntryTunnelPlugin, mockOrchestrionPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, @@ -133,6 +154,8 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockTunnelBasePathPlugin, + mockServerEntryTunnelPlugin, mockOrchestrionPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, @@ -147,6 +170,8 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockTunnelBasePathPlugin, + mockServerEntryTunnelPlugin, mockOrchestrionPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, @@ -161,6 +186,8 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockTunnelBasePathPlugin, + mockServerEntryTunnelPlugin, mockOrchestrionPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, @@ -176,6 +203,8 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockTunnelBasePathPlugin, + mockServerEntryTunnelPlugin, mockOrchestrionPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, @@ -191,6 +220,8 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockTunnelBasePathPlugin, + mockServerEntryTunnelPlugin, mockOrchestrionPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, @@ -222,6 +253,8 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockTunnelBasePathPlugin, + mockServerEntryTunnelPlugin, mockTunnelRoutePlugin, mockOrchestrionPlugin, mockSourceMapsConfigPlugin, @@ -242,6 +275,7 @@ describe('sentryTanstackStart()', () => { sentryTanstackStart(options); expect(makeTunnelRoutePlugin).toHaveBeenCalledWith(options.tunnelRoute, undefined); + expect(makeServerEntryTunnelPlugin).toHaveBeenCalledWith(options.tunnelRoute, expect.any(Function)); }); }); diff --git a/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts b/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts index 22b3a9be56a5..e0f734de68a2 100644 --- a/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts +++ b/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts @@ -1,62 +1,25 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { vi } from 'vitest'; -import { injectManagedTunnelRoute, makeTunnelRoutePlugin, resolveTunnelRoute } from '../../src/vite/tunnelRoute'; - -const ROUTE_TREE_SOURCE = `import { Route as rootRouteImport } from './routes/__root' -import { Route as IndexRouteImport } from './routes/index' - -const IndexRoute = IndexRouteImport.update({ - id: '/', - path: '/', - getParentRoute: () => rootRouteImport, -} as any) - -export interface FileRoutesByFullPath { - '/': typeof IndexRoute -} -export interface RootRouteChildren { - IndexRoute: typeof IndexRoute -} - -const rootRouteChildren: RootRouteChildren = { - IndexRoute: IndexRoute, -} -export const routeTree = rootRouteImport - ._addFileChildren(rootRouteChildren) -`; - -const UNTYPED_ROUTE_TREE_SOURCE = ROUTE_TREE_SOURCE.replace( - 'const rootRouteChildren: RootRouteChildren = {', - 'const rootRouteChildren = {', -); +import type { Client } from '@sentry/core'; +import { getCurrentScope, getTunnelPath, makeDsn } from '@sentry/core'; +import type { Plugin, ResolvedConfig, UserConfig } from 'vite'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + makeServerEntryTunnelPlugin, + makeTunnelBasePathPlugin, + makeTunnelRoutePlugin, + normalizeTunnelRouteOptions, +} from '../../src/vite/tunnelRoute'; describe('tunnelRoute vite plugin', () => { - beforeEach(() => { - delete process.env.__SENTRY_INTERNAL_TANSTACKSTART_TUNNEL_ROUTE__; - }); - - afterEach(() => { - delete process.env.__SENTRY_INTERNAL_TANSTACKSTART_TUNNEL_ROUTE__; - }); - - it('reuses the same generated tunnel route within one process', () => { - const firstTunnelRoute = resolveTunnelRoute(true); - const secondTunnelRoute = resolveTunnelRoute(true); - - expect(firstTunnelRoute).toBe(secondTunnelRoute); - expect(firstTunnelRoute).toMatch(/^\/[a-z0-9]{8}$/); - }); - - it('always generates an 8-character tunnel route', () => { - vi.spyOn(Math, 'random').mockReturnValue(0.5); - - expect(resolveTunnelRoute(true)).toBe('/iiiiiiii'); - }); - - it('returns the provided static tunnel route without reusing a generated one', () => { - resolveTunnelRoute(true); - - expect(resolveTunnelRoute('/monitor')).toBe('/monitor'); + it('maps `tunnelRoute` onto the `tunnel` init options', () => { + expect(normalizeTunnelRouteOptions(true)).toEqual({}); + expect(normalizeTunnelRouteOptions('/monitor')).toEqual({ path: '/monitor' }); + expect(normalizeTunnelRouteOptions({ path: '', allowedDsns: [] })).toEqual({ + path: undefined, + allowedDsns: undefined, + }); + expect( + normalizeTunnelRouteOptions({ path: '/monitor', allowedDsns: ['https://public@o0.ingest.sentry.io/0'] }), + ).toEqual({ path: '/monitor', allowedDsns: ['https://public@o0.ingest.sentry.io/0'] }); }); it('rejects invalid static tunnel routes', () => { @@ -71,89 +34,151 @@ describe('tunnelRoute vite plugin', () => { ); }); - it('injects the managed tunnel route into the generated TanStack route tree', () => { - const transformedRouteTree = injectManagedTunnelRoute(ROUTE_TREE_SOURCE, '/monitor'); + it('tells the client the static path, or that the path comes from the DSN', () => { + const staticPlugin = makeTunnelRoutePlugin({ path: '/monitor' }); + const dsnPlugin = makeTunnelRoutePlugin(true); - expect(transformedRouteTree).toContain( - "import { Route as SentryManagedTunnelRouteImport } from 'virtual:sentry-tanstackstart-react/tunnel-route'", - ); - expect(transformedRouteTree).toContain('const SentryManagedTunnelRoute = SentryManagedTunnelRouteImport.update({'); - expect(transformedRouteTree).toContain('id: "/monitor"'); - expect(transformedRouteTree).toContain('path: "/monitor"'); - expect(transformedRouteTree).toContain('SentryManagedTunnelRoute: SentryManagedTunnelRoute,'); - expect(transformedRouteTree).toContain('IndexRoute: IndexRoute,'); + expect(staticPlugin.config && staticPlugin.config()).toEqual({ + define: { __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__: '"/monitor"' }, + }); + expect(dsnPlugin.config && dsnPlugin.config()).toEqual({ + define: { __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__: 'true' }, + }); }); - it('injects the managed tunnel route when rootRouteChildren is untyped', () => { - const transformedRouteTree = injectManagedTunnelRoute(UNTYPED_ROUTE_TREE_SOURCE, '/monitor'); + it('fails when a static tunnel route conflicts with an existing route', () => { + const transform = (path: string, source: string): unknown => + (makeTunnelRoutePlugin(path).transform as (source: string, id: string) => unknown)( + source, + '/app/src/routeTree.gen.ts', + ); + const singleQuoted = "const MonitorRoute = MonitorRouteImport.update({\n id: '/monitor',\n path: '/monitor',"; + const doubleQuoted = singleQuoted.replace(/'/g, '"'); - expect(transformedRouteTree).toContain('const rootRouteChildren = {'); - expect(transformedRouteTree).toContain('SentryManagedTunnelRoute: SentryManagedTunnelRoute,'); + expect(() => transform('/monitor', singleQuoted)).toThrow( + 'Cannot register managed tunnel route "/monitor" because an existing TanStack Start route already uses that path.', + ); + expect(() => transform('/monitor', doubleQuoted)).toThrow('already uses that path'); + expect(transform('/other', singleQuoted)).toBeNull(); + expect( + (makeTunnelRoutePlugin(true).transform as (s: string, id: string) => unknown)( + singleQuoted, + '/app/src/routeTree.gen.ts', + ), + ).toBeNull(); }); +}); - it('fails when the managed tunnel route conflicts with an existing route', () => { - expect(() => injectManagedTunnelRoute(ROUTE_TREE_SOURCE, '/')).toThrow( - 'Cannot register managed tunnel route "/" because an existing TanStack Start route already uses that path.', +describe('server entry tunnel vite plugin', () => { + const USER_ENTRY = '/app/src/server.ts'; + + function setup( + tunnelRoute?: Parameters[0], + basePath = '', + ): (id: string) => unknown { + const plugin = makeServerEntryTunnelPlugin(tunnelRoute, () => basePath); + const config = { + resolve: { alias: [{ find: 'virtual:tanstack-start-server-entry', replacement: USER_ENTRY }] }, + } as unknown as ResolvedConfig; + (plugin.configResolved as (config: ResolvedConfig) => void)(config); + + return id => (plugin.load as Extract unknown>).call({} as never, id); + } + + it('wraps the app server entry and the default entry', () => { + const load = setup(); + + expect(load(USER_ENTRY)).toContain('export default wrapServerEntryWithSentryTunnel(entry, undefined, "");'); + expect(load(`${USER_ENTRY}?sentry-tunnel-original`)).toBeNull(); + expect(load('/app/node_modules/@tanstack/react-start/dist/default-entry/esm/server.js')).toContain( + 'wrapServerEntryWithSentryTunnel', + ); + expect(load('C:\\app\\node_modules\\@tanstack\\react-start\\dist\\plugin\\default-entry\\server.ts')).toContain( + 'wrapServerEntryWithSentryTunnel', ); + expect(load('/app/src/routes/index.tsx')).toBeNull(); }); - it('fails on route conflict when routeTree.gen.ts uses double quotes (tsr quoteStyle: double)', () => { - const doubleQuotedMonitorTree = ROUTE_TREE_SOURCE.replace("path: '/'", 'path: "/monitor"').replace( - "id: '/'", - 'id: "/monitor"', - ); + it('re-exports everything from the original server entry', () => { + const load = setup(); - expect(() => injectManagedTunnelRoute(doubleQuotedMonitorTree, '/monitor')).toThrow( - 'Cannot register managed tunnel route "/monitor" because an existing TanStack Start route already uses that path.', - ); + expect(load(USER_ENTRY)).toContain(`export * from "${USER_ENTRY}?sentry-tunnel-original";`); }); - it('loads a virtual managed tunnel route module for a static tunnel path', async () => { - const plugin = makeTunnelRoutePlugin({ - allowedDsns: ['http://public@localhost:3031/1337'], - path: '/monitor', - }); + it('passes the build-time options when `tunnelRoute` derives the path from the DSN', () => { + expect(setup(true)(USER_ENTRY)).toContain('wrapServerEntryWithSentryTunnel(entry, {}, "");'); + expect(setup({ allowedDsns: ['https://public@o0.ingest.sentry.io/0'] })(USER_ENTRY)).toContain( + 'wrapServerEntryWithSentryTunnel(entry, {"allowedDsns":["https://public@o0.ingest.sentry.io/0"]}, "");', + ); + }); - expect(plugin.config && plugin.config()).toEqual({ - define: { - __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__: '"/monitor"', - }, - }); + it('serves a static `tunnelRoute` path from the wrapper too', () => { + expect(setup('/monitor')(USER_ENTRY)).toContain('wrapServerEntryWithSentryTunnel(entry, {"path":"/monitor"}, "");'); + }); - expect(plugin.resolveId && plugin.resolveId('virtual:sentry-tanstackstart-react/tunnel-route')).toBe( - '\0virtual:sentry-tanstackstart-react/tunnel-route', + it('passes the base path Start routes on to the wrapper', () => { + expect(setup(undefined, '/app')(USER_ENTRY)).toContain( + 'wrapServerEntryWithSentryTunnel(entry, undefined, "/app");', ); + }); +}); - const virtualRouteModule = plugin.load && (await plugin.load('\0virtual:sentry-tanstackstart-react/tunnel-route')); +describe('tunnel base path vite plugin', () => { + function resolve(config: UserConfig): { define: unknown; basePath: string } { + const { plugin, getBasePath } = makeTunnelBasePathPlugin(); + const result = (plugin.config as (config: UserConfig) => { define: unknown })(config); - expect(virtualRouteModule).toContain('createFileRoute("/monitor")'); - expect(virtualRouteModule).toContain('allowedDsns: ["http://public@localhost:3031/1337"]'); + return { define: result.define, basePath: getBasePath() }; + } + + it('reads the base path Start defines for every environment', () => { + expect(resolve({ define: { 'process.env.TSS_ROUTER_BASEPATH': JSON.stringify('/app/') } })).toEqual({ + define: { __SENTRY_TANSTACKSTART_TUNNEL_BASE_PATH__: '"/app"' }, + basePath: '/app', + }); }); - it('omits allowedDsns from the virtual managed tunnel route module when not provided', async () => { - const plugin = makeTunnelRoutePlugin('/monitor'); + it("falls back to Vite's `base`, ignoring an absolute one", () => { + expect(resolve({ base: '/my-app/' }).basePath).toBe('/my-app'); + expect(resolve({ base: 'https://cdn.example.com/assets/' }).basePath).toBe(''); + }); + + it('serves the tunnel from the root without a base path', () => { + expect(resolve({ define: { 'process.env.TSS_ROUTER_BASEPATH': JSON.stringify('/') } }).basePath).toBe(''); + expect(resolve({}).basePath).toBe(''); + }); +}); - const virtualRouteModule = plugin.load && (await plugin.load('\0virtual:sentry-tanstackstart-react/tunnel-route')); +describe('server entry tunnel vite plugin in `vite dev`', () => { + const DSN = 'https://public@o1.ingest.sentry.io/1337'; - expect(virtualRouteModule).toContain('createFileRoute("/monitor")'); - expect(virtualRouteModule).toContain('createSentryTunnelRoute({})'); + afterEach(() => { + getCurrentScope().setClient(undefined); }); - it('rejects non-POST requests in the virtual managed tunnel route module', async () => { - const plugin = makeTunnelRoutePlugin('/monitor'); + function setClient(managedTunnel: object | undefined): Record { + const options = { dsn: DSN, _managedTunnel: managedTunnel, ignoreSpans: [] as unknown[] }; + getCurrentScope().setClient({ getDsn: () => makeDsn(DSN), getOptions: () => options } as unknown as Client); + + return options; + } - const virtualRouteModule = plugin.load && (await plugin.load('\0virtual:sentry-tanstackstart-react/tunnel-route')); + it("drops the dev server's spans for a `tunnelRoute` under the base path", () => { + const options = setClient(undefined); + const plugin = makeServerEntryTunnelPlugin(true, () => '/app'); - expect(virtualRouteModule).toContain('ANY()'); - expect(virtualRouteModule).toContain("status: 405, headers: { Allow: 'POST' }"); + (plugin.configureServer as () => void)(); + + const [filter] = options.ignoreSpans as { attributes: Record }[]; + expect(filter?.attributes['url.path']?.test(`/app${getTunnelPath(DSN)}`)).toBe(true); }); - it('treats an empty string `path` like omitted and uses a generated tunnel route', () => { - const plugin = makeTunnelRoutePlugin({ path: '' }); + it("serves the dev server's managed tunnel from `init` under the base path", () => { + const options = setClient({}); + const plugin = makeServerEntryTunnelPlugin(undefined, () => '/app'); + + (plugin.configureServer as () => void)(); - const defined = plugin.config && plugin.config(); - const serialized = defined?.define?.__SENTRY_TANSTACKSTART_TUNNEL_ROUTE__; - expect(typeof serialized).toBe('string'); - expect(serialized).toMatch(/^"\/[a-z0-9]{8}"$/); + expect(options._managedTunnel).toEqual({ basePath: '/app' }); }); }); diff --git a/packages/vercel-edge/src/sdk.ts b/packages/vercel-edge/src/sdk.ts index bc2d74822d65..e909ea255640 100644 --- a/packages/vercel-edge/src/sdk.ts +++ b/packages/vercel-edge/src/sdk.ts @@ -1,7 +1,6 @@ import { diag, DiagLogLevel, propagation, trace } from '@opentelemetry/api'; import type { Client, Integration } from '@sentry/core'; import { - _INTERNAL_warnIfClientIsActive, consoleIntegration, consoleSandbox, conversationIdIntegration, @@ -14,11 +13,12 @@ import { getIntegrationsToSetup, getVercelEnv, GLOBAL_OBJ, + _INTERNAL_warnIfClientIsActive, linkedErrorsIntegration, requestDataIntegration, stackParserFromStackParserOptions, } from '@sentry/core'; -import { nodeStackLineParser } from '@sentry/core/server'; +import { nodeStackLineParser, resolveServerTunnelOption } from '@sentry/core/server'; import { SentryPropagator, SentryTracerProvider, @@ -86,6 +86,7 @@ export function init(options: VercelEdgeOptions = {}): Client { const client = new VercelEdgeClient({ ...options, + ...resolveServerTunnelOption(options.tunnel), stackParser: stackParserFromStackParserOptions(options.stackParser || nodeStackParser), integrations: getIntegrationsToSetup(options), transport: options.transport || makeEdgeTransport,