diff --git a/packages/core/src/utils/getRootSpan.ts b/packages/core/src/utils/getRootSpan.ts index 9a0f5d642a77..1104469c0adf 100644 --- a/packages/core/src/utils/getRootSpan.ts +++ b/packages/core/src/utils/getRootSpan.ts @@ -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; } diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index ad66d1e77801..6e0b6cab966d 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -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, diff --git a/packages/react/src/reactrouterv4v5/global-flags.ts b/packages/react/src/reactrouterv4v5/global-flags.ts new file mode 100644 index 000000000000..6a5ad1ce3d22 --- /dev/null +++ b/packages/react/src/reactrouterv4v5/global-flags.ts @@ -0,0 +1,5 @@ +import type { Client } from '@sentry/types'; + +export const V4_SETUP_CLIENTS = new WeakMap(); + +export const V5_SETUP_CLIENTS = new WeakMap(); diff --git a/packages/react/src/reactrouterv4v5/integration.ts b/packages/react/src/reactrouterv4v5/integration.ts new file mode 100644 index 000000000000..f70f34408a6f --- /dev/null +++ b/packages/react/src/reactrouterv4v5/integration.ts @@ -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; + +// @ts-expect-error Don't type narrow on routes or matchPath to save on bundle size +const _reactRouterV4 = (({ history, routes, matchPath }: ReactRouterOptions) => { + 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.'); + } +} diff --git a/packages/react/src/reactrouterv4v5/route-utils.ts b/packages/react/src/reactrouterv4v5/route-utils.ts new file mode 100644 index 000000000000..9172b1405022 --- /dev/null +++ b/packages/react/src/reactrouterv4v5/route-utils.ts @@ -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 === '/' }; +} diff --git a/packages/react/src/reactrouter.tsx b/packages/react/src/reactrouterv4v5/routing-instrumentation.tsx similarity index 66% rename from packages/react/src/reactrouter.tsx rename to packages/react/src/reactrouterv4v5/routing-instrumentation.tsx index 04995ee4bc44..4bd68c27a4f7 100644 --- a/packages/react/src/reactrouter.tsx +++ b/packages/react/src/reactrouterv4v5/routing-instrumentation.tsx @@ -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; isExact: boolean }; // eslint-disable-line @typescript-eslint/no-explicit-any - -export type RouterHistory = { - location?: Location; - listen?(cb: (location: Location, action: Action) => void): void; -} & Record; // 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; @@ -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 { @@ -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

, R extends React.ComponentType

>(Route: R): R { const componentDisplayName = (Route as any).displayName || (Route as any).name; const WrappedRoute: React.FC

= (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 diff --git a/packages/react/src/reactrouterv4v5/types.ts b/packages/react/src/reactrouterv4v5/types.ts new file mode 100644 index 000000000000..b5963d450a01 --- /dev/null +++ b/packages/react/src/reactrouterv4v5/types.ts @@ -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; isExact: boolean }; // eslint-disable-line @typescript-eslint/no-explicit-any + +export type RouterHistory = { + location?: Location; + listen?(cb: (location: Location, action: Action) => void): void; +} & Record; // 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 diff --git a/packages/react/test/reactrouterv4-integration.test.tsx b/packages/react/test/reactrouterv4-integration.test.tsx new file mode 100644 index 000000000000..17abe4db40b7 --- /dev/null +++ b/packages/react/test/reactrouterv4-integration.test.tsx @@ -0,0 +1,348 @@ +import { BrowserClient } from '@sentry/browser'; +import { + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + createTransport, + getCurrentScope, + setCurrentClient, +} from '@sentry/core'; +import { act, render } from '@testing-library/react'; +import { createMemoryHistory } from 'history-4'; +// biome-ignore lint/nursery/noUnusedImports: Need React import for JSX +import * as React from 'react'; +import { Route, Router, Switch, matchPath } from 'react-router-4'; +import { withSentryRouting } from '../src'; + +import { reactRouterV4Integration } from '../src/reactrouterv4v5/integration'; +import type { RouteConfig } from '../src/reactrouterv4v5/types'; + +const mockStartBrowserTracingPageLoadSpan = jest.fn(); +const mockStartBrowserTracingNavigationSpan = jest.fn(); + +const mockRootSpan = { + updateName: jest.fn(), + setAttribute: jest.fn(), +}; + +jest.mock('@sentry/browser', () => { + const actual = jest.requireActual('@sentry/browser'); + return { + ...actual, + startBrowserTracingNavigationSpan: (...args: unknown[]) => { + mockStartBrowserTracingNavigationSpan(...args); + return actual.startBrowserTracingNavigationSpan(...args); + }, + startBrowserTracingPageLoadSpan: (...args: unknown[]) => { + mockStartBrowserTracingPageLoadSpan(...args); + return actual.startBrowserTracingPageLoadSpan(...args); + }, + }; +}); + +jest.mock('@sentry/core', () => { + const actual = jest.requireActual('@sentry/core'); + return { + ...actual, + getRootSpan: () => { + return mockRootSpan; + }, + }; +}); + +function createMockBrowserClient(): BrowserClient { + return new BrowserClient({ + integrations: [], + transport: () => createTransport({ recordDroppedEvent: () => undefined }, _ => Promise.resolve({})), + stackParser: () => [], + }); +} + +describe('reactRouterV4Integration', () => { + beforeEach(() => { + jest.clearAllMocks(); + getCurrentScope().setClient(undefined); + }); + + it('starts a pageload transaction when instrumentation is started', () => { + const client = createMockBrowserClient(); + + const history = createMemoryHistory(); + client.addIntegration(reactRouterV4Integration({ history })); + + client.init(); + + expect(mockStartBrowserTracingPageLoadSpan).toHaveBeenCalledTimes(1); + expect(mockStartBrowserTracingPageLoadSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/', + tags: { 'routing.instrumentation': 'react-router-v4' }, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.react.reactrouter', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload', + }, + }); + }); + + it('starts a navigation transaction', () => { + const client = createMockBrowserClient(); + + const history = createMemoryHistory(); + client.addIntegration(reactRouterV4Integration({ history })); + + client.init(); + + render( + + +

Features
} /> +
About
} /> +
Home
} /> + + , + ); + + act(() => { + history.push('/about'); + }); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/about', + tags: { 'routing.instrumentation': 'react-router-v4' }, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + }, + }); + + act(() => { + history.push('/features'); + }); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(2); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/features', + tags: { 'routing.instrumentation': 'react-router-v4' }, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + }, + }); + }); + + it('only starts a navigation transaction on push', () => { + const client = createMockBrowserClient(); + + const history = createMemoryHistory(); + client.addIntegration(reactRouterV4Integration({ history })); + + client.init(); + + render( + + +
Features
} /> +
About
} /> +
Home
} /> +
+
, + ); + + act(() => { + history.replace('hello'); + }); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(0); + }); + + it('does not normalize transaction name ', () => { + const client = createMockBrowserClient(); + + const history = createMemoryHistory(); + client.addIntegration(reactRouterV4Integration({ history })); + + client.init(); + + const { getByText } = render( + + +
UserId
} /> +
Users
} /> +
Home
} /> +
+
, + ); + + act(() => { + history.push('/users/123'); + }); + getByText('UserId'); + + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/users/123', + tags: { 'routing.instrumentation': 'react-router-v4' }, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + }, + }); + }); + + it('normalizes transaction name with custom Route', () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + const history = createMemoryHistory(); + client.addIntegration(reactRouterV4Integration({ history })); + + client.init(); + + const SentryRoute = withSentryRouting(Route); + + const { getByText } = render( + + +
UserId
} /> +
Users
} /> +
Home
} /> +
+
, + ); + + act(() => { + history.push('/users/123'); + }); + getByText('UserId'); + + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/users/123', + tags: { 'routing.instrumentation': 'react-router-v4' }, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + }, + }); + expect(mockRootSpan.updateName).toHaveBeenCalledTimes(2); + expect(mockRootSpan.updateName).toHaveBeenLastCalledWith('/users/:userid'); + expect(mockRootSpan.setAttribute).toHaveBeenCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + }); + + it('normalizes nested transaction names with custom Route', () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + const history = createMemoryHistory(); + client.addIntegration(reactRouterV4Integration({ history })); + + client.init(); + + const SentryRoute = withSentryRouting(Route); + + const { getByText } = render( + + +
Team
} /> +
OrgId
} /> +
Home
} /> +
+
, + ); + + act(() => { + history.push('/organizations/1234/v1/758'); + }); + getByText('Team'); + + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/organizations/1234/v1/758', + tags: { 'routing.instrumentation': 'react-router-v4' }, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + }, + }); + expect(mockRootSpan.updateName).toHaveBeenCalledTimes(2); + expect(mockRootSpan.updateName).toHaveBeenLastCalledWith('/organizations/:orgid/v1/:teamid'); + expect(mockRootSpan.setAttribute).toHaveBeenLastCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + + act(() => { + history.push('/organizations/543'); + }); + getByText('OrgId'); + + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(2); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/organizations/543', + tags: { 'routing.instrumentation': 'react-router-v4' }, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + }, + }); + expect(mockRootSpan.updateName).toHaveBeenCalledTimes(3); + expect(mockRootSpan.updateName).toHaveBeenLastCalledWith('/organizations/:orgid'); + expect(mockRootSpan.setAttribute).toHaveBeenLastCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + }); + + it('matches with route object', () => { + const routes: RouteConfig[] = [ + { + path: '/organizations/:orgid/v1/:teamid', + }, + { path: '/organizations/:orgid' }, + { path: '/' }, + ]; + const client = createMockBrowserClient(); + setCurrentClient(client); + + const history = createMemoryHistory(); + client.addIntegration(reactRouterV4Integration({ history, routes, matchPath })); + + client.init(); + + render( + + +
Team
} /> +
OrgId
} /> +
Home
} /> +
+
, + ); + + act(() => { + history.push('/organizations/1234/v1/758'); + }); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/organizations/:orgid/v1/:teamid', + tags: { 'routing.instrumentation': 'react-router-v4' }, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + }, + }); + + act(() => { + history.push('/organizations/1234'); + }); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(2); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/organizations/:orgid', + tags: { 'routing.instrumentation': 'react-router-v4' }, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + }, + }); + }); +}); diff --git a/packages/react/test/reactrouterv4.test.tsx b/packages/react/test/reactrouterv4.test.tsx index 5849bb688598..00c6a667ab8b 100644 --- a/packages/react/test/reactrouterv4.test.tsx +++ b/packages/react/test/reactrouterv4.test.tsx @@ -6,7 +6,7 @@ import * as React from 'react'; import { Route, Router, Switch, matchPath } from 'react-router-4'; import { reactRouterV4Instrumentation, withSentryRouting } from '../src'; -import type { RouteConfig } from '../src/reactrouter'; +import type { RouteConfig } from '../src/reactrouterv4v5/types'; describe('React Router v4', () => { function createInstrumentation(_opts?: { @@ -28,6 +28,7 @@ describe('React Router v4', () => { const mockStartTransaction = jest .fn() .mockReturnValue({ updateName: mockUpdateName, end: mockFinish, setAttribute: mockSetAttribute }); + reactRouterV4Instrumentation(history, options.routes, options.matchPath)( mockStartTransaction, options.startTransactionOnPageLoad, diff --git a/packages/react/test/reactrouterv5-integration.test.tsx b/packages/react/test/reactrouterv5-integration.test.tsx new file mode 100644 index 000000000000..431ec91c9bdc --- /dev/null +++ b/packages/react/test/reactrouterv5-integration.test.tsx @@ -0,0 +1,348 @@ +import { BrowserClient } from '@sentry/browser'; +import { + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + createTransport, + getCurrentScope, + setCurrentClient, +} from '@sentry/core'; +import { act, render } from '@testing-library/react'; +import { createMemoryHistory } from 'history-4'; +// biome-ignore lint/nursery/noUnusedImports: Need React import for JSX +import * as React from 'react'; +import { Route, Router, Switch, matchPath } from 'react-router-5'; +import { withSentryRouting } from '../src'; + +import { reactRouterV4Integration } from '../src/reactrouterv4v5/integration'; +import type { RouteConfig } from '../src/reactrouterv4v5/types'; + +const mockStartBrowserTracingPageLoadSpan = jest.fn(); +const mockStartBrowserTracingNavigationSpan = jest.fn(); + +const mockRootSpan = { + updateName: jest.fn(), + setAttribute: jest.fn(), +}; + +jest.mock('@sentry/browser', () => { + const actual = jest.requireActual('@sentry/browser'); + return { + ...actual, + startBrowserTracingNavigationSpan: (...args: unknown[]) => { + mockStartBrowserTracingNavigationSpan(...args); + return actual.startBrowserTracingNavigationSpan(...args); + }, + startBrowserTracingPageLoadSpan: (...args: unknown[]) => { + mockStartBrowserTracingPageLoadSpan(...args); + return actual.startBrowserTracingPageLoadSpan(...args); + }, + }; +}); + +jest.mock('@sentry/core', () => { + const actual = jest.requireActual('@sentry/core'); + return { + ...actual, + getRootSpan: () => { + return mockRootSpan; + }, + }; +}); + +function createMockBrowserClient(): BrowserClient { + return new BrowserClient({ + integrations: [], + transport: () => createTransport({ recordDroppedEvent: () => undefined }, _ => Promise.resolve({})), + stackParser: () => [], + }); +} + +describe('reactRouterV4Integration', () => { + beforeEach(() => { + jest.clearAllMocks(); + getCurrentScope().setClient(undefined); + }); + + it('starts a pageload transaction when instrumentation is started', () => { + const client = createMockBrowserClient(); + + const history = createMemoryHistory(); + client.addIntegration(reactRouterV4Integration({ history })); + + client.init(); + + expect(mockStartBrowserTracingPageLoadSpan).toHaveBeenCalledTimes(1); + expect(mockStartBrowserTracingPageLoadSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/', + tags: { 'routing.instrumentation': 'react-router-v4' }, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.react.reactrouter', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload', + }, + }); + }); + + it('starts a navigation transaction', () => { + const client = createMockBrowserClient(); + + const history = createMemoryHistory(); + client.addIntegration(reactRouterV4Integration({ history })); + + client.init(); + + render( + + +
Features
} /> +
About
} /> +
Home
} /> +
+
, + ); + + act(() => { + history.push('/about'); + }); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/about', + tags: { 'routing.instrumentation': 'react-router-v4' }, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + }, + }); + + act(() => { + history.push('/features'); + }); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(2); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/features', + tags: { 'routing.instrumentation': 'react-router-v4' }, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + }, + }); + }); + + it('only starts a navigation transaction on push', () => { + const client = createMockBrowserClient(); + + const history = createMemoryHistory(); + client.addIntegration(reactRouterV4Integration({ history })); + + client.init(); + + render( + + +
Features
} /> +
About
} /> +
Home
} /> +
+
, + ); + + act(() => { + history.replace('hello'); + }); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(0); + }); + + it('does not normalize transaction name ', () => { + const client = createMockBrowserClient(); + + const history = createMemoryHistory(); + client.addIntegration(reactRouterV4Integration({ history })); + + client.init(); + + const { getByText } = render( + + +
UserId
} /> +
Users
} /> +
Home
} /> +
+
, + ); + + act(() => { + history.push('/users/123'); + }); + getByText('UserId'); + + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/users/123', + tags: { 'routing.instrumentation': 'react-router-v4' }, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + }, + }); + }); + + it('normalizes transaction name with custom Route', () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + const history = createMemoryHistory(); + client.addIntegration(reactRouterV4Integration({ history })); + + client.init(); + + const SentryRoute = withSentryRouting(Route); + + const { getByText } = render( + + +
UserId
} /> +
Users
} /> +
Home
} /> +
+
, + ); + + act(() => { + history.push('/users/123'); + }); + getByText('UserId'); + + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/users/123', + tags: { 'routing.instrumentation': 'react-router-v4' }, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + }, + }); + expect(mockRootSpan.updateName).toHaveBeenCalledTimes(2); + expect(mockRootSpan.updateName).toHaveBeenLastCalledWith('/users/:userid'); + expect(mockRootSpan.setAttribute).toHaveBeenCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + }); + + it('normalizes nested transaction names with custom Route', () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + const history = createMemoryHistory(); + client.addIntegration(reactRouterV4Integration({ history })); + + client.init(); + + const SentryRoute = withSentryRouting(Route); + + const { getByText } = render( + + +
Team
} /> +
OrgId
} /> +
Home
} /> +
+
, + ); + + act(() => { + history.push('/organizations/1234/v1/758'); + }); + getByText('Team'); + + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/organizations/1234/v1/758', + tags: { 'routing.instrumentation': 'react-router-v4' }, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + }, + }); + expect(mockRootSpan.updateName).toHaveBeenCalledTimes(2); + expect(mockRootSpan.updateName).toHaveBeenLastCalledWith('/organizations/:orgid/v1/:teamid'); + expect(mockRootSpan.setAttribute).toHaveBeenLastCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + + act(() => { + history.push('/organizations/543'); + }); + getByText('OrgId'); + + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(2); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/organizations/543', + tags: { 'routing.instrumentation': 'react-router-v4' }, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + }, + }); + expect(mockRootSpan.updateName).toHaveBeenCalledTimes(3); + expect(mockRootSpan.updateName).toHaveBeenLastCalledWith('/organizations/:orgid'); + expect(mockRootSpan.setAttribute).toHaveBeenLastCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + }); + + it('matches with route object', () => { + const routes: RouteConfig[] = [ + { + path: '/organizations/:orgid/v1/:teamid', + }, + { path: '/organizations/:orgid' }, + { path: '/' }, + ]; + const client = createMockBrowserClient(); + setCurrentClient(client); + + const history = createMemoryHistory(); + client.addIntegration(reactRouterV4Integration({ history, routes, matchPath })); + + client.init(); + + render( + + +
Team
} /> +
OrgId
} /> +
Home
} /> +
+
, + ); + + act(() => { + history.push('/organizations/1234/v1/758'); + }); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/organizations/:orgid/v1/:teamid', + tags: { 'routing.instrumentation': 'react-router-v4' }, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + }, + }); + + act(() => { + history.push('/organizations/1234'); + }); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(2); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/organizations/:orgid', + tags: { 'routing.instrumentation': 'react-router-v4' }, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + }, + }); + }); +}); diff --git a/packages/react/test/reactrouterv5.test.tsx b/packages/react/test/reactrouterv5.test.tsx index c571b3590b8f..01c238704ca7 100644 --- a/packages/react/test/reactrouterv5.test.tsx +++ b/packages/react/test/reactrouterv5.test.tsx @@ -6,7 +6,7 @@ import * as React from 'react'; import { Route, Router, Switch, matchPath } from 'react-router-5'; import { reactRouterV5Instrumentation, withSentryRouting } from '../src'; -import type { RouteConfig } from '../src/reactrouter'; +import type { RouteConfig } from '../src/reactrouterv4v5/types'; describe('React Router v5', () => { function createInstrumentation(_opts?: {