Repository navigation
feat(react-router): Serve the managed tunnel from a route in the server build #25422
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
247e009
04524f7
7fb4801
dea4de2
3c77575
cc975f9
31d6278
6661532
25e0be5
76c39fa
71fb917
b00bc31
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| declare const __E2E_TEST_TUNNEL__: boolean; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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/`, | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| import { expect, test } from '@playwright/test'; | ||
| import { isServerSpanAt, watchForTunnelSpan } 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 raceTunnelSpan = watchForTunnelSpan(APP_NAME, isServerSpanAt(TUNNEL_PATH)); | ||
| const tunnelResponsePromise = page.waitForResponse( | ||
| response => response.request().method() === 'POST' && TUNNEL_PATH.test(new URL(response.url()).pathname), | ||
| ); | ||
|
|
||
| await page.goto(`${BASE_PATH}/`); | ||
|
|
||
| expect((await tunnelResponsePromise).status()).toBe(200); | ||
| expect( | ||
| await raceTunnelSpan(isServerSpanAt(`${BASE_PATH}/performance`), () => | ||
| page.request.get(`${BASE_PATH}/performance`), | ||
| ), | ||
| ).toBe('anchor'); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,8 +1,10 @@ | ||
| import { defineCloudflareOptions } from '@sentry/cloudflare'; | ||
|
|
||
| declare const __E2E_TEST_TUNNEL__: boolean; | ||
|
|
||
| export default defineCloudflareOptions({ | ||
| dsn: 'https://username@domain/123', | ||
| dsn: __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: __E2E_TEST_TUNNEL__ ? true : 'http://localhost:3031/', // proxy server | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,70 @@ | ||
| import { debug, getClient } from '@sentry/core'; | ||
| import { | ||
| _INTERNAL_getTunnelPaths as getTunnelPaths, | ||
| handleTunnelRequestIfMatched, | ||
| _INTERNAL_setManagedTunnelDefaults as 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<string, Record<string, unknown>> { | ||
| // 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( | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. With Suggestion: in |
||
| 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<string, unknown> { | ||
| 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' } }), | ||
| }, | ||
| }; | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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)); | ||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||
| `, | ||||||||||
| map: null, | ||||||||||
| }; | ||||||||||
| }, | ||||||||||
| }; | ||||||||||
| } | ||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The browser rewrites an explicit relative
tunnelstring. This is the same decision as in #25415 and #25419. Withbasename: '/app/',tunnel: '/tunnel'in the browser now posts to/app/tunnel. Before this PR it posted to/tunnel. A tunnel that lives outside React Router (a proxy or separate service) breaks silently. Here the rewrite happens becauseapplyTunnelDefaultshandles relative strings, not because of a framework-specific commit.Suggestion: settle this once in core (
applyTunnelDefaults) for the whole stack. Either leave explicit strings as written, or document the change in the changelog.