Skip to content

Commit e1da135

Browse files
committed
feat(react-router): Serve the managed tunnel from a route in the server build
React Router has no request hook to answer from, so `sentryReactRouter()` adds a resource route whose path is read from the DSN at runtime. React Router reads it on its first request, so a request before `Sentry.init` keeps the tunnel off until restart.
1 parent 6da24a1 commit e1da135

15 files changed

Lines changed: 225 additions & 8 deletions

File tree

‎dev-packages/e2e-tests/test-applications/react-router-8-framework/app/entry.client.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ import { HydratedRouter } from 'react-router/dom';
66
Sentry.init({
77
environment: 'qa', // dynamic sampling bias to keep transactions
88
// todo: get this from env
9-
dsn: 'https://username@domain/123',
10-
tunnel: `http://localhost:3031/`, // proxy server
9+
dsn: __E2E_TEST_TUNNEL__ ? 'http://public@localhost:3031/1337' : 'https://username@domain/123',
10+
tunnel: __E2E_TEST_TUNNEL__ ? true : `http://localhost:3031/`, // proxy server
1111
integrations: [Sentry.reactRouterTracingIntegration()],
1212
tracesSampleRate: 1.0,
1313
tracePropagationTargets: [/^\//],
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
declare const __E2E_TEST_TUNNEL__: boolean;

‎dev-packages/e2e-tests/test-applications/react-router-8-framework/app/root.tsx‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,20 @@ import { Links, Meta, Outlet, Scripts, ScrollRestoration, isRouteErrorResponse }
22
import type { Route } from './+types/root';
33
import stylesheet from './app.css?url';
44

5+
// The tunnel variant checks that the managed tunnel answers before an auth middleware like this one. Other variants
6+
// get none, so their span assertions stay as they are.
7+
export const middleware: Route.MiddlewareFunction[] = __E2E_TEST_TUNNEL__
8+
? [
9+
({ request }, next) => {
10+
if (request.method === 'POST' && request.headers.get('x-auth') !== 'ok') {
11+
return new Response('blocked by user auth', { status: 401 });
12+
}
13+
14+
return next();
15+
},
16+
]
17+
: [];
18+
519
export const links: Route.LinksFunction = () => [
620
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' },
721
{
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import * as Sentry from '@sentry/react-router';
22

33
Sentry.init({
4-
dsn: 'https://username@domain/123',
4+
dsn: process.env.E2E_TEST_TUNNEL ? 'http://public@localhost:3031/1337' : 'https://username@domain/123',
55
environment: 'qa', // dynamic sampling bias to keep transactions
66
tracesSampleRate: 1.0,
7-
tunnel: `http://localhost:3031/`, // proxy server
7+
tunnel: process.env.E2E_TEST_TUNNEL ? true : `http://localhost:3031/`, // proxy server
88
});

‎dev-packages/e2e-tests/test-applications/react-router-8-framework/package.json‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,9 @@
4444
"test:build": "pnpm install && pnpm build",
4545
"test:assert": "pnpm test:ts && pnpm test:playwright",
4646
"test:ts": "pnpm typecheck",
47-
"test:playwright": "playwright test"
47+
"test:playwright": "playwright test",
48+
"test:build:tunnel": "pnpm install && E2E_TEST_TUNNEL=1 pnpm build",
49+
"test:assert:tunnel": "E2E_TEST_TUNNEL=1 pnpm test:playwright tunnel"
4850
},
4951
"eslintConfig": {
5052
"extends": [
@@ -85,6 +87,11 @@
8587
{
8688
"runtime": "cloudflare",
8789
"label": "react-router-8-framework (cloudflare)"
90+
},
91+
{
92+
"build-command": "pnpm test:build:tunnel",
93+
"assert-command": "pnpm test:assert:tunnel",
94+
"label": "react-router-8-framework (tunnel)"
8895
}
8996
]
9097
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { expect, test } from '@playwright/test';
2+
import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils';
3+
4+
test.skip(!process.env.E2E_TEST_TUNNEL, 'Only runs in the tunnel variant');
5+
6+
const APP_NAME = 'react-router-8-framework';
7+
const TUNNEL_PATH = /^\/[a-z0-9]{1,7}$/;
8+
9+
test('answers the managed tunnel before the root middleware', async ({ page }) => {
10+
// The variant's root middleware rejects every other POST.
11+
expect((await page.request.post('/', { data: 'x' })).status()).toBe(401);
12+
13+
const tunnelServerSpanPromise = waitForStreamedSpan(
14+
APP_NAME,
15+
span => getSpanOp(span) === 'http.server' && TUNNEL_PATH.test(String(span.attributes['url.path']?.value ?? '')),
16+
);
17+
const tunnelResponsePromise = page.waitForResponse(
18+
response => response.request().method() === 'POST' && TUNNEL_PATH.test(new URL(response.url()).pathname),
19+
);
20+
21+
await page.goto('/');
22+
const tunnelResponse = await tunnelResponsePromise;
23+
24+
expect(tunnelResponse.status()).toBe(200);
25+
expect(new URL(tunnelResponse.url()).origin).toBe(new URL(page.url()).origin);
26+
27+
// Anchor on a regular server span requested after the tunnel traffic: spans flush in order, so a tunnel span
28+
// would arrive first.
29+
const anchorSpanPromise = waitForStreamedSpan(
30+
APP_NAME,
31+
span => getSpanOp(span) === 'http.server' && String(span.attributes['url.path']?.value ?? '') === '/performance',
32+
);
33+
await page.request.get('/performance');
34+
35+
const winner = await Promise.race([
36+
tunnelServerSpanPromise.then(() => 'tunnel' as const),
37+
anchorSpanPromise.then(() => 'anchor' as const),
38+
]);
39+
expect(winner).toBe('anchor');
40+
});

‎dev-packages/e2e-tests/test-applications/react-router-8-framework/vite.config.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ import { defineConfig } from 'vite';
44

55
export default defineConfig(config => {
66
return {
7+
define: {
8+
__E2E_TEST_TUNNEL__: JSON.stringify(!!process.env.E2E_TEST_TUNNEL),
9+
},
710
plugins: [reactRouter(), sentryReactRouter({ sourcemaps: { disable: true } }, config)],
811
};
912
});

‎packages/react-router/src/cloudflare/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { isPrerenderRequest } from '../server/serverBuild';
44
export * from '../client';
55

66
export { wrapSentryHandleRequest } from '../server/wrapSentryHandleRequest';
7+
export { createSentryTunnelRoute } from '../server/tunnelRoute';
78
export {
89
createSentryServerInstrumentation,
910
type CreateSentryServerInstrumentationOptions,

‎packages/react-router/src/server/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export { wrapSentryHandleRequest } from './wrapSentryHandleRequest';
88
export { createSentryHandleRequest, type SentryHandleRequestOptions } from './createSentryHandleRequest';
99
export { createSentryHandleError, type SentryHandleErrorOptions } from './createSentryHandleError';
1010
export { getMetaTagTransformer } from './getMetaTagTransformer';
11+
export { createSentryTunnelRoute } from './tunnelRoute';
1112

1213
// React Router instrumentation API support
1314
export {
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { debug, getClient, getTunnelPaths, handleTunnelRequestIfMatched } from '@sentry/core';
2+
import { DEBUG_BUILD } from '../common/debug-build';
3+
4+
// Never matches a request, for when the managed tunnel is off.
5+
const DISABLED_TUNNEL_PATH = '/__sentry-tunnel-disabled__';
6+
7+
/**
8+
* A resource route that serves the managed tunnel. `sentryReactRouter()` adds it to the server build, since React
9+
* Router has no request hook to answer from. Not meant to be used directly.
10+
*/
11+
export function createSentryTunnelRoute(): Record<string, unknown> {
12+
return {
13+
id: 'sentry-tunnel',
14+
parentId: undefined,
15+
index: undefined,
16+
caseSensitive: undefined,
17+
// React Router reads the path once, on its first request, and keeps it until the server restarts.
18+
get path(): string {
19+
if (!getClient()) {
20+
DEBUG_BUILD &&
21+
debug.warn('The managed tunnel stays off until restart: a request came in before `Sentry.init`.');
22+
return DISABLED_TUNNEL_PATH;
23+
}
24+
25+
const [path, ...otherPaths] = getTunnelPaths();
26+
if (otherPaths.length) {
27+
DEBUG_BUILD && debug.warn(`React Router can only serve one managed tunnel path, so only ${path} is served.`);
28+
}
29+
30+
return path ?? DISABLED_TUNNEL_PATH;
31+
},
32+
module: {
33+
action: ({ request }: { request: Request }) =>
34+
handleTunnelRequestIfMatched(request) ?? new Response(null, { status: 404 }),
35+
loader: () => new Response(null, { status: 405, headers: { Allow: 'POST' } }),
36+
},
37+
};
38+
}

0 commit comments

Comments
 (0)