Skip to content

Commit 6fc494c

Browse files
authored
feat: Add instrumentation for React Router v3 (getsentry#2759)
1 parent 33a414f commit 6fc494c

File tree

5 files changed

+447
-8
lines changed

5 files changed

+447
-8
lines changed

packages/react/package.json

+2
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,15 @@
3232
"@testing-library/react-hooks": "^3.3.0",
3333
"@types/hoist-non-react-statics": "^3.3.1",
3434
"@types/react": "^16.9.35",
35+
"@types/react-router-3": "npm:@types/react-router@^3.2.0",
3536
"jest": "^24.7.1",
3637
"jsdom": "^16.2.2",
3738
"npm-run-all": "^4.1.2",
3839
"prettier": "^1.17.0",
3940
"prettier-check": "^2.0.0",
4041
"react": "^16.0.0",
4142
"react-dom": "^16.0.0",
43+
"react-router-3": "npm:react-router@^3.2.0",
4244
"react-test-renderer": "^16.13.1",
4345
"redux": "^4.0.5",
4446
"rimraf": "^2.6.3",

packages/react/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -26,5 +26,6 @@ export * from '@sentry/browser';
2626
export { Profiler, withProfiler, useProfiler } from './profiler';
2727
export { ErrorBoundary, withErrorBoundary } from './errorboundary';
2828
export { createReduxEnhancer } from './redux';
29+
export { reactRouterV3Instrumentation } from './reactrouter';
2930

3031
createReactEventProcessor();

packages/react/src/reactrouter.tsx

+139
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { Transaction, TransactionContext } from '@sentry/types';
2+
import { getGlobalObject } from '@sentry/utils';
3+
4+
type ReactRouterInstrumentation = <T extends Transaction>(
5+
startTransaction: (context: TransactionContext) => T | undefined,
6+
startTransactionOnPageLoad?: boolean,
7+
startTransactionOnLocationChange?: boolean,
8+
) => void;
9+
10+
// Many of the types below had to be mocked out to prevent typescript issues
11+
// these types are required for correct functionality.
12+
13+
export type Route = { path?: string; childRoutes?: Route[] };
14+
15+
export type Match = (
16+
props: { location: Location; routes: Route[] },
17+
cb: (error?: Error, _redirectLocation?: Location, renderProps?: { routes?: Route[] }) => void,
18+
) => void;
19+
20+
type Location = {
21+
pathname: string;
22+
action?: 'PUSH' | 'REPLACE' | 'POP';
23+
} & Record<string, any>;
24+
25+
type History = {
26+
location?: Location;
27+
listen?(cb: (location: Location) => void): void;
28+
} & Record<string, any>;
29+
30+
const global = getGlobalObject<Window>();
31+
32+
/**
33+
* Creates routing instrumentation for React Router v3
34+
* Works for React Router >= 3.2.0 and < 4.0.0
35+
*
36+
* @param history object from the `history` library
37+
* @param routes a list of all routes, should be
38+
* @param match `Router.match` utility
39+
*/
40+
export function reactRouterV3Instrumentation(
41+
history: History,
42+
routes: Route[],
43+
match: Match,
44+
): ReactRouterInstrumentation {
45+
return (
46+
startTransaction: (context: TransactionContext) => Transaction | undefined,
47+
startTransactionOnPageLoad: boolean = true,
48+
startTransactionOnLocationChange: boolean = true,
49+
) => {
50+
let activeTransaction: Transaction | undefined;
51+
let prevName: string | undefined;
52+
53+
if (startTransactionOnPageLoad && global && global.location) {
54+
// Have to use global.location because history.location might not be defined.
55+
prevName = normalizeTransactionName(routes, global.location, match);
56+
activeTransaction = startTransaction({
57+
name: prevName,
58+
op: 'pageload',
59+
tags: {
60+
'routing.instrumentation': 'react-router-v3',
61+
},
62+
});
63+
}
64+
65+
if (startTransactionOnLocationChange && history.listen) {
66+
history.listen(location => {
67+
if (location.action === 'PUSH') {
68+
if (activeTransaction) {
69+
activeTransaction.finish();
70+
}
71+
const tags: Record<string, string> = { 'routing.instrumentation': 'react-router-v3' };
72+
if (prevName) {
73+
tags.from = prevName;
74+
}
75+
76+
prevName = normalizeTransactionName(routes, location, match);
77+
activeTransaction = startTransaction({
78+
name: prevName,
79+
op: 'navigation',
80+
tags,
81+
});
82+
}
83+
});
84+
}
85+
};
86+
}
87+
88+
/**
89+
* Normalize transaction names using `Router.match`
90+
*/
91+
function normalizeTransactionName(appRoutes: Route[], location: Location, match: Match): string {
92+
let name = location.pathname;
93+
match(
94+
{
95+
location,
96+
routes: appRoutes,
97+
},
98+
(error, _redirectLocation, renderProps) => {
99+
if (error || !renderProps) {
100+
return name;
101+
}
102+
103+
const routePath = getRouteStringFromRoutes(renderProps.routes || []);
104+
if (routePath.length === 0 || routePath === '/*') {
105+
return name;
106+
}
107+
108+
name = routePath;
109+
return name;
110+
},
111+
);
112+
return name;
113+
}
114+
115+
/**
116+
* Generate route name from array of routes
117+
*/
118+
function getRouteStringFromRoutes(routes: Route[]): string {
119+
if (!Array.isArray(routes) || routes.length === 0) {
120+
return '';
121+
}
122+
123+
const routesWithPaths: Route[] = routes.filter((route: Route) => !!route.path);
124+
125+
let index = -1;
126+
for (let x = routesWithPaths.length - 1; x >= 0; x--) {
127+
const route = routesWithPaths[x];
128+
if (route.path && route.path.startsWith('/')) {
129+
index = x;
130+
break;
131+
}
132+
}
133+
134+
return routesWithPaths
135+
.slice(index)
136+
.filter(({ path }) => !!path)
137+
.map(({ path }) => path)
138+
.join('');
139+
}
+147
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { render } from '@testing-library/react';
2+
import * as React from 'react';
3+
import { createMemoryHistory, createRoutes, IndexRoute, match, Route, Router } from 'react-router-3';
4+
5+
import { Match, reactRouterV3Instrumentation, Route as RouteType } from '../src/reactrouter';
6+
7+
// Have to manually set types because we are using package-alias
8+
declare module 'react-router-3' {
9+
type History = { replace: Function; push: Function };
10+
export function createMemoryHistory(): History;
11+
export const Router: React.ComponentType<{ history: History }>;
12+
export const Route: React.ComponentType<{ path: string; component?: React.ComponentType<any> }>;
13+
export const IndexRoute: React.ComponentType<{ component: React.ComponentType<any> }>;
14+
export const match: Match;
15+
export const createRoutes: (routes: any) => RouteType[];
16+
}
17+
18+
describe('React Router V3', () => {
19+
const routes = (
20+
<Route path="/" component={({ children }: { children: JSX.Element }) => <div>{children}</div>}>
21+
<IndexRoute component={() => <div>Home</div>} />
22+
<Route path="about" component={() => <div>About</div>} />
23+
<Route path="features" component={() => <div>Features</div>} />
24+
<Route
25+
path="users/:userid"
26+
component={({ params }: { params: Record<string, string> }) => <div>{params.userid}</div>}
27+
/>
28+
<Route path="organizations/">
29+
<Route path=":orgid" component={() => <div>OrgId</div>} />
30+
<Route path=":orgid/v1/:teamid" component={() => <div>Team</div>} />
31+
</Route>
32+
</Route>
33+
);
34+
const history = createMemoryHistory();
35+
36+
const instrumentationRoutes = createRoutes(routes);
37+
const instrumentation = reactRouterV3Instrumentation(history, instrumentationRoutes, match);
38+
39+
it('starts a pageload transaction when instrumentation is started', () => {
40+
const mockStartTransaction = jest.fn();
41+
instrumentation(mockStartTransaction);
42+
expect(mockStartTransaction).toHaveBeenCalledTimes(1);
43+
expect(mockStartTransaction).toHaveBeenLastCalledWith({
44+
name: '/',
45+
op: 'pageload',
46+
tags: { 'routing.instrumentation': 'react-router-v3' },
47+
});
48+
});
49+
50+
it('does not start pageload transaction if option is false', () => {
51+
const mockStartTransaction = jest.fn();
52+
instrumentation(mockStartTransaction, false);
53+
expect(mockStartTransaction).toHaveBeenCalledTimes(0);
54+
});
55+
56+
it('starts a navigation transaction', () => {
57+
const mockStartTransaction = jest.fn();
58+
instrumentation(mockStartTransaction);
59+
render(<Router history={history}>{routes}</Router>);
60+
61+
history.push('/about');
62+
expect(mockStartTransaction).toHaveBeenCalledTimes(2);
63+
expect(mockStartTransaction).toHaveBeenLastCalledWith({
64+
name: '/about',
65+
op: 'navigation',
66+
tags: { from: '/', 'routing.instrumentation': 'react-router-v3' },
67+
});
68+
69+
history.push('/features');
70+
expect(mockStartTransaction).toHaveBeenCalledTimes(3);
71+
expect(mockStartTransaction).toHaveBeenLastCalledWith({
72+
name: '/features',
73+
op: 'navigation',
74+
tags: { from: '/about', 'routing.instrumentation': 'react-router-v3' },
75+
});
76+
});
77+
78+
it('does not start a transaction if option is false', () => {
79+
const mockStartTransaction = jest.fn();
80+
instrumentation(mockStartTransaction, true, false);
81+
render(<Router history={history}>{routes}</Router>);
82+
expect(mockStartTransaction).toHaveBeenCalledTimes(1);
83+
});
84+
85+
it('only starts a navigation transaction on push', () => {
86+
const mockStartTransaction = jest.fn();
87+
instrumentation(mockStartTransaction);
88+
render(<Router history={history}>{routes}</Router>);
89+
90+
history.replace('hello');
91+
expect(mockStartTransaction).toHaveBeenCalledTimes(1);
92+
});
93+
94+
it('finishes a transaction on navigation', () => {
95+
const mockFinish = jest.fn();
96+
const mockStartTransaction = jest.fn().mockReturnValue({ finish: mockFinish });
97+
instrumentation(mockStartTransaction);
98+
render(<Router history={history}>{routes}</Router>);
99+
expect(mockStartTransaction).toHaveBeenCalledTimes(1);
100+
101+
history.push('/features');
102+
expect(mockFinish).toHaveBeenCalledTimes(1);
103+
expect(mockStartTransaction).toHaveBeenCalledTimes(2);
104+
});
105+
106+
it('normalizes transaction names', () => {
107+
const mockStartTransaction = jest.fn();
108+
instrumentation(mockStartTransaction);
109+
const { container } = render(<Router history={history}>{routes}</Router>);
110+
111+
history.push('/users/123');
112+
expect(container.innerHTML).toContain('123');
113+
114+
expect(mockStartTransaction).toHaveBeenCalledTimes(2);
115+
expect(mockStartTransaction).toHaveBeenLastCalledWith({
116+
name: '/users/:userid',
117+
op: 'navigation',
118+
tags: { from: '/', 'routing.instrumentation': 'react-router-v3' },
119+
});
120+
});
121+
122+
it('normalizes nested transaction names', () => {
123+
const mockStartTransaction = jest.fn();
124+
instrumentation(mockStartTransaction);
125+
const { container } = render(<Router history={history}>{routes}</Router>);
126+
127+
history.push('/organizations/1234/v1/758');
128+
expect(container.innerHTML).toContain('Team');
129+
130+
expect(mockStartTransaction).toHaveBeenCalledTimes(2);
131+
expect(mockStartTransaction).toHaveBeenLastCalledWith({
132+
name: '/organizations/:orgid/v1/:teamid',
133+
op: 'navigation',
134+
tags: { from: '/', 'routing.instrumentation': 'react-router-v3' },
135+
});
136+
137+
history.push('/organizations/543');
138+
expect(container.innerHTML).toContain('OrgId');
139+
140+
expect(mockStartTransaction).toHaveBeenCalledTimes(3);
141+
expect(mockStartTransaction).toHaveBeenLastCalledWith({
142+
name: '/organizations/:orgid',
143+
op: 'navigation',
144+
tags: { from: '/organizations/:orgid/v1/:teamid', 'routing.instrumentation': 'react-router-v3' },
145+
});
146+
});
147+
});

0 commit comments

Comments
 (0)