diff --git a/packages/react/package.json b/packages/react/package.json index 998f30628410..836c66f07af2 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -30,9 +30,15 @@ "devDependencies": { "@testing-library/react": "^10.0.6", "@testing-library/react-hooks": "^3.3.0", + "@types/history-4": "npm:@types/history@4.7.7", + "@types/history-5": "npm:@types/history@4.7.7", "@types/hoist-non-react-statics": "^3.3.1", "@types/react": "^16.9.35", "@types/react-router-3": "npm:@types/react-router@3.0.20", + "@types/react-router-4": "npm:@types/react-router@5.0.0", + "@types/react-router-5": "npm:@types/react-router@5.0.0", + "history-4": "npm:history@4.6.0", + "history-5": "npm:history@4.9.0", "jest": "^24.7.1", "jsdom": "^16.2.2", "npm-run-all": "^4.1.2", @@ -40,7 +46,9 @@ "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:react-router@3.2.0", + "react-router-4": "npm:react-router@4.1.0", + "react-router-5": "npm:react-router@5.0.0", "react-test-renderer": "^16.13.1", "redux": "^4.0.5", "rimraf": "^2.6.3", diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 22ce1ce6e8d8..7012eed355e1 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -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(); diff --git a/packages/react/src/reactrouter.tsx b/packages/react/src/reactrouter.tsx index 86ee8302ce9b..e3d04f14137b 100644 --- a/packages/react/src/reactrouter.tsx +++ b/packages/react/src/reactrouter.tsx @@ -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 = ( - 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; 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; - -type History = { +export type RouterHistory = { location?: Location; - listen?(cb: (location: Location) => void): void; + listen?(cb: (location: Location, action: Action) => void): void; } & Record; +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(); -/** - * 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 = { '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, }); @@ -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 ; +}; diff --git a/packages/react/src/reactrouterv3.ts b/packages/react/src/reactrouterv3.ts new file mode 100644 index 000000000000..c37eacef4f4d --- /dev/null +++ b/packages/react/src/reactrouterv3.ts @@ -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; + +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(); + +/** + * 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 = { '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(''); +} diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts new file mode 100644 index 000000000000..632a4db6c0b0 --- /dev/null +++ b/packages/react/src/types.ts @@ -0,0 +1,14 @@ +import { Transaction, TransactionContext } from '@sentry/types'; + +export type Action = 'PUSH' | 'REPLACE' | 'POP'; + +export type Location = { + pathname: string; + action?: Action; +} & Record; + +export type ReactRouterInstrumentation = ( + startTransaction: (context: TransactionContext) => T | undefined, + startTransactionOnPageLoad?: boolean, + startTransactionOnLocationChange?: boolean, +) => void; diff --git a/packages/react/test/reactrouterv3.test.tsx b/packages/react/test/reactrouterv3.test.tsx index eeab53a00f74..092c949196a8 100644 --- a/packages/react/test/reactrouterv3.test.tsx +++ b/packages/react/test/reactrouterv3.test.tsx @@ -2,7 +2,7 @@ import { render } from '@testing-library/react'; import * as React from 'react'; import { createMemoryHistory, createRoutes, IndexRoute, match, Route, Router } from 'react-router-3'; -import { Match, reactRouterV3Instrumentation, Route as RouteType } from '../src/reactrouter'; +import { Match, reactRouterV3Instrumentation, Route as RouteType } from '../src/reactrouterv3'; // Have to manually set types because we are using package-alias declare module 'react-router-3' { diff --git a/packages/react/test/reactrouterv4.test.tsx b/packages/react/test/reactrouterv4.test.tsx new file mode 100644 index 000000000000..c3177021f94b --- /dev/null +++ b/packages/react/test/reactrouterv4.test.tsx @@ -0,0 +1,249 @@ +// tslint:disable: no-unsafe-any +import { render } from '@testing-library/react'; +import { createMemoryHistory } from 'history-4'; +import * as React from 'react'; +import { matchPath, Route, Router, Switch } from 'react-router-4'; + +import { reactRouterV4Instrumentation, withSentryRouting } from '../src'; +import { RouteConfig } from '../src/reactrouter'; + +describe('React Router v4', () => { + function createInstrumentation(_opts?: { + startTransactionOnPageLoad?: boolean; + startTransactionOnLocationChange?: boolean; + routes?: RouteConfig[]; + }): [jest.Mock, any, { mockSetName: jest.Mock; mockFinish: jest.Mock }] { + const options = { + matchPath: _opts && _opts.routes !== undefined ? matchPath : undefined, + routes: undefined, + startTransactionOnLocationChange: true, + startTransactionOnPageLoad: true, + ..._opts, + }; + const history = createMemoryHistory(); + const mockFinish = jest.fn(); + const mockSetName = jest.fn(); + const mockStartTransaction = jest.fn().mockReturnValue({ setName: mockSetName, finish: mockFinish }); + reactRouterV4Instrumentation(history, options.routes, options.matchPath)( + mockStartTransaction, + options.startTransactionOnPageLoad, + options.startTransactionOnLocationChange, + ); + return [mockStartTransaction, history, { mockSetName, mockFinish }]; + } + + it('starts a pageload transaction when instrumentation is started', () => { + const [mockStartTransaction] = createInstrumentation(); + expect(mockStartTransaction).toHaveBeenCalledTimes(1); + expect(mockStartTransaction).toHaveBeenLastCalledWith({ + name: '/', + op: 'pageload', + tags: { 'routing.instrumentation': 'react-router-v4' }, + }); + }); + + it('does not start pageload transaction if option is false', () => { + const [mockStartTransaction] = createInstrumentation({ startTransactionOnPageLoad: false }); + expect(mockStartTransaction).toHaveBeenCalledTimes(0); + }); + + it('starts a navigation transaction', () => { + const [mockStartTransaction, history] = createInstrumentation(); + render( + + +
Features
} /> +
About
} /> +
Home
} /> +
+
, + ); + + history.push('/about'); + expect(mockStartTransaction).toHaveBeenCalledTimes(2); + expect(mockStartTransaction).toHaveBeenLastCalledWith({ + name: '/about', + op: 'navigation', + tags: { 'routing.instrumentation': 'react-router-v4' }, + }); + + history.push('/features'); + expect(mockStartTransaction).toHaveBeenCalledTimes(3); + expect(mockStartTransaction).toHaveBeenLastCalledWith({ + name: '/features', + op: 'navigation', + tags: { 'routing.instrumentation': 'react-router-v4' }, + }); + }); + + it('does not start a transaction if option is false', () => { + const [mockStartTransaction, history] = createInstrumentation({ startTransactionOnLocationChange: false }); + render( + + +
Features
} /> +
About
} /> +
Home
} /> +
+
, + ); + expect(mockStartTransaction).toHaveBeenCalledTimes(1); + }); + + it('only starts a navigation transaction on push', () => { + const [mockStartTransaction, history] = createInstrumentation(); + render( + + +
Features
} /> +
About
} /> +
Home
} /> +
+
, + ); + + history.replace('hello'); + expect(mockStartTransaction).toHaveBeenCalledTimes(1); + }); + + it('finishes a transaction on navigation', () => { + const [mockStartTransaction, history, { mockFinish }] = createInstrumentation(); + render( + + +
Features
} /> +
About
} /> +
Home
} /> +
+
, + ); + expect(mockStartTransaction).toHaveBeenCalledTimes(1); + + history.push('/features'); + expect(mockFinish).toHaveBeenCalledTimes(1); + expect(mockStartTransaction).toHaveBeenCalledTimes(2); + }); + + it('does not normalize transaction name ', () => { + const [mockStartTransaction, history] = createInstrumentation(); + const { container } = render( + + +
UserId
} /> +
Users
} /> +
Home
} /> +
+
, + ); + + history.push('/users/123'); + expect(container.innerHTML).toContain('UserId'); + + expect(mockStartTransaction).toHaveBeenCalledTimes(2); + expect(mockStartTransaction).toHaveBeenLastCalledWith({ + name: '/users/123', + op: 'navigation', + tags: { 'routing.instrumentation': 'react-router-v4' }, + }); + }); + + it('normalizes transaction name with custom Route', () => { + const [mockStartTransaction, history, { mockSetName }] = createInstrumentation(); + const SentryRoute = withSentryRouting(Route); + const { container } = render( + + +
UserId
} /> +
Users
} /> +
Home
} /> +
+
, + ); + + history.push('/users/123'); + expect(container.innerHTML).toContain('UserId'); + + expect(mockStartTransaction).toHaveBeenCalledTimes(2); + expect(mockStartTransaction).toHaveBeenLastCalledWith({ + name: '/users/123', + op: 'navigation', + tags: { 'routing.instrumentation': 'react-router-v4' }, + }); + expect(mockSetName).toHaveBeenCalledTimes(2); + expect(mockSetName).toHaveBeenLastCalledWith('/users/:userid'); + }); + + it('normalizes nested transaction names with custom Route', () => { + const [mockStartTransaction, history, { mockSetName }] = createInstrumentation(); + const SentryRoute = withSentryRouting(Route); + const { container } = render( + + +
Team
} /> +
OrgId
} /> +
Home
} /> +
+
, + ); + + history.push('/organizations/1234/v1/758'); + expect(container.innerHTML).toContain('Team'); + + expect(mockStartTransaction).toHaveBeenCalledTimes(2); + expect(mockStartTransaction).toHaveBeenLastCalledWith({ + name: '/organizations/1234/v1/758', + op: 'navigation', + tags: { 'routing.instrumentation': 'react-router-v4' }, + }); + expect(mockSetName).toHaveBeenCalledTimes(2); + expect(mockSetName).toHaveBeenLastCalledWith('/organizations/:orgid/v1/:teamid'); + + history.push('/organizations/543'); + expect(container.innerHTML).toContain('OrgId'); + + expect(mockStartTransaction).toHaveBeenCalledTimes(3); + expect(mockStartTransaction).toHaveBeenLastCalledWith({ + name: '/organizations/543', + op: 'navigation', + tags: { 'routing.instrumentation': 'react-router-v4' }, + }); + expect(mockSetName).toHaveBeenCalledTimes(3); + expect(mockSetName).toHaveBeenLastCalledWith('/organizations/:orgid'); + }); + + it('matches with route object', () => { + const routes: RouteConfig[] = [ + { + path: '/organizations/:orgid/v1/:teamid', + }, + { path: '/organizations/:orgid' }, + { path: '/' }, + ]; + const [mockStartTransaction, history] = createInstrumentation({ routes }); + render( + + +
Team
} /> +
OrgId
} /> +
Home
} /> +
+
, + ); + + history.push('/organizations/1234/v1/758'); + expect(mockStartTransaction).toHaveBeenCalledTimes(2); + expect(mockStartTransaction).toHaveBeenLastCalledWith({ + name: '/organizations/:orgid/v1/:teamid', + op: 'navigation', + tags: { 'routing.instrumentation': 'react-router-v4' }, + }); + + history.push('/organizations/1234'); + expect(mockStartTransaction).toHaveBeenCalledTimes(3); + expect(mockStartTransaction).toHaveBeenLastCalledWith({ + name: '/organizations/:orgid', + op: 'navigation', + tags: { 'routing.instrumentation': 'react-router-v4' }, + }); + }); +}); diff --git a/packages/react/test/reactrouterv5.test.tsx b/packages/react/test/reactrouterv5.test.tsx new file mode 100644 index 000000000000..ab0c45a6c186 --- /dev/null +++ b/packages/react/test/reactrouterv5.test.tsx @@ -0,0 +1,249 @@ +// tslint:disable: no-unsafe-any +import { render } from '@testing-library/react'; +import { createMemoryHistory } from 'history-4'; +import * as React from 'react'; +import { matchPath, Route, Router, Switch } from 'react-router-5'; + +import { reactRouterV5Instrumentation, withSentryRouting } from '../src'; +import { RouteConfig } from '../src/reactrouter'; + +describe('React Router v5', () => { + function createInstrumentation(_opts?: { + startTransactionOnPageLoad?: boolean; + startTransactionOnLocationChange?: boolean; + routes?: RouteConfig[]; + }): [jest.Mock, any, { mockSetName: jest.Mock; mockFinish: jest.Mock }] { + const options = { + matchPath: _opts && _opts.routes !== undefined ? matchPath : undefined, + routes: undefined, + startTransactionOnLocationChange: true, + startTransactionOnPageLoad: true, + ..._opts, + }; + const history = createMemoryHistory(); + const mockFinish = jest.fn(); + const mockSetName = jest.fn(); + const mockStartTransaction = jest.fn().mockReturnValue({ setName: mockSetName, finish: mockFinish }); + reactRouterV5Instrumentation(history, options.routes, options.matchPath)( + mockStartTransaction, + options.startTransactionOnPageLoad, + options.startTransactionOnLocationChange, + ); + return [mockStartTransaction, history, { mockSetName, mockFinish }]; + } + + it('starts a pageload transaction when instrumentation is started', () => { + const [mockStartTransaction] = createInstrumentation(); + expect(mockStartTransaction).toHaveBeenCalledTimes(1); + expect(mockStartTransaction).toHaveBeenLastCalledWith({ + name: '/', + op: 'pageload', + tags: { 'routing.instrumentation': 'react-router-v5' }, + }); + }); + + it('does not start pageload transaction if option is false', () => { + const [mockStartTransaction] = createInstrumentation({ startTransactionOnPageLoad: false }); + expect(mockStartTransaction).toHaveBeenCalledTimes(0); + }); + + it('starts a navigation transaction', () => { + const [mockStartTransaction, history] = createInstrumentation(); + render( + + +
Features
} /> +
About
} /> +
Home
} /> +
+
, + ); + + history.push('/about'); + expect(mockStartTransaction).toHaveBeenCalledTimes(2); + expect(mockStartTransaction).toHaveBeenLastCalledWith({ + name: '/about', + op: 'navigation', + tags: { 'routing.instrumentation': 'react-router-v5' }, + }); + + history.push('/features'); + expect(mockStartTransaction).toHaveBeenCalledTimes(3); + expect(mockStartTransaction).toHaveBeenLastCalledWith({ + name: '/features', + op: 'navigation', + tags: { 'routing.instrumentation': 'react-router-v5' }, + }); + }); + + it('does not start a transaction if option is false', () => { + const [mockStartTransaction, history] = createInstrumentation({ startTransactionOnLocationChange: false }); + render( + + +
Features
} /> +
About
} /> +
Home
} /> +
+
, + ); + expect(mockStartTransaction).toHaveBeenCalledTimes(1); + }); + + it('only starts a navigation transaction on push', () => { + const [mockStartTransaction, history] = createInstrumentation(); + render( + + +
Features
} /> +
About
} /> +
Home
} /> +
+
, + ); + + history.replace('hello'); + expect(mockStartTransaction).toHaveBeenCalledTimes(1); + }); + + it('finishes a transaction on navigation', () => { + const [mockStartTransaction, history, { mockFinish }] = createInstrumentation(); + render( + + +
Features
} /> +
About
} /> +
Home
} /> +
+
, + ); + expect(mockStartTransaction).toHaveBeenCalledTimes(1); + + history.push('/features'); + expect(mockFinish).toHaveBeenCalledTimes(1); + expect(mockStartTransaction).toHaveBeenCalledTimes(2); + }); + + it('does not normalize transaction name ', () => { + const [mockStartTransaction, history] = createInstrumentation(); + const { container } = render( + + +
UserId
} /> +
Users
} /> +
Home
} /> +
+
, + ); + + history.push('/users/123'); + expect(container.innerHTML).toContain('UserId'); + + expect(mockStartTransaction).toHaveBeenCalledTimes(2); + expect(mockStartTransaction).toHaveBeenLastCalledWith({ + name: '/users/123', + op: 'navigation', + tags: { 'routing.instrumentation': 'react-router-v5' }, + }); + }); + + it('normalizes transaction name with custom Route', () => { + const [mockStartTransaction, history, { mockSetName }] = createInstrumentation(); + const SentryRoute = withSentryRouting(Route); + const { container } = render( + + +
UserId
} /> +
Users
} /> +
Home
} /> +
+
, + ); + + history.push('/users/123'); + expect(container.innerHTML).toContain('UserId'); + + expect(mockStartTransaction).toHaveBeenCalledTimes(2); + expect(mockStartTransaction).toHaveBeenLastCalledWith({ + name: '/users/123', + op: 'navigation', + tags: { 'routing.instrumentation': 'react-router-v5' }, + }); + expect(mockSetName).toHaveBeenCalledTimes(2); + expect(mockSetName).toHaveBeenLastCalledWith('/users/:userid'); + }); + + it('normalizes nested transaction names with custom Route', () => { + const [mockStartTransaction, history, { mockSetName }] = createInstrumentation(); + const SentryRoute = withSentryRouting(Route); + const { container } = render( + + +
Team
} /> +
OrgId
} /> +
Home
} /> +
+
, + ); + + history.push('/organizations/1234/v1/758'); + expect(container.innerHTML).toContain('Team'); + + expect(mockStartTransaction).toHaveBeenCalledTimes(2); + expect(mockStartTransaction).toHaveBeenLastCalledWith({ + name: '/organizations/1234/v1/758', + op: 'navigation', + tags: { 'routing.instrumentation': 'react-router-v5' }, + }); + expect(mockSetName).toHaveBeenCalledTimes(2); + expect(mockSetName).toHaveBeenLastCalledWith('/organizations/:orgid/v1/:teamid'); + + history.push('/organizations/543'); + expect(container.innerHTML).toContain('OrgId'); + + expect(mockStartTransaction).toHaveBeenCalledTimes(3); + expect(mockStartTransaction).toHaveBeenLastCalledWith({ + name: '/organizations/543', + op: 'navigation', + tags: { 'routing.instrumentation': 'react-router-v5' }, + }); + expect(mockSetName).toHaveBeenCalledTimes(3); + expect(mockSetName).toHaveBeenLastCalledWith('/organizations/:orgid'); + }); + + it('matches with route object', () => { + const routes: RouteConfig[] = [ + { + path: '/organizations/:orgid/v1/:teamid', + }, + { path: '/organizations/:orgid' }, + { path: '/' }, + ]; + const [mockStartTransaction, history] = createInstrumentation({ routes }); + render( + + +
Team
} /> +
OrgId
} /> +
Home
} /> +
+
, + ); + + history.push('/organizations/1234/v1/758'); + expect(mockStartTransaction).toHaveBeenCalledTimes(2); + expect(mockStartTransaction).toHaveBeenLastCalledWith({ + name: '/organizations/:orgid/v1/:teamid', + op: 'navigation', + tags: { 'routing.instrumentation': 'react-router-v5' }, + }); + + history.push('/organizations/1234'); + expect(mockStartTransaction).toHaveBeenCalledTimes(3); + expect(mockStartTransaction).toHaveBeenLastCalledWith({ + name: '/organizations/:orgid', + op: 'navigation', + tags: { 'routing.instrumentation': 'react-router-v5' }, + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index bc6d39837649..9db02a6f18f1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -838,7 +838,7 @@ core-js-pure "^3.0.0" regenerator-runtime "^0.13.4" -"@babel/runtime@^7.10.2", "@babel/runtime@^7.10.3", "@babel/runtime@^7.5.4", "@babel/runtime@^7.8.4": +"@babel/runtime@^7.1.2", "@babel/runtime@^7.10.2", "@babel/runtime@^7.10.3", "@babel/runtime@^7.5.4", "@babel/runtime@^7.8.4": version "7.10.5" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.10.5.tgz#303d8bd440ecd5a491eae6117fd3367698674c5c" integrity sha512-otddXKhdNn7d0ptoFRHtMLa8LqDxLYwTjB4nYgM1yy5N6gU/MUf8zqyyLltCH3yAVitBzmwK4us+DD0l/MauAg== @@ -2508,6 +2508,12 @@ resolved "https://registry.yarnpkg.com/@types/highlight.js/-/highlight.js-9.12.4.tgz#8c3496bd1b50cc04aeefd691140aa571d4dbfa34" integrity sha512-t2szdkwmg2JJyuCM20e8kR2X59WCE5Zkl4bzm1u1Oukjm79zpbiAv+QjnwLnuuV0WHEcX2NgUItu0pAMKuOPww== +"@types/history-4@npm:@types/history@4.7.7", "@types/history-5@npm:@types/history@4.7.7", "@types/history@*": + name "@types/history-4" + version "4.7.7" + resolved "https://registry.yarnpkg.com/@types/history/-/history-4.7.7.tgz#613957d900fab9ff84c8dfb24fa3eef0c2a40896" + integrity sha512-2xtoL22/3Mv6a70i4+4RB7VgbDDORoWwjcqeNysojZA0R7NK17RbY5Gof/2QiFfJgX+KkWghbwJ+d/2SB8Ndzg== + "@types/history@^3": version "3.2.4" resolved "https://registry.yarnpkg.com/@types/history/-/history-3.2.4.tgz#0b6c62240d1fac020853aa5608758991d9f6ef3d" @@ -2674,6 +2680,14 @@ "@types/history" "^3" "@types/react" "*" +"@types/react-router-4@npm:@types/react-router@5.0.0", "@types/react-router-5@npm:@types/react-router@5.0.0": + version "5.0.0" + resolved "https://registry.yarnpkg.com/@types/react-router/-/react-router-5.0.0.tgz#22ae8f55d8af770ea1f755218936f01bfe1bfe27" + integrity sha512-0JjtJMxkQSyWUHTHaD3GhKf6rcZSUFmcQob8OlPTsbnxnIg2Nh3btkss4uke5CKVRtbCMipGU7My5jtfZQC+jw== + dependencies: + "@types/history" "*" + "@types/react" "*" + "@types/react-test-renderer@*": version "16.9.2" resolved "https://registry.yarnpkg.com/@types/react-test-renderer/-/react-test-renderer-16.9.2.tgz#e1c408831e8183e5ad748fdece02214a7c2ab6c5" @@ -6742,6 +6756,14 @@ create-react-class@^15.5.1: loose-envify "^1.3.1" object-assign "^4.1.1" +create-react-context@^0.2.2: + version "0.2.3" + resolved "https://registry.yarnpkg.com/create-react-context/-/create-react-context-0.2.3.tgz#9ec140a6914a22ef04b8b09b7771de89567cb6f3" + integrity sha512-CQBmD0+QGgTaxDL3OX1IDXYqjkp2It4RIbcb99jS6AEg27Ga+a9G3JtK6SIu0HBwPLZlmwt9F7UwWA4Bn92Rag== + dependencies: + fbjs "^0.8.0" + gud "^1.0.0" + cross-spawn-async@^2.1.1: version "2.2.5" resolved "https://registry.yarnpkg.com/cross-spawn-async/-/cross-spawn-async-2.2.5.tgz#845ff0c0834a3ded9d160daca6d390906bb288cc" @@ -9205,7 +9227,7 @@ fb-watchman@^2.0.0: dependencies: bser "2.1.1" -fbjs@^0.8.9: +fbjs@^0.8.0, fbjs@^0.8.9: version "0.8.17" resolved "https://registry.yarnpkg.com/fbjs/-/fbjs-0.8.17.tgz#c4d598ead6949112653d6588b01a5cdcd9f90fdd" integrity sha1-xNWY6taUkRJlPWWIsBpc3Nn5D90= @@ -10221,6 +10243,11 @@ gtoken@^2.3.2: mime "^2.2.0" pify "^4.0.0" +gud@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/gud/-/gud-1.0.0.tgz#a489581b17e6a70beca9abe3ae57de7a499852c0" + integrity sha512-zGEOVKFM5sVPPrYs7J5/hYEw2Pof8KCyOwyhG8sAF26mCAeUFAcYPu1mwB7hhpIP29zOIBaDqwuHdLp0jvZXjw== + gzip-size@^5.0.0: version "5.1.1" resolved "https://registry.yarnpkg.com/gzip-size/-/gzip-size-5.1.1.tgz#cb9bee692f87c0612b232840a873904e4c135274" @@ -10455,6 +10482,29 @@ highlight.js@^9.13.1, highlight.js@^9.15.6: resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-9.18.1.tgz#ed21aa001fe6252bb10a3d76d47573c6539fe13c" integrity sha512-OrVKYz70LHsnCgmbXctv/bfuvntIKDz177h0Co37DQ5jamGZLVmoCVMtjMtNZY3X9DrCcKfklHPNeA0uPZhSJg== +"history-4@npm:history@4.6.0": + version "4.6.0" + resolved "https://registry.yarnpkg.com/history/-/history-4.6.0.tgz#2e09f7b173333040044c9fede373ad29bc2e2186" + integrity sha1-Lgn3sXMzMEAETJ/t43OtKbwuIYY= + dependencies: + invariant "^2.2.1" + loose-envify "^1.2.0" + resolve-pathname "^2.0.0" + value-equal "^0.2.0" + warning "^3.0.0" + +"history-5@npm:history@4.9.0": + version "4.9.0" + resolved "https://registry.yarnpkg.com/history/-/history-4.9.0.tgz#84587c2068039ead8af769e9d6a6860a14fa1bca" + integrity sha512-H2DkjCjXf0Op9OAr6nJ56fcRkTSNrUiv41vNJ6IswJjif6wlpZK0BTfFbi7qK9dXLSYZxkq5lBsj3vUjlYBYZA== + dependencies: + "@babel/runtime" "^7.1.2" + loose-envify "^1.2.0" + resolve-pathname "^2.2.0" + tiny-invariant "^1.0.2" + tiny-warning "^1.0.0" + value-equal "^0.4.0" + history@^3.0.0: version "3.3.0" resolved "https://registry.yarnpkg.com/history/-/history-3.3.0.tgz#fcedcce8f12975371545d735461033579a6dae9c" @@ -10465,6 +10515,18 @@ history@^3.0.0: query-string "^4.2.2" warning "^3.0.0" +history@^4.6.0, history@^4.9.0: + version "4.10.1" + resolved "https://registry.yarnpkg.com/history/-/history-4.10.1.tgz#33371a65e3a83b267434e2b3f3b1b4c58aad4cf3" + integrity sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew== + dependencies: + "@babel/runtime" "^7.1.2" + loose-envify "^1.2.0" + resolve-pathname "^3.0.0" + tiny-invariant "^1.0.2" + tiny-warning "^1.0.0" + value-equal "^1.0.1" + hmac-drbg@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1" @@ -10474,7 +10536,12 @@ hmac-drbg@^1.0.0: minimalistic-assert "^1.0.0" minimalistic-crypto-utils "^1.0.1" -hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2: +hoist-non-react-statics@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-1.2.0.tgz#aa448cf0986d55cc40773b17174b7dd066cb7cfb" + integrity sha1-qkSM8JhtVcxAdzsXF0t90GbLfPs= + +hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2: version "3.3.2" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw== @@ -14931,7 +14998,7 @@ path-to-regexp@0.1.7: resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" integrity sha1-32BBeABfUi8V60SQ5yR6G/qmf4w= -path-to-regexp@^1.7.0: +path-to-regexp@^1.5.3, path-to-regexp@^1.7.0: version "1.8.0" resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-1.8.0.tgz#887b3ba9d84393e87a0a0b9f4cb756198b53548a" integrity sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA== @@ -15570,7 +15637,7 @@ promzard@^0.3.0: dependencies: read "1" -prop-types@^15.6.2, prop-types@^15.7.2: +prop-types@^15.5.4, prop-types@^15.5.6, prop-types@^15.6.2: version "15.7.2" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5" integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ== @@ -15835,25 +15902,53 @@ react-dom@^16.0.0: prop-types "^15.6.2" scheduler "^0.19.1" -react-is@^16.12.0, react-is@^16.13.0, react-is@^16.7.0, react-is@^16.8.1, react-is@^16.8.4, react-is@^16.8.6: +react-is@^16.12.0, react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.1, react-is@^16.8.4, react-is@^16.8.6: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== -"react-router-3@npm:react-router@^3.2.0": - version "3.2.6" - resolved "https://registry.yarnpkg.com/react-router/-/react-router-3.2.6.tgz#cad202796a7bba3efc2100da453b3379c9d4aeb4" - integrity sha512-nlxtQE8B22hb/JxdaslI1tfZacxFU8x8BJryXOnR2RxB4vc01zuHYAHAIgmBkdk1kzXaA25hZxK6KAH/+CXArw== +"react-router-3@npm:react-router@3.2.0": + version "3.2.0" + resolved "https://registry.yarnpkg.com/react-router/-/react-router-3.2.0.tgz#62b6279d589b70b34e265113e4c0a9261a02ed36" + integrity sha512-sXlLOg0TRCqnjCVskqBHGjzNjcJKUqXEKnDSuxMYJSPJNq9hROE9VsiIW2kfIq7Ev+20Iz0nxayekXyv0XNmsg== dependencies: create-react-class "^15.5.1" history "^3.0.0" - hoist-non-react-statics "^3.3.2" + hoist-non-react-statics "^1.2.0" invariant "^2.2.1" loose-envify "^1.2.0" - prop-types "^15.7.2" - react-is "^16.13.0" + prop-types "^15.5.6" warning "^3.0.0" +"react-router-4@npm:react-router@4.1.0": + version "4.1.0" + resolved "https://registry.yarnpkg.com/react-router/-/react-router-4.1.0.tgz#959365d54bd4ffaf1e0219f878bb1f24366b73ac" + integrity sha1-lZNl1UvU/68eAhn4eLsfJDZrc6w= + dependencies: + history "^4.6.0" + hoist-non-react-statics "^1.2.0" + invariant "^2.2.2" + loose-envify "^1.3.1" + path-to-regexp "^1.5.3" + prop-types "^15.5.4" + warning "^3.0.0" + +"react-router-5@npm:react-router@5.0.0": + version "5.0.0" + resolved "https://registry.yarnpkg.com/react-router/-/react-router-5.0.0.tgz#349863f769ffc2fa10ee7331a4296e86bc12879d" + integrity sha512-6EQDakGdLG/it2x9EaCt9ZpEEPxnd0OCLBHQ1AcITAAx7nCnyvnzf76jKWG1s2/oJ7SSviUgfWHofdYljFexsA== + dependencies: + "@babel/runtime" "^7.1.2" + create-react-context "^0.2.2" + history "^4.9.0" + hoist-non-react-statics "^3.1.0" + loose-envify "^1.3.1" + path-to-regexp "^1.7.0" + prop-types "^15.6.2" + react-is "^16.6.0" + tiny-invariant "^1.0.2" + tiny-warning "^1.0.0" + react-test-renderer@^16.13.1: version "16.13.1" resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-16.13.1.tgz#de25ea358d9012606de51e012d9742e7f0deabc1" @@ -16437,6 +16532,16 @@ resolve-path@^1.4.0: http-errors "~1.6.2" path-is-absolute "1.0.1" +resolve-pathname@^2.0.0, resolve-pathname@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/resolve-pathname/-/resolve-pathname-2.2.0.tgz#7e9ae21ed815fd63ab189adeee64dc831eefa879" + integrity sha512-bAFz9ld18RzJfddgrO2e/0S2O81710++chRMUxHjXOYKF6jTAMrUNZrEZ1PvV0zlhfjidm08iRPdTLPno1FuRg== + +resolve-pathname@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/resolve-pathname/-/resolve-pathname-3.0.0.tgz#99d02224d3cf263689becbb393bc560313025dcd" + integrity sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng== + resolve-url@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a" @@ -18190,6 +18295,11 @@ timsort@^0.3.0: resolved "https://registry.yarnpkg.com/timsort/-/timsort-0.3.0.tgz#405411a8e7e6339fe64db9a234de11dc31e02bd4" integrity sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q= +tiny-invariant@^1.0.2: + version "1.1.0" + resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.1.0.tgz#634c5f8efdc27714b7f386c35e6760991d230875" + integrity sha512-ytxQvrb1cPc9WBEI/HSeYYoGD0kWnGEOR8RY6KomWLBVhqz0RgTwVO9dLrGz7dC+nN9llyI7OKAgRq8Vq4ZBSw== + tiny-lr@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/tiny-lr/-/tiny-lr-1.1.1.tgz#9fa547412f238fedb068ee295af8b682c98b2aab" @@ -18202,6 +18312,11 @@ tiny-lr@^1.1.1: object-assign "^4.1.0" qs "^6.4.0" +tiny-warning@^1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754" + integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA== + tmp@0.0.28: version "0.0.28" resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.28.tgz#172735b7f614ea7af39664fa84cf0de4e515d120" @@ -18974,6 +19089,21 @@ validate-npm-package-name@^3.0.0: dependencies: builtins "^1.0.3" +value-equal@^0.2.0: + version "0.2.1" + resolved "https://registry.yarnpkg.com/value-equal/-/value-equal-0.2.1.tgz#c220a304361fce6994dbbedaa3c7e1a1b895871d" + integrity sha1-wiCjBDYfzmmU277ao8fhobiVhx0= + +value-equal@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/value-equal/-/value-equal-0.4.0.tgz#c5bdd2f54ee093c04839d71ce2e4758a6890abc7" + integrity sha512-x+cYdNnaA3CxvMaTX0INdTCN8m8aF2uY9BvEqmxuYp8bL09cs/kWVQPVGcA35fMktdOsP69IgU7wFj/61dJHEw== + +value-equal@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/value-equal/-/value-equal-1.0.1.tgz#1e0b794c734c5c0cade179c437d356d931a34d6c" + integrity sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw== + vary@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"