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,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": [
Expand Down Expand Up @@ -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)"
}
]
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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');
});
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)],
};
});
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
38 changes: 38 additions & 0 deletions packages/react-router/src/server/tunnelRoute.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
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' } }),
},
};
}
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.
*/
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,
};
},
};
}
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
57 changes: 57 additions & 0 deletions packages/react-router/test/server/tunnelRoute.test.ts
Original file line number Diff line number Diff line change
@@ -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> | 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);
});
});
20 changes: 20 additions & 0 deletions packages/react-router/test/vite/makeTunnelRoutePlugin.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
11 changes: 8 additions & 3 deletions packages/react-router/test/vite/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand All @@ -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.
Expand All @@ -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(() => {
Expand All @@ -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(() => {
Expand All @@ -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();

Expand All @@ -61,15 +65,15 @@ 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();
});

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();
});
Expand All @@ -83,6 +87,7 @@ describe('sentryReactRouter', () => {
expect(result).toEqual([
mockConfigInjectorPlugin,
mockServerBuildCapturePlugin,
mockTunnelRoutePlugin,
mockOrchestrionPlugin,
mockSourceMapsPlugin,
...mockPlugins,
Expand Down
Loading