Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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: [/^\//],
Expand Down
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
Expand Up @@ -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' },
{
Expand Down
Original file line number Diff line number Diff line change
@@ -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
});
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down Expand Up @@ -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"
}
]
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Original file line number Diff line number Diff line change
@@ -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');
});
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)],
};
});
18 changes: 17 additions & 1 deletion packages/react-router/src/client/sdk.ts
Original file line number Diff line number Diff line change
@@ -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.
*/
Expand All @@ -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);
Expand Down
1 change: 1 addition & 0 deletions packages/react-router/src/cloudflare/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/react-router/src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
46 changes: 46 additions & 0 deletions packages/react-router/src/server/tunnelRoute.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { debug, getClient, getTunnelPaths, handleTunnelRequestIfMatched, setManagedTunnelBasePath } 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.
*
* @param basename The app's `basename`, which React Router matches the route under but leaves in the request URL.
*/
export function createSentryTunnelRoute(basename?: string): Record<string, unknown> {
// `init` runs from `--import` before the server build loads, so the client is usually there already.
setManagedTunnelBasePath(getClient(), basename);

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 {
const client = getClient();
if (!client) {
DEBUG_BUILD &&
debug.warn('The managed tunnel stays off until restart: a request came in before `Sentry.init`.');
return DISABLED_TUNNEL_PATH;
}

setManagedTunnelBasePath(client, basename);

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' } }),
},
};
}
28 changes: 28 additions & 0 deletions packages/react-router/src/vite/makeTunnelRoutePlugin.ts
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 route to the server build. It only serves 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 { createSentryTunnelRoute as __sentryCreateTunnelRoute } from '@sentry/react-router';
routes['sentry-tunnel'] = __sentryCreateTunnelRoute(typeof basename === 'string' ? basename : undefined);
`,
map: null,
};
},
};
}
2 changes: 2 additions & 0 deletions packages/react-router/src/vite/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand All @@ -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 }));
Expand Down
12 changes: 12 additions & 0 deletions packages/react-router/test/client/sdk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand Down
Loading
Loading