Skip to content

feat(react-router): Add client-side router instrumentation #16185

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

Merged
merged 15 commits into from
May 5, 2025
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Sentry.init({
// todo: get this from env
dsn: 'https://username@domain/123',
tunnel: `http://localhost:3031/`, // proxy server
integrations: [Sentry.browserTracingIntegration()],
integrations: [Sentry.reactRouterTracingIntegration()],
tracesSampleRate: 1.0,
tracePropagationTargets: [/^\//],
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export default [
]),
...prefix('performance', [
index('routes/performance/index.tsx'),
route('ssr', 'routes/performance/ssr.tsx'),
route('with/:param', 'routes/performance/dynamic-param.tsx'),
route('static', 'routes/performance/static.tsx'),
]),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
import { Link } from 'react-router';

export default function PerformancePage() {
return <h1>Performance Page</h1>;
return (
<div>
<h1>Performance Page</h1>
<nav>
<Link to="/performance/ssr">SSR Page</Link>
<Link to="/performance/with/sentry">With Param Page</Link>
</nav>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export default function SsrPage() {
return (
<div>
<h1>SSR Page</h1>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { expect, test } from '@playwright/test';
import { waitForTransaction } from '@sentry-internal/test-utils';
import { APP_NAME } from '../constants';

test.describe('client - navigation performance', () => {
test('should create navigation transaction', async ({ page }) => {
const navigationPromise = waitForTransaction(APP_NAME, async transactionEvent => {
return transactionEvent.transaction === '/performance/ssr';
});

await page.goto(`/performance`); // pageload
await page.waitForTimeout(1000); // give it a sec before navigation
await page.getByRole('link', { name: 'SSR Page' }).click(); // navigation

const transaction = await navigationPromise;

expect(transaction).toMatchObject({
contexts: {
trace: {
span_id: expect.any(String),
trace_id: expect.any(String),
data: {
'sentry.origin': 'auto.navigation.react-router',
'sentry.op': 'navigation',
'sentry.source': 'url',
},
op: 'navigation',
origin: 'auto.navigation.react-router',
},
},
spans: expect.any(Array),
start_timestamp: expect.any(Number),
timestamp: expect.any(Number),
transaction: '/performance/ssr',
type: 'transaction',
transaction_info: { source: 'url' },
platform: 'javascript',
request: {
url: expect.stringContaining('/performance/ssr'),
headers: expect.any(Object),
},
event_id: expect.any(String),
environment: 'qa',
sdk: {
integrations: expect.arrayContaining([expect.any(String)]),
name: 'sentry.javascript.react-router',
version: expect.any(String),
packages: [
{ name: 'npm:@sentry/react-router', version: expect.any(String) },
{ name: 'npm:@sentry/browser', version: expect.any(String) },
],
},
tags: { runtime: 'browser' },
});
});

test('should update navigation transaction for dynamic routes', async ({ page }) => {
const txPromise = waitForTransaction(APP_NAME, async transactionEvent => {
return transactionEvent.transaction === '/performance/with/:param';
});

await page.goto(`/performance`); // pageload
await page.waitForTimeout(1000); // give it a sec before navigation
await page.getByRole('link', { name: 'With Param Page' }).click(); // navigation

const transaction = await txPromise;

expect(transaction).toMatchObject({
contexts: {
trace: {
span_id: expect.any(String),
trace_id: expect.any(String),
data: {
'sentry.origin': 'auto.navigation.react-router',
'sentry.op': 'navigation',
'sentry.source': 'route',
},
op: 'navigation',
origin: 'auto.navigation.react-router',
},
},
spans: expect.any(Array),
start_timestamp: expect.any(Number),
timestamp: expect.any(Number),
transaction: '/performance/with/:param',
type: 'transaction',
transaction_info: { source: 'route' },
platform: 'javascript',
request: {
url: expect.stringContaining('/performance/with/sentry'),
headers: expect.any(Object),
},
event_id: expect.any(String),
environment: 'qa',
sdk: {
integrations: expect.arrayContaining([expect.any(String)]),
name: 'sentry.javascript.react-router',
version: expect.any(String),
packages: [
{ name: 'npm:@sentry/react-router', version: expect.any(String) },
{ name: 'npm:@sentry/browser', version: expect.any(String) },
],
},
tags: { runtime: 'browser' },
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,56 @@ test.describe('client - pageload performance', () => {
});
});

test('should update pageload transaction for dynamic routes', async ({ page }) => {
const txPromise = waitForTransaction(APP_NAME, async transactionEvent => {
return transactionEvent.transaction === '/performance/with/:param';
});

await page.goto(`/performance/with/sentry`);

const transaction = await txPromise;

expect(transaction).toMatchObject({
contexts: {
trace: {
span_id: expect.any(String),
trace_id: expect.any(String),
data: {
'sentry.origin': 'auto.pageload.browser',
'sentry.op': 'pageload',
'sentry.source': 'route',
},
op: 'pageload',
origin: 'auto.pageload.browser',
},
},
spans: expect.any(Array),
start_timestamp: expect.any(Number),
timestamp: expect.any(Number),
transaction: '/performance/with/:param',
type: 'transaction',
transaction_info: { source: 'route' },
measurements: expect.any(Object),
platform: 'javascript',
request: {
url: expect.stringContaining('/performance/with/sentry'),
headers: expect.any(Object),
},
event_id: expect.any(String),
environment: 'qa',
sdk: {
integrations: expect.arrayContaining([expect.any(String)]),
name: 'sentry.javascript.react-router',
version: expect.any(String),
packages: [
{ name: 'npm:@sentry/react-router', version: expect.any(String) },
{ name: 'npm:@sentry/browser', version: expect.any(String) },
],
},
tags: { runtime: 'browser' },
});
});

// todo: this page is currently not prerendered (see react-router.config.ts)
test('should send pageload transaction for prerendered pages', async ({ page }) => {
const txPromise = waitForTransaction(APP_NAME, async transactionEvent => {
Expand Down
138 changes: 138 additions & 0 deletions packages/react-router/src/client/hydratedRouter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { startBrowserTracingNavigationSpan } from '@sentry/browser';
import type { Span } from '@sentry/core';
import {
getActiveSpan,
getClient,
getRootSpan,
GLOBAL_OBJ,
SEMANTIC_ATTRIBUTE_SENTRY_OP,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
spanToJSON,
} from '@sentry/core';
import type { DataRouter, RouterState } from 'react-router';

const GLOBAL_OBJ_WITH_DATA_ROUTER = GLOBAL_OBJ as typeof GLOBAL_OBJ & {
__reactRouterDataRouter?: DataRouter;
};

const MAX_RETRIES = 40; // 2 seconds at 50ms interval

/**
* Instruments the React Router Data Router for pageloads and navigation.
*
* This function waits for the router to be available after hydration, then:
* 1. Updates the pageload transaction with parameterized route info
* 2. Patches router.navigate() to create navigation transactions
* 3. Subscribes to router state changes to update navigation transactions with parameterized routes
*/
export function instrumentHydratedRouter(): void {
function trySubscribe(): boolean {
const router = GLOBAL_OBJ_WITH_DATA_ROUTER.__reactRouterDataRouter;

if (router) {
// The first time we hit the router, we try to update the pageload transaction
// todo: update pageload tx here
const pageloadSpan = getActiveRootSpan();
const pageloadName = pageloadSpan ? spanToJSON(pageloadSpan).description : undefined;
const parameterizePageloadRoute = getParameterizedRoute(router.state);
if (
pageloadName &&
normalizePathname(router.state.location.pathname) === normalizePathname(pageloadName) && // this event is for the currently active pageload
normalizePathname(parameterizePageloadRoute) !== normalizePathname(pageloadName) // route is not parameterized yet
) {
pageloadSpan?.updateName(parameterizePageloadRoute);
pageloadSpan?.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route');
}

// Patching navigate for creating accurate navigation transactions
if (typeof router.navigate === 'function') {
const originalNav = router.navigate.bind(router);
router.navigate = function sentryPatchedNavigate(...args) {
maybeCreateNavigationTransaction(
String(args[0]) || '<unknown route>', // will be updated anyway
'url', // this also will be updated once we have the parameterized route
);
return originalNav(...args);
};
}

// Subscribe to router state changes to update navigation transactions with parameterized routes
router.subscribe(newState => {
const navigationSpan = getActiveRootSpan();
const navigationSpanName = navigationSpan ? spanToJSON(navigationSpan).description : undefined;
const parameterizedNavRoute = getParameterizedRoute(newState);

if (
navigationSpanName && // we have an active pageload tx
newState.navigation.state === 'idle' && // navigation has completed
normalizePathname(newState.location.pathname) === normalizePathname(navigationSpanName) && // this event is for the currently active navigation
normalizePathname(parameterizedNavRoute) !== normalizePathname(navigationSpanName) // route is not parameterized yet
) {
navigationSpan?.updateName(parameterizedNavRoute);
navigationSpan?.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route');
}
});
return true;
}
return false;
}

// Wait until the router is available (since the SDK loads before hydration)
if (!trySubscribe()) {
let retryCount = 0;
// Retry until the router is available or max retries reached
const interval = setInterval(() => {
if (trySubscribe() || retryCount >= MAX_RETRIES) {
clearInterval(interval);
}
retryCount++;
}, 50);
}
}

function maybeCreateNavigationTransaction(name: string, source: 'url' | 'route'): Span | undefined {
const client = getClient();

if (!client) {
return undefined;
}

return startBrowserTracingNavigationSpan(client, {
name,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source,
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react-router',
},
});
}

function getActiveRootSpan(): Span | undefined {
const activeSpan = getActiveSpan();
if (!activeSpan) {
return undefined;
}

const rootSpan = getRootSpan(activeSpan);

const op = spanToJSON(rootSpan).op;

// Only use this root span if it is a pageload or navigation span
return op === 'navigation' || op === 'pageload' ? rootSpan : undefined;
}

function getParameterizedRoute(routerState: RouterState): string {
const lastMatch = routerState.matches[routerState.matches.length - 1];
return normalizePathname(lastMatch?.route.path ?? routerState.location.pathname);
}

function normalizePathname(pathname: string): string {
// Ensure it starts with a single slash
let normalized = pathname.startsWith('/') ? pathname : `/${pathname}`;
// Remove trailing slash unless it's the root
if (normalized.length > 1 && normalized.endsWith('/')) {
normalized = normalized.slice(0, -1);
}
return normalized;
}
1 change: 1 addition & 0 deletions packages/react-router/src/client/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from '@sentry/browser';

export { init } from './sdk';
export { reactRouterTracingIntegration } from './tracingIntegration';
26 changes: 20 additions & 6 deletions packages/react-router/src/client/sdk.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,33 @@
import type { BrowserOptions } from '@sentry/browser';
import { init as browserInit } from '@sentry/browser';
import type { Client } from '@sentry/core';
import { applySdkMetadata, setTag } from '@sentry/core';
import { applySdkMetadata, consoleSandbox, setTag } from '@sentry/core';

const BROWSER_TRACING_INTEGRATION_ID = 'BrowserTracing';

/**
* Initializes the client side of the React Router SDK.
*/
export function init(options: BrowserOptions): Client | undefined {
const opts = {
...options,
};
// If BrowserTracing integration was passed to options, emit a warning
if (options.integrations && Array.isArray(options.integrations)) {
const hasBrowserTracing = options.integrations.some(
integration => integration.name === BROWSER_TRACING_INTEGRATION_ID,
);

if (hasBrowserTracing) {
consoleSandbox(() => {
// eslint-disable-next-line no-console
console.warn(
'browserTracingIntegration is not fully compatible with @sentry/react-router. Please use reactRouterTracingIntegration instead.',
);
});
}
}

applySdkMetadata(opts, 'react-router', ['react-router', 'browser']);
applySdkMetadata(options, 'react-router', ['react-router', 'browser']);

const client = browserInit(opts);
const client = browserInit(options);

setTag('runtime', 'browser');

Expand Down
Loading
Loading