diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index a7115aa7a15b..1027efa79677 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -1008,6 +1008,7 @@ jobs:
'nextjs-14',
'react-create-hash-router',
'react-router-6-use-routes',
+ 'react-router-5',
'standard-frontend-react',
'svelte-5',
'sveltekit',
diff --git a/dev-packages/e2e-tests/test-applications/react-router-5/.gitignore b/dev-packages/e2e-tests/test-applications/react-router-5/.gitignore
new file mode 100644
index 000000000000..84634c973eeb
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-5/.gitignore
@@ -0,0 +1,29 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.js
+
+# testing
+/coverage
+
+# production
+/build
+
+# misc
+.DS_Store
+.env.local
+.env.development.local
+.env.test.local
+.env.production.local
+
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+/test-results/
+/playwright-report/
+/playwright/.cache/
+
+!*.d.ts
diff --git a/dev-packages/e2e-tests/test-applications/react-router-5/.npmrc b/dev-packages/e2e-tests/test-applications/react-router-5/.npmrc
new file mode 100644
index 000000000000..070f80f05092
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-5/.npmrc
@@ -0,0 +1,2 @@
+@sentry:registry=http://127.0.0.1:4873
+@sentry-internal:registry=http://127.0.0.1:4873
diff --git a/dev-packages/e2e-tests/test-applications/react-router-5/package.json b/dev-packages/e2e-tests/test-applications/react-router-5/package.json
new file mode 100644
index 000000000000..921f67e212b3
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-5/package.json
@@ -0,0 +1,60 @@
+{
+ "name": "react-router-5-e2e-test-app",
+ "version": "0.1.0",
+ "private": true,
+ "dependencies": {
+ "@sentry/react": "latest || *",
+ "@testing-library/jest-dom": "5.14.1",
+ "@testing-library/react": "13.0.0",
+ "@testing-library/user-event": "13.2.1",
+ "history": "4.9.0",
+ "@types/history": "4.7.11",
+ "@types/jest": "27.0.1",
+ "@types/node": "16.7.13",
+ "@types/react": "18.0.0",
+ "@types/react-dom": "18.0.0",
+ "@types/react-router": "5.1.20",
+ "@types/react-router-dom": "5.3.3",
+ "react": "18.2.0",
+ "react-dom": "18.2.0",
+ "react-router-dom": "5.3.4",
+ "react-scripts": "5.0.1",
+ "typescript": "4.9.5",
+ "web-vitals": "2.1.0"
+ },
+ "scripts": {
+ "build": "react-scripts build",
+ "start": "serve -s build",
+ "test": "playwright test",
+ "clean": "npx rimraf node_modules,pnpm-lock.yaml",
+ "test:build": "pnpm install && npx playwright install && pnpm build",
+ "test:assert": "pnpm test"
+ },
+ "eslintConfig": {
+ "extends": [
+ "react-app",
+ "react-app/jest"
+ ]
+ },
+ "browserslist": {
+ "production": [
+ ">0.2%",
+ "not dead",
+ "not op_mini all"
+ ],
+ "development": [
+ "last 1 chrome version",
+ "last 1 firefox version",
+ "last 1 safari version"
+ ]
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.43.1",
+ "@sentry-internal/event-proxy-server": "link:../../../event-proxy-server",
+ "ts-node": "^10.9.2",
+ "serve": "14.0.1"
+ },
+ "volta": {
+ "extends": "../../package.json"
+ }
+}
diff --git a/dev-packages/e2e-tests/test-applications/react-router-5/playwright.config.ts b/dev-packages/e2e-tests/test-applications/react-router-5/playwright.config.ts
new file mode 100644
index 000000000000..abee4975ed4e
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-5/playwright.config.ts
@@ -0,0 +1,82 @@
+import type { PlaywrightTestConfig } from '@playwright/test';
+import { devices } from '@playwright/test';
+
+const reactPort = 3030;
+const eventProxyPort = 3031;
+
+/**
+ * See https://playwright.dev/docs/test-configuration.
+ */
+const config: PlaywrightTestConfig = {
+ testDir: './tests',
+ /* Maximum time one test can run for. */
+ timeout: 150_000,
+ expect: {
+ /**
+ * Maximum time expect() should wait for the condition to be met.
+ * For example in `await expect(locator).toHaveText();`
+ */
+ timeout: 5000,
+ },
+ /* Run tests in files in parallel */
+ fullyParallel: true,
+ /* Fail the build on CI if you accidentally left test.only in the source code. */
+ forbidOnly: !!process.env.CI,
+ /* Retry on CI only */
+ retries: 0,
+ /* Opt out of parallel tests on CI. */
+ workers: 1,
+ /* Reporter to use. See https://playwright.dev/docs/test-reporters */
+ reporter: 'list',
+ /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
+ use: {
+ /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
+ actionTimeout: 0,
+
+ /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
+ trace: 'on-first-retry',
+
+ baseURL: `http://localhost:${reactPort}`,
+ },
+
+ /* Configure projects for major browsers */
+ projects: [
+ {
+ name: 'chromium',
+ use: {
+ ...devices['Desktop Chrome'],
+ },
+ },
+ // For now we only test Chrome!
+ // {
+ // name: 'firefox',
+ // use: {
+ // ...devices['Desktop Firefox'],
+ // },
+ // },
+ // {
+ // name: 'webkit',
+ // use: {
+ // ...devices['Desktop Safari'],
+ // },
+ // },
+ ],
+
+ /* Run your local dev server before starting the tests */
+
+ webServer: [
+ {
+ command: 'pnpm ts-node-script start-event-proxy.ts',
+ port: eventProxyPort,
+ },
+ {
+ command: 'pnpm start',
+ port: reactPort,
+ env: {
+ PORT: `${reactPort}`,
+ },
+ },
+ ],
+};
+
+export default config;
diff --git a/dev-packages/e2e-tests/test-applications/react-router-5/public/index.html b/dev-packages/e2e-tests/test-applications/react-router-5/public/index.html
new file mode 100644
index 000000000000..39da76522bea
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-5/public/index.html
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+ React App
+
+
+
+
+
+
+
diff --git a/dev-packages/e2e-tests/test-applications/react-router-5/src/globals.d.ts b/dev-packages/e2e-tests/test-applications/react-router-5/src/globals.d.ts
new file mode 100644
index 000000000000..ffa61ca49acc
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-5/src/globals.d.ts
@@ -0,0 +1,5 @@
+interface Window {
+ recordedTransactions?: string[];
+ capturedExceptionId?: string;
+ sentryReplayId?: string;
+}
diff --git a/dev-packages/e2e-tests/test-applications/react-router-5/src/index.tsx b/dev-packages/e2e-tests/test-applications/react-router-5/src/index.tsx
new file mode 100644
index 000000000000..315ba07ad8c4
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-5/src/index.tsx
@@ -0,0 +1,42 @@
+import * as Sentry from '@sentry/react';
+import { createBrowserHistory } from 'history';
+// biome-ignore lint/nursery/noUnusedImports:
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import { Route, Router, Switch } from 'react-router-dom';
+import Index from './pages/Index';
+import User from './pages/User';
+
+const replay = Sentry.replayIntegration();
+
+const history = createBrowserHistory();
+
+Sentry.init({
+ environment: 'qa', // dynamic sampling bias to keep transactions
+ dsn:
+ process.env.REACT_APP_E2E_TEST_DSN ||
+ 'https://3b6c388182fb435097f41d181be2b2ba@o4504321058471936.ingest.sentry.io/4504321066008576',
+ integrations: [Sentry.reactRouterV5BrowserTracingIntegration({ history }), replay],
+ // We recommend adjusting this value in production, or using tracesSampler
+ // for finer control
+ tracesSampleRate: 1.0,
+ release: 'e2e-test',
+ tunnel: 'http://localhost:3031/', // proxy server
+
+ // Always capture replays, so we can test this properly
+ replaysSessionSampleRate: 1.0,
+ replaysOnErrorSampleRate: 0.0,
+});
+
+// Create Custom Sentry Route component
+export const SentryRoute = Sentry.withSentryRouting(Route);
+
+const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
+root.render(
+
+
+
+
+
+ ,
+);
diff --git a/dev-packages/e2e-tests/test-applications/react-router-5/src/pages/Index.tsx b/dev-packages/e2e-tests/test-applications/react-router-5/src/pages/Index.tsx
new file mode 100644
index 000000000000..7789a2773224
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-5/src/pages/Index.tsx
@@ -0,0 +1,25 @@
+import * as Sentry from '@sentry/react';
+// biome-ignore lint/nursery/noUnusedImports: Need React import for JSX
+import * as React from 'react';
+import { Link } from 'react-router-dom';
+
+const Index = () => {
+ return (
+ <>
+ {
+ const eventId = Sentry.captureException(new Error('I am an error!'));
+ window.capturedExceptionId = eventId;
+ }}
+ />
+
+ navigate
+
+ >
+ );
+};
+
+export default Index;
diff --git a/dev-packages/e2e-tests/test-applications/react-router-5/src/pages/User.tsx b/dev-packages/e2e-tests/test-applications/react-router-5/src/pages/User.tsx
new file mode 100644
index 000000000000..3b41552d35d3
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-5/src/pages/User.tsx
@@ -0,0 +1,8 @@
+// biome-ignore lint/nursery/noUnusedImports: Need React import for JSX
+import * as React from 'react';
+
+const User = (params: { id: string }) => {
+ return Show user details for {params.id}
;
+};
+
+export default User;
diff --git a/dev-packages/e2e-tests/test-applications/react-router-5/src/react-app-env.d.ts b/dev-packages/e2e-tests/test-applications/react-router-5/src/react-app-env.d.ts
new file mode 100644
index 000000000000..6431bc5fc6b2
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-5/src/react-app-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/dev-packages/e2e-tests/test-applications/react-router-5/start-event-proxy.ts b/dev-packages/e2e-tests/test-applications/react-router-5/start-event-proxy.ts
new file mode 100644
index 000000000000..4b18df9aacaf
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-5/start-event-proxy.ts
@@ -0,0 +1,6 @@
+import { startEventProxyServer } from '@sentry-internal/event-proxy-server';
+
+startEventProxyServer({
+ port: 3031,
+ proxyServerName: 'react-router-5',
+});
diff --git a/dev-packages/e2e-tests/test-applications/react-router-5/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/react-router-5/tests/errors.test.ts
new file mode 100644
index 000000000000..bcfafe8b6624
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-5/tests/errors.test.ts
@@ -0,0 +1,59 @@
+import { expect, test } from '@playwright/test';
+import { waitForError, waitForTransaction } from '@sentry-internal/event-proxy-server';
+
+test('Sends correct error event', async ({ page }) => {
+ const errorEventPromise = waitForError('react-router-5', event => {
+ return !event.type && event.exception?.values?.[0]?.value === 'I am an error!';
+ });
+
+ await page.goto('/');
+
+ const exceptionButton = page.locator('id=exception-button');
+ await exceptionButton.click();
+
+ const errorEvent = await errorEventPromise;
+
+ expect(errorEvent.exception?.values).toHaveLength(1);
+ expect(errorEvent.exception?.values?.[0]?.value).toBe('I am an error!');
+
+ expect(errorEvent.request).toEqual({
+ headers: expect.any(Object),
+ url: 'http://localhost:3030/',
+ });
+
+ expect(errorEvent.transaction).toEqual('/');
+
+ expect(errorEvent.contexts?.trace).toEqual({
+ trace_id: expect.any(String),
+ span_id: expect.any(String),
+ });
+});
+
+test('Sets correct transactionName', async ({ page }) => {
+ const transactionPromise = waitForTransaction('react-router-5', async transactionEvent => {
+ return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload';
+ });
+
+ const errorEventPromise = waitForError('react-router-5', event => {
+ return !event.type && event.exception?.values?.[0]?.value === 'I am an error!';
+ });
+
+ await page.goto('/');
+ const transactionEvent = await transactionPromise;
+
+ // Only capture error once transaction was sent
+ const exceptionButton = page.locator('id=exception-button');
+ await exceptionButton.click();
+
+ const errorEvent = await errorEventPromise;
+
+ expect(errorEvent.exception?.values).toHaveLength(1);
+ expect(errorEvent.exception?.values?.[0]?.value).toBe('I am an error!');
+
+ expect(errorEvent.transaction).toEqual('/');
+
+ expect(errorEvent.contexts?.trace).toEqual({
+ trace_id: transactionEvent.contexts?.trace?.trace_id,
+ span_id: expect.not.stringContaining(transactionEvent.contexts?.trace?.span_id || ''),
+ });
+});
diff --git a/dev-packages/e2e-tests/test-applications/react-router-5/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-router-5/tests/transactions.test.ts
new file mode 100644
index 000000000000..e13c4702dc55
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-5/tests/transactions.test.ts
@@ -0,0 +1,56 @@
+import { expect, test } from '@playwright/test';
+import { waitForTransaction } from '@sentry-internal/event-proxy-server';
+
+test('sends a pageload transaction with a parameterized URL', async ({ page }) => {
+ const transactionPromise = waitForTransaction('react-router-5', async transactionEvent => {
+ return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload';
+ });
+
+ await page.goto(`/`);
+
+ const rootSpan = await transactionPromise;
+
+ expect(rootSpan).toMatchObject({
+ contexts: {
+ trace: {
+ op: 'pageload',
+ origin: 'auto.pageload.react.reactrouter_v5',
+ },
+ },
+ transaction: '/',
+ transaction_info: {
+ source: 'route',
+ },
+ });
+});
+
+test('sends a navigation transaction with a parameterized URL', async ({ page }) => {
+ page.on('console', msg => console.log(msg.text()));
+ const pageloadTxnPromise = waitForTransaction('react-router-5', async transactionEvent => {
+ return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload';
+ });
+
+ const navigationTxnPromise = waitForTransaction('react-router-5', async transactionEvent => {
+ return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation';
+ });
+
+ await page.goto(`/`);
+ await pageloadTxnPromise;
+
+ const linkElement = page.locator('id=navigation');
+
+ const [_, navigationTxn] = await Promise.all([linkElement.click(), navigationTxnPromise]);
+
+ expect(navigationTxn).toMatchObject({
+ contexts: {
+ trace: {
+ op: 'navigation',
+ origin: 'auto.navigation.react.reactrouter_v5',
+ },
+ },
+ transaction: '/user/:id',
+ transaction_info: {
+ source: 'route',
+ },
+ });
+});
diff --git a/dev-packages/e2e-tests/test-applications/react-router-5/tsconfig.json b/dev-packages/e2e-tests/test-applications/react-router-5/tsconfig.json
new file mode 100644
index 000000000000..c137d51512ef
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-5/tsconfig.json
@@ -0,0 +1,25 @@
+{
+ "compilerOptions": {
+ "target": "es2018",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
+ "strict": true,
+ "forceConsistentCasingInFileNames": true,
+ "noFallthroughCasesInSwitch": true,
+ "module": "esnext",
+ "moduleResolution": "node",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react"
+ },
+ "include": ["src", "tests"],
+ "ts-node": {
+ "compilerOptions": {
+ "module": "CommonJS"
+ }
+ }
+}
diff --git a/packages/react/src/reactrouter.tsx b/packages/react/src/reactrouter.tsx
index 3fe40df8ece8..3adf59326012 100644
--- a/packages/react/src/reactrouter.tsx
+++ b/packages/react/src/reactrouter.tsx
@@ -224,14 +224,15 @@ function computeRootMatch(pathname: string): Match {
export function withSentryRouting, R extends React.ComponentType
>(Route: R): R {
const componentDisplayName = (Route as any).displayName || (Route as any).name;
- const activeRootSpan = getActiveRootSpan();
-
const WrappedRoute: React.FC
= (props: P) => {
if (props && props.computedMatch && props.computedMatch.isExact) {
- getCurrentScope().setTransactionName(props.computedMatch.path);
+ const route = props.computedMatch.path;
+ const activeRootSpan = getActiveRootSpan();
+
+ getCurrentScope().setTransactionName(route);
if (activeRootSpan) {
- activeRootSpan.updateName(props.computedMatch.path);
+ activeRootSpan.updateName(route);
activeRootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route');
}
}