Skip to content

Commit 0e268d3

Browse files
AbhiPrasadkamilogorek
authored andcommitted
feat: Add routing instrumentation for react router v4/v5 (#2780)
1 parent 1829886 commit 0e268d3

File tree

9 files changed

+892
-112
lines changed

9 files changed

+892
-112
lines changed

packages/react/package.json

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,17 +30,25 @@
3030
"devDependencies": {
3131
"@testing-library/react": "^10.0.6",
3232
"@testing-library/react-hooks": "^3.3.0",
33+
"@types/history-4": "npm:@types/[email protected]",
34+
"@types/history-5": "npm:@types/[email protected]",
3335
"@types/hoist-non-react-statics": "^3.3.1",
3436
"@types/react": "^16.9.35",
3537
"@types/react-router-3": "npm:@types/[email protected]",
38+
"@types/react-router-4": "npm:@types/[email protected]",
39+
"@types/react-router-5": "npm:@types/[email protected]",
40+
"history-4": "npm:[email protected]",
41+
"history-5": "npm:[email protected]",
3642
"jest": "^24.7.1",
3743
"jsdom": "^16.2.2",
3844
"npm-run-all": "^4.1.2",
3945
"prettier": "^1.17.0",
4046
"prettier-check": "^2.0.0",
4147
"react": "^16.0.0",
4248
"react-dom": "^16.0.0",
43-
"react-router-3": "npm:react-router@^3.2.0",
49+
"react-router-3": "npm:[email protected]",
50+
"react-router-4": "npm:[email protected]",
51+
"react-router-5": "npm:[email protected]",
4452
"react-test-renderer": "^16.13.1",
4553
"redux": "^4.0.5",
4654
"rimraf": "^2.6.3",

packages/react/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ 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';
29+
export { reactRouterV3Instrumentation } from './reactrouterv3';
30+
export { reactRouterV4Instrumentation, reactRouterV5Instrumentation, withSentryRouting } from './reactrouter';
3031

3132
createReactEventProcessor();

packages/react/src/reactrouter.tsx

Lines changed: 95 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -1,81 +1,91 @@
1-
import { Transaction, TransactionContext } from '@sentry/types';
1+
import { Transaction } from '@sentry/types';
22
import { getGlobalObject } from '@sentry/utils';
3+
import * as React from 'react';
34

4-
type ReactRouterInstrumentation = <T extends Transaction>(
5-
startTransaction: (context: TransactionContext) => T | undefined,
6-
startTransactionOnPageLoad?: boolean,
7-
startTransactionOnLocationChange?: boolean,
8-
) => void;
5+
import { Action, Location, ReactRouterInstrumentation } from './types';
96

10-
// Many of the types below had to be mocked out to prevent typescript issues
11-
// these types are required for correct functionality.
7+
type Match = { path: string; url: string; params: Record<string, any>; isExact: boolean };
128

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 = {
9+
export type RouterHistory = {
2610
location?: Location;
27-
listen?(cb: (location: Location) => void): void;
11+
listen?(cb: (location: Location, action: Action) => void): void;
2812
} & Record<string, any>;
2913

14+
export type RouteConfig = {
15+
path?: string | string[];
16+
exact?: boolean;
17+
component?: JSX.Element;
18+
routes?: RouteConfig[];
19+
[propName: string]: any;
20+
};
21+
22+
type MatchPath = (pathname: string, props: string | string[] | any, parent?: Match | null) => Match | null;
23+
3024
const global = getGlobalObject<Window>();
3125

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,
26+
let activeTransaction: Transaction | undefined;
27+
28+
export function reactRouterV4Instrumentation(
29+
history: RouterHistory,
30+
routes?: RouteConfig[],
31+
matchPath?: MatchPath,
32+
): ReactRouterInstrumentation {
33+
return reactRouterInstrumentation(history, 'react-router-v4', routes, matchPath);
34+
}
35+
36+
export function reactRouterV5Instrumentation(
37+
history: RouterHistory,
38+
routes?: RouteConfig[],
39+
matchPath?: MatchPath,
4440
): 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;
41+
return reactRouterInstrumentation(history, 'react-router-v5', routes, matchPath);
42+
}
43+
44+
function reactRouterInstrumentation(
45+
history: RouterHistory,
46+
name: string,
47+
allRoutes: RouteConfig[] = [],
48+
matchPath?: MatchPath,
49+
): ReactRouterInstrumentation {
50+
function getName(pathname: string): string {
51+
if (allRoutes === [] || !matchPath) {
52+
return pathname;
53+
}
5254

55+
const branches = matchRoutes(allRoutes, pathname, matchPath);
56+
// tslint:disable-next-line: prefer-for-of
57+
for (let x = 0; x < branches.length; x++) {
58+
if (branches[x].match.isExact) {
59+
return branches[x].match.path;
60+
}
61+
}
62+
63+
return pathname;
64+
}
65+
66+
return (startTransaction, startTransactionOnPageLoad = true, startTransactionOnLocationChange = true) => {
5367
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);
5668
activeTransaction = startTransaction({
57-
name: prevName,
69+
name: getName(global.location.pathname),
5870
op: 'pageload',
5971
tags: {
60-
'routing.instrumentation': 'react-router-v3',
72+
'routing.instrumentation': name,
6173
},
6274
});
6375
}
6476

6577
if (startTransactionOnLocationChange && history.listen) {
66-
history.listen(location => {
67-
if (location.action === 'PUSH') {
78+
history.listen((location, action) => {
79+
if (action && (action === 'PUSH' || action === 'POP')) {
6880
if (activeTransaction) {
6981
activeTransaction.finish();
7082
}
71-
const tags: Record<string, string> = { 'routing.instrumentation': 'react-router-v3' };
72-
if (prevName) {
73-
tags.from = prevName;
74-
}
83+
const tags = {
84+
'routing.instrumentation': name,
85+
};
7586

76-
prevName = normalizeTransactionName(routes, location, match);
7787
activeTransaction = startTransaction({
78-
name: prevName,
88+
name: getName(location.pathname),
7989
op: 'navigation',
8090
tags,
8191
});
@@ -86,54 +96,43 @@ export function reactRouterV3Instrumentation(
8696
}
8797

8898
/**
89-
* Normalize transaction names using `Router.match`
99+
* Matches a set of routes to a pathname
100+
* Based on implementation from
90101
*/
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;
102+
function matchRoutes(
103+
routes: RouteConfig[],
104+
pathname: string,
105+
matchPath: MatchPath,
106+
branch: Array<{ route: RouteConfig; match: Match }> = [],
107+
): Array<{ route: RouteConfig; match: Match }> {
108+
routes.some(route => {
109+
const match = route.path
110+
? matchPath(pathname, route)
111+
: branch.length
112+
? branch[branch.length - 1].match // use parent match
113+
: computeRootMatch(pathname); // use default "root" match
114+
115+
if (match) {
116+
branch.push({ route, match });
117+
118+
if (route.routes) {
119+
matchRoutes(route.routes, pathname, matchPath, branch);
101120
}
121+
}
102122

103-
const routePath = getRouteStringFromRoutes(renderProps.routes || []);
104-
if (routePath.length === 0 || routePath === '/*') {
105-
return name;
106-
}
123+
return !!match;
124+
});
107125

108-
name = routePath;
109-
return name;
110-
},
111-
);
112-
return name;
126+
return branch;
113127
}
114128

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);
129+
function computeRootMatch(pathname: string): Match {
130+
return { path: '/', url: '/', params: {}, isExact: pathname === '/' };
131+
}
124132

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-
}
133+
export const withSentryRouting = (Route: React.ElementType) => (props: { computedMatch?: Match }) => {
134+
if (activeTransaction && props && props.computedMatch && props.computedMatch.isExact) {
135+
activeTransaction.setName(props.computedMatch.path);
132136
}
133-
134-
return routesWithPaths
135-
.slice(index)
136-
.filter(({ path }) => !!path)
137-
.map(({ path }) => path)
138-
.join('');
139-
}
137+
return <Route {...props} />;
138+
};

packages/react/src/reactrouterv3.ts

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { Transaction, TransactionContext } from '@sentry/types';
2+
import { getGlobalObject } from '@sentry/utils';
3+
4+
import { Location, ReactRouterInstrumentation } from './types';
5+
6+
// Many of the types below had to be mocked out to prevent typescript issues
7+
// these types are required for correct functionality.
8+
9+
type HistoryV3 = {
10+
location?: Location;
11+
listen?(cb: (location: Location) => void): void;
12+
} & Record<string, any>;
13+
14+
export type Route = { path?: string; childRoutes?: Route[] };
15+
16+
export type Match = (
17+
props: { location: Location; routes: Route[] },
18+
cb: (error?: Error, _redirectLocation?: Location, renderProps?: { routes?: Route[] }) => void,
19+
) => void;
20+
21+
const global = getGlobalObject<Window>();
22+
23+
/**
24+
* Creates routing instrumentation for React Router v3
25+
* Works for React Router >= 3.2.0 and < 4.0.0
26+
*
27+
* @param history object from the `history` library
28+
* @param routes a list of all routes, should be
29+
* @param match `Router.match` utility
30+
*/
31+
export function reactRouterV3Instrumentation(
32+
history: HistoryV3,
33+
routes: Route[],
34+
match: Match,
35+
): ReactRouterInstrumentation {
36+
return (
37+
startTransaction: (context: TransactionContext) => Transaction | undefined,
38+
startTransactionOnPageLoad: boolean = true,
39+
startTransactionOnLocationChange: boolean = true,
40+
) => {
41+
let activeTransaction: Transaction | undefined;
42+
let prevName: string | undefined;
43+
44+
// Have to use global.location because history.location might not be defined.
45+
if (startTransactionOnPageLoad && global && global.location) {
46+
prevName = normalizeTransactionName(routes, global.location, match);
47+
48+
activeTransaction = startTransaction({
49+
name: prevName,
50+
op: 'pageload',
51+
tags: {
52+
'routing.instrumentation': 'react-router-v3',
53+
},
54+
});
55+
}
56+
57+
if (startTransactionOnLocationChange && history.listen) {
58+
history.listen(location => {
59+
if (location.action === 'PUSH' || location.action === 'POP') {
60+
if (activeTransaction) {
61+
activeTransaction.finish();
62+
}
63+
const tags: Record<string, string> = { 'routing.instrumentation': 'react-router-v3' };
64+
if (prevName) {
65+
tags.from = prevName;
66+
}
67+
prevName = normalizeTransactionName(routes, location, match);
68+
activeTransaction = startTransaction({
69+
name: prevName,
70+
op: 'navigation',
71+
tags,
72+
});
73+
}
74+
});
75+
}
76+
};
77+
}
78+
79+
/**
80+
* Normalize transaction names using `Router.match`
81+
*/
82+
function normalizeTransactionName(appRoutes: Route[], location: Location, match: Match): string {
83+
let name = location.pathname;
84+
match(
85+
{
86+
location,
87+
routes: appRoutes,
88+
},
89+
(error, _redirectLocation, renderProps) => {
90+
if (error || !renderProps) {
91+
return name;
92+
}
93+
94+
const routePath = getRouteStringFromRoutes(renderProps.routes || []);
95+
if (routePath.length === 0 || routePath === '/*') {
96+
return name;
97+
}
98+
99+
name = routePath;
100+
return name;
101+
},
102+
);
103+
return name;
104+
}
105+
106+
/**
107+
* Generate route name from array of routes
108+
*/
109+
function getRouteStringFromRoutes(routes: Route[]): string {
110+
if (!Array.isArray(routes) || routes.length === 0) {
111+
return '';
112+
}
113+
114+
const routesWithPaths: Route[] = routes.filter((route: Route) => !!route.path);
115+
116+
let index = -1;
117+
for (let x = routesWithPaths.length - 1; x >= 0; x--) {
118+
const route = routesWithPaths[x];
119+
if (route.path && route.path.startsWith('/')) {
120+
index = x;
121+
break;
122+
}
123+
}
124+
125+
return routesWithPaths
126+
.slice(index)
127+
.filter(({ path }) => !!path)
128+
.map(({ path }) => path)
129+
.join('');
130+
}

0 commit comments

Comments
 (0)