Skip to content

Commit 640b57f

Browse files
authored
feat(react-router): Add client-side router instrumentation (#16185)
- Adds client-side instrumentation for react router's `HydratedRouter` (which is basically a `DataRouter` enhanced with ssr data) - Updates pageloads to have parameterized transaction names - Adds parameterized navigation payloads - Adds `reactRouterTracingIntegration` which needs to be used instead of `browserTracingIntegration` (this in turn inits the router instrumentation) - Logs a warning whenever `browserTracingIntegration` is used within this package closes #16160
1 parent 4d46e53 commit 640b57f

File tree

13 files changed

+527
-8
lines changed

13 files changed

+527
-8
lines changed

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

+1-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ Sentry.init({
88
// todo: get this from env
99
dsn: 'https://username@domain/123',
1010
tunnel: `http://localhost:3031/`, // proxy server
11-
integrations: [Sentry.browserTracingIntegration()],
11+
integrations: [Sentry.reactRouterTracingIntegration()],
1212
tracesSampleRate: 1.0,
1313
tracePropagationTargets: [/^\//],
1414
});

dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes.ts

+1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export default [
1212
]),
1313
...prefix('performance', [
1414
index('routes/performance/index.tsx'),
15+
route('ssr', 'routes/performance/ssr.tsx'),
1516
route('with/:param', 'routes/performance/dynamic-param.tsx'),
1617
route('static', 'routes/performance/static.tsx'),
1718
]),
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,13 @@
1+
import { Link } from 'react-router';
2+
13
export default function PerformancePage() {
2-
return <h1>Performance Page</h1>;
4+
return (
5+
<div>
6+
<h1>Performance Page</h1>
7+
<nav>
8+
<Link to="/performance/ssr">SSR Page</Link>
9+
<Link to="/performance/with/sentry">With Param Page</Link>
10+
</nav>
11+
</div>
12+
);
313
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export default function SsrPage() {
2+
return (
3+
<div>
4+
<h1>SSR Page</h1>
5+
</div>
6+
);
7+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { expect, test } from '@playwright/test';
2+
import { waitForTransaction } from '@sentry-internal/test-utils';
3+
import { APP_NAME } from '../constants';
4+
5+
test.describe('client - navigation performance', () => {
6+
test('should create navigation transaction', async ({ page }) => {
7+
const navigationPromise = waitForTransaction(APP_NAME, async transactionEvent => {
8+
return transactionEvent.transaction === '/performance/ssr';
9+
});
10+
11+
await page.goto(`/performance`); // pageload
12+
await page.waitForTimeout(1000); // give it a sec before navigation
13+
await page.getByRole('link', { name: 'SSR Page' }).click(); // navigation
14+
15+
const transaction = await navigationPromise;
16+
17+
expect(transaction).toMatchObject({
18+
contexts: {
19+
trace: {
20+
span_id: expect.any(String),
21+
trace_id: expect.any(String),
22+
data: {
23+
'sentry.origin': 'auto.navigation.react-router',
24+
'sentry.op': 'navigation',
25+
'sentry.source': 'url',
26+
},
27+
op: 'navigation',
28+
origin: 'auto.navigation.react-router',
29+
},
30+
},
31+
spans: expect.any(Array),
32+
start_timestamp: expect.any(Number),
33+
timestamp: expect.any(Number),
34+
transaction: '/performance/ssr',
35+
type: 'transaction',
36+
transaction_info: { source: 'url' },
37+
platform: 'javascript',
38+
request: {
39+
url: expect.stringContaining('/performance/ssr'),
40+
headers: expect.any(Object),
41+
},
42+
event_id: expect.any(String),
43+
environment: 'qa',
44+
sdk: {
45+
integrations: expect.arrayContaining([expect.any(String)]),
46+
name: 'sentry.javascript.react-router',
47+
version: expect.any(String),
48+
packages: [
49+
{ name: 'npm:@sentry/react-router', version: expect.any(String) },
50+
{ name: 'npm:@sentry/browser', version: expect.any(String) },
51+
],
52+
},
53+
tags: { runtime: 'browser' },
54+
});
55+
});
56+
57+
test('should update navigation transaction for dynamic routes', async ({ page }) => {
58+
const txPromise = waitForTransaction(APP_NAME, async transactionEvent => {
59+
return transactionEvent.transaction === '/performance/with/:param';
60+
});
61+
62+
await page.goto(`/performance`); // pageload
63+
await page.waitForTimeout(1000); // give it a sec before navigation
64+
await page.getByRole('link', { name: 'With Param Page' }).click(); // navigation
65+
66+
const transaction = await txPromise;
67+
68+
expect(transaction).toMatchObject({
69+
contexts: {
70+
trace: {
71+
span_id: expect.any(String),
72+
trace_id: expect.any(String),
73+
data: {
74+
'sentry.origin': 'auto.navigation.react-router',
75+
'sentry.op': 'navigation',
76+
'sentry.source': 'route',
77+
},
78+
op: 'navigation',
79+
origin: 'auto.navigation.react-router',
80+
},
81+
},
82+
spans: expect.any(Array),
83+
start_timestamp: expect.any(Number),
84+
timestamp: expect.any(Number),
85+
transaction: '/performance/with/:param',
86+
type: 'transaction',
87+
transaction_info: { source: 'route' },
88+
platform: 'javascript',
89+
request: {
90+
url: expect.stringContaining('/performance/with/sentry'),
91+
headers: expect.any(Object),
92+
},
93+
event_id: expect.any(String),
94+
environment: 'qa',
95+
sdk: {
96+
integrations: expect.arrayContaining([expect.any(String)]),
97+
name: 'sentry.javascript.react-router',
98+
version: expect.any(String),
99+
packages: [
100+
{ name: 'npm:@sentry/react-router', version: expect.any(String) },
101+
{ name: 'npm:@sentry/browser', version: expect.any(String) },
102+
],
103+
},
104+
tags: { runtime: 'browser' },
105+
});
106+
});
107+
});

dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/pageload.client.test.ts

+50
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,56 @@ test.describe('client - pageload performance', () => {
5353
});
5454
});
5555

56+
test('should update pageload transaction for dynamic routes', async ({ page }) => {
57+
const txPromise = waitForTransaction(APP_NAME, async transactionEvent => {
58+
return transactionEvent.transaction === '/performance/with/:param';
59+
});
60+
61+
await page.goto(`/performance/with/sentry`);
62+
63+
const transaction = await txPromise;
64+
65+
expect(transaction).toMatchObject({
66+
contexts: {
67+
trace: {
68+
span_id: expect.any(String),
69+
trace_id: expect.any(String),
70+
data: {
71+
'sentry.origin': 'auto.pageload.browser',
72+
'sentry.op': 'pageload',
73+
'sentry.source': 'route',
74+
},
75+
op: 'pageload',
76+
origin: 'auto.pageload.browser',
77+
},
78+
},
79+
spans: expect.any(Array),
80+
start_timestamp: expect.any(Number),
81+
timestamp: expect.any(Number),
82+
transaction: '/performance/with/:param',
83+
type: 'transaction',
84+
transaction_info: { source: 'route' },
85+
measurements: expect.any(Object),
86+
platform: 'javascript',
87+
request: {
88+
url: expect.stringContaining('/performance/with/sentry'),
89+
headers: expect.any(Object),
90+
},
91+
event_id: expect.any(String),
92+
environment: 'qa',
93+
sdk: {
94+
integrations: expect.arrayContaining([expect.any(String)]),
95+
name: 'sentry.javascript.react-router',
96+
version: expect.any(String),
97+
packages: [
98+
{ name: 'npm:@sentry/react-router', version: expect.any(String) },
99+
{ name: 'npm:@sentry/browser', version: expect.any(String) },
100+
],
101+
},
102+
tags: { runtime: 'browser' },
103+
});
104+
});
105+
56106
// todo: this page is currently not prerendered (see react-router.config.ts)
57107
test('should send pageload transaction for prerendered pages', async ({ page }) => {
58108
const txPromise = waitForTransaction(APP_NAME, async transactionEvent => {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { startBrowserTracingNavigationSpan } from '@sentry/browser';
2+
import type { Span } from '@sentry/core';
3+
import {
4+
consoleSandbox,
5+
getActiveSpan,
6+
getClient,
7+
getRootSpan,
8+
GLOBAL_OBJ,
9+
SEMANTIC_ATTRIBUTE_SENTRY_OP,
10+
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
11+
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
12+
spanToJSON,
13+
} from '@sentry/core';
14+
import type { DataRouter, RouterState } from 'react-router';
15+
import { DEBUG_BUILD } from '../common/debug-build';
16+
17+
const GLOBAL_OBJ_WITH_DATA_ROUTER = GLOBAL_OBJ as typeof GLOBAL_OBJ & {
18+
__reactRouterDataRouter?: DataRouter;
19+
};
20+
21+
const MAX_RETRIES = 40; // 2 seconds at 50ms interval
22+
23+
/**
24+
* Instruments the React Router Data Router for pageloads and navigation.
25+
*
26+
* This function waits for the router to be available after hydration, then:
27+
* 1. Updates the pageload transaction with parameterized route info
28+
* 2. Patches router.navigate() to create navigation transactions
29+
* 3. Subscribes to router state changes to update navigation transactions with parameterized routes
30+
*/
31+
export function instrumentHydratedRouter(): void {
32+
function trySubscribe(): boolean {
33+
const router = GLOBAL_OBJ_WITH_DATA_ROUTER.__reactRouterDataRouter;
34+
35+
if (router) {
36+
// The first time we hit the router, we try to update the pageload transaction
37+
// todo: update pageload tx here
38+
const pageloadSpan = getActiveRootSpan();
39+
const pageloadName = pageloadSpan ? spanToJSON(pageloadSpan).description : undefined;
40+
const parameterizePageloadRoute = getParameterizedRoute(router.state);
41+
if (
42+
pageloadName &&
43+
normalizePathname(router.state.location.pathname) === normalizePathname(pageloadName) && // this event is for the currently active pageload
44+
normalizePathname(parameterizePageloadRoute) !== normalizePathname(pageloadName) // route is not parameterized yet
45+
) {
46+
pageloadSpan?.updateName(parameterizePageloadRoute);
47+
pageloadSpan?.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route');
48+
}
49+
50+
// Patching navigate for creating accurate navigation transactions
51+
if (typeof router.navigate === 'function') {
52+
const originalNav = router.navigate.bind(router);
53+
router.navigate = function sentryPatchedNavigate(...args) {
54+
maybeCreateNavigationTransaction(
55+
String(args[0]) || '<unknown route>', // will be updated anyway
56+
'url', // this also will be updated once we have the parameterized route
57+
);
58+
return originalNav(...args);
59+
};
60+
}
61+
62+
// Subscribe to router state changes to update navigation transactions with parameterized routes
63+
router.subscribe(newState => {
64+
const navigationSpan = getActiveRootSpan();
65+
const navigationSpanName = navigationSpan ? spanToJSON(navigationSpan).description : undefined;
66+
const parameterizedNavRoute = getParameterizedRoute(newState);
67+
68+
if (
69+
navigationSpanName && // we have an active pageload tx
70+
newState.navigation.state === 'idle' && // navigation has completed
71+
normalizePathname(newState.location.pathname) === normalizePathname(navigationSpanName) && // this event is for the currently active navigation
72+
normalizePathname(parameterizedNavRoute) !== normalizePathname(navigationSpanName) // route is not parameterized yet
73+
) {
74+
navigationSpan?.updateName(parameterizedNavRoute);
75+
navigationSpan?.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route');
76+
}
77+
});
78+
return true;
79+
}
80+
return false;
81+
}
82+
83+
// Wait until the router is available (since the SDK loads before hydration)
84+
if (!trySubscribe()) {
85+
let retryCount = 0;
86+
// Retry until the router is available or max retries reached
87+
const interval = setInterval(() => {
88+
if (trySubscribe() || retryCount >= MAX_RETRIES) {
89+
if (retryCount >= MAX_RETRIES) {
90+
DEBUG_BUILD &&
91+
consoleSandbox(() => {
92+
// eslint-disable-next-line no-console
93+
console.warn('Unable to instrument React Router: router not found after hydration.');
94+
});
95+
}
96+
clearInterval(interval);
97+
}
98+
retryCount++;
99+
}, 50);
100+
}
101+
}
102+
103+
function maybeCreateNavigationTransaction(name: string, source: 'url' | 'route'): Span | undefined {
104+
const client = getClient();
105+
106+
if (!client) {
107+
return undefined;
108+
}
109+
110+
return startBrowserTracingNavigationSpan(client, {
111+
name,
112+
attributes: {
113+
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source,
114+
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation',
115+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react-router',
116+
},
117+
});
118+
}
119+
120+
function getActiveRootSpan(): Span | undefined {
121+
const activeSpan = getActiveSpan();
122+
if (!activeSpan) {
123+
return undefined;
124+
}
125+
126+
const rootSpan = getRootSpan(activeSpan);
127+
128+
const op = spanToJSON(rootSpan).op;
129+
130+
// Only use this root span if it is a pageload or navigation span
131+
return op === 'navigation' || op === 'pageload' ? rootSpan : undefined;
132+
}
133+
134+
function getParameterizedRoute(routerState: RouterState): string {
135+
const lastMatch = routerState.matches[routerState.matches.length - 1];
136+
return normalizePathname(lastMatch?.route.path ?? routerState.location.pathname);
137+
}
138+
139+
function normalizePathname(pathname: string): string {
140+
// Ensure it starts with a single slash
141+
let normalized = pathname.startsWith('/') ? pathname : `/${pathname}`;
142+
// Remove trailing slash unless it's the root
143+
if (normalized.length > 1 && normalized.endsWith('/')) {
144+
normalized = normalized.slice(0, -1);
145+
}
146+
return normalized;
147+
}
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export * from '@sentry/browser';
22

33
export { init } from './sdk';
4+
export { reactRouterTracingIntegration } from './tracingIntegration';

packages/react-router/src/client/sdk.ts

+20-6
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,33 @@
11
import type { BrowserOptions } from '@sentry/browser';
22
import { init as browserInit } from '@sentry/browser';
33
import type { Client } from '@sentry/core';
4-
import { applySdkMetadata, setTag } from '@sentry/core';
4+
import { applySdkMetadata, consoleSandbox, setTag } from '@sentry/core';
5+
6+
const BROWSER_TRACING_INTEGRATION_ID = 'BrowserTracing';
57

68
/**
79
* Initializes the client side of the React Router SDK.
810
*/
911
export function init(options: BrowserOptions): Client | undefined {
10-
const opts = {
11-
...options,
12-
};
12+
// If BrowserTracing integration was passed to options, emit a warning
13+
if (options.integrations && Array.isArray(options.integrations)) {
14+
const hasBrowserTracing = options.integrations.some(
15+
integration => integration.name === BROWSER_TRACING_INTEGRATION_ID,
16+
);
17+
18+
if (hasBrowserTracing) {
19+
consoleSandbox(() => {
20+
// eslint-disable-next-line no-console
21+
console.warn(
22+
'browserTracingIntegration is not fully compatible with @sentry/react-router. Please use reactRouterTracingIntegration instead.',
23+
);
24+
});
25+
}
26+
}
1327

14-
applySdkMetadata(opts, 'react-router', ['react-router', 'browser']);
28+
applySdkMetadata(options, 'react-router', ['react-router', 'browser']);
1529

16-
const client = browserInit(opts);
30+
const client = browserInit(options);
1731

1832
setTag('runtime', 'browser');
1933

0 commit comments

Comments
 (0)