Skip to content

Commit da4e2b3

Browse files
committed
feat: Add instrumentation for React Router v3
1 parent 16f57a5 commit da4e2b3

File tree

6 files changed

+199
-75
lines changed

6 files changed

+199
-75
lines changed

packages/react/package.json

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,21 +28,19 @@
2828
"react-dom": "15.x || 16.x"
2929
},
3030
"devDependencies": {
31-
"@sentry/tracing": "5.19.2",
3231
"@testing-library/react": "^10.0.6",
3332
"@testing-library/react-hooks": "^3.3.0",
34-
"@types/history": "^4.7.6",
3533
"@types/hoist-non-react-statics": "^3.3.1",
3634
"@types/react": "^16.9.35",
37-
"@types/react-router-3": "npm:@types/react-router@3.0.20",
35+
"@types/react-router-3": "npm:@types/react-router@^3.2.0",
3836
"jest": "^24.7.1",
3937
"jsdom": "^16.2.2",
4038
"npm-run-all": "^4.1.2",
4139
"prettier": "^1.17.0",
4240
"prettier-check": "^2.0.0",
4341
"react": "^16.0.0",
4442
"react-dom": "^16.0.0",
45-
"react-router-3": "npm:[email protected]",
43+
"react-router-3": "npm:react-router@^3.2.0",
4644
"react-test-renderer": "^16.13.1",
4745
"redux": "^4.0.5",
4846
"rimraf": "^2.6.3",

packages/react/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +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 { reactRouterV3Instrumenation } from './reactrouter';
29+
export { reactRouterV3Instrumentation } from './reactrouter';
3030

3131
createReactEventProcessor();

packages/react/src/reactrouter.tsx

Lines changed: 54 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,70 +1,81 @@
11
import { Transaction, TransactionContext } from '@sentry/types';
2+
import { getGlobalObject } from '@sentry/utils';
23

3-
type routingInstrumentation = (
4-
startTransaction: (context: TransactionContext) => Transaction,
4+
type ReactRouterInstrumentation = <T extends Transaction>(
5+
startTransaction: (context: TransactionContext) => T | undefined,
56
startTransactionOnPageLoad?: boolean,
67
startTransactionOnLocationChange?: boolean,
78
) => void;
89

9-
// Many of the types below had to be mocked out to lower bundle size.
10-
type PlainRoute = { path: string; childRoutes: PlainRoute[] };
10+
// Many of the types below had to be mocked out to prevent typescript issues
11+
// these types are required for correct functionality.
1112

12-
type Match = (
13-
props: { location: Location; routes: PlainRoute[] },
14-
cb: (error?: Error, _redirectLocation?: Location, renderProps?: { routes?: PlainRoute[] }) => void,
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,
1518
) => void;
1619

1720
type Location = {
1821
pathname: string;
19-
action: 'PUSH' | 'REPLACE' | 'POP';
20-
key: string;
22+
action?: 'PUSH' | 'REPLACE' | 'POP';
2123
} & Record<string, any>;
2224

2325
type History = {
24-
location: Location;
25-
listen(cb: (location: Location) => void): void;
26+
location?: Location;
27+
listen?(cb: (location: Location) => void): void;
2628
} & Record<string, any>;
2729

30+
const global = getGlobalObject<Window>();
31+
2832
/**
2933
* Creates routing instrumentation for React Router v3
34+
* Works for React Router >= 3.2.0 and < 4.0.0
3035
*
3136
* @param history object from the `history` library
3237
* @param routes a list of all routes, should be
3338
* @param match `Router.match` utility
3439
*/
35-
export function reactRouterV3Instrumenation(
40+
export function reactRouterV3Instrumentation(
3641
history: History,
37-
routes: PlainRoute[],
42+
routes: Route[],
3843
match: Match,
39-
): routingInstrumentation {
44+
): ReactRouterInstrumentation {
4045
return (
4146
startTransaction: (context: TransactionContext) => Transaction | undefined,
4247
startTransactionOnPageLoad: boolean = true,
4348
startTransactionOnLocationChange: boolean = true,
4449
) => {
4550
let activeTransaction: Transaction | undefined;
46-
let name = normalizeTransactionName(routes, history.location, match);
47-
if (startTransactionOnPageLoad) {
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);
4856
activeTransaction = startTransaction({
49-
name,
57+
name: prevName,
5058
op: 'pageload',
5159
tags: {
52-
from: name,
53-
routingInstrumentation: 'react-router-v3',
60+
'routing.instrumentation': 'react-router-v3',
5461
},
5562
});
5663
}
5764

58-
if (startTransactionOnLocationChange) {
65+
if (startTransactionOnLocationChange && history.listen) {
5966
history.listen(location => {
6067
if (location.action === 'PUSH') {
6168
if (activeTransaction) {
6269
activeTransaction.finish();
6370
}
64-
const tags = { from: name, routingInstrumentation: 'react-router-v3' };
65-
name = normalizeTransactionName(routes, history.location, match);
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);
6677
activeTransaction = startTransaction({
67-
name,
78+
name: prevName,
6879
op: 'navigation',
6980
tags,
7081
});
@@ -74,41 +85,47 @@ export function reactRouterV3Instrumenation(
7485
};
7586
}
7687

77-
export function normalizeTransactionName(appRoutes: PlainRoute[], location: Location, match: Match): string {
78-
const defaultName = location.pathname;
88+
/**
89+
* Normalize transaction names using `Router.match`
90+
*/
91+
function normalizeTransactionName(appRoutes: Route[], location: Location, match: Match): string {
92+
let name = location.pathname;
7993
match(
8094
{
8195
location,
8296
routes: appRoutes,
8397
},
84-
(error?: Error, _redirectLocation?: Location, renderProps?: { routes?: PlainRoute[] }) => {
98+
(error, _redirectLocation, renderProps) => {
8599
if (error || !renderProps) {
86-
return defaultName;
100+
return name;
87101
}
88102

89103
const routePath = getRouteStringFromRoutes(renderProps.routes || []);
90-
91104
if (routePath.length === 0 || routePath === '/*') {
92-
return defaultName;
105+
return name;
93106
}
94107

95-
return routePath;
108+
name = routePath;
109+
return name;
96110
},
97111
);
98-
99-
return defaultName;
112+
return name;
100113
}
101114

102-
function getRouteStringFromRoutes(routes: PlainRoute[]): string {
103-
if (!Array.isArray(routes)) {
115+
/**
116+
* Generate route name from array of routes
117+
*/
118+
function getRouteStringFromRoutes(routes: Route[]): string {
119+
if (!Array.isArray(routes) || routes.length === 0) {
104120
return '';
105121
}
106122

107-
const routesWithPaths: PlainRoute[] = routes.filter((route: PlainRoute) => !!route.path);
123+
const routesWithPaths: Route[] = routes.filter((route: Route) => !!route.path);
108124

109125
let index = -1;
110-
for (let x = routesWithPaths.length; x >= 0; x--) {
111-
if (routesWithPaths[x].path.startsWith('/')) {
126+
for (let x = routesWithPaths.length - 1; x >= 0; x--) {
127+
const route = routesWithPaths[x];
128+
if (route.path && route.path.startsWith('/')) {
112129
index = x;
113130
break;
114131
}

packages/react/test/reactrouter.test.tsx

Lines changed: 0 additions & 6 deletions
This file was deleted.
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
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+
const App: React.FC = ({ children }) => <div>{children}</div>;
19+
20+
function Home(): JSX.Element {
21+
return <div>Home</div>;
22+
}
23+
24+
function About(): JSX.Element {
25+
return <div>About</div>;
26+
}
27+
28+
function Features(): JSX.Element {
29+
return <div>Features</div>;
30+
}
31+
32+
function Users({ params }: { params: Record<string, string> }): JSX.Element {
33+
return <div>{params.userid}</div>;
34+
}
35+
36+
describe('React Router V3', () => {
37+
const routes = (
38+
<Route path="/" component={App}>
39+
<IndexRoute component={Home} />
40+
<Route path="about" component={About} />
41+
<Route path="features" component={Features} />
42+
<Route path="users/:userid" component={Users} />
43+
</Route>
44+
);
45+
const history = createMemoryHistory();
46+
47+
const instrumentationRoutes = createRoutes(routes);
48+
const instrumentation = reactRouterV3Instrumentation(history, instrumentationRoutes, match);
49+
50+
it('starts a pageload transaction when instrumentation is started', () => {
51+
const mockStartTransaction = jest.fn();
52+
instrumentation(mockStartTransaction);
53+
expect(mockStartTransaction).toHaveBeenCalledTimes(1);
54+
expect(mockStartTransaction).toHaveBeenLastCalledWith({
55+
name: '/',
56+
op: 'pageload',
57+
tags: { 'routing.instrumentation': 'react-router-v3' },
58+
});
59+
});
60+
61+
it('does not start pageload transaction if option is false', () => {
62+
const mockStartTransaction = jest.fn();
63+
instrumentation(mockStartTransaction, false);
64+
expect(mockStartTransaction).toHaveBeenCalledTimes(0);
65+
});
66+
67+
it('starts a navigation transaction', () => {
68+
const mockStartTransaction = jest.fn();
69+
instrumentation(mockStartTransaction);
70+
render(<Router history={history}>{routes}</Router>);
71+
72+
history.push('/about');
73+
expect(mockStartTransaction).toHaveBeenCalledTimes(2);
74+
expect(mockStartTransaction).toHaveBeenLastCalledWith({
75+
name: '/about',
76+
op: 'navigation',
77+
tags: { from: '/', 'routing.instrumentation': 'react-router-v3' },
78+
});
79+
80+
history.push('/features');
81+
expect(mockStartTransaction).toHaveBeenCalledTimes(3);
82+
expect(mockStartTransaction).toHaveBeenLastCalledWith({
83+
name: '/features',
84+
op: 'navigation',
85+
tags: { from: '/about', 'routing.instrumentation': 'react-router-v3' },
86+
});
87+
});
88+
89+
it('does not start a transaction if option is false', () => {
90+
const mockStartTransaction = jest.fn();
91+
instrumentation(mockStartTransaction, true, false);
92+
render(<Router history={history}>{routes}</Router>);
93+
expect(mockStartTransaction).toHaveBeenCalledTimes(1);
94+
});
95+
96+
it('only starts a navigation transaction on push', () => {
97+
const mockStartTransaction = jest.fn();
98+
instrumentation(mockStartTransaction);
99+
render(<Router history={history}>{routes}</Router>);
100+
101+
history.replace('hello');
102+
expect(mockStartTransaction).toHaveBeenCalledTimes(1);
103+
});
104+
105+
it('finishes a transaction on navigation', () => {
106+
const mockFinish = jest.fn();
107+
const mockStartTransaction = jest.fn().mockReturnValue({ finish: mockFinish });
108+
instrumentation(mockStartTransaction);
109+
render(<Router history={history}>{routes}</Router>);
110+
expect(mockStartTransaction).toHaveBeenCalledTimes(1);
111+
112+
history.push('/features');
113+
expect(mockFinish).toHaveBeenCalledTimes(1);
114+
expect(mockStartTransaction).toHaveBeenCalledTimes(2);
115+
});
116+
117+
it('normalizes transaction names', () => {
118+
const mockStartTransaction = jest.fn();
119+
instrumentation(mockStartTransaction);
120+
const { container } = render(<Router history={history}>{routes}</Router>);
121+
122+
history.push('/users/123');
123+
expect(container.innerHTML).toContain('123');
124+
125+
expect(mockStartTransaction).toHaveBeenCalledTimes(2);
126+
expect(mockStartTransaction).toHaveBeenLastCalledWith({
127+
name: '/users/:userid',
128+
op: 'navigation',
129+
tags: { from: '/', 'routing.instrumentation': 'react-router-v3' },
130+
});
131+
});
132+
});

0 commit comments

Comments
 (0)