Skip to content

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

Closed
wants to merge 1 commit into from
Closed
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
4 changes: 2 additions & 2 deletions packages/core/src/utils/getRootSpan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import type { Span } from '@sentry/types';
*
* If the given span has no root span or transaction, `undefined` is returned.
*/
export function getRootSpan(span: Span): Span | undefined {
export function getRootSpan(span: Span | undefined): Span | undefined {
// TODO (v8): Remove this check and just return span
// eslint-disable-next-line deprecation/deprecation
return span.transaction;
return span && span.transaction;
Copy link
Member Author

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).

Copy link
Member

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? 🤔

Copy link
Member Author

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

}
10 changes: 9 additions & 1 deletion packages/react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,15 @@ export type { ErrorBoundaryProps, FallbackRender } from './errorboundary';
export { ErrorBoundary, withErrorBoundary } from './errorboundary';
export { createReduxEnhancer } from './redux';
export { reactRouterV3Instrumentation } from './reactrouterv3';
export { reactRouterV4Instrumentation, reactRouterV5Instrumentation, withSentryRouting } from './reactrouter';
export {
reactRouterV4Instrumentation,
reactRouterV5Instrumentation,
withSentryRouting,
} from './reactrouterv4v5/routing-instrumentation';
export {
reactRouterV4Integration,
reactRouterV5Integration,
} from './reactrouterv4v5/integration';
export {
reactRouterV6Instrumentation,
withSentryReactRouterV6Routing,
Expand Down
5 changes: 5 additions & 0 deletions packages/react/src/reactrouterv4v5/global-flags.ts
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>();
Copy link
Member Author

Choose a reason for hiding this comment

The 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 withSentryRouting helper.

158 changes: 158 additions & 0 deletions packages/react/src/reactrouterv4v5/integration.ts
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;
Copy link
Member Author

Choose a reason for hiding this comment

The 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 routingInstrumentation in fear of breaking anything.


// @ts-expect-error Don't type narrow on routes or matchPath to save on bundle size
const _reactRouterV4 = (({ history, routes, matchPath }: ReactRouterOptions) => {
Copy link
Member

Choose a reason for hiding this comment

The 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 browserTracingIntegration() here 🤔 So this would be emitted twice when a router integration is added, wouldn't it?

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.');
}
}
35 changes: 35 additions & 0 deletions packages/react/src/reactrouterv4v5/route-utils.ts
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
@@ -1,29 +1,13 @@
import { WINDOW } from '@sentry/browser';
import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core';
import type { Transaction, TransactionSource } from '@sentry/types';
import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, getActiveSpan, getClient, getRootSpan } from '@sentry/core';
import type { Client, Transaction, TransactionSource } from '@sentry/types';
import hoistNonReactStatics from 'hoist-non-react-statics';
import * as React from 'react';

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

// We need to disable eslint no-explict-any because any is required for the
// react-router typings.
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[];
};

type MatchPath = (pathname: string, props: string | string[] | any, parent?: Match | null) => Match | null; // eslint-disable-line @typescript-eslint/no-explicit-any
import type { ReactRouterInstrumentation } from '../types';
import { V4_SETUP_CLIENTS, V5_SETUP_CLIENTS } from './global-flags';
import { matchRoutes } from './route-utils';
import type { MatchPath, RouteConfig, RouterHistory } from './types';

let activeTransaction: Transaction | undefined;

Expand All @@ -45,7 +29,7 @@ export function reactRouterV5Instrumentation(

function createReactRouterInstrumentation(
history: RouterHistory,
name: string,
name: 'react-router-v4' | 'react-router-v5',
allRoutes: RouteConfig[] = [],
matchPath?: MatchPath,
): ReactRouterInstrumentation {
Expand Down Expand Up @@ -125,49 +109,21 @@ function createReactRouterInstrumentation(
};
}

/**
* Matches a set of routes to a pathname
* Based on implementation from
*/
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 === '/' };
}

/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access */
export function withSentryRouting<P extends Record<string, any>, R extends React.ComponentType<P>>(Route: R): R {
const componentDisplayName = (Route as any).displayName || (Route as any).name;

const WrappedRoute: React.FC<P> = (props: P) => {
if (activeTransaction && props && props.computedMatch && props.computedMatch.isExact) {
activeTransaction.updateName(props.computedMatch.path);
activeTransaction.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route');
// If we see a client has been set on the SETUP_CLIENTS weakmap, we know that the user is using the integration instead
// of the routing instrumentation. This means we have to get the root span ourselves instead of relying on `activeTransaction`.
const client = getClient();
const transaction =
V4_SETUP_CLIENTS.has(client as Client) || V5_SETUP_CLIENTS.has(client as Client)
? getRootSpan(getActiveSpan() as any)
: activeTransaction;
if (transaction && props && props.computedMatch && props.computedMatch.isExact) {
transaction.updateName(props.computedMatch.path);
transaction.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route');
}

// @ts-expect-error Setting more specific React Component typing for `R` generic above
Expand Down
21 changes: 21 additions & 0 deletions packages/react/src/reactrouterv4v5/types.ts
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
Loading