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..9f2ece437fd1 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,8 @@ 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 + tunnel: process.env.E2E_TEST_TUNNEL ? true : `http://localhost:3031/`, // proxy server }); 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..0dc083191099 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,9 @@ "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:assert:tunnel": "E2E_TEST_TUNNEL=1 pnpm test:playwright tunnel" }, "eslintConfig": { "extends": [ @@ -85,6 +87,11 @@ { "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)" } ] } 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..cf5b3322993f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/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 = 'react-router-8-framework'; +const TUNNEL_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('/', { 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 ?? '') === '/performance', + ); + await page.request.get('/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/packages/react-router/src/cloudflare/index.ts b/packages/react-router/src/cloudflare/index.ts index b94430728125..d691eac21ed2 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 { createSentryTunnelRoute } 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..99fb32c72ad3 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 { createSentryTunnelRoute } 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..1c281cc46adb --- /dev/null +++ b/packages/react-router/src/server/tunnelRoute.ts @@ -0,0 +1,38 @@ +import { debug, getClient, getTunnelPaths, handleTunnelRequestIfMatched } from '@sentry/core'; +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__'; + +/** + * A resource route that serves the managed tunnel. `sentryReactRouter()` adds it to the server build, since React + * Router has no request hook to answer from. Not meant to be used directly. + */ +export function createSentryTunnelRoute(): Record { + return { + id: 'sentry-tunnel', + parentId: undefined, + index: undefined, + caseSensitive: undefined, + // React Router reads the path once, on its first request, and keeps it until the server restarts. + get path(): 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(`React Router can only serve one managed tunnel path, so only ${path} is served.`); + } + + return path ?? DISABLED_TUNNEL_PATH; + }, + 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..c8ed968d5e76 --- /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 route to the server build. It only serves requests when `Sentry.init` enables the + * managed tunnel. + */ +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 { createSentryTunnelRoute as __sentryCreateTunnelRoute } from '@sentry/react-router'; +routes['sentry-tunnel'] = __sentryCreateTunnelRoute(); +`, + 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/server/tunnelRoute.test.ts b/packages/react-router/test/server/tunnelRoute.test.ts new file mode 100644 index 000000000000..b540cf6c5c7e --- /dev/null +++ b/packages/react-router/test/server/tunnelRoute.test.ts @@ -0,0 +1,57 @@ +import type { Client } from '@sentry/core'; +import { createEnvelope, getCurrentScope, getTunnelPath, makeDsn, serializeEnvelope } from '@sentry/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createSentryTunnelRoute } 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 setClient(managedTunnel: object | undefined): void { + const options = { dsn: DSN, _managedTunnel: managedTunnel }; + getCurrentScope().setClient({ getDsn: () => makeDsn(DSN), getOptions: () => options } as unknown as Client); +} + +describe('createSentryTunnelRoute', () => { + 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 = createSentryTunnelRoute() as TunnelRoute; + + expect(route.parentId).toBeUndefined(); + expect(route.path).toBe(getTunnelPath(DSN)); + }); + + it('serves a path that never matches when the tunnel is off or Sentry is not initialized yet', () => { + const route = createSentryTunnelRoute() as TunnelRoute; + const pathWithoutClient = route.path; + setClient(undefined); + + expect(pathWithoutClient).toBe('/__sentry-tunnel-disabled__'); + expect(route.path).toBe('/__sentry-tunnel-disabled__'); + }); + + it('forwards tunnel POSTs and rejects other methods', async () => { + setClient({}); + vi.stubGlobal( + 'fetch', + vi.fn(() => Promise.resolve(new Response('forwarded'))), + ); + const route = createSentryTunnelRoute() as TunnelRoute; + 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); + }); +}); 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..758d0a6f56b6 --- /dev/null +++ b/packages/react-router/test/vite/makeTunnelRoutePlugin.test.ts @@ -0,0 +1,20 @@ +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("routes['sentry-tunnel'] = __sentryCreateTunnelRoute();"); + 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,