-
-
Notifications
You must be signed in to change notification settings - Fork 1.7k
feat(react): React Router v4/v5 integration #10430
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
import type { Client } from '@sentry/types'; | ||
|
||
export const V4_SETUP_CLIENTS = new WeakMap<Client, boolean>(); | ||
|
||
export const V5_SETUP_CLIENTS = new WeakMap<Client, boolean>(); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These weak maps are used to validate if the integration should work with |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,158 @@ | ||
import { WINDOW, startBrowserTracingNavigationSpan, startBrowserTracingPageLoadSpan } from '@sentry/browser'; | ||
import { | ||
SEMANTIC_ATTRIBUTE_SENTRY_OP, | ||
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, | ||
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, | ||
defineIntegration, | ||
} from '@sentry/core'; | ||
import type { Client, IntegrationFn, TransactionSource } from '@sentry/types'; | ||
import { logger } from '@sentry/utils'; | ||
import { DEBUG_BUILD } from '../debug-build'; | ||
import { V4_SETUP_CLIENTS, V5_SETUP_CLIENTS } from './global-flags'; | ||
import { matchRoutes } from './route-utils'; | ||
import type { MatchPath, RouteConfig, RouterHistory } from './types'; | ||
|
||
const INTEGRATION_NAME_V4 = 'ReactRouterV4'; | ||
|
||
const INTEGRATION_NAME_V5 = 'ReactRouterV5'; | ||
|
||
interface DefaultReactRouterOptions { | ||
/** | ||
* The history object from `createBrowserHistory` (or equivalent). | ||
*/ | ||
history: RouterHistory; | ||
} | ||
|
||
interface RouteConfigReactRouterOptions extends DefaultReactRouterOptions { | ||
/** | ||
* An array of route configs as per the `react-router-config` library | ||
*/ | ||
routes: RouteConfig[]; | ||
/** | ||
* The `matchPath` function from the `react-router` library | ||
*/ | ||
matchPath: MatchPath; | ||
} | ||
|
||
/** | ||
* Options for React Router v4 and v4 integration | ||
*/ | ||
type ReactRouterOptions = DefaultReactRouterOptions | RouteConfigReactRouterOptions; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This union makes the DX waaay nicer, but I don't want to port it to the |
||
|
||
// @ts-expect-error Don't type narrow on routes or matchPath to save on bundle size | ||
const _reactRouterV4 = (({ history, routes, matchPath }: ReactRouterOptions) => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So the main thing I see here is - or maybe I am missing this somehow - that we do not disable the default page load/navigation spans emitted by the default |
||
return { | ||
name: INTEGRATION_NAME_V4, | ||
// TODO v8: Remove this | ||
setupOnce() {}, // eslint-disable-line @typescript-eslint/no-empty-function | ||
setup(client) { | ||
V4_SETUP_CLIENTS.set(client, true); | ||
startRoutingInstrumentation('react-router-v4', client, history, routes, matchPath); | ||
}, | ||
}; | ||
}) satisfies IntegrationFn; | ||
|
||
// @ts-expect-error Don't type narrow on routes or matchPath to save on bundle size | ||
const _reactRouterV5 = (({ history, routes, matchPath }: ReactRouterOptions) => { | ||
return { | ||
name: INTEGRATION_NAME_V5, | ||
// TODO v8: Remove this | ||
setupOnce() {}, // eslint-disable-line @typescript-eslint/no-empty-function | ||
setup(client) { | ||
V5_SETUP_CLIENTS.set(client, true); | ||
startRoutingInstrumentation('react-router-v5', client, history, routes, matchPath); | ||
}, | ||
}; | ||
}) satisfies IntegrationFn; | ||
|
||
/** | ||
* An integration for React Router v4, meant to be used with | ||
* `browserTracingIntegration`. | ||
*/ | ||
export const reactRouterV4Integration = defineIntegration(_reactRouterV4); | ||
|
||
/** | ||
* An integration for React Router v5, meant to be used with | ||
* `browserTracingIntegration`. | ||
*/ | ||
export const reactRouterV5Integration = defineIntegration(_reactRouterV5); | ||
|
||
function startRoutingInstrumentation( | ||
routerName: 'react-router-v4' | 'react-router-v5', | ||
client: Client, | ||
history: RouterHistory, | ||
allRoutes: RouteConfig[] = [], | ||
matchPath?: MatchPath, | ||
): void { | ||
function getInitPathName(): string | undefined { | ||
if (history && history.location) { | ||
return history.location.pathname; | ||
} | ||
|
||
if (WINDOW && WINDOW.location) { | ||
return WINDOW.location.pathname; | ||
} | ||
|
||
return undefined; | ||
} | ||
|
||
/** | ||
* Normalizes a transaction name. Returns the new name as well as the | ||
* source of the transaction. | ||
* | ||
* @param pathname The initial pathname we normalize | ||
*/ | ||
function normalizeTransactionName(pathname: string): [string, TransactionSource] { | ||
if (allRoutes.length === 0 || !matchPath) { | ||
return [pathname, 'url']; | ||
} | ||
|
||
const branches = matchRoutes(allRoutes, pathname, matchPath); | ||
// eslint-disable-next-line @typescript-eslint/prefer-for-of | ||
for (let x = 0; x < branches.length; x++) { | ||
if (branches[x].match.isExact) { | ||
return [branches[x].match.path, 'route']; | ||
} | ||
} | ||
|
||
return [pathname, 'url']; | ||
} | ||
|
||
const tags = { | ||
'routing.instrumentation': routerName, | ||
}; | ||
|
||
const initPathName = getInitPathName(); | ||
if (initPathName) { | ||
const [name, source] = normalizeTransactionName(initPathName); | ||
startBrowserTracingPageLoadSpan(client, { | ||
name, | ||
tags, | ||
attributes: { | ||
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source, | ||
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.react.reactrouter', | ||
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload', | ||
}, | ||
}); | ||
} | ||
|
||
if (history.listen) { | ||
history.listen((location, action) => { | ||
if (action && (action === 'PUSH' || action === 'POP')) { | ||
const [name, source] = normalizeTransactionName(location.pathname); | ||
startBrowserTracingNavigationSpan(client, { | ||
name, | ||
tags, | ||
attributes: { | ||
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source, | ||
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter', | ||
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', | ||
}, | ||
}); | ||
} | ||
}); | ||
} else { | ||
DEBUG_BUILD && | ||
logger.warn('history.listen is not available, automatic instrumentation for navigations will not work.'); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,35 @@ | ||
import type { Match, MatchPath, RouteConfig } from './types'; | ||
|
||
/** | ||
* Matches a set of routes to a pathname | ||
*/ | ||
export 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); | ||
} | ||
} | ||
|
||
return !!match; | ||
}); | ||
|
||
return branch; | ||
} | ||
|
||
function computeRootMatch(pathname: string): Match { | ||
return { path: '/', url: '/', params: {}, isExact: pathname === '/' }; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,21 @@ | ||
// We need to disable eslint no-explict-any because any is required for the | ||
|
||
import type { Action, Location } from '../types'; | ||
|
||
// react-router typings. | ||
export type Match = { path: string; url: string; params: Record<string, any>; isExact: boolean }; // eslint-disable-line @typescript-eslint/no-explicit-any | ||
|
||
export type RouterHistory = { | ||
location?: Location; | ||
listen?(cb: (location: Location, action: Action) => void): void; | ||
} & Record<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any | ||
|
||
export type RouteConfig = { | ||
[propName: string]: unknown; | ||
path?: string | string[]; | ||
exact?: boolean; | ||
component?: JSX.Element; | ||
routes?: RouteConfig[]; | ||
}; | ||
|
||
export type MatchPath = (pathname: string, props: string | string[] | any, parent?: Match | null) => Match | null; // eslint-disable-line @typescript-eslint/no-explicit-any |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
needed this because the integration should only be changing the name of the root span (for transaction name paramaterization).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
hmm not sure, is it not cleaner to do
span ? getRootSpan(span) : undefined
, or do we need this so often? 🤔There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It means I can do
getRootSpan(getActiveSpan())
, which helps with DX a lot