-
-
Notifications
You must be signed in to change notification settings - Fork 1.8k
Expand file tree
/
Copy pathhydratedRouter.ts
More file actions
158 lines (138 loc) · 5.47 KB
/
hydratedRouter.ts
File metadata and controls
158 lines (138 loc) · 5.47 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
import { startBrowserTracingNavigationSpan } from '@sentry/browser';
import type { Span } from '@sentry/core';
import {
debug,
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';
import { DEBUG_BUILD } from '../common/debug-build';
import { isClientInstrumentationApiUsed } from './createClientInstrumentation';
import { resolveNavigateArg } from './utils';
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
const pageloadSpan = getActiveRootSpan();
if (pageloadSpan) {
const pageloadName = spanToJSON(pageloadSpan).description;
const parameterizePageloadRoute = getParameterizedRoute(router.state);
if (
pageloadName &&
// this event is for the currently active pageload
normalizePathname(router.state.location.pathname) === normalizePathname(pageloadName)
) {
pageloadSpan.updateName(parameterizePageloadRoute);
pageloadSpan.setAttributes({
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.react_router',
});
}
}
// Patching navigate for creating accurate navigation transactions
if (typeof router.navigate === 'function') {
const originalNav = router.navigate.bind(router);
router.navigate = function sentryPatchedNavigate(...args) {
// Skip if instrumentation API is enabled (it handles navigation spans itself)
if (!isClientInstrumentationApiUsed()) {
maybeCreateNavigationTransaction(resolveNavigateArg(args[0]) || '<unknown route>', 'url');
}
return originalNav(...args);
};
}
// Subscribe to router state changes to update navigation transactions with parameterized routes
router.subscribe(newState => {
const navigationSpan = getActiveRootSpan();
if (!navigationSpan) {
return;
}
const navigationSpanName = spanToJSON(navigationSpan).description;
const parameterizedNavRoute = getParameterizedRoute(newState);
if (
navigationSpanName &&
newState.navigation.state === 'idle' && // navigation has completed
// this event is for the currently active navigation
normalizePathname(newState.location.pathname) === normalizePathname(navigationSpanName)
) {
navigationSpan.updateName(parameterizedNavRoute);
navigationSpan.setAttributes({
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react_router',
});
}
});
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) {
if (retryCount >= MAX_RETRIES) {
DEBUG_BUILD && debug.warn('Unable to instrument React Router: router not found after hydration.');
}
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;
}