Skip to content

Commit 24911e5

Browse files
committed
add router instrumentation
1 parent a67ebc4 commit 24911e5

File tree

1 file changed

+138
-0
lines changed

1 file changed

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

0 commit comments

Comments
 (0)