Skip to content
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

feat(react): Add reactRouterV4/V5BrowserTracingIntegration for react router v4 & v5 #10488

Merged
merged 4 commits into from Feb 5, 2024
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
10 changes: 9 additions & 1 deletion packages/react/src/index.ts
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 {
// eslint-disable-next-line deprecation/deprecation
reactRouterV4Instrumentation,
// eslint-disable-next-line deprecation/deprecation
reactRouterV5Instrumentation,
withSentryRouting,
reactRouterV4BrowserTracingIntegration,
reactRouterV5BrowserTracingIntegration,
} from './reactrouter';
export {
reactRouterV6Instrumentation,
withSentryReactRouterV6Routing,
Expand Down
168 changes: 144 additions & 24 deletions packages/react/src/reactrouter.tsx
@@ -1,6 +1,18 @@
import { WINDOW } from '@sentry/browser';
import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core';
import type { Transaction, TransactionSource } from '@sentry/types';
import {
WINDOW,
browserTracingIntegration,
startBrowserTracingNavigationSpan,
startBrowserTracingPageLoadSpan,
} from '@sentry/browser';
import {
SEMANTIC_ATTRIBUTE_SENTRY_OP,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
getActiveSpan,
getRootSpan,
spanToJSON,
} from '@sentry/core';
import type { Integration, Span, StartSpanOptions, Transaction, TransactionSource } from '@sentry/types';
import hoistNonReactStatics from 'hoist-non-react-statics';
import * as React from 'react';

Expand All @@ -23,29 +35,121 @@ export type RouteConfig = {
routes?: RouteConfig[];
};

type MatchPath = (pathname: string, props: string | string[] | any, parent?: Match | null) => Match | null; // eslint-disable-line @typescript-eslint/no-explicit-any
export type MatchPath = (pathname: string, props: string | string[] | any, parent?: Match | null) => Match | null; // eslint-disable-line @typescript-eslint/no-explicit-any

interface ReactRouterOptions {
history: RouterHistory;
routes?: RouteConfig[];
matchPath?: MatchPath;
}

let activeTransaction: Transaction | undefined;

/**
* A browser tracing integration that uses React Router v4 to instrument navigations.
* Expects `history` (and optionally `routes` and `matchPath`) to be passed as options.
*/
export function reactRouterV4BrowserTracingIntegration(
options: Parameters<typeof browserTracingIntegration>[0] & ReactRouterOptions,
): Integration {
const integration = browserTracingIntegration({
...options,
instrumentPageLoad: false,
instrumentNavigation: false,
});

const { history, routes, matchPath, instrumentPageLoad = true, instrumentNavigation = true } = options;

return {
...integration,
afterAllSetup(client) {
integration.afterAllSetup(client);

const startPageloadCallback = (startSpanOptions: StartSpanOptions): undefined => {
startBrowserTracingPageLoadSpan(client, startSpanOptions);
return undefined;
};

const startNavigationCallback = (startSpanOptions: StartSpanOptions): undefined => {
startBrowserTracingNavigationSpan(client, startSpanOptions);
return undefined;
};

// eslint-disable-next-line deprecation/deprecation
const instrumentation = reactRouterV4Instrumentation(history, routes, matchPath);

// Now instrument page load & navigation with correct settings
instrumentation(startPageloadCallback, instrumentPageLoad, false);
instrumentation(startNavigationCallback, false, instrumentNavigation);
},
};
}

/**
* A browser tracing integration that uses React Router v5 to instrument navigations.
* Expects `history` (and optionally `routes` and `matchPath`) to be passed as options.
*/
export function reactRouterV5BrowserTracingIntegration(
options: Parameters<typeof browserTracingIntegration>[0] & ReactRouterOptions,
): Integration {
const integration = browserTracingIntegration({
...options,
instrumentPageLoad: false,
instrumentNavigation: false,
});

const { history, routes, matchPath } = options;

return {
...integration,
afterAllSetup(client) {
integration.afterAllSetup(client);

const startPageloadCallback = (startSpanOptions: StartSpanOptions): undefined => {
startBrowserTracingPageLoadSpan(client, startSpanOptions);
return undefined;
};

const startNavigationCallback = (startSpanOptions: StartSpanOptions): undefined => {
startBrowserTracingNavigationSpan(client, startSpanOptions);
return undefined;
};

// eslint-disable-next-line deprecation/deprecation
const instrumentation = reactRouterV5Instrumentation(history, routes, matchPath);

// Now instrument page load & navigation with correct settings
instrumentation(startPageloadCallback, options.instrumentPageLoad, false);
instrumentation(startNavigationCallback, false, options.instrumentNavigation);
},
};
}

/**
* @deprecated Use `browserTracingReactRouterV4()` instead.
*/
export function reactRouterV4Instrumentation(
history: RouterHistory,
routes?: RouteConfig[],
matchPath?: MatchPath,
): ReactRouterInstrumentation {
return createReactRouterInstrumentation(history, 'react-router-v4', routes, matchPath);
return createReactRouterInstrumentation(history, 'reactrouter_v4', routes, matchPath);
}

/**
* @deprecated Use `browserTracingReactRouterV5()` instead.
*/
export function reactRouterV5Instrumentation(
history: RouterHistory,
routes?: RouteConfig[],
matchPath?: MatchPath,
): ReactRouterInstrumentation {
return createReactRouterInstrumentation(history, 'react-router-v5', routes, matchPath);
return createReactRouterInstrumentation(history, 'reactrouter_v5', routes, matchPath);
}

function createReactRouterInstrumentation(
history: RouterHistory,
name: string,
instrumentationName: string,
allRoutes: RouteConfig[] = [],
matchPath?: MatchPath,
): ReactRouterInstrumentation {
Expand Down Expand Up @@ -83,21 +187,17 @@ function createReactRouterInstrumentation(
return [pathname, 'url'];
}

const tags = {
'routing.instrumentation': name,
};

return (customStartTransaction, startTransactionOnPageLoad = true, startTransactionOnLocationChange = true): void => {
const initPathName = getInitPathName();

if (startTransactionOnPageLoad && initPathName) {
const [name, source] = normalizeTransactionName(initPathName);
activeTransaction = customStartTransaction({
name,
op: 'pageload',
origin: 'auto.pageload.react.reactrouter',
tags,
metadata: {
source,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: `auto.pageload.react.${instrumentationName}`,
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source,
},
});
}
Expand All @@ -112,11 +212,10 @@ function createReactRouterInstrumentation(
const [name, source] = normalizeTransactionName(location.pathname);
activeTransaction = customStartTransaction({
name,
op: 'navigation',
origin: 'auto.navigation.react.reactrouter',
tags,
metadata: {
source,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: `auto.navigation.react.${instrumentationName}`,
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source,
},
});
}
Expand Down Expand Up @@ -164,10 +263,12 @@ function computeRootMatch(pathname: string): Match {
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 activeRootSpan = getActiveRootSpan();

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 (activeRootSpan && props && props.computedMatch && props.computedMatch.isExact) {
activeRootSpan.updateName(props.computedMatch.path);
activeRootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route');
}

// @ts-expect-error Setting more specific React Component typing for `R` generic above
Expand All @@ -184,3 +285,22 @@ export function withSentryRouting<P extends Record<string, any>, R extends React
return WrappedRoute;
}
/* eslint-enable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access */

function getActiveRootSpan(): Span | undefined {
// Legacy behavior for "old" react router instrumentation
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

might be worth lifting this utility out

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, but here it's a bit special because it also takes the activeTransaction into consideration!

if (activeTransaction) {
return activeTransaction;
}

const span = getActiveSpan();
const rootSpan = span ? getRootSpan(span) : undefined;

if (!rootSpan) {
return undefined;
}

const op = spanToJSON(rootSpan).op;

// Only use this root span if it is a pageload or navigation span
return op === 'navigation' || op === 'pageload' ? rootSpan : undefined;
}