Skip to content

feat: Add routing instrumentation for react router v4/v5 #2780

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Aug 10, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,17 +30,25 @@
"devDependencies": {
"@testing-library/react": "^10.0.6",
"@testing-library/react-hooks": "^3.3.0",
"@types/history-4": "npm:@types/[email protected]",
"@types/history-5": "npm:@types/[email protected]",
"@types/hoist-non-react-statics": "^3.3.1",
"@types/react": "^16.9.35",
"@types/react-router-3": "npm:@types/[email protected]",
"@types/react-router-4": "npm:@types/[email protected]",
"@types/react-router-5": "npm:@types/[email protected]",
"history-4": "npm:[email protected]",
"history-5": "npm:[email protected]",
"jest": "^24.7.1",
"jsdom": "^16.2.2",
"npm-run-all": "^4.1.2",
"prettier": "^1.17.0",
"prettier-check": "^2.0.0",
"react": "^16.0.0",
"react-dom": "^16.0.0",
"react-router-3": "npm:react-router@^3.2.0",
"react-router-3": "npm:[email protected]",
"react-router-4": "npm:[email protected]",
"react-router-5": "npm:[email protected]",
"react-test-renderer": "^16.13.1",
"redux": "^4.0.5",
"rimraf": "^2.6.3",
Expand Down
3 changes: 2 additions & 1 deletion packages/react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export * from '@sentry/browser';
export { Profiler, withProfiler, useProfiler } from './profiler';
export { ErrorBoundary, withErrorBoundary } from './errorboundary';
export { createReduxEnhancer } from './redux';
export { reactRouterV3Instrumentation } from './reactrouter';
export { reactRouterV3Instrumentation } from './reactrouterv3';
export { reactRouterV4Instrumentation, reactRouterV5Instrumentation, withSentryRouting } from './reactrouter';

createReactEventProcessor();
191 changes: 95 additions & 96 deletions packages/react/src/reactrouter.tsx
Original file line number Diff line number Diff line change
@@ -1,81 +1,91 @@
import { Transaction, TransactionContext } from '@sentry/types';
import { Transaction } from '@sentry/types';
import { getGlobalObject } from '@sentry/utils';
import * as React from 'react';

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

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

export type Route = { path?: string; childRoutes?: Route[] };

export type Match = (
props: { location: Location; routes: Route[] },
cb: (error?: Error, _redirectLocation?: Location, renderProps?: { routes?: Route[] }) => void,
) => void;

type Location = {
pathname: string;
action?: 'PUSH' | 'REPLACE' | 'POP';
} & Record<string, any>;

type History = {
export type RouterHistory = {
location?: Location;
listen?(cb: (location: Location) => void): void;
listen?(cb: (location: Location, action: Action) => void): void;
} & Record<string, any>;

export type RouteConfig = {
path?: string | string[];
exact?: boolean;
component?: JSX.Element;
routes?: RouteConfig[];
[propName: string]: any;
};

type MatchPath = (pathname: string, props: string | string[] | any, parent?: Match | null) => Match | null;

const global = getGlobalObject<Window>();

/**
* Creates routing instrumentation for React Router v3
* Works for React Router >= 3.2.0 and < 4.0.0
*
* @param history object from the `history` library
* @param routes a list of all routes, should be
* @param match `Router.match` utility
*/
export function reactRouterV3Instrumentation(
history: History,
routes: Route[],
match: Match,
let activeTransaction: Transaction | undefined;

export function reactRouterV4Instrumentation(
history: RouterHistory,
routes?: RouteConfig[],
matchPath?: MatchPath,
): ReactRouterInstrumentation {
return reactRouterInstrumentation(history, 'react-router-v4', routes, matchPath);
}

export function reactRouterV5Instrumentation(
history: RouterHistory,
routes?: RouteConfig[],
matchPath?: MatchPath,
): ReactRouterInstrumentation {
return (
startTransaction: (context: TransactionContext) => Transaction | undefined,
startTransactionOnPageLoad: boolean = true,
startTransactionOnLocationChange: boolean = true,
) => {
let activeTransaction: Transaction | undefined;
let prevName: string | undefined;
return reactRouterInstrumentation(history, 'react-router-v5', routes, matchPath);
}

function reactRouterInstrumentation(
history: RouterHistory,
name: string,
allRoutes: RouteConfig[] = [],
matchPath?: MatchPath,
): ReactRouterInstrumentation {
function getName(pathname: string): string {
if (allRoutes === [] || !matchPath) {
return pathname;
}

const branches = matchRoutes(allRoutes, pathname, matchPath);
// tslint:disable-next-line: prefer-for-of
for (let x = 0; x < branches.length; x++) {
if (branches[x].match.isExact) {
return branches[x].match.path;
}
}

return pathname;
}

return (startTransaction, startTransactionOnPageLoad = true, startTransactionOnLocationChange = true) => {
if (startTransactionOnPageLoad && global && global.location) {
// Have to use global.location because history.location might not be defined.
prevName = normalizeTransactionName(routes, global.location, match);
activeTransaction = startTransaction({
name: prevName,
name: getName(global.location.pathname),
op: 'pageload',
tags: {
'routing.instrumentation': 'react-router-v3',
'routing.instrumentation': name,
},
});
}

if (startTransactionOnLocationChange && history.listen) {
history.listen(location => {
if (location.action === 'PUSH') {
history.listen((location, action) => {
if (action && (action === 'PUSH' || action === 'POP')) {
if (activeTransaction) {
activeTransaction.finish();
}
const tags: Record<string, string> = { 'routing.instrumentation': 'react-router-v3' };
if (prevName) {
tags.from = prevName;
}
const tags = {
'routing.instrumentation': name,
};

prevName = normalizeTransactionName(routes, location, match);
activeTransaction = startTransaction({
name: prevName,
name: getName(location.pathname),
op: 'navigation',
tags,
});
Expand All @@ -86,54 +96,43 @@ export function reactRouterV3Instrumentation(
}

/**
* Normalize transaction names using `Router.match`
* Matches a set of routes to a pathname
* Based on implementation from
*/
function normalizeTransactionName(appRoutes: Route[], location: Location, match: Match): string {
let name = location.pathname;
match(
{
location,
routes: appRoutes,
},
(error, _redirectLocation, renderProps) => {
if (error || !renderProps) {
return name;
function matchRoutes(
routes: RouteConfig[],
pathname: string,
matchPath: MatchPath,
branch: Array<{ route: RouteConfig; match: Match }> = [],
): Array<{ route: RouteConfig; match: Match }> {
routes.some(route => {
const match = route.path
? matchPath(pathname, route)
: branch.length
? branch[branch.length - 1].match // use parent match
: computeRootMatch(pathname); // use default "root" match

if (match) {
branch.push({ route, match });

if (route.routes) {
matchRoutes(route.routes, pathname, matchPath, branch);
}
}

const routePath = getRouteStringFromRoutes(renderProps.routes || []);
if (routePath.length === 0 || routePath === '/*') {
return name;
}
return !!match;
});

name = routePath;
return name;
},
);
return name;
return branch;
}

/**
* Generate route name from array of routes
*/
function getRouteStringFromRoutes(routes: Route[]): string {
if (!Array.isArray(routes) || routes.length === 0) {
return '';
}

const routesWithPaths: Route[] = routes.filter((route: Route) => !!route.path);
function computeRootMatch(pathname: string): Match {
return { path: '/', url: '/', params: {}, isExact: pathname === '/' };
}

let index = -1;
for (let x = routesWithPaths.length - 1; x >= 0; x--) {
const route = routesWithPaths[x];
if (route.path && route.path.startsWith('/')) {
index = x;
break;
}
export const withSentryRouting = (Route: React.ElementType) => (props: { computedMatch?: Match }) => {
if (activeTransaction && props && props.computedMatch && props.computedMatch.isExact) {
activeTransaction.setName(props.computedMatch.path);
}

return routesWithPaths
.slice(index)
.filter(({ path }) => !!path)
.map(({ path }) => path)
.join('');
}
return <Route {...props} />;
};
130 changes: 130 additions & 0 deletions packages/react/src/reactrouterv3.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { Transaction, TransactionContext } from '@sentry/types';
import { getGlobalObject } from '@sentry/utils';

import { Location, ReactRouterInstrumentation } from './types';

// Many of the types below had to be mocked out to prevent typescript issues
// these types are required for correct functionality.

type HistoryV3 = {
location?: Location;
listen?(cb: (location: Location) => void): void;
} & Record<string, any>;

export type Route = { path?: string; childRoutes?: Route[] };

export type Match = (
props: { location: Location; routes: Route[] },
cb: (error?: Error, _redirectLocation?: Location, renderProps?: { routes?: Route[] }) => void,
) => void;

const global = getGlobalObject<Window>();

/**
* Creates routing instrumentation for React Router v3
* Works for React Router >= 3.2.0 and < 4.0.0
*
* @param history object from the `history` library
* @param routes a list of all routes, should be
* @param match `Router.match` utility
*/
export function reactRouterV3Instrumentation(
history: HistoryV3,
routes: Route[],
match: Match,
): ReactRouterInstrumentation {
return (
startTransaction: (context: TransactionContext) => Transaction | undefined,
startTransactionOnPageLoad: boolean = true,
startTransactionOnLocationChange: boolean = true,
) => {
let activeTransaction: Transaction | undefined;
let prevName: string | undefined;

// Have to use global.location because history.location might not be defined.
if (startTransactionOnPageLoad && global && global.location) {
prevName = normalizeTransactionName(routes, global.location, match);

activeTransaction = startTransaction({
name: prevName,
op: 'pageload',
tags: {
'routing.instrumentation': 'react-router-v3',
},
});
}

if (startTransactionOnLocationChange && history.listen) {
history.listen(location => {
if (location.action === 'PUSH' || location.action === 'POP') {
if (activeTransaction) {
activeTransaction.finish();
}
const tags: Record<string, string> = { 'routing.instrumentation': 'react-router-v3' };
if (prevName) {
tags.from = prevName;
}
prevName = normalizeTransactionName(routes, location, match);
activeTransaction = startTransaction({
name: prevName,
op: 'navigation',
tags,
});
}
});
}
};
}

/**
* Normalize transaction names using `Router.match`
*/
function normalizeTransactionName(appRoutes: Route[], location: Location, match: Match): string {
let name = location.pathname;
match(
{
location,
routes: appRoutes,
},
(error, _redirectLocation, renderProps) => {
if (error || !renderProps) {
return name;
}

const routePath = getRouteStringFromRoutes(renderProps.routes || []);
if (routePath.length === 0 || routePath === '/*') {
return name;
}

name = routePath;
return name;
},
);
return name;
}

/**
* Generate route name from array of routes
*/
function getRouteStringFromRoutes(routes: Route[]): string {
if (!Array.isArray(routes) || routes.length === 0) {
return '';
}

const routesWithPaths: Route[] = routes.filter((route: Route) => !!route.path);

let index = -1;
for (let x = routesWithPaths.length - 1; x >= 0; x--) {
const route = routesWithPaths[x];
if (route.path && route.path.startsWith('/')) {
index = x;
break;
}
}

return routesWithPaths
.slice(index)
.filter(({ path }) => !!path)
.map(({ path }) => path)
.join('');
}
Loading