-
-
Notifications
You must be signed in to change notification settings - Fork 1.8k
Expand file tree
/
Copy pathhydratedRouter.ts
More file actions
169 lines (147 loc) · 5.8 KB
/
Copy pathhydratedRouter.ts
File metadata and controls
169 lines (147 loc) · 5.8 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
159
160
161
162
163
164
165
166
167
168
169
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 (and any pageload
// whose route info only became available after `trySubscribe`, e.g. lazy routes) with the
// parameterized route.
router.subscribe(newState => {
const rootSpan = getActiveRootSpan();
if (!rootSpan) {
return;
}
const rootSpanJson = spanToJSON(rootSpan);
// When the instrumentation API is active, navigation roots are parameterized
// by the native route hooks
if (
rootSpanJson.op === 'navigation' &&
isClientInstrumentationApiUsed() &&
rootSpanJson.data?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] === 'route'
) {
return;
}
const rootSpanName = rootSpanJson.description;
const parameterizedRoute = getParameterizedRoute(newState);
if (
rootSpanName &&
newState.navigation.state === 'idle' && // navigation has completed
// this event is for the currently active root span
normalizePathname(newState.location.pathname) === normalizePathname(rootSpanName)
) {
rootSpan.updateName(parameterizedRoute);
rootSpan.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) {
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;
}