diff --git a/CHANGELOG.md b/CHANGELOG.md
index ce525aa2baec..c19d70873a62 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,21 @@
- "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott
+- **feat(react): Add version-agnostic React Router SPA exports ([#21633](https://github.com/getsentry/sentry-javascript/pull/21633))**
+
+ `@sentry/react` now exports version-agnostic wrappers for React Router v6+ SPA instrumentation.
+ The new exports replace the version-specific `V6`/`V7` variants, which are now deprecated:
+
+ | Deprecated | New |
+ | ----------------------------------------------- | -------------------------------------- |
+ | `reactRouterV6BrowserTracingIntegration` / `V7` | `reactRouterBrowserTracingIntegration` |
+ | `withSentryReactRouterV6Routing` / `V7` | `wrapReactRouterRouting` |
+ | `wrapCreateBrowserRouterV6` / `V7` | `wrapCreateBrowserRouter` |
+ | `wrapCreateMemoryRouterV6` / `V7` | `wrapCreateMemoryRouter` |
+ | `wrapUseRoutesV6` / `V7` | `wrapUseRoutes` |
+
+ The deprecated exports continue to work and will be removed in the next major version.
+
## 10.58.0
### Important Changes
diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-use-routes/src/index.tsx b/dev-packages/e2e-tests/test-applications/react-router-6-use-routes/src/index.tsx
index 3fe4310a8470..0ac33b9f6c5f 100644
--- a/dev-packages/e2e-tests/test-applications/react-router-6-use-routes/src/index.tsx
+++ b/dev-packages/e2e-tests/test-applications/react-router-6-use-routes/src/index.tsx
@@ -18,7 +18,7 @@ Sentry.init({
environment: 'qa', // dynamic sampling bias to keep transactions
dsn: process.env.REACT_APP_E2E_TEST_DSN,
integrations: [
- Sentry.reactRouterV6BrowserTracingIntegration({
+ Sentry.reactRouterBrowserTracingIntegration({
useEffect: React.useEffect,
useLocation,
useNavigationType,
@@ -39,7 +39,7 @@ Sentry.init({
tunnel: 'http://localhost:3031', // proxy server
});
-const useSentryRoutes = Sentry.wrapUseRoutesV6(useRoutes);
+const useSentryRoutes = Sentry.wrapUseRoutes(useRoutes);
function App() {
return useSentryRoutes([
diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-use-routes/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-router-6-use-routes/tests/transactions.test.ts
index b8d2c9865e25..160ab8a4909f 100644
--- a/dev-packages/e2e-tests/test-applications/react-router-6-use-routes/tests/transactions.test.ts
+++ b/dev-packages/e2e-tests/test-applications/react-router-6-use-routes/tests/transactions.test.ts
@@ -14,7 +14,7 @@ test('sends a pageload transaction with a parameterized URL', async ({ page }) =
contexts: {
trace: {
op: 'pageload',
- origin: 'auto.pageload.react.reactrouter_v6',
+ origin: 'auto.pageload.react.reactrouter',
},
},
transaction: '/',
@@ -45,7 +45,7 @@ test('sends a navigation transaction with a parameterized URL', async ({ page })
contexts: {
trace: {
op: 'navigation',
- origin: 'auto.navigation.react.reactrouter_v6',
+ origin: 'auto.navigation.react.reactrouter',
},
},
transaction: '/user/:id',
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/src/index.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/src/index.tsx
index 089b27ab974a..5c381487587f 100644
--- a/dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/src/index.tsx
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/src/index.tsx
@@ -21,7 +21,7 @@ Sentry.init({
environment: 'qa', // dynamic sampling bias to keep transactions
dsn: process.env.REACT_APP_E2E_TEST_DSN,
integrations: [
- Sentry.reactRouterV7BrowserTracingIntegration({
+ Sentry.reactRouterBrowserTracingIntegration({
useEffect: React.useEffect,
useLocation,
useNavigationType,
@@ -43,9 +43,9 @@ Sentry.init({
tunnel: 'http://localhost:3031',
});
-const SentryRoutes = Sentry.withSentryReactRouterV7Routing(Routes);
-const sentryUseRoutes = Sentry.wrapUseRoutesV7(useRoutes);
-const sentryCreateBrowserRouter = Sentry.wrapCreateBrowserRouterV7(createBrowserRouter);
+const SentryRoutes = Sentry.wrapReactRouterRouting(Routes);
+const sentryUseRoutes = Sentry.wrapUseRoutes(useRoutes);
+const sentryCreateBrowserRouter = Sentry.wrapCreateBrowserRouter(createBrowserRouter);
const DetailsRoutes = () =>
sentryUseRoutes([
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/tests/transactions.test.ts
index 1b521964f770..d889793aa73a 100644
--- a/dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/tests/transactions.test.ts
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/tests/transactions.test.ts
@@ -15,7 +15,7 @@ test('sends a pageload transaction with a parameterized URL', async ({ page }) =
contexts: {
trace: {
op: 'pageload',
- origin: 'auto.pageload.react.reactrouter_v7',
+ origin: 'auto.pageload.react.reactrouter',
},
},
transaction: '/projects/:projectId/views/:viewId/:detailId',
@@ -39,7 +39,7 @@ test('sends a pageload transaction with a parameterized URL - alternative route'
contexts: {
trace: {
op: 'pageload',
- origin: 'auto.pageload.react.reactrouter_v7',
+ origin: 'auto.pageload.react.reactrouter',
},
},
transaction: '/projects/:projectId/old-views/:viewId/:detailId',
@@ -65,7 +65,7 @@ test('sends a navigation transaction with a parameterized URL', async ({ page })
contexts: {
trace: {
op: 'pageload',
- origin: 'auto.pageload.react.reactrouter_v7',
+ origin: 'auto.pageload.react.reactrouter',
},
},
transaction: '/',
@@ -83,7 +83,7 @@ test('sends a navigation transaction with a parameterized URL', async ({ page })
contexts: {
trace: {
op: 'navigation',
- origin: 'auto.navigation.react.reactrouter_v7',
+ origin: 'auto.navigation.react.reactrouter',
},
},
transaction: '/projects/:projectId/views/:viewId/:detailId',
@@ -109,7 +109,7 @@ test('sends a navigation transaction with a parameterized URL - alternative rout
contexts: {
trace: {
op: 'pageload',
- origin: 'auto.pageload.react.reactrouter_v7',
+ origin: 'auto.pageload.react.reactrouter',
},
},
transaction: '/',
@@ -127,7 +127,7 @@ test('sends a navigation transaction with a parameterized URL - alternative rout
contexts: {
trace: {
op: 'navigation',
- origin: 'auto.navigation.react.reactrouter_v7',
+ origin: 'auto.navigation.react.reactrouter',
},
},
transaction: '/projects/:projectId/old-views/:viewId/:detailId',
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa/src/main.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-spa/src/main.tsx
index 73335b9a9f10..202de7be19b4 100644
--- a/dev-packages/e2e-tests/test-applications/react-router-7-spa/src/main.tsx
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa/src/main.tsx
@@ -21,7 +21,7 @@ Sentry.init({
environment: 'qa', // dynamic sampling bias to keep transactions
dsn: import.meta.env.PUBLIC_E2E_TEST_DSN,
integrations: [
- Sentry.reactRouterV7BrowserTracingIntegration({
+ Sentry.reactRouterBrowserTracingIntegration({
useEffect: React.useEffect,
useLocation,
useNavigationType,
@@ -43,7 +43,7 @@ Sentry.init({
dataCollection: { userInfo: true },
});
-const SentryRoutes = Sentry.withSentryReactRouterV7Routing(Routes);
+const SentryRoutes = Sentry.wrapReactRouterRouting(Routes);
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
root.render(
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-spa/tests/transactions.test.ts
index f0c7c680d07e..27554491019f 100644
--- a/dev-packages/e2e-tests/test-applications/react-router-7-spa/tests/transactions.test.ts
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa/tests/transactions.test.ts
@@ -14,7 +14,7 @@ test('sends a pageload transaction with a parameterized URL', async ({ page }) =
contexts: {
trace: {
op: 'pageload',
- origin: 'auto.pageload.react.reactrouter_v7',
+ origin: 'auto.pageload.react.reactrouter',
},
},
transaction: '/',
@@ -45,7 +45,7 @@ test('sends a navigation transaction with a parameterized URL', async ({ page })
contexts: {
trace: {
op: 'navigation',
- origin: 'auto.navigation.react.reactrouter_v7',
+ origin: 'auto.navigation.react.reactrouter',
},
},
transaction: '/user/:id',
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-cross-usage/.gitignore b/dev-packages/e2e-tests/test-applications/react-router-8-cross-usage/.gitignore
new file mode 100644
index 000000000000..84634c973eeb
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-cross-usage/.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-8-cross-usage/package.json b/dev-packages/e2e-tests/test-applications/react-router-8-cross-usage/package.json
new file mode 100644
index 000000000000..ae35c97e950d
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-cross-usage/package.json
@@ -0,0 +1,53 @@
+{
+ "name": "react-router-8-cross-usage",
+ "version": "0.1.0",
+ "private": true,
+ "dependencies": {
+ "@sentry/react": "file:../../packed/sentry-react-packed.tgz",
+ "@types/react": "19.2.17",
+ "@types/react-dom": "19.2.3",
+ "express": "^4.21.2",
+ "react": "19.2.7",
+ "react-dom": "19.2.7",
+ "react-router": "^8.0.0",
+ "react-scripts": "5.0.1",
+ "typescript": "^5.6.3"
+ },
+ "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:build-ts3.8": "pnpm install && pnpm add typescript@3.8 && npx playwright install && pnpm build",
+ "test:build-canary": "pnpm install && pnpm add react@canary react-dom@canary && 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.56.0",
+ "@sentry-internal/test-utils": "link:../../../test-utils",
+ "serve": "14.0.1",
+ "npm-run-all2": "^6.2.0"
+ },
+ "volta": {
+ "extends": "../../package.json"
+ }
+}
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-cross-usage/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/react-router-8-cross-usage/playwright.config.mjs
new file mode 100644
index 000000000000..31f2b913b58b
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-cross-usage/playwright.config.mjs
@@ -0,0 +1,7 @@
+import { getPlaywrightConfig } from '@sentry-internal/test-utils';
+
+const config = getPlaywrightConfig({
+ startCommand: `pnpm start`,
+});
+
+export default config;
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-cross-usage/public/index.html b/dev-packages/e2e-tests/test-applications/react-router-8-cross-usage/public/index.html
new file mode 100644
index 000000000000..68f33b623e38
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-cross-usage/public/index.html
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+ React App
+
+
+ You need to enable JavaScript to run this app.
+
+
+
+
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-cross-usage/src/globals.d.ts b/dev-packages/e2e-tests/test-applications/react-router-8-cross-usage/src/globals.d.ts
new file mode 100644
index 000000000000..ffa61ca49acc
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-cross-usage/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-8-cross-usage/src/index.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-cross-usage/src/index.tsx
new file mode 100644
index 000000000000..46be7181b87c
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-cross-usage/src/index.tsx
@@ -0,0 +1,115 @@
+import * as Sentry from '@sentry/react';
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import {
+ Outlet,
+ Route,
+ Routes,
+ createRoutesFromChildren,
+ matchRoutes,
+ useLocation,
+ useNavigationType,
+ useRoutes,
+ RouterProvider,
+ createBrowserRouter,
+} from 'react-router';
+import Index from './pages/Index';
+
+const replay = Sentry.replayIntegration();
+
+Sentry.init({
+ environment: 'qa', // dynamic sampling bias to keep transactions
+ dsn: process.env.REACT_APP_E2E_TEST_DSN,
+ integrations: [
+ Sentry.reactRouterBrowserTracingIntegration({
+ useEffect: React.useEffect,
+ useLocation,
+ useNavigationType,
+ createRoutesFromChildren,
+ matchRoutes,
+ trackFetchStreamPerformance: true,
+ }),
+ replay,
+ ],
+ // We recommend adjusting this value in production, or using tracesSampler
+ // for finer control
+ tracesSampleRate: 1.0,
+ release: 'e2e-test',
+
+ // Always capture replays, so we can test this properly
+ replaysSessionSampleRate: 1.0,
+ replaysOnErrorSampleRate: 0.0,
+
+ tunnel: 'http://localhost:3031',
+});
+
+const SentryRoutes = Sentry.wrapReactRouterRouting(Routes);
+const sentryUseRoutes = Sentry.wrapUseRoutes(useRoutes);
+const sentryCreateBrowserRouter = Sentry.wrapCreateBrowserRouter(createBrowserRouter);
+
+const DetailsRoutes = () =>
+ sentryUseRoutes([
+ {
+ path: ':detailId',
+ element: Details
,
+ },
+ ]);
+
+const DetailsRoutesAlternative = () => (
+
+ Details} />
+
+);
+
+const ViewsRoutes = () =>
+ sentryUseRoutes([
+ {
+ index: true,
+ element: Views
,
+ },
+ {
+ path: 'views/:viewId/*',
+ element: ,
+ },
+ {
+ path: 'old-views/:viewId/*',
+ element: ,
+ },
+ ]);
+
+const ProjectsRoutes = () => (
+
+ }>
+ Project Page Root} />
+ }>
+ } />
+
+
+
+);
+
+const router = sentryCreateBrowserRouter([
+ {
+ path: '/post/:post',
+ element: Post
,
+ children: [
+ { index: true, element: Post Index
},
+ { path: '/post/:post/related', element: Related Posts
},
+ ],
+ },
+ {
+ children: [
+ {
+ path: '/',
+ element: ,
+ },
+ {
+ path: '/*',
+ element: ,
+ },
+ ],
+ },
+]);
+
+const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
+root.render( );
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-cross-usage/src/pages/Index.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-cross-usage/src/pages/Index.tsx
new file mode 100644
index 000000000000..c9c72f81a50a
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-cross-usage/src/pages/Index.tsx
@@ -0,0 +1,17 @@
+import * as React from 'react';
+import { Link } from 'react-router';
+
+const Index = () => {
+ return (
+ <>
+
+ navigate
+
+
+ navigate old
+
+ >
+ );
+};
+
+export default Index;
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-cross-usage/src/react-app-env.d.ts b/dev-packages/e2e-tests/test-applications/react-router-8-cross-usage/src/react-app-env.d.ts
new file mode 100644
index 000000000000..6431bc5fc6b2
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-cross-usage/src/react-app-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-cross-usage/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/react-router-8-cross-usage/start-event-proxy.mjs
new file mode 100644
index 000000000000..791e41ada7db
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-cross-usage/start-event-proxy.mjs
@@ -0,0 +1,6 @@
+import { startEventProxyServer } from '@sentry-internal/test-utils';
+
+startEventProxyServer({
+ port: 3031,
+ proxyServerName: 'react-router-8-cross-usage',
+});
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-cross-usage/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-router-8-cross-usage/tests/transactions.test.ts
new file mode 100644
index 000000000000..a1731af3e968
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-cross-usage/tests/transactions.test.ts
@@ -0,0 +1,138 @@
+import { expect, test } from '@playwright/test';
+import { waitForTransaction } from '@sentry-internal/test-utils';
+
+test('sends a pageload transaction with a parameterized URL', async ({ page }) => {
+ const transactionPromise = waitForTransaction('react-router-8-cross-usage', async transactionEvent => {
+ return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload';
+ });
+
+ await page.goto(`/projects/123/views/234/567`);
+
+ const rootSpan = await transactionPromise;
+
+ expect((await page.innerHTML('#root')).includes('Details')).toBe(true);
+ expect(rootSpan).toMatchObject({
+ contexts: {
+ trace: {
+ op: 'pageload',
+ origin: 'auto.pageload.react.reactrouter',
+ },
+ },
+ transaction: '/projects/:projectId/views/:viewId/:detailId',
+ transaction_info: {
+ source: 'route',
+ },
+ });
+});
+
+test('sends a pageload transaction with a parameterized URL - alternative route', async ({ page }) => {
+ const transactionPromise = waitForTransaction('react-router-8-cross-usage', async transactionEvent => {
+ return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload';
+ });
+
+ await page.goto(`/projects/234/old-views/234/567`);
+
+ const rootSpan = await transactionPromise;
+
+ expect((await page.innerHTML('#root')).includes('Details')).toBe(true);
+ expect(rootSpan).toMatchObject({
+ contexts: {
+ trace: {
+ op: 'pageload',
+ origin: 'auto.pageload.react.reactrouter',
+ },
+ },
+ transaction: '/projects/:projectId/old-views/:viewId/:detailId',
+ transaction_info: {
+ source: 'route',
+ },
+ });
+});
+
+test('sends a navigation transaction with a parameterized URL', async ({ page }) => {
+ const pageloadTxnPromise = waitForTransaction('react-router-8-cross-usage', async transactionEvent => {
+ return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload';
+ });
+
+ const navigationTxnPromise = waitForTransaction('react-router-8-cross-usage', async transactionEvent => {
+ return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation';
+ });
+
+ await page.goto(`/`);
+ const pageloadTxn = await pageloadTxnPromise;
+
+ expect(pageloadTxn).toMatchObject({
+ contexts: {
+ trace: {
+ op: 'pageload',
+ origin: 'auto.pageload.react.reactrouter',
+ },
+ },
+ transaction: '/',
+ transaction_info: {
+ source: 'route',
+ },
+ });
+
+ const linkElement = page.locator('id=navigation');
+
+ const [_, navigationTxn] = await Promise.all([linkElement.click(), navigationTxnPromise]);
+
+ expect((await page.innerHTML('#root')).includes('Details')).toBe(true);
+ expect(navigationTxn).toMatchObject({
+ contexts: {
+ trace: {
+ op: 'navigation',
+ origin: 'auto.navigation.react.reactrouter',
+ },
+ },
+ transaction: '/projects/:projectId/views/:viewId/:detailId',
+ transaction_info: {
+ source: 'route',
+ },
+ });
+});
+
+test('sends a navigation transaction with a parameterized URL - alternative route', async ({ page }) => {
+ const pageloadTxnPromise = waitForTransaction('react-router-8-cross-usage', async transactionEvent => {
+ return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload';
+ });
+
+ const navigationTxnPromise = waitForTransaction('react-router-8-cross-usage', async transactionEvent => {
+ return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation';
+ });
+
+ await page.goto(`/`);
+ const pageloadTxn = await pageloadTxnPromise;
+
+ expect(pageloadTxn).toMatchObject({
+ contexts: {
+ trace: {
+ op: 'pageload',
+ origin: 'auto.pageload.react.reactrouter',
+ },
+ },
+ transaction: '/',
+ transaction_info: {
+ source: 'route',
+ },
+ });
+
+ const linkElement = page.locator('id=old-navigation');
+
+ const [_, navigationTxn] = await Promise.all([linkElement.click(), navigationTxnPromise]);
+
+ expect((await page.innerHTML('#root')).includes('Details')).toBe(true);
+ expect(navigationTxn).toMatchObject({
+ contexts: {
+ trace: {
+ op: 'navigation',
+ origin: 'auto.navigation.react.reactrouter',
+ },
+ },
+ transaction: '/projects/:projectId/old-views/:viewId/:detailId',
+ transaction_info: {
+ source: 'route',
+ },
+ });
+});
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-cross-usage/tsconfig.json b/dev-packages/e2e-tests/test-applications/react-router-8-cross-usage/tsconfig.json
new file mode 100644
index 000000000000..74afe717c988
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-cross-usage/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "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": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react"
+ },
+ "include": ["src", "tests"]
+}
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/.gitignore b/dev-packages/e2e-tests/test-applications/react-router-8-framework/.gitignore
new file mode 100644
index 000000000000..ebb991370034
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/.gitignore
@@ -0,0 +1,32 @@
+# 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
+
+# react router
+.react-router
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/app.css b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/app.css
new file mode 100644
index 000000000000..b31c3a9d0ddf
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/app.css
@@ -0,0 +1,6 @@
+html,
+body {
+ @media (prefers-color-scheme: dark) {
+ color-scheme: dark;
+ }
+}
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/context.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/context.ts
new file mode 100644
index 000000000000..a15189e5bed8
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/context.ts
@@ -0,0 +1,8 @@
+import { createContext } from 'react-router';
+
+export type User = {
+ id: string;
+ name: string;
+};
+
+export const userContext = createContext(null);
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/entry.client.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/entry.client.tsx
new file mode 100644
index 000000000000..005268b40ad0
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/entry.client.tsx
@@ -0,0 +1,23 @@
+import * as Sentry from '@sentry/react-router';
+import { StrictMode, startTransition } from 'react';
+import { hydrateRoot } from 'react-dom/client';
+import { HydratedRouter } from 'react-router/dom';
+
+Sentry.init({
+ environment: 'qa', // dynamic sampling bias to keep transactions
+ // todo: get this from env
+ dsn: 'https://username@domain/123',
+ tunnel: `http://localhost:3031/`, // proxy server
+ integrations: [Sentry.reactRouterTracingIntegration()],
+ tracesSampleRate: 1.0,
+ tracePropagationTargets: [/^\//],
+});
+
+startTransition(() => {
+ hydrateRoot(
+ document,
+
+
+ ,
+ );
+});
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/entry.server.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/entry.server.tsx
new file mode 100644
index 000000000000..738cd1515a4d
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/entry.server.tsx
@@ -0,0 +1,18 @@
+import { createReadableStreamFromReadable } from '@react-router/node';
+import * as Sentry from '@sentry/react-router';
+import { renderToPipeableStream } from 'react-dom/server';
+import { ServerRouter } from 'react-router';
+import { type HandleErrorFunction } from 'react-router';
+
+const ABORT_DELAY = 5_000;
+
+const handleRequest = Sentry.createSentryHandleRequest({
+ streamTimeout: ABORT_DELAY,
+ ServerRouter,
+ renderToPipeableStream,
+ createReadableStreamFromReadable,
+});
+
+export default handleRequest;
+
+export const handleError: HandleErrorFunction = Sentry.createSentryHandleError({ logErrors: true });
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/root.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/root.tsx
new file mode 100644
index 000000000000..bc1b8f1236c0
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/root.tsx
@@ -0,0 +1,67 @@
+import { Links, Meta, Outlet, Scripts, ScrollRestoration, isRouteErrorResponse } from 'react-router';
+import type { Route } from './+types/root';
+import stylesheet from './app.css?url';
+
+export const links: Route.LinksFunction = () => [
+ { rel: 'preconnect', href: 'https://fonts.googleapis.com' },
+ {
+ rel: 'preconnect',
+ href: 'https://fonts.gstatic.com',
+ crossOrigin: 'anonymous',
+ },
+ {
+ rel: 'stylesheet',
+ href: 'https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap',
+ },
+ { rel: 'stylesheet', href: stylesheet },
+];
+
+export function Layout({ children }: { children: React.ReactNode }) {
+ return (
+
+
+
+
+
+
+
+
+ {children}
+
+
+
+
+ );
+}
+
+export default function App() {
+ return ;
+}
+
+export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
+ let message = 'Oops!';
+ let details = 'An unexpected error occurred.';
+ let stack: string | undefined;
+
+ if (isRouteErrorResponse(error)) {
+ message = error.status === 404 ? '404' : 'Error';
+ details = error.status === 404 ? 'The requested page could not be found.' : error.statusText || details;
+ } else if (error && error instanceof Error) {
+ if (import.meta.env.DEV) {
+ details = error.message;
+ stack = error.stack;
+ }
+ }
+
+ return (
+
+ {message}
+ {details}
+ {stack && (
+
+ {stack}
+
+ )}
+
+ );
+}
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes.ts
new file mode 100644
index 000000000000..3e3839295b1d
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes.ts
@@ -0,0 +1,24 @@
+import { type RouteConfig, index, prefix, route } from '@react-router/dev/routes';
+
+export default [
+ index('routes/home.tsx'),
+ route('__sentry-flush', 'routes/sentry-flush.tsx'),
+ ...prefix('errors', [
+ route('client', 'routes/errors/client.tsx'),
+ route('client/:client-param', 'routes/errors/client-param.tsx'),
+ route('client-loader', 'routes/errors/client-loader.tsx'),
+ route('server-loader', 'routes/errors/server-loader.tsx'),
+ route('client-action', 'routes/errors/client-action.tsx'),
+ route('server-action', 'routes/errors/server-action.tsx'),
+ ]),
+ ...prefix('performance', [
+ index('routes/performance/index.tsx'),
+ route('ssr', 'routes/performance/ssr.tsx'),
+ route('with/:param', 'routes/performance/dynamic-param.tsx'),
+ route('static', 'routes/performance/static.tsx'),
+ route('server-loader', 'routes/performance/server-loader.tsx'),
+ route('server-action', 'routes/performance/server-action.tsx'),
+ route('with-middleware', 'routes/performance/with-middleware.tsx'),
+ route('redis', 'routes/performance/redis.tsx'),
+ ]),
+] satisfies RouteConfig;
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/errors/client-action.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/errors/client-action.tsx
new file mode 100644
index 000000000000..d3b2d08eef2e
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/errors/client-action.tsx
@@ -0,0 +1,18 @@
+import { Form } from 'react-router';
+
+export function clientAction() {
+ throw new Error('Madonna mia! Che casino nella Client Action!');
+}
+
+export default function ClientActionErrorPage() {
+ return (
+
+
Client Error Action Page
+
+
+ );
+}
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/errors/client-loader.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/errors/client-loader.tsx
new file mode 100644
index 000000000000..72d9e62a99dc
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/errors/client-loader.tsx
@@ -0,0 +1,16 @@
+import type { Route } from './+types/server-loader';
+
+export function clientLoader() {
+ throw new Error('¡Madre mía del client loader!');
+ return { data: 'sad' };
+}
+
+export default function ClientLoaderErrorPage({ loaderData }: Route.ComponentProps) {
+ const { data } = loaderData;
+ return (
+
+
Client Loader Error Page
+
{data}
+
+ );
+}
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/errors/client-param.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/errors/client-param.tsx
new file mode 100644
index 000000000000..a2e423391f03
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/errors/client-param.tsx
@@ -0,0 +1,17 @@
+import type { Route } from './+types/client-param';
+
+export default function ClientErrorParamPage({ params }: Route.ComponentProps) {
+ return (
+
+
Client Error Param Page
+ {
+ throw new Error(`¡Madre mía de ${params['client-param']}!`);
+ }}
+ >
+ Throw Error
+
+
+ );
+}
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/errors/client.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/errors/client.tsx
new file mode 100644
index 000000000000..190074a5ef09
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/errors/client.tsx
@@ -0,0 +1,15 @@
+export default function ClientErrorPage() {
+ return (
+
+
Client Error Page
+ {
+ throw new Error('¡Madre mía!');
+ }}
+ >
+ Throw Error
+
+
+ );
+}
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/errors/server-action.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/errors/server-action.tsx
new file mode 100644
index 000000000000..863c320f3557
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/errors/server-action.tsx
@@ -0,0 +1,18 @@
+import { Form } from 'react-router';
+
+export function action() {
+ throw new Error('Madonna mia! Che casino nella Server Action!');
+}
+
+export default function ServerActionErrorPage() {
+ return (
+
+
Server Error Action Page
+
+
+ );
+}
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/errors/server-loader.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/errors/server-loader.tsx
new file mode 100644
index 000000000000..cb777686d540
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/errors/server-loader.tsx
@@ -0,0 +1,16 @@
+import type { Route } from './+types/server-loader';
+
+export function loader() {
+ throw new Error('¡Madre mía del server!');
+ return { data: 'sad' };
+}
+
+export default function ServerLoaderErrorPage({ loaderData }: Route.ComponentProps) {
+ const { data } = loaderData;
+ return (
+
+
Server Error Page
+
{data}
+
+ );
+}
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/home.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/home.tsx
new file mode 100644
index 000000000000..4498e7a0d017
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/home.tsx
@@ -0,0 +1,9 @@
+import type { Route } from './+types/home';
+
+export function meta({}: Route.MetaArgs) {
+ return [{ title: 'New React Router App' }, { name: 'description', content: 'Welcome to React Router!' }];
+}
+
+export default function Home() {
+ return home
;
+}
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/performance/dynamic-param.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/performance/dynamic-param.tsx
new file mode 100644
index 000000000000..1ac02775f2ff
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/performance/dynamic-param.tsx
@@ -0,0 +1,17 @@
+import type { Route } from './+types/dynamic-param';
+
+export async function loader() {
+ await new Promise(resolve => setTimeout(resolve, 500));
+ return { data: 'burritos' };
+}
+
+export default function DynamicParamPage({ params }: Route.ComponentProps) {
+ const { param } = params;
+
+ return (
+
+
Dynamic Parameter Page
+
The parameter value is: {param}
+
+ );
+}
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/performance/index.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/performance/index.tsx
new file mode 100644
index 000000000000..4c086645603e
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/performance/index.tsx
@@ -0,0 +1,17 @@
+import { Link } from 'react-router';
+
+export default function PerformancePage() {
+ return (
+
+
Performance Page
+
+ SSR Page
+ With Param Page
+ Object Navigate
+ Search Only Navigate
+ Server Loader
+ Redis
+
+
+ );
+}
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/performance/redis.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/performance/redis.tsx
new file mode 100644
index 000000000000..cba8275fcf63
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/performance/redis.tsx
@@ -0,0 +1,22 @@
+import Redis from 'ioredis';
+import type { Route } from './+types/redis';
+
+const redis = new Redis();
+
+export async function loader() {
+ const key = 'cache:greeting';
+ await redis.set(key, 'hello from react-router');
+ const value = await redis.get(key);
+
+ return { value };
+}
+
+export default function RedisPage({ loaderData }: Route.ComponentProps) {
+ const { value } = loaderData;
+ return (
+
+ );
+}
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/performance/server-action.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/performance/server-action.tsx
new file mode 100644
index 000000000000..462fc6fbf54c
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/performance/server-action.tsx
@@ -0,0 +1,24 @@
+import { Form } from 'react-router';
+import type { Route } from './+types/server-action';
+
+export async function action({ request }: Route.ActionArgs) {
+ let formData = await request.formData();
+ let name = formData.get('name');
+ await new Promise(resolve => setTimeout(resolve, 1000));
+ return {
+ greeting: `Hola ${name}`,
+ };
+}
+
+export default function Project({ actionData }: Route.ComponentProps) {
+ return (
+
+
Server action page
+
+ {actionData ?
{actionData.greeting}
: null}
+
+ );
+}
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/performance/server-loader.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/performance/server-loader.tsx
new file mode 100644
index 000000000000..e5c222ff4c05
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/performance/server-loader.tsx
@@ -0,0 +1,16 @@
+import type { Route } from './+types/server-loader';
+
+export async function loader() {
+ await new Promise(resolve => setTimeout(resolve, 500));
+ return { data: 'burritos' };
+}
+
+export default function ServerLoaderPage({ loaderData }: Route.ComponentProps) {
+ const { data } = loaderData;
+ return (
+
+
Server Loader Page
+
{data}
+
+ );
+}
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/performance/ssr.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/performance/ssr.tsx
new file mode 100644
index 000000000000..253e964ff15d
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/performance/ssr.tsx
@@ -0,0 +1,7 @@
+export default function SsrPage() {
+ return (
+
+
SSR Page
+
+ );
+}
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/performance/static.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/performance/static.tsx
new file mode 100644
index 000000000000..3dea24381fdc
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/performance/static.tsx
@@ -0,0 +1,3 @@
+export default function StaticPage() {
+ return Static Page ;
+}
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/performance/with-middleware.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/performance/with-middleware.tsx
new file mode 100644
index 000000000000..c86f78e17164
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/performance/with-middleware.tsx
@@ -0,0 +1,38 @@
+import type { Route } from './+types/with-middleware';
+import type { User } from '../../context';
+import { userContext } from '../../context';
+import * as Sentry from '@sentry/react-router';
+
+async function getUser() {
+ await new Promise(resolve => setTimeout(resolve, 500));
+ return {
+ id: '1',
+ name: 'Carlos Gomez',
+ };
+}
+
+const authMiddleware: Route.MiddlewareFunction = async ({ request, context }, next) => {
+ Sentry.startSpan({ name: 'authMiddleware', op: 'middleware.auth' }, async () => {
+ const user: User = await getUser();
+ context.set(userContext, user);
+ await next();
+ });
+};
+
+export const middleware: Route.MiddlewareFunction[] = [authMiddleware];
+
+export const loader = async ({ context }: Route.LoaderArgs) => {
+ const user = context.get(userContext);
+ return { user };
+};
+
+export default function WithMiddlewarePage({ loaderData }: Route.ComponentProps) {
+ const { user } = loaderData;
+
+ return (
+
+
With Middleware Page
+
User: {user?.name}
+
+ );
+}
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/sentry-flush.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/sentry-flush.tsx
new file mode 100644
index 000000000000..c72024185046
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/sentry-flush.tsx
@@ -0,0 +1,6 @@
+import * as Sentry from '@sentry/react-router';
+
+export async function loader() {
+ await Sentry.flush(2000);
+ return new Response(null, { status: 204 });
+}
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/docker-compose.yml b/dev-packages/e2e-tests/test-applications/react-router-8-framework/docker-compose.yml
new file mode 100644
index 000000000000..27367c5ae12a
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/docker-compose.yml
@@ -0,0 +1,12 @@
+services:
+ redis:
+ image: redis:8
+ restart: always
+ container_name: e2e-tests-react-router-8-redis
+ ports:
+ - '6379:6379'
+ healthcheck:
+ test: ['CMD', 'redis-cli', 'ping']
+ interval: 1s
+ timeout: 3s
+ retries: 30
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/global-setup.mjs b/dev-packages/e2e-tests/test-applications/react-router-8-framework/global-setup.mjs
new file mode 100644
index 000000000000..7c508eced2c9
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/global-setup.mjs
@@ -0,0 +1,12 @@
+import { execSync } from 'child_process';
+import { dirname } from 'path';
+import { fileURLToPath } from 'url';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+
+// Boot Redis here (rather than in the `start` script) so the cold `redis:8` image
+// pull happens outside Playwright's webServer startup-timeout window. `--wait`
+// blocks until the healthcheck passes;
+export default async function globalSetup() {
+ execSync('docker compose up -d --wait', { cwd: __dirname, stdio: 'inherit' });
+}
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/instrument.mjs b/dev-packages/e2e-tests/test-applications/react-router-8-framework/instrument.mjs
new file mode 100644
index 000000000000..c16240141b6d
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/instrument.mjs
@@ -0,0 +1,8 @@
+import * as Sentry from '@sentry/react-router';
+
+Sentry.init({
+ dsn: 'https://username@domain/123',
+ environment: 'qa', // dynamic sampling bias to keep transactions
+ tracesSampleRate: 1.0,
+ tunnel: `http://localhost:3031/`, // proxy server
+});
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/package.json b/dev-packages/e2e-tests/test-applications/react-router-8-framework/package.json
new file mode 100644
index 000000000000..ba508e918a49
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/package.json
@@ -0,0 +1,69 @@
+{
+ "name": "react-router-8-framework",
+ "version": "0.1.0",
+ "type": "module",
+ "private": true,
+ "dependencies": {
+ "react": "^19.2.7",
+ "react-dom": "^19.2.7",
+ "react-router": "^8.0.0",
+ "@react-router/node": "^8.0.0",
+ "@react-router/serve": "^8.0.0",
+ "@sentry/react-router": "file:../../packed/sentry-react-router-packed.tgz",
+ "ioredis": "^5.11.1",
+ "isbot": "^5.1.43"
+ },
+ "devDependencies": {
+ "@types/react": "19.2.17",
+ "@types/react-dom": "19.2.3",
+ "@types/node": "^22",
+ "@react-router/dev": "^8.0.0",
+ "@playwright/test": "~1.58.0",
+ "@sentry-internal/test-utils": "link:../../../test-utils",
+ "typescript": "^5.6.3",
+ "vite": "^7.3.2"
+ },
+ "scripts": {
+ "build": "react-router build",
+ "test:build-latest": "pnpm install && pnpm add react-router@latest && pnpm add @react-router/node@latest && pnpm add @react-router/serve@latest && pnpm build",
+ "dev": "NODE_OPTIONS='--import ./instrument.mjs' react-router dev",
+ "start": "NODE_ENV=production NODE_OPTIONS='--import ./instrument.mjs' react-router-serve ./build/server/index.js",
+ "proxy": "node start-event-proxy.mjs",
+ "typecheck": "react-router typegen && tsc",
+ "clean": "npx rimraf node_modules pnpm-lock.yaml",
+ "test:build": "pnpm install && pnpm build",
+ "test:assert": "pnpm test:ts && pnpm test:playwright",
+ "test:ts": "pnpm typecheck",
+ "test:playwright": "playwright 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"
+ ]
+ },
+ "volta": {
+ "extends": "../../package.json",
+ "node": "22.22.0"
+ },
+ "sentryTest": {
+ "variants": [
+ {
+ "build-command": "pnpm test:build-latest",
+ "label": "react-router-8-framework (latest)"
+ }
+ ]
+ }
+}
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/react-router-8-framework/playwright.config.mjs
new file mode 100644
index 000000000000..70e62c2b9e3b
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/playwright.config.mjs
@@ -0,0 +1,13 @@
+import { getPlaywrightConfig } from '@sentry-internal/test-utils';
+import { fileURLToPath } from 'url';
+
+const config = getPlaywrightConfig(
+ {
+ startCommand: `PORT=3030 pnpm start`,
+ port: 3030,
+ },
+ // Boot Redis before the tests run, outside the webServer startup-timeout window.
+ { globalSetup: fileURLToPath(new URL('./global-setup.mjs', import.meta.url)) },
+);
+
+export default config;
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/public/favicon.ico b/dev-packages/e2e-tests/test-applications/react-router-8-framework/public/favicon.ico
new file mode 100644
index 000000000000..5dbdfcddcb14
Binary files /dev/null and b/dev-packages/e2e-tests/test-applications/react-router-8-framework/public/favicon.ico differ
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/react-router.config.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/react-router.config.ts
new file mode 100644
index 000000000000..bb1f96469dd2
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/react-router.config.ts
@@ -0,0 +1,6 @@
+import type { Config } from '@react-router/dev/config';
+
+export default {
+ ssr: true,
+ prerender: ['/performance/static'],
+} satisfies Config;
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/react-router-8-framework/start-event-proxy.mjs
new file mode 100644
index 000000000000..97d290e430eb
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/start-event-proxy.mjs
@@ -0,0 +1,6 @@
+import { startEventProxyServer } from '@sentry-internal/test-utils';
+
+startEventProxyServer({
+ port: 3031,
+ proxyServerName: 'react-router-8-framework',
+});
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/constants.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/constants.ts
new file mode 100644
index 000000000000..f6971b89941e
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/constants.ts
@@ -0,0 +1 @@
+export const APP_NAME = 'react-router-8-framework';
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/errors/errors.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/errors/errors.client.test.ts
new file mode 100644
index 000000000000..c1a7de46f1b6
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/errors/errors.client.test.ts
@@ -0,0 +1,140 @@
+import { expect, test } from '@playwright/test';
+import { waitForError } from '@sentry-internal/test-utils';
+import { APP_NAME } from '../constants';
+
+test.describe('client-side errors', () => {
+ const errorMessage = '¡Madre mía!';
+ test('captures error thrown on click', async ({ page }) => {
+ const errorPromise = waitForError(APP_NAME, async errorEvent => {
+ return errorEvent?.exception?.values?.[0]?.value === errorMessage;
+ });
+
+ await page.goto(`/errors/client`);
+ await page.locator('#throw-on-click').click();
+
+ const error = await errorPromise;
+
+ expect(error).toMatchObject({
+ exception: {
+ values: [
+ {
+ type: 'Error',
+ value: errorMessage,
+ mechanism: {
+ handled: false,
+ },
+ },
+ ],
+ },
+ transaction: '/errors/client',
+ request: {
+ url: expect.stringContaining('errors/client'),
+ headers: expect.any(Object),
+ },
+ level: 'error',
+ platform: 'javascript',
+ environment: 'qa',
+ sdk: {
+ integrations: expect.any(Array),
+ name: 'sentry.javascript.react-router',
+ version: expect.any(String),
+ },
+ tags: { runtime: 'browser' },
+ contexts: {
+ trace: {
+ span_id: expect.any(String),
+ trace_id: expect.any(String),
+ },
+ },
+ breadcrumbs: [
+ {
+ category: 'ui.click',
+ message: 'body > div > button#throw-on-click',
+ },
+ ],
+ });
+ });
+
+ test('captures error thrown on click from a parameterized route', async ({ page }) => {
+ const errorMessage = '¡Madre mía de churros!';
+ const errorPromise = waitForError(APP_NAME, async errorEvent => {
+ return errorEvent?.exception?.values?.[0]?.value === errorMessage;
+ });
+
+ await page.goto('/errors/client/churros');
+ await page.locator('#throw-on-click').click();
+
+ const error = await errorPromise;
+
+ expect(error).toMatchObject({
+ exception: {
+ values: [
+ {
+ type: 'Error',
+ value: '¡Madre mía de churros!',
+ mechanism: {
+ handled: false,
+ },
+ },
+ ],
+ },
+ // todo: should be '/errors/client/:client-param'
+ transaction: '/errors/client/churros',
+ });
+ });
+
+ test('captures error thrown in a clientLoader', async ({ page }) => {
+ const errorMessage = '¡Madre mía del client loader!';
+ const errorPromise = waitForError(APP_NAME, async errorEvent => {
+ return errorEvent?.exception?.values?.[0]?.value === errorMessage;
+ });
+
+ await page.goto('/errors/client-loader');
+
+ const error = await errorPromise;
+
+ expect(error).toMatchObject({
+ exception: {
+ values: [
+ {
+ type: 'Error',
+ value: errorMessage,
+ mechanism: {
+ handled: false,
+ type: 'auto.function.react_router.on_error',
+ },
+ },
+ ],
+ },
+ transaction: '/errors/client-loader',
+ });
+ });
+
+ test('captures error thrown in a clientAction', async ({ page }) => {
+ const errorMessage = 'Madonna mia! Che casino nella Client Action!';
+ const errorPromise = waitForError(APP_NAME, async errorEvent => {
+ return errorEvent?.exception?.values?.[0]?.value === errorMessage;
+ });
+
+ await page.goto('/errors/client-action');
+ await page.locator('#submit').click();
+
+ const error = await errorPromise;
+
+ expect(error).toMatchObject({
+ exception: {
+ values: [
+ {
+ type: 'Error',
+ value: errorMessage,
+ mechanism: {
+ handled: false,
+ type: 'auto.function.react_router.on_error',
+ },
+ },
+ ],
+ },
+ transaction: '/errors/client-action',
+ });
+ });
+});
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/errors/errors.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/errors/errors.server.test.ts
new file mode 100644
index 000000000000..acb11603fedc
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/errors/errors.server.test.ts
@@ -0,0 +1,100 @@
+import { expect, test } from '@playwright/test';
+import { waitForError } from '@sentry-internal/test-utils';
+import { APP_NAME } from '../constants';
+
+test.describe('server-side errors', () => {
+ test('captures error thrown in server loader', async ({ page }) => {
+ const errorMessage = '¡Madre mía del server!';
+ const errorPromise = waitForError(APP_NAME, async errorEvent => {
+ return errorEvent?.exception?.values?.[0]?.value === errorMessage;
+ });
+
+ await page.goto(`/errors/server-loader`);
+
+ const error = await errorPromise;
+
+ expect(error).toMatchObject({
+ exception: {
+ values: [
+ {
+ type: 'Error',
+ value: errorMessage,
+ mechanism: {
+ handled: false,
+ type: 'react-router',
+ },
+ },
+ ],
+ },
+ // todo: should be 'GET /errors/server-loader'
+ transaction: 'GET /{*splat}',
+ request: {
+ url: expect.stringContaining('errors/server-loader'),
+ headers: expect.any(Object),
+ },
+ level: 'error',
+ platform: 'node',
+ environment: 'qa',
+ sdk: {
+ integrations: expect.any(Array),
+ name: 'sentry.javascript.react-router',
+ version: expect.any(String),
+ },
+ tags: { runtime: 'node' },
+ contexts: {
+ trace: {
+ span_id: expect.any(String),
+ trace_id: expect.any(String),
+ },
+ },
+ });
+ });
+
+ test('captures error thrown in server action', async ({ page }) => {
+ const errorMessage = 'Madonna mia! Che casino nella Server Action!';
+ const errorPromise = waitForError(APP_NAME, async errorEvent => {
+ return errorEvent?.exception?.values?.[0]?.value === errorMessage;
+ });
+
+ await page.goto(`/errors/server-action`);
+ await page.locator('#submit').click();
+
+ const error = await errorPromise;
+
+ expect(error).toMatchObject({
+ exception: {
+ values: [
+ {
+ type: 'Error',
+ value: errorMessage,
+ mechanism: {
+ handled: false,
+ type: 'react-router',
+ },
+ },
+ ],
+ },
+ // todo: should be 'POST /errors/server-action'
+ transaction: 'POST /{*splat}',
+ request: {
+ url: expect.stringContaining('errors/server-action'),
+ headers: expect.any(Object),
+ },
+ level: 'error',
+ platform: 'node',
+ environment: 'qa',
+ sdk: {
+ integrations: expect.any(Array),
+ name: 'sentry.javascript.react-router',
+ version: expect.any(String),
+ },
+ tags: { runtime: 'node' },
+ contexts: {
+ trace: {
+ span_id: expect.any(String),
+ trace_id: expect.any(String),
+ },
+ },
+ });
+ });
+});
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/low-quality-filter.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/low-quality-filter.server.test.ts
new file mode 100644
index 000000000000..0e5351a5704f
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/low-quality-filter.server.test.ts
@@ -0,0 +1,34 @@
+import { expect, test } from '@playwright/test';
+import { waitForTransaction } from '@sentry-internal/test-utils';
+import { APP_NAME } from '../constants';
+
+test.describe('low-quality transaction filter', () => {
+ test('does not send a server transaction for /__manifest? requests', async ({ page }) => {
+ const serverTxns: Array<{ contexts?: { trace?: { data?: Record } } }> = [];
+
+ const navigationPromise = waitForTransaction(APP_NAME, async transactionEvent => {
+ return (
+ transactionEvent.transaction === '/performance/ssr' && transactionEvent.contexts?.trace?.op === 'navigation'
+ );
+ });
+
+ waitForTransaction(APP_NAME, async evt => {
+ serverTxns.push(evt);
+ return false;
+ });
+
+ await page.goto('/performance');
+ await page.waitForTimeout(1000);
+ await page.getByRole('link', { name: 'SSR Page' }).click();
+
+ await navigationPromise;
+
+ // Force the server to flush any in-flight transactions before we assert
+ await page.evaluate(() => fetch('/__sentry-flush'));
+
+ const targetIsManifest = (t: (typeof serverTxns)[number]) =>
+ typeof t.contexts?.trace?.data?.['http.target'] === 'string' &&
+ (t.contexts.trace.data['http.target'] as string).includes('/__manifest');
+ expect(serverTxns.some(targetIsManifest)).toBe(false);
+ });
+});
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/middleware.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/middleware.server.test.ts
new file mode 100644
index 000000000000..dbce05350ad9
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/middleware.server.test.ts
@@ -0,0 +1,38 @@
+import { expect, test } from '@playwright/test';
+import { waitForTransaction } from '@sentry-internal/test-utils';
+import { APP_NAME } from '../constants';
+
+test.describe('server - middleware', () => {
+ test('should send middleware transaction on pageload', async ({ page }) => {
+ const serverTxPromise = waitForTransaction(APP_NAME, async transactionEvent => {
+ return transactionEvent.transaction === 'GET /performance/with-middleware';
+ });
+
+ const pageloadTxPromise = waitForTransaction(APP_NAME, async transactionEvent => {
+ return transactionEvent.transaction === '/performance/with-middleware';
+ });
+
+ const customMiddlewareTxPromise = waitForTransaction(APP_NAME, async transactionEvent => {
+ return transactionEvent.transaction === 'authMiddleware';
+ });
+
+ await page.goto(`/performance/with-middleware`);
+
+ const serverTx = await serverTxPromise;
+ const pageloadTx = await pageloadTxPromise;
+ const customMiddlewareTx = await customMiddlewareTxPromise;
+
+ const traceIds = {
+ server: serverTx?.contexts?.trace?.trace_id,
+ pageload: pageloadTx?.contexts?.trace?.trace_id,
+ customMiddleware: customMiddlewareTx?.contexts?.trace?.trace_id,
+ };
+
+ expect(pageloadTx).toBeDefined();
+ expect(customMiddlewareTx).toBeDefined();
+
+ // Assert that all transactions belong to the same trace
+ expect(traceIds.server).toBe(traceIds.pageload);
+ expect(traceIds.server).toBe(traceIds.customMiddleware);
+ });
+});
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/navigation.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/navigation.client.test.ts
new file mode 100644
index 000000000000..a31d716f7120
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/navigation.client.test.ts
@@ -0,0 +1,165 @@
+import { expect, test } from '@playwright/test';
+import { waitForTransaction } from '@sentry-internal/test-utils';
+import { APP_NAME } from '../constants';
+
+test.describe('client - navigation performance', () => {
+ test('should create navigation transaction', async ({ page }) => {
+ const navigationPromise = waitForTransaction(APP_NAME, async transactionEvent => {
+ return (
+ transactionEvent.transaction === '/performance/ssr' && transactionEvent.contexts?.trace?.op === 'navigation'
+ );
+ });
+
+ await page.goto(`/performance`); // pageload
+ await page.waitForTimeout(1000); // give it a sec before navigation
+ await page.getByRole('link', { name: 'SSR Page' }).click(); // navigation
+
+ const transaction = await navigationPromise;
+
+ expect(transaction).toMatchObject({
+ contexts: {
+ trace: {
+ span_id: expect.any(String),
+ trace_id: expect.any(String),
+ data: {
+ 'sentry.origin': 'auto.navigation.react_router',
+ 'sentry.op': 'navigation',
+ 'sentry.source': 'route',
+ },
+ op: 'navigation',
+ origin: 'auto.navigation.react_router',
+ },
+ },
+ spans: expect.any(Array),
+ start_timestamp: expect.any(Number),
+ timestamp: expect.any(Number),
+ transaction: '/performance/ssr',
+ type: 'transaction',
+ transaction_info: { source: 'route' },
+ platform: 'javascript',
+ request: {
+ url: expect.stringContaining('/performance/ssr'),
+ headers: expect.any(Object),
+ },
+ event_id: expect.any(String),
+ environment: 'qa',
+ sdk: {
+ integrations: expect.arrayContaining([expect.any(String)]),
+ name: 'sentry.javascript.react-router',
+ version: expect.any(String),
+ packages: [
+ { name: 'npm:@sentry/react-router', version: expect.any(String) },
+ { name: 'npm:@sentry/browser', version: expect.any(String) },
+ ],
+ },
+ tags: { runtime: 'browser' },
+ });
+ });
+
+ test('should create navigation transaction when navigating with object `to` prop', async ({ page }) => {
+ const txPromise = waitForTransaction(APP_NAME, async transactionEvent => {
+ return (
+ transactionEvent.transaction === '/performance/with/:param' &&
+ transactionEvent.contexts?.trace?.op === 'navigation'
+ );
+ });
+
+ await page.goto(`/performance`); // pageload
+ await page.waitForTimeout(1000); // give it a sec before navigation
+ await page.getByRole('link', { name: 'Object Navigate' }).click(); // navigation with object to
+
+ const transaction = await txPromise;
+
+ expect(transaction).toMatchObject({
+ contexts: {
+ trace: {
+ op: 'navigation',
+ origin: 'auto.navigation.react_router',
+ data: {
+ 'sentry.source': 'route',
+ },
+ },
+ },
+ transaction: '/performance/with/:param',
+ type: 'transaction',
+ transaction_info: { source: 'route' },
+ });
+ });
+
+ test('should create navigation transaction when navigating with search-only object `to` prop', async ({ page }) => {
+ const txPromise = waitForTransaction(APP_NAME, async transactionEvent => {
+ return transactionEvent.transaction === '/performance' && transactionEvent.contexts?.trace?.op === 'navigation';
+ });
+
+ await page.goto(`/performance`); // pageload
+ await page.waitForTimeout(1000); // give it a sec before navigation
+ await page.getByRole('link', { name: 'Search Only Navigate' }).click(); // navigation with search-only object to
+
+ const transaction = await txPromise;
+
+ expect(transaction).toMatchObject({
+ contexts: {
+ trace: {
+ op: 'navigation',
+ origin: 'auto.navigation.react_router',
+ },
+ },
+ transaction: '/performance',
+ type: 'transaction',
+ });
+ });
+
+ test('should update navigation transaction for dynamic routes', async ({ page }) => {
+ const txPromise = waitForTransaction(APP_NAME, async transactionEvent => {
+ return (
+ transactionEvent.transaction === '/performance/with/:param' &&
+ transactionEvent.contexts?.trace?.op === 'navigation'
+ );
+ });
+
+ await page.goto(`/performance`); // pageload
+ await page.waitForTimeout(1000); // give it a sec before navigation
+ await page.getByRole('link', { name: 'With Param Page' }).click(); // navigation
+
+ const transaction = await txPromise;
+
+ expect(transaction).toMatchObject({
+ contexts: {
+ trace: {
+ span_id: expect.any(String),
+ trace_id: expect.any(String),
+ data: {
+ 'sentry.origin': 'auto.navigation.react_router',
+ 'sentry.op': 'navigation',
+ 'sentry.source': 'route',
+ },
+ op: 'navigation',
+ origin: 'auto.navigation.react_router',
+ },
+ },
+ spans: expect.any(Array),
+ start_timestamp: expect.any(Number),
+ timestamp: expect.any(Number),
+ transaction: '/performance/with/:param',
+ type: 'transaction',
+ transaction_info: { source: 'route' },
+ platform: 'javascript',
+ request: {
+ url: expect.stringContaining('/performance/with/sentry'),
+ headers: expect.any(Object),
+ },
+ event_id: expect.any(String),
+ environment: 'qa',
+ sdk: {
+ integrations: expect.arrayContaining([expect.any(String)]),
+ name: 'sentry.javascript.react-router',
+ version: expect.any(String),
+ packages: [
+ { name: 'npm:@sentry/react-router', version: expect.any(String) },
+ { name: 'npm:@sentry/browser', version: expect.any(String) },
+ ],
+ },
+ tags: { runtime: 'browser' },
+ });
+ });
+});
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/pageload.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/pageload.client.test.ts
new file mode 100644
index 000000000000..3095f720eb71
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/pageload.client.test.ts
@@ -0,0 +1,137 @@
+import { expect, test } from '@playwright/test';
+import { waitForTransaction } from '@sentry-internal/test-utils';
+import { APP_NAME } from '../constants';
+
+test.describe('client - pageload performance', () => {
+ test('should send pageload transaction', async ({ page }) => {
+ const txPromise = waitForTransaction(APP_NAME, async transactionEvent => {
+ return transactionEvent.transaction === '/performance' && transactionEvent.contexts?.trace?.op === 'pageload';
+ });
+
+ await page.goto(`/performance`);
+
+ const transaction = await txPromise;
+
+ expect(transaction).toMatchObject({
+ contexts: {
+ trace: {
+ span_id: expect.any(String),
+ trace_id: expect.any(String),
+ data: {
+ 'sentry.origin': 'auto.pageload.react_router',
+ 'sentry.op': 'pageload',
+ 'sentry.source': 'route',
+ },
+ op: 'pageload',
+ origin: 'auto.pageload.react_router',
+ },
+ },
+ spans: expect.any(Array),
+ start_timestamp: expect.any(Number),
+ timestamp: expect.any(Number),
+ transaction: '/performance',
+ type: 'transaction',
+ transaction_info: { source: 'route' },
+ measurements: expect.any(Object),
+ platform: 'javascript',
+ request: {
+ url: expect.stringContaining('/performance'),
+ headers: expect.any(Object),
+ },
+ event_id: expect.any(String),
+ environment: 'qa',
+ sdk: {
+ integrations: expect.arrayContaining([expect.any(String)]),
+ name: 'sentry.javascript.react-router',
+ version: expect.any(String),
+ packages: [
+ { name: 'npm:@sentry/react-router', version: expect.any(String) },
+ { name: 'npm:@sentry/browser', version: expect.any(String) },
+ ],
+ },
+ tags: { runtime: 'browser' },
+ });
+ });
+
+ test('should update pageload transaction for dynamic routes', async ({ page }) => {
+ const txPromise = waitForTransaction(APP_NAME, async transactionEvent => {
+ return (
+ transactionEvent.transaction === '/performance/with/:param' &&
+ transactionEvent.contexts?.trace?.op === 'pageload'
+ );
+ });
+
+ await page.goto(`/performance/with/sentry`);
+
+ const transaction = await txPromise;
+
+ expect(transaction).toMatchObject({
+ contexts: {
+ trace: {
+ span_id: expect.any(String),
+ trace_id: expect.any(String),
+ data: {
+ 'sentry.origin': 'auto.pageload.react_router',
+ 'sentry.op': 'pageload',
+ 'sentry.source': 'route',
+ },
+ op: 'pageload',
+ origin: 'auto.pageload.react_router',
+ },
+ },
+ spans: expect.any(Array),
+ start_timestamp: expect.any(Number),
+ timestamp: expect.any(Number),
+ transaction: '/performance/with/:param',
+ type: 'transaction',
+ transaction_info: { source: 'route' },
+ measurements: expect.any(Object),
+ platform: 'javascript',
+ request: {
+ url: expect.stringContaining('/performance/with/sentry'),
+ headers: expect.any(Object),
+ },
+ event_id: expect.any(String),
+ environment: 'qa',
+ sdk: {
+ integrations: expect.arrayContaining([expect.any(String)]),
+ name: 'sentry.javascript.react-router',
+ version: expect.any(String),
+ packages: [
+ { name: 'npm:@sentry/react-router', version: expect.any(String) },
+ { name: 'npm:@sentry/browser', version: expect.any(String) },
+ ],
+ },
+ tags: { runtime: 'browser' },
+ });
+ });
+
+ test('should send pageload transaction for prerendered pages', async ({ page }) => {
+ const txPromise = waitForTransaction(APP_NAME, async transactionEvent => {
+ return (
+ transactionEvent.transaction === '/performance/static' && transactionEvent.contexts?.trace?.op === 'pageload'
+ );
+ });
+
+ await page.goto(`/performance/static`);
+
+ const transaction = await txPromise;
+
+ expect(transaction).toMatchObject({
+ transaction: '/performance/static',
+ contexts: {
+ trace: {
+ span_id: expect.any(String),
+ trace_id: expect.any(String),
+ data: {
+ 'sentry.origin': 'auto.pageload.react_router',
+ 'sentry.op': 'pageload',
+ 'sentry.source': 'route',
+ },
+ op: 'pageload',
+ origin: 'auto.pageload.react_router',
+ },
+ },
+ });
+ });
+});
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/performance.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/performance.server.test.ts
new file mode 100644
index 000000000000..8cbc4c46a460
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/performance.server.test.ts
@@ -0,0 +1,164 @@
+import { expect, test } from '@playwright/test';
+import { waitForTransaction } from '@sentry-internal/test-utils';
+import { APP_NAME } from '../constants';
+
+test.describe('server - performance', () => {
+ test('should send server transaction on pageload', async ({ page }) => {
+ const txPromise = waitForTransaction(APP_NAME, async transactionEvent => {
+ return transactionEvent.transaction === 'GET /performance';
+ });
+
+ await page.goto(`/performance`);
+
+ const transaction = await txPromise;
+
+ expect(transaction).toMatchObject({
+ contexts: {
+ trace: {
+ span_id: expect.any(String),
+ trace_id: expect.any(String),
+ data: {
+ 'sentry.op': 'http.server',
+ 'sentry.origin': 'auto.http.react_router.request_handler',
+ 'sentry.source': 'route',
+ },
+ op: 'http.server',
+ origin: 'auto.http.react_router.request_handler',
+ },
+ },
+ spans: expect.any(Array),
+ start_timestamp: expect.any(Number),
+ timestamp: expect.any(Number),
+ transaction: 'GET /performance',
+ type: 'transaction',
+ transaction_info: { source: 'route' },
+ platform: 'node',
+ request: {
+ url: expect.stringContaining('/performance'),
+ headers: expect.any(Object),
+ },
+ event_id: expect.any(String),
+ environment: 'qa',
+ sdk: {
+ integrations: expect.arrayContaining([expect.any(String)]),
+ name: 'sentry.javascript.react-router',
+ version: expect.any(String),
+ packages: [
+ { name: 'npm:@sentry/react-router', version: expect.any(String) },
+ { name: 'npm:@sentry/node', version: expect.any(String) },
+ ],
+ },
+ tags: {
+ runtime: 'node',
+ },
+ });
+ });
+
+ test('should send server transaction on parameterized route', async ({ page }) => {
+ const txPromise = waitForTransaction(APP_NAME, async transactionEvent => {
+ return transactionEvent.transaction === 'GET /performance/with/:param';
+ });
+
+ await page.goto(`/performance/with/some-param`);
+
+ const transaction = await txPromise;
+
+ expect(transaction).toMatchObject({
+ contexts: {
+ trace: {
+ span_id: expect.any(String),
+ trace_id: expect.any(String),
+ data: {
+ 'sentry.op': 'http.server',
+ 'sentry.origin': 'auto.http.react_router.request_handler',
+ 'sentry.source': 'route',
+ },
+ op: 'http.server',
+ origin: 'auto.http.react_router.request_handler',
+ },
+ },
+ spans: expect.any(Array),
+ start_timestamp: expect.any(Number),
+ timestamp: expect.any(Number),
+ transaction: 'GET /performance/with/:param',
+ type: 'transaction',
+ transaction_info: { source: 'route' },
+ platform: 'node',
+ request: {
+ url: expect.stringContaining('/performance/with/some-param'),
+ headers: expect.any(Object),
+ },
+ event_id: expect.any(String),
+ environment: 'qa',
+ sdk: {
+ integrations: expect.arrayContaining([expect.any(String)]),
+ name: 'sentry.javascript.react-router',
+ version: expect.any(String),
+ packages: [
+ { name: 'npm:@sentry/react-router', version: expect.any(String) },
+ { name: 'npm:@sentry/node', version: expect.any(String) },
+ ],
+ },
+ tags: {
+ runtime: 'node',
+ },
+ });
+ });
+
+ // This does not work on Node 20.19, sadly
+ test.skip('should automatically instrument server loader', async ({ page }) => {
+ const txPromise = waitForTransaction(APP_NAME, async transactionEvent => {
+ return transactionEvent.transaction === 'GET /performance/server-loader.data';
+ });
+
+ await page.goto(`/performance`); // initial ssr pageloads do not contain .data requests
+ await page.waitForTimeout(500); // quick breather before navigation
+ await page.getByRole('link', { name: 'Server Loader' }).click(); // this will actually trigger a .data request
+
+ const transaction = await txPromise;
+
+ expect(transaction?.spans?.[transaction.spans?.length - 1]).toMatchObject({
+ span_id: expect.any(String),
+ trace_id: expect.any(String),
+ data: {
+ 'sentry.origin': 'auto.http.react_router',
+ 'sentry.op': 'function.react_router.loader',
+ },
+ description: 'Executing Server Loader',
+ parent_span_id: expect.any(String),
+ start_timestamp: expect.any(Number),
+ timestamp: expect.any(Number),
+ status: 'ok',
+ op: 'function.react_router.loader',
+ origin: 'auto.http.react_router',
+ });
+ });
+
+ // This does not work on Node 20.19, sadly
+ test.skip('should automatically instrument server action', async ({ page }) => {
+ const txPromise = waitForTransaction(APP_NAME, async transactionEvent => {
+ return transactionEvent.transaction === 'POST /performance/server-action.data';
+ });
+
+ await page.goto(`/performance/server-action`);
+ await page.getByRole('button', { name: 'Submit' }).click(); // this will trigger a .data request
+
+ const transaction = await txPromise;
+
+ expect(transaction?.spans?.[transaction.spans?.length - 1]).toMatchObject({
+ span_id: expect.any(String),
+ trace_id: expect.any(String),
+ data: {
+ 'sentry.origin': 'auto.http.react_router',
+ 'sentry.op': 'function.react_router.action',
+ },
+ description: 'Executing Server Action',
+ parent_span_id: expect.any(String),
+ start_timestamp: expect.any(Number),
+ timestamp: expect.any(Number),
+ status: 'ok',
+ op: 'function.react_router.action',
+ origin: 'auto.http.react_router',
+ });
+ });
+});
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/redis.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/redis.server.test.ts
new file mode 100644
index 000000000000..1040902647c4
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/redis.server.test.ts
@@ -0,0 +1,35 @@
+import { expect, test } from '@playwright/test';
+import { waitForTransaction } from '@sentry-internal/test-utils';
+import { APP_NAME } from '../constants';
+
+test.describe('server - redis db spans', () => {
+ test('server loader emits db.redis child spans on the http.server transaction', async ({ page }) => {
+ const txPromise = waitForTransaction(APP_NAME, async transactionEvent => {
+ return (
+ transactionEvent.transaction === 'GET /performance/redis' &&
+ (transactionEvent.spans?.some(span => span.op === 'db.redis') ?? false)
+ );
+ });
+
+ await page.goto('/performance/redis');
+
+ const transaction = await txPromise;
+
+ expect(transaction.contexts?.trace?.op).toBe('http.server');
+
+ // Collect every span id in the transaction (root + children) so we can verify nesting.
+ const rootSpanId = transaction.contexts?.trace?.span_id;
+ const spanIds = new Set([rootSpanId, ...(transaction.spans ?? []).map(span => span.span_id)]);
+
+ const redisSpans = transaction.spans!.filter(span => span.op === 'db.redis');
+
+ // loader runs SET then GET => at least two redis command spans
+ expect(redisSpans.length).toBeGreaterThanOrEqual(2);
+
+ // every redis span nests under the http.server transaction (its parent is part of the same span tree)
+ const allNested = redisSpans.every(
+ span => typeof span.parent_span_id === 'string' && spanIds.has(span.parent_span_id),
+ );
+ expect(allNested).toBe(true);
+ });
+});
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/trace-propagation.test.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/trace-propagation.test.ts
new file mode 100644
index 000000000000..4ce133514ce3
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/trace-propagation.test.ts
@@ -0,0 +1,47 @@
+import { expect, test } from '@playwright/test';
+import { waitForTransaction } from '@sentry-internal/test-utils';
+import { APP_NAME } from '../constants';
+
+test.describe('Trace propagation', () => {
+ test('should inject metatags in ssr pageload', async ({ page }) => {
+ await page.goto(`/`);
+ const sentryTraceContent = await page.getAttribute('meta[name="sentry-trace"]', 'content');
+ expect(sentryTraceContent).toBeDefined();
+ expect(sentryTraceContent).toMatch(/^[a-f0-9]{32}-[a-f0-9]{16}-[01]$/);
+ const baggageContent = await page.getAttribute('meta[name="baggage"]', 'content');
+ expect(baggageContent).toBeDefined();
+ expect(baggageContent).toContain('sentry-environment=qa');
+ expect(baggageContent).toContain('sentry-public_key=');
+ expect(baggageContent).toContain('sentry-trace_id=');
+ expect(baggageContent).toContain('sentry-transaction=');
+ expect(baggageContent).toContain('sentry-sampled=');
+ });
+
+ test('should have trace connection', async ({ page }) => {
+ const serverTxPromise = waitForTransaction(APP_NAME, async transactionEvent => {
+ return transactionEvent.transaction === 'GET /{*splat}';
+ });
+
+ const clientTxPromise = waitForTransaction(APP_NAME, async transactionEvent => {
+ return transactionEvent.transaction === '/';
+ });
+
+ await page.goto(`/`);
+ const serverTx = await serverTxPromise;
+ const clientTx = await clientTxPromise;
+
+ expect(clientTx.contexts?.trace?.trace_id).toEqual(serverTx.contexts?.trace?.trace_id);
+
+ const requestHandlerSpan = serverTx.spans?.find(span => span.op === 'request_handler.express');
+
+ expect(requestHandlerSpan).toBeDefined();
+ expect(clientTx.contexts?.trace?.parent_span_id).toBe(requestHandlerSpan?.span_id);
+ });
+
+ test('should not have trace connection for prerendered pages', async ({ page }) => {
+ await page.goto('/performance/static');
+
+ const sentryTraceElement = await page.$('meta[name="sentry-trace"]');
+ expect(sentryTraceElement).toBeNull();
+ });
+});
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/tsconfig.json b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tsconfig.json
new file mode 100644
index 000000000000..a16df276e8bc
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "compilerOptions": {
+ "lib": ["DOM", "DOM.Iterable", "ES2022"],
+ "types": ["node", "vite/client"],
+ "target": "ES2022",
+ "module": "ES2022",
+ "moduleResolution": "bundler",
+ "jsx": "react-jsx",
+ "rootDirs": [".", "./.react-router/types"],
+ "baseUrl": ".",
+
+ "esModuleInterop": true,
+ "verbatimModuleSyntax": true,
+ "noEmit": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "strict": true
+ },
+ "include": ["**/*", "**/.server/**/*", "**/.client/**/*", ".react-router/types/**/*"]
+}
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/vite.config.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/vite.config.ts
new file mode 100644
index 000000000000..68ba30d69397
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/vite.config.ts
@@ -0,0 +1,6 @@
+import { reactRouter } from '@react-router/dev/vite';
+import { defineConfig } from 'vite';
+
+export default defineConfig({
+ plugins: [reactRouter()],
+});
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-spa/.gitignore b/dev-packages/e2e-tests/test-applications/react-router-8-spa/.gitignore
new file mode 100644
index 000000000000..84634c973eeb
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-spa/.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-8-spa/index.html b/dev-packages/e2e-tests/test-applications/react-router-8-spa/index.html
new file mode 100644
index 000000000000..e4b78eae1230
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-spa/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Vite + React + TS
+
+
+
+
+
+
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-spa/package.json b/dev-packages/e2e-tests/test-applications/react-router-8-spa/package.json
new file mode 100644
index 000000000000..67b016407d0c
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-spa/package.json
@@ -0,0 +1,60 @@
+{
+ "name": "react-router-8-spa",
+ "version": "0.1.0",
+ "private": true,
+ "dependencies": {
+ "@sentry/react": "file:../../packed/sentry-react-packed.tgz",
+ "@types/react": "19.2.17",
+ "@types/react-dom": "19.2.3",
+ "react": "19.2.7",
+ "react-dom": "19.2.7",
+ "react-router": "^8.0.0"
+ },
+ "devDependencies": {
+ "@playwright/test": "~1.56.0",
+ "@sentry-internal/test-utils": "link:../../../test-utils",
+ "vite": "^7.3.2",
+ "@vitejs/plugin-react": "^5.2.0",
+ "typescript": "^5.6.3"
+ },
+ "scripts": {
+ "build": "vite build",
+ "dev": "vite",
+ "preview": "vite preview",
+ "test": "playwright test",
+ "clean": "npx rimraf node_modules pnpm-lock.yaml",
+ "test:build": "pnpm install && pnpm build",
+ "test:build-ts3.8": "pnpm install && pnpm add typescript@3.8 && pnpm build",
+ "test:build-canary": "pnpm install && pnpm add react@canary react-dom@canary && 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"
+ ]
+ },
+ "volta": {
+ "extends": "../../package.json"
+ },
+ "sentryTest": {
+ "variants": [
+ {
+ "build-command": "pnpm test:build-ts3.8",
+ "label": "react-router-8-spa (TS 3.8)"
+ }
+ ]
+ }
+}
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-spa/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/react-router-8-spa/playwright.config.mjs
new file mode 100644
index 000000000000..7fda76df18ae
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-spa/playwright.config.mjs
@@ -0,0 +1,8 @@
+import { getPlaywrightConfig } from '@sentry-internal/test-utils';
+
+const config = getPlaywrightConfig({
+ startCommand: `pnpm preview --port 3030`,
+ port: 3030,
+});
+
+export default config;
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-spa/src/globals.d.ts b/dev-packages/e2e-tests/test-applications/react-router-8-spa/src/globals.d.ts
new file mode 100644
index 000000000000..ffa61ca49acc
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-spa/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-8-spa/src/main.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-spa/src/main.tsx
new file mode 100644
index 000000000000..202de7be19b4
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-spa/src/main.tsx
@@ -0,0 +1,58 @@
+import * as Sentry from '@sentry/react';
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import {
+ BrowserRouter,
+ Route,
+ Routes,
+ createRoutesFromChildren,
+ matchRoutes,
+ useLocation,
+ useNavigationType,
+} from 'react-router';
+import Index from './pages/Index';
+import Products from './pages/Products';
+import SSE from './pages/SSE';
+import User from './pages/User';
+
+const replay = Sentry.replayIntegration();
+
+Sentry.init({
+ environment: 'qa', // dynamic sampling bias to keep transactions
+ dsn: import.meta.env.PUBLIC_E2E_TEST_DSN,
+ integrations: [
+ Sentry.reactRouterBrowserTracingIntegration({
+ useEffect: React.useEffect,
+ useLocation,
+ useNavigationType,
+ createRoutesFromChildren,
+ matchRoutes,
+ trackFetchStreamPerformance: true,
+ }),
+ replay,
+ ],
+ // We recommend adjusting this value in production, or using tracesSampler
+ // for finer control
+ tracesSampleRate: 1.0,
+ release: 'e2e-test',
+
+ // Always capture replays, so we can test this properly
+ replaysSessionSampleRate: 1.0,
+ replaysOnErrorSampleRate: 0.0,
+ tunnel: 'http://localhost:3031',
+ dataCollection: { userInfo: true },
+});
+
+const SentryRoutes = Sentry.wrapReactRouterRouting(Routes);
+
+const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
+root.render(
+
+
+ } />
+ } />
+ } />
+ } />
+
+ ,
+);
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-spa/src/pages/Index.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-spa/src/pages/Index.tsx
new file mode 100644
index 000000000000..7a6832307834
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-spa/src/pages/Index.tsx
@@ -0,0 +1,25 @@
+import * as React from 'react';
+import { Link } from 'react-router';
+
+const Index = () => {
+ return (
+ <>
+ {
+ throw new Error('I am an error!');
+ }}
+ />
+
+ navigate
+
+
+ products
+
+ >
+ );
+};
+
+export default Index;
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-spa/src/pages/Products.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-spa/src/pages/Products.tsx
new file mode 100644
index 000000000000..fb0768b264ff
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-spa/src/pages/Products.tsx
@@ -0,0 +1,16 @@
+import * as React from 'react';
+
+const Products = () => {
+ // Fired on mount, i.e. while navigating to /products. This mirrors a typical
+ // route component that loads its data in an effect. The request is same-origin,
+ // so the SDK attaches `sentry-trace`/`baggage` headers by default.
+ React.useEffect(() => {
+ fetch('/api/products').catch(() => {
+ // ignore network errors in the test environment
+ });
+ }, []);
+
+ return Products
;
+};
+
+export default Products;
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-spa/src/pages/SSE.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-spa/src/pages/SSE.tsx
new file mode 100644
index 000000000000..4c0ae97036ad
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-spa/src/pages/SSE.tsx
@@ -0,0 +1,58 @@
+import * as Sentry from '@sentry/react';
+import * as React from 'react';
+
+const fetchSSE = async ({ timeout, abort = false }: { timeout: boolean; abort?: boolean }) => {
+ Sentry.startSpanManual({ name: 'sse stream using fetch' }, async span => {
+ const controller = new AbortController();
+
+ const res = await Sentry.startSpan({ name: 'sse fetch call' }, async () => {
+ const endpoint = `http://localhost:8080/${timeout ? 'sse-timeout' : 'sse'}`;
+
+ const signal = controller.signal;
+ return await fetch(endpoint, { signal });
+ });
+
+ const stream = res.body;
+ const reader = stream?.getReader();
+
+ const readChunk = async () => {
+ if (abort) {
+ controller.abort();
+ }
+ const readRes = await reader?.read();
+ if (readRes?.done) {
+ return;
+ }
+
+ new TextDecoder().decode(readRes?.value);
+
+ await readChunk();
+ };
+
+ try {
+ await readChunk();
+ } catch (error) {
+ console.error('Could not fetch sse', error);
+ }
+
+ span.end();
+ });
+};
+
+const SSE = () => {
+ return (
+ <>
+ fetchSSE({ timeout: false })}>
+ Fetch SSE
+
+ fetchSSE({ timeout: true })}>
+ Fetch timeout SSE
+
+ fetchSSE({ timeout: false, abort: true })}>
+ Fetch SSE with error
+
+ >
+ );
+};
+
+export default SSE;
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-spa/src/pages/User.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-spa/src/pages/User.tsx
new file mode 100644
index 000000000000..671455a92fff
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-spa/src/pages/User.tsx
@@ -0,0 +1,7 @@
+import * as React from 'react';
+
+const User = () => {
+ return I am a blank page :)
;
+};
+
+export default User;
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-spa/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/react-router-8-spa/start-event-proxy.mjs
new file mode 100644
index 000000000000..98bb1db1c281
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-spa/start-event-proxy.mjs
@@ -0,0 +1,6 @@
+import { startEventProxyServer } from '@sentry-internal/test-utils';
+
+startEventProxyServer({
+ port: 3031,
+ proxyServerName: 'react-router-8-spa',
+});
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-spa/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/react-router-8-spa/tests/errors.test.ts
new file mode 100644
index 000000000000..97a147f065e0
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-spa/tests/errors.test.ts
@@ -0,0 +1,59 @@
+import { expect, test } from '@playwright/test';
+import { waitForError, waitForTransaction } from '@sentry-internal/test-utils';
+
+test('Sends correct error event', async ({ page, baseURL }) => {
+ const errorEventPromise = waitForError('react-router-8-spa', 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-8-spa', async transactionEvent => {
+ return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload';
+ });
+
+ const errorEventPromise = waitForError('react-router-8-spa', 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-8-spa/tests/navigation-trace-propagation.test.ts b/dev-packages/e2e-tests/test-applications/react-router-8-spa/tests/navigation-trace-propagation.test.ts
new file mode 100644
index 000000000000..921c20dee117
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-spa/tests/navigation-trace-propagation.test.ts
@@ -0,0 +1,44 @@
+import { expect, test } from '@playwright/test';
+import { waitForTransaction } from '@sentry-internal/test-utils';
+
+test('propagates the navigation trace (not the stale pageload trace) for a fetch in a route mount effect', async ({
+ page,
+}) => {
+ // Intercept the /products data fetch and capture the tracing header the SDK attached.
+ let productsRequestSentryTrace: string | undefined;
+ await page.route('**/api/products', async route => {
+ productsRequestSentryTrace = route.request().headers()['sentry-trace'];
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: '[]',
+ });
+ });
+
+ const pageloadTxnPromise = waitForTransaction('react-router-8-spa', async transactionEvent => {
+ return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload';
+ });
+
+ const navigationTxnPromise = waitForTransaction('react-router-8-spa', async transactionEvent => {
+ return transactionEvent.contexts?.trace?.op === 'navigation' && transactionEvent.transaction === '/products';
+ });
+
+ await page.goto('/');
+ const pageloadTxn = await pageloadTxnPromise;
+
+ await page.locator('id=navigation-products').click();
+ const navigationTxn = await navigationTxnPromise;
+
+ const pageloadTraceId = pageloadTxn.contexts?.trace?.trace_id;
+ const navigationTraceId = navigationTxn.contexts?.trace?.trace_id;
+ const propagatedTraceId = productsRequestSentryTrace?.split('-')[0];
+
+ expect(pageloadTraceId).toBeDefined();
+ expect(navigationTraceId).toBeDefined();
+ expect(propagatedTraceId).toBeDefined();
+ expect(navigationTraceId).not.toEqual(pageloadTraceId);
+
+ // The fetch fired on /products must carry the navigation trace, not the stale pageload trace.
+ expect(propagatedTraceId).toEqual(navigationTraceId);
+ expect(propagatedTraceId).not.toEqual(pageloadTraceId);
+});
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-spa/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-router-8-spa/tests/transactions.test.ts
new file mode 100644
index 000000000000..fcf012f4b51c
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-spa/tests/transactions.test.ts
@@ -0,0 +1,100 @@
+import { expect, test } from '@playwright/test';
+import { waitForEnvelopeItem, waitForTransaction } from '@sentry-internal/test-utils';
+
+test('sends a pageload transaction with a parameterized URL', async ({ page }) => {
+ const transactionPromise = waitForTransaction('react-router-8-spa', 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',
+ },
+ },
+ 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-8-spa', async transactionEvent => {
+ return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload';
+ });
+
+ const navigationTxnPromise = waitForTransaction('react-router-8-spa', 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',
+ },
+ },
+ transaction: '/user/:id',
+ transaction_info: {
+ source: 'route',
+ },
+ });
+});
+
+test('sends an INP span', async ({ page }) => {
+ const inpSpanPromise = waitForEnvelopeItem('react-router-8-spa', item => {
+ return item[0].type === 'span';
+ });
+
+ await page.goto(`/`);
+
+ await page.click('#exception-button');
+
+ await page.waitForTimeout(500);
+
+ // Page hide to trigger INP
+ await page.evaluate(() => {
+ window.dispatchEvent(new Event('pagehide'));
+ });
+
+ const inpSpan = await inpSpanPromise;
+
+ expect(inpSpan[1]).toEqual({
+ data: {
+ 'sentry.origin': 'auto.http.browser.inp',
+ 'sentry.op': 'ui.interaction.click',
+ release: 'e2e-test',
+ environment: 'qa',
+ transaction: '/',
+ 'sentry.exclusive_time': expect.any(Number),
+ replay_id: expect.any(String),
+ 'user_agent.original': expect.stringContaining('Chrome'),
+ 'client.address': '{{auto}}',
+ },
+ description: 'body > div#root > input#exception-button[type="button"]',
+ op: 'ui.interaction.click',
+ parent_span_id: expect.any(String),
+ span_id: expect.any(String),
+ start_timestamp: expect.any(Number),
+ timestamp: expect.any(Number),
+ trace_id: expect.any(String),
+ origin: 'auto.http.browser.inp',
+ exclusive_time: expect.any(Number),
+ measurements: { inp: { unit: 'millisecond', value: expect.any(Number) } },
+ segment_id: expect.any(String),
+ });
+});
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-spa/tsconfig.json b/dev-packages/e2e-tests/test-applications/react-router-8-spa/tsconfig.json
new file mode 100644
index 000000000000..7af258198f12
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-spa/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "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",
+ "types": ["vite/client"]
+ },
+ "include": ["src", "tests"]
+}
diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-spa/vite.config.ts b/dev-packages/e2e-tests/test-applications/react-router-8-spa/vite.config.ts
new file mode 100644
index 000000000000..63c2c4317df7
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-8-spa/vite.config.ts
@@ -0,0 +1,8 @@
+import react from '@vitejs/plugin-react';
+import { defineConfig } from 'vite';
+
+// https://vite.dev/config/
+export default defineConfig({
+ plugins: [react()],
+ envPrefix: 'PUBLIC_',
+});
diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts
index 139401da7a6e..a25f05e62a2f 100644
--- a/packages/react/src/index.ts
+++ b/packages/react/src/index.ts
@@ -16,6 +16,7 @@ export {
reactRouterV4BrowserTracingIntegration,
reactRouterV5BrowserTracingIntegration,
} from './reactrouter';
+/* oxlint-disable typescript/no-deprecated -- Intentional re-exports for backwards compatibility */
export {
reactRouterV6BrowserTracingIntegration,
withSentryReactRouterV6Routing,
@@ -30,3 +31,11 @@ export {
wrapCreateMemoryRouterV7,
wrapUseRoutesV7,
} from './reactrouterv7';
+/* oxlint-enable typescript/no-deprecated */
+export {
+ reactRouterBrowserTracingIntegration,
+ wrapReactRouterRouting,
+ wrapCreateBrowserRouter,
+ wrapCreateMemoryRouter,
+ wrapUseRoutes,
+} from './reactrouter.compat';
diff --git a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx
index da4e3e078aa8..5bc58325f963 100644
--- a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx
+++ b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx
@@ -225,7 +225,7 @@ export interface ReactRouterOptions {
lazyRouteManifest?: string[];
}
-type V6CompatibleVersion = '6' | '7';
+type V6CompatibleVersion = '6' | '7' | '';
export function addResolvedRoutesToParent(resolvedRoutes: RouteObject[], parentRoute: RouteObject): void {
const existingChildren = parentRoute.children || [];
@@ -500,7 +500,7 @@ export function createV6CompatibleWrapCreateBrowserRouter<
if (!_useEffect || !_useLocation || !_useNavigationType || !_matchRoutes) {
DEBUG_BUILD &&
debug.warn(
- `reactRouterV${version}Instrumentation was unable to wrap the \`createRouter\` function because of one or more missing parameters.`,
+ `reactRouter${version ? `V${version}` : ''}Instrumentation was unable to wrap the \`createRouter\` function because of one or more missing parameters.`,
);
return createRouterFunction;
@@ -572,7 +572,7 @@ export function createV6CompatibleWrapCreateMemoryRouter<
if (!_useEffect || !_useLocation || !_useNavigationType || !_matchRoutes) {
DEBUG_BUILD &&
debug.warn(
- `reactRouterV${version}Instrumentation was unable to wrap the \`createMemoryRouter\` function because of one or more missing parameters.`,
+ `reactRouter${version ? `V${version}` : ''}Instrumentation was unable to wrap the \`createMemoryRouter\` function because of one or more missing parameters.`,
);
return createRouterFunction;
@@ -735,7 +735,7 @@ export function createReactRouterV6CompatibleTracingIntegration(
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url',
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload',
- [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: `auto.pageload.react.reactrouter_v${version}`,
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: `auto.pageload.react.reactrouter${version ? `_v${version}` : ''}`,
},
});
}
@@ -1031,7 +1031,7 @@ export function handleNavigation(opts: {
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source,
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation',
- [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: `auto.navigation.react.reactrouter_v${version}`,
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: `auto.navigation.react.reactrouter${version ? `_v${version}` : ''}`,
},
});
} catch (e) {
diff --git a/packages/react/src/reactrouter.compat.tsx b/packages/react/src/reactrouter.compat.tsx
new file mode 100644
index 000000000000..814c8f74ee41
--- /dev/null
+++ b/packages/react/src/reactrouter.compat.tsx
@@ -0,0 +1,72 @@
+import type { browserTracingIntegration } from '@sentry/browser';
+import type { Integration } from '@sentry/core';
+import type { ReactRouterOptions } from './reactrouter-compat-utils';
+import {
+ createReactRouterV6CompatibleTracingIntegration,
+ createV6CompatibleWithSentryReactRouterRouting,
+ createV6CompatibleWrapCreateBrowserRouter,
+ createV6CompatibleWrapCreateMemoryRouter,
+ createV6CompatibleWrapUseRoutes,
+} from './reactrouter-compat-utils';
+import type { CreateRouterFunction, Router, RouterState, UseRoutes } from './types';
+
+/**
+ * A browser tracing integration that uses React Router to instrument navigations.
+ * Expects `useEffect`, `useLocation`, `useNavigationType`, `createRoutesFromChildren` and `matchRoutes` to be passed as options.
+ *
+ * Works with React Router v6+.
+ */
+export function reactRouterBrowserTracingIntegration(
+ options: Parameters[0] & ReactRouterOptions,
+): Integration {
+ return createReactRouterV6CompatibleTracingIntegration(options, '');
+}
+
+/**
+ * A higher-order component that adds Sentry routing instrumentation to a React Router Route component.
+ * This is used to automatically capture route changes as transactions.
+ *
+ * Works with React Router v6+.
+ */
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export function wrapReactRouterRouting, R extends React.FC
>(routes: R): R {
+ return createV6CompatibleWithSentryReactRouterRouting
(routes, '');
+}
+
+/**
+ * A wrapper function that adds Sentry routing instrumentation to a React Router createBrowserRouter function.
+ * This is used to automatically capture route changes as transactions when using the createBrowserRouter API.
+ *
+ * Works with React Router v6+.
+ */
+export function wrapCreateBrowserRouter<
+ TState extends RouterState = RouterState,
+ TRouter extends Router = Router,
+>(createRouterFunction: CreateRouterFunction): CreateRouterFunction {
+ return createV6CompatibleWrapCreateBrowserRouter(createRouterFunction, '');
+}
+
+/**
+ * A wrapper function that adds Sentry routing instrumentation to a React Router createMemoryRouter function.
+ * This is used to automatically capture route changes as transactions when using the createMemoryRouter API.
+ * The difference between createBrowserRouter and createMemoryRouter is that with createMemoryRouter,
+ * optional `initialEntries` are also taken into account.
+ *
+ * Works with React Router v6+.
+ */
+export function wrapCreateMemoryRouter<
+ TState extends RouterState = RouterState,
+ TRouter extends Router = Router,
+>(createMemoryRouterFunction: CreateRouterFunction): CreateRouterFunction {
+ return createV6CompatibleWrapCreateMemoryRouter(createMemoryRouterFunction, '');
+}
+
+/**
+ * A wrapper function that adds Sentry routing instrumentation to a React Router useRoutes hook.
+ * This is used to automatically capture route changes as transactions when using the useRoutes hook.
+ *
+ * Works with React Router v6+.
+ */
+export function wrapUseRoutes(origUseRoutes: UseRoutes): UseRoutes {
+ return createV6CompatibleWrapUseRoutes(origUseRoutes, '');
+}
diff --git a/packages/react/src/reactrouterv6.tsx b/packages/react/src/reactrouterv6.tsx
index 0c58bdb68f35..d6a467aa10e7 100644
--- a/packages/react/src/reactrouterv6.tsx
+++ b/packages/react/src/reactrouterv6.tsx
@@ -13,6 +13,8 @@ import type { CreateRouterFunction, Router, RouterState, UseRoutes } from './typ
/**
* A browser tracing integration that uses React Router v6 to instrument navigations.
* Expects `useEffect`, `useLocation`, `useNavigationType`, `createRoutesFromChildren` and `matchRoutes` to be passed as options.
+ *
+ * @deprecated Use `reactRouterBrowserTracingIntegration` instead.
*/
export function reactRouterV6BrowserTracingIntegration(
options: Parameters[0] & ReactRouterOptions,
@@ -23,6 +25,8 @@ export function reactRouterV6BrowserTracingIntegration(
/**
* A wrapper function that adds Sentry routing instrumentation to a React Router v6 useRoutes hook.
* This is used to automatically capture route changes as transactions when using the useRoutes hook.
+ *
+ * @deprecated Use `wrapUseRoutes` instead.
*/
export function wrapUseRoutesV6(origUseRoutes: UseRoutes): UseRoutes {
return createV6CompatibleWrapUseRoutes(origUseRoutes, '6');
@@ -31,6 +35,8 @@ export function wrapUseRoutesV6(origUseRoutes: UseRoutes): UseRoutes {
/**
* A wrapper function that adds Sentry routing instrumentation to a React Router v6 createBrowserRouter function.
* This is used to automatically capture route changes as transactions when using the createBrowserRouter API.
+ *
+ * @deprecated Use `wrapCreateBrowserRouter` instead.
*/
export function wrapCreateBrowserRouterV6<
TState extends RouterState = RouterState,
@@ -44,6 +50,8 @@ export function wrapCreateBrowserRouterV6<
* This is used to automatically capture route changes as transactions when using the createMemoryRouter API.
* The difference between createBrowserRouter and createMemoryRouter is that with createMemoryRouter,
* optional `initialEntries` are also taken into account.
+ *
+ * @deprecated Use `wrapCreateMemoryRouter` instead.
*/
export function wrapCreateMemoryRouterV6<
TState extends RouterState = RouterState,
@@ -55,6 +63,8 @@ export function wrapCreateMemoryRouterV6<
/**
* A higher-order component that adds Sentry routing instrumentation to a React Router v6 Route component.
* This is used to automatically capture route changes as transactions.
+ *
+ * @deprecated Use `wrapReactRouterRouting` instead.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function withSentryReactRouterV6Routing, R extends React.FC
>(routes: R): R {
diff --git a/packages/react/src/reactrouterv7.tsx b/packages/react/src/reactrouterv7.tsx
index a25e12d40e68..335a1d0886f7 100644
--- a/packages/react/src/reactrouterv7.tsx
+++ b/packages/react/src/reactrouterv7.tsx
@@ -14,6 +14,8 @@ import type { CreateRouterFunction, Router, RouterState, UseRoutes } from './typ
/**
* A browser tracing integration that uses React Router v7 to instrument navigations.
* Expects `useEffect`, `useLocation`, `useNavigationType`, `createRoutesFromChildren` and `matchRoutes` to be passed as options.
+ *
+ * @deprecated Use `reactRouterBrowserTracingIntegration` instead.
*/
export function reactRouterV7BrowserTracingIntegration(
options: Parameters[0] & ReactRouterOptions,
@@ -24,6 +26,8 @@ export function reactRouterV7BrowserTracingIntegration(
/**
* A higher-order component that adds Sentry routing instrumentation to a React Router v7 Route component.
* This is used to automatically capture route changes as transactions.
+ *
+ * @deprecated Use `wrapReactRouterRouting` instead.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function withSentryReactRouterV7Routing, R extends React.FC
>(routes: R): R {
@@ -33,6 +37,8 @@ export function withSentryReactRouterV7Routing
, R
/**
* A wrapper function that adds Sentry routing instrumentation to a React Router v7 createBrowserRouter function.
* This is used to automatically capture route changes as transactions when using the createBrowserRouter API.
+ *
+ * @deprecated Use `wrapCreateBrowserRouter` instead.
*/
export function wrapCreateBrowserRouterV7<
TState extends RouterState = RouterState,
@@ -46,6 +52,8 @@ export function wrapCreateBrowserRouterV7<
* This is used to automatically capture route changes as transactions when using the createMemoryRouter API.
* The difference between createBrowserRouter and createMemoryRouter is that with createMemoryRouter,
* optional `initialEntries` are also taken into account.
+ *
+ * @deprecated Use `wrapCreateMemoryRouter` instead.
*/
export function wrapCreateMemoryRouterV7<
TState extends RouterState = RouterState,
@@ -57,6 +65,8 @@ export function wrapCreateMemoryRouterV7<
/**
* A wrapper function that adds Sentry routing instrumentation to a React Router v7 useRoutes hook.
* This is used to automatically capture route changes as transactions when using the useRoutes hook.
+ *
+ * @deprecated Use `wrapUseRoutes` instead.
*/
export function wrapUseRoutesV7(origUseRoutes: UseRoutes): UseRoutes {
return createV6CompatibleWrapUseRoutes(origUseRoutes, '7');