Skip to content

Commit a3cac7b

Browse files
authored
fix(react): Fix react router v4/v5 instrumentation (#11855)
Turns out we were not correctly setting parametrized route names for react router v4/v5 😬 We had no proper test covering this. So I added a new E2E test `react-router-5` that actually checks that parametrization etc. works as expected. While at it, I also updated the react-router-6 E2E test to actually check these things as well - previously, we mostly only checked that _anything_ was sent to sentry, but didn't look at the content we sent. Fixes #11815
1 parent eadcac5 commit a3cac7b

File tree

16 files changed

+430
-4
lines changed

16 files changed

+430
-4
lines changed

.github/workflows/build.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1008,6 +1008,7 @@ jobs:
10081008
'nextjs-14',
10091009
'react-create-hash-router',
10101010
'react-router-6-use-routes',
1011+
'react-router-5',
10111012
'standard-frontend-react',
10121013
'svelte-5',
10131014
'sveltekit',
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2+
3+
# dependencies
4+
/node_modules
5+
/.pnp
6+
.pnp.js
7+
8+
# testing
9+
/coverage
10+
11+
# production
12+
/build
13+
14+
# misc
15+
.DS_Store
16+
.env.local
17+
.env.development.local
18+
.env.test.local
19+
.env.production.local
20+
21+
npm-debug.log*
22+
yarn-debug.log*
23+
yarn-error.log*
24+
25+
/test-results/
26+
/playwright-report/
27+
/playwright/.cache/
28+
29+
!*.d.ts
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
@sentry:registry=http://127.0.0.1:4873
2+
@sentry-internal:registry=http://127.0.0.1:4873
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
{
2+
"name": "react-router-5-e2e-test-app",
3+
"version": "0.1.0",
4+
"private": true,
5+
"dependencies": {
6+
"@sentry/react": "latest || *",
7+
"@testing-library/jest-dom": "5.14.1",
8+
"@testing-library/react": "13.0.0",
9+
"@testing-library/user-event": "13.2.1",
10+
"history": "4.9.0",
11+
"@types/history": "4.7.11",
12+
"@types/jest": "27.0.1",
13+
"@types/node": "16.7.13",
14+
"@types/react": "18.0.0",
15+
"@types/react-dom": "18.0.0",
16+
"@types/react-router": "5.1.20",
17+
"@types/react-router-dom": "5.3.3",
18+
"react": "18.2.0",
19+
"react-dom": "18.2.0",
20+
"react-router-dom": "5.3.4",
21+
"react-scripts": "5.0.1",
22+
"typescript": "4.9.5",
23+
"web-vitals": "2.1.0"
24+
},
25+
"scripts": {
26+
"build": "react-scripts build",
27+
"start": "serve -s build",
28+
"test": "playwright test",
29+
"clean": "npx rimraf node_modules,pnpm-lock.yaml",
30+
"test:build": "pnpm install && npx playwright install && pnpm build",
31+
"test:assert": "pnpm test"
32+
},
33+
"eslintConfig": {
34+
"extends": [
35+
"react-app",
36+
"react-app/jest"
37+
]
38+
},
39+
"browserslist": {
40+
"production": [
41+
">0.2%",
42+
"not dead",
43+
"not op_mini all"
44+
],
45+
"development": [
46+
"last 1 chrome version",
47+
"last 1 firefox version",
48+
"last 1 safari version"
49+
]
50+
},
51+
"devDependencies": {
52+
"@playwright/test": "^1.43.1",
53+
"@sentry-internal/event-proxy-server": "link:../../../event-proxy-server",
54+
"ts-node": "^10.9.2",
55+
"serve": "14.0.1"
56+
},
57+
"volta": {
58+
"extends": "../../package.json"
59+
}
60+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import type { PlaywrightTestConfig } from '@playwright/test';
2+
import { devices } from '@playwright/test';
3+
4+
const reactPort = 3030;
5+
const eventProxyPort = 3031;
6+
7+
/**
8+
* See https://playwright.dev/docs/test-configuration.
9+
*/
10+
const config: PlaywrightTestConfig = {
11+
testDir: './tests',
12+
/* Maximum time one test can run for. */
13+
timeout: 150_000,
14+
expect: {
15+
/**
16+
* Maximum time expect() should wait for the condition to be met.
17+
* For example in `await expect(locator).toHaveText();`
18+
*/
19+
timeout: 5000,
20+
},
21+
/* Run tests in files in parallel */
22+
fullyParallel: true,
23+
/* Fail the build on CI if you accidentally left test.only in the source code. */
24+
forbidOnly: !!process.env.CI,
25+
/* Retry on CI only */
26+
retries: 0,
27+
/* Opt out of parallel tests on CI. */
28+
workers: 1,
29+
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
30+
reporter: 'list',
31+
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
32+
use: {
33+
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
34+
actionTimeout: 0,
35+
36+
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
37+
trace: 'on-first-retry',
38+
39+
baseURL: `http://localhost:${reactPort}`,
40+
},
41+
42+
/* Configure projects for major browsers */
43+
projects: [
44+
{
45+
name: 'chromium',
46+
use: {
47+
...devices['Desktop Chrome'],
48+
},
49+
},
50+
// For now we only test Chrome!
51+
// {
52+
// name: 'firefox',
53+
// use: {
54+
// ...devices['Desktop Firefox'],
55+
// },
56+
// },
57+
// {
58+
// name: 'webkit',
59+
// use: {
60+
// ...devices['Desktop Safari'],
61+
// },
62+
// },
63+
],
64+
65+
/* Run your local dev server before starting the tests */
66+
67+
webServer: [
68+
{
69+
command: 'pnpm ts-node-script start-event-proxy.ts',
70+
port: eventProxyPort,
71+
},
72+
{
73+
command: 'pnpm start',
74+
port: reactPort,
75+
env: {
76+
PORT: `${reactPort}`,
77+
},
78+
},
79+
],
80+
};
81+
82+
export default config;
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1" />
6+
<meta name="theme-color" content="#000000" />
7+
<meta name="description" content="Web site created using create-react-app" />
8+
<title>React App</title>
9+
</head>
10+
<body>
11+
<noscript>You need to enable JavaScript to run this app.</noscript>
12+
<div id="root"></div>
13+
<!--
14+
This HTML file is a template.
15+
If you open it directly in the browser, you will see an empty page.
16+
17+
You can add webfonts, meta tags, or analytics to this file.
18+
The build step will place the bundled scripts into the <body> tag.
19+
20+
To begin the development, run `npm start` or `yarn start`.
21+
To create a production bundle, use `npm run build` or `yarn build`.
22+
-->
23+
</body>
24+
</html>
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
interface Window {
2+
recordedTransactions?: string[];
3+
capturedExceptionId?: string;
4+
sentryReplayId?: string;
5+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import * as Sentry from '@sentry/react';
2+
import { createBrowserHistory } from 'history';
3+
// biome-ignore lint/nursery/noUnusedImports: <explanation>
4+
import React from 'react';
5+
import ReactDOM from 'react-dom/client';
6+
import { Route, Router, Switch } from 'react-router-dom';
7+
import Index from './pages/Index';
8+
import User from './pages/User';
9+
10+
const replay = Sentry.replayIntegration();
11+
12+
const history = createBrowserHistory();
13+
14+
Sentry.init({
15+
environment: 'qa', // dynamic sampling bias to keep transactions
16+
dsn:
17+
process.env.REACT_APP_E2E_TEST_DSN ||
18+
'https://3b6c388182fb435097f41d181be2b2ba@o4504321058471936.ingest.sentry.io/4504321066008576',
19+
integrations: [Sentry.reactRouterV5BrowserTracingIntegration({ history }), replay],
20+
// We recommend adjusting this value in production, or using tracesSampler
21+
// for finer control
22+
tracesSampleRate: 1.0,
23+
release: 'e2e-test',
24+
tunnel: 'http://localhost:3031/', // proxy server
25+
26+
// Always capture replays, so we can test this properly
27+
replaysSessionSampleRate: 1.0,
28+
replaysOnErrorSampleRate: 0.0,
29+
});
30+
31+
// Create Custom Sentry Route component
32+
export const SentryRoute = Sentry.withSentryRouting(Route);
33+
34+
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
35+
root.render(
36+
<Router history={history}>
37+
<Switch>
38+
<SentryRoute path="/user/:id" component={User} />
39+
<SentryRoute path="/" component={Index} />
40+
</Switch>
41+
</Router>,
42+
);
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import * as Sentry from '@sentry/react';
2+
// biome-ignore lint/nursery/noUnusedImports: Need React import for JSX
3+
import * as React from 'react';
4+
import { Link } from 'react-router-dom';
5+
6+
const Index = () => {
7+
return (
8+
<>
9+
<input
10+
type="button"
11+
value="Capture Exception"
12+
id="exception-button"
13+
onClick={() => {
14+
const eventId = Sentry.captureException(new Error('I am an error!'));
15+
window.capturedExceptionId = eventId;
16+
}}
17+
/>
18+
<Link to="/user/5" id="navigation">
19+
navigate
20+
</Link>
21+
</>
22+
);
23+
};
24+
25+
export default Index;
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
// biome-ignore lint/nursery/noUnusedImports: Need React import for JSX
2+
import * as React from 'react';
3+
4+
const User = (params: { id: string }) => {
5+
return <p>Show user details for {params.id}</p>;
6+
};
7+
8+
export default User;
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/// <reference types="react-scripts" />
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { startEventProxyServer } from '@sentry-internal/event-proxy-server';
2+
3+
startEventProxyServer({
4+
port: 3031,
5+
proxyServerName: 'react-router-5',
6+
});
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { expect, test } from '@playwright/test';
2+
import { waitForError, waitForTransaction } from '@sentry-internal/event-proxy-server';
3+
4+
test('Sends correct error event', async ({ page }) => {
5+
const errorEventPromise = waitForError('react-router-5', event => {
6+
return !event.type && event.exception?.values?.[0]?.value === 'I am an error!';
7+
});
8+
9+
await page.goto('/');
10+
11+
const exceptionButton = page.locator('id=exception-button');
12+
await exceptionButton.click();
13+
14+
const errorEvent = await errorEventPromise;
15+
16+
expect(errorEvent.exception?.values).toHaveLength(1);
17+
expect(errorEvent.exception?.values?.[0]?.value).toBe('I am an error!');
18+
19+
expect(errorEvent.request).toEqual({
20+
headers: expect.any(Object),
21+
url: 'http://localhost:3030/',
22+
});
23+
24+
expect(errorEvent.transaction).toEqual('/');
25+
26+
expect(errorEvent.contexts?.trace).toEqual({
27+
trace_id: expect.any(String),
28+
span_id: expect.any(String),
29+
});
30+
});
31+
32+
test('Sets correct transactionName', async ({ page }) => {
33+
const transactionPromise = waitForTransaction('react-router-5', async transactionEvent => {
34+
return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload';
35+
});
36+
37+
const errorEventPromise = waitForError('react-router-5', event => {
38+
return !event.type && event.exception?.values?.[0]?.value === 'I am an error!';
39+
});
40+
41+
await page.goto('/');
42+
const transactionEvent = await transactionPromise;
43+
44+
// Only capture error once transaction was sent
45+
const exceptionButton = page.locator('id=exception-button');
46+
await exceptionButton.click();
47+
48+
const errorEvent = await errorEventPromise;
49+
50+
expect(errorEvent.exception?.values).toHaveLength(1);
51+
expect(errorEvent.exception?.values?.[0]?.value).toBe('I am an error!');
52+
53+
expect(errorEvent.transaction).toEqual('/');
54+
55+
expect(errorEvent.contexts?.trace).toEqual({
56+
trace_id: transactionEvent.contexts?.trace?.trace_id,
57+
span_id: expect.not.stringContaining(transactionEvent.contexts?.trace?.span_id || ''),
58+
});
59+
});

0 commit comments

Comments
 (0)