diff --git a/dev-packages/e2e-tests/package.json b/dev-packages/e2e-tests/package.json
index 8a0eb8010128..039a9eb8760b 100644
--- a/dev-packages/e2e-tests/package.json
+++ b/dev-packages/e2e-tests/package.json
@@ -16,7 +16,7 @@
"clean": "rimraf tmp node_modules && yarn clean:test-applications && yarn clean:pnpm",
"ci:build-matrix": "ts-node ./lib/getTestMatrix.ts",
"ci:build-matrix-optional": "ts-node ./lib/getTestMatrix.ts --optional=true",
- "clean:test-applications": "rimraf --glob test-applications/**/{node_modules,dist,build,.next,.nuxt,.sveltekit,pnpm-lock.yaml,.last-run.json,test-results}",
+ "clean:test-applications": "rimraf --glob test-applications/**/{node_modules,dist,build,.next,.nuxt,.sveltekit,.react-router,pnpm-lock.yaml,.last-run.json,test-results}",
"clean:pnpm": "pnpm store prune"
},
"devDependencies": {
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/.gitignore b/dev-packages/e2e-tests/test-applications/react-router-7-framework/.gitignore
new file mode 100644
index 000000000000..ebb991370034
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-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-7-framework/.npmrc b/dev-packages/e2e-tests/test-applications/react-router-7-framework/.npmrc
new file mode 100644
index 000000000000..070f80f05092
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/.npmrc
@@ -0,0 +1,2 @@
+@sentry:registry=http://127.0.0.1:4873
+@sentry-internal:registry=http://127.0.0.1:4873
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/app.css b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/app.css
new file mode 100644
index 000000000000..b31c3a9d0ddf
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-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-7-framework/app/entry.client.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/entry.client.tsx
new file mode 100644
index 000000000000..2200fcea97c3
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-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.browserTracingIntegration()],
+ tracesSampleRate: 1.0,
+ tracePropagationTargets: [/^\//],
+});
+
+startTransition(() => {
+ hydrateRoot(
+ document,
+
+
+ ,
+ );
+});
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/entry.server.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/entry.server.tsx
new file mode 100644
index 000000000000..faa62bd97197
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/entry.server.tsx
@@ -0,0 +1,73 @@
+import { PassThrough } from 'node:stream';
+
+import { createReadableStreamFromReadable } from '@react-router/node';
+import * as Sentry from '@sentry/react-router';
+import { isbot } from 'isbot';
+import type { RenderToPipeableStreamOptions } from 'react-dom/server';
+import { renderToPipeableStream } from 'react-dom/server';
+import type { AppLoadContext, EntryContext } from 'react-router';
+import { ServerRouter } from 'react-router';
+const ABORT_DELAY = 5_000;
+
+export default function handleRequest(
+ request: Request,
+ responseStatusCode: number,
+ responseHeaders: Headers,
+ routerContext: EntryContext,
+ loadContext: AppLoadContext,
+) {
+ return new Promise((resolve, reject) => {
+ let shellRendered = false;
+ let userAgent = request.headers.get('user-agent');
+
+ // Ensure requests from bots and SPA Mode renders wait for all content to load before responding
+ // https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation
+ let readyOption: keyof RenderToPipeableStreamOptions =
+ (userAgent && isbot(userAgent)) || routerContext.isSpaMode ? 'onAllReady' : 'onShellReady';
+
+ const { pipe, abort } = renderToPipeableStream(, {
+ [readyOption]() {
+ shellRendered = true;
+ const body = new PassThrough();
+ const stream = createReadableStreamFromReadable(body);
+
+ responseHeaders.set('Content-Type', 'text/html');
+
+ resolve(
+ new Response(stream, {
+ headers: responseHeaders,
+ status: responseStatusCode,
+ }),
+ );
+
+ pipe(body);
+ },
+ onShellError(error: unknown) {
+ reject(error);
+ },
+ onError(error: unknown) {
+ responseStatusCode = 500;
+ // Log streaming rendering errors from inside the shell. Don't log
+ // errors encountered during initial shell rendering since they'll
+ // reject and get logged in handleDocumentRequest.
+ if (shellRendered) {
+ console.error(error);
+ }
+ },
+ });
+
+ setTimeout(abort, ABORT_DELAY);
+ });
+}
+
+import { type HandleErrorFunction } from 'react-router';
+
+export const handleError: HandleErrorFunction = (error, { request }) => {
+ // React Router may abort some interrupted requests, don't log those
+ if (!request.signal.aborted) {
+ Sentry.captureException(error);
+
+ // make sure to still log the error so you can see it
+ console.error(error);
+ }
+};
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/root.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/root.tsx
new file mode 100644
index 000000000000..227c08f7730c
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/root.tsx
@@ -0,0 +1,69 @@
+import * as Sentry from '@sentry/react-router';
+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) {
+ Sentry.captureException(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-7-framework/app/routes.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes.ts
new file mode 100644
index 000000000000..bb7472366681
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes.ts
@@ -0,0 +1,18 @@
+import { type RouteConfig, index, prefix, route } from '@react-router/dev/routes';
+
+export default [
+ index('routes/home.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('with/:param', 'routes/performance/dynamic-param.tsx'),
+ route('static', 'routes/performance/static.tsx'),
+ ]),
+] satisfies RouteConfig;
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/errors/client-action.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-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-7-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-7-framework/app/routes/errors/client-loader.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-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-7-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-7-framework/app/routes/errors/client-param.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-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-7-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
+
+
+ );
+}
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/errors/client.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/errors/client.tsx
new file mode 100644
index 000000000000..190074a5ef09
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/errors/client.tsx
@@ -0,0 +1,15 @@
+export default function ClientErrorPage() {
+ return (
+
+
Client Error Page
+
+
+ );
+}
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/errors/server-action.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-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-7-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-7-framework/app/routes/errors/server-loader.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-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-7-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-7-framework/app/routes/home.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/home.tsx
new file mode 100644
index 000000000000..4498e7a0d017
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-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-7-framework/app/routes/performance/dynamic-param.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/performance/dynamic-param.tsx
new file mode 100644
index 000000000000..39cf7bd5dbf6
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/performance/dynamic-param.tsx
@@ -0,0 +1,12 @@
+import type { Route } from './+types/dynamic-param';
+
+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-7-framework/app/routes/performance/index.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/performance/index.tsx
new file mode 100644
index 000000000000..9d55975e61a5
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/performance/index.tsx
@@ -0,0 +1,3 @@
+export default function PerformancePage() {
+ return Performance Page
;
+}
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/performance/static.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/performance/static.tsx
new file mode 100644
index 000000000000..3dea24381fdc
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-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-7-framework/instrument.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-framework/instrument.mjs
new file mode 100644
index 000000000000..70768dd2a6b4
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/instrument.mjs
@@ -0,0 +1,9 @@
+import * as Sentry from '@sentry/react-router';
+
+Sentry.init({
+ // todo: grab from env
+ 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-7-framework/package.json b/dev-packages/e2e-tests/test-applications/react-router-7-framework/package.json
new file mode 100644
index 000000000000..cdd96f39569e
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/package.json
@@ -0,0 +1,58 @@
+{
+ "name": "react-router-7-framework",
+ "version": "0.1.0",
+ "type": "module",
+ "private": true,
+ "dependencies": {
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "react-router": "^7.1.5",
+ "@react-router/node": "^7.1.5",
+ "@react-router/serve": "^7.1.5",
+ "@sentry/react-router": "latest || *",
+ "isbot": "^5.1.17"
+ },
+ "devDependencies": {
+ "@types/react": "18.3.1",
+ "@types/react-dom": "18.3.1",
+ "@types/node": "^20",
+ "@react-router/dev": "^7.1.5",
+ "@playwright/test": "~1.50.0",
+ "@sentry-internal/test-utils": "link:../../../test-utils",
+ "typescript": "^5.6.3",
+ "vite": "^5.4.11"
+ },
+ "scripts": {
+ "build": "react-router build",
+ "dev": "NODE_OPTIONS='--import ./instrument.mjs' react-router dev",
+ "start": "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"
+ }
+}
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-framework/playwright.config.mjs
new file mode 100644
index 000000000000..3ed5721107a7
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/playwright.config.mjs
@@ -0,0 +1,8 @@
+import { getPlaywrightConfig } from '@sentry-internal/test-utils';
+
+const config = getPlaywrightConfig({
+ startCommand: `PORT=3030 pnpm start`,
+ port: 3030,
+});
+
+export default config;
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/public/favicon.ico b/dev-packages/e2e-tests/test-applications/react-router-7-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-7-framework/public/favicon.ico differ
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/react-router.config.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework/react-router.config.ts
new file mode 100644
index 000000000000..73b647e4eea6
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/react-router.config.ts
@@ -0,0 +1,7 @@
+import type { Config } from '@react-router/dev/config';
+
+export default {
+ ssr: true,
+ // todo: check why this messes up client tracing in tests
+ // prerender: ['/performance/static'],
+} satisfies Config;
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-framework/start-event-proxy.mjs
new file mode 100644
index 000000000000..7a8110ee5ccb
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/start-event-proxy.mjs
@@ -0,0 +1,6 @@
+import { startEventProxyServer } from '@sentry-internal/test-utils';
+
+startEventProxyServer({
+ port: 3031,
+ proxyServerName: 'react-router-7-framework',
+});
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/constants.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/constants.ts
new file mode 100644
index 000000000000..3f70e5327bd6
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/constants.ts
@@ -0,0 +1 @@
+export const APP_NAME = 'react-router-7-framework';
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/errors/errors.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/errors/errors.client.test.ts
new file mode 100644
index 000000000000..d6c80924c121
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/errors/errors.client.test.ts
@@ -0,0 +1,138 @@
+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: true,
+ },
+ },
+ ],
+ },
+ 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: true,
+ },
+ },
+ ],
+ },
+ transaction: '/errors/client-action',
+ });
+ });
+});
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/errors/errors.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/errors/errors.server.test.ts
new file mode 100644
index 000000000000..d702f8cee597
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/errors/errors.server.test.ts
@@ -0,0 +1,98 @@
+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: true,
+ },
+ },
+ ],
+ },
+ // todo: should be 'GET /errors/server-loader'
+ transaction: 'GET *',
+ 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: true,
+ },
+ },
+ ],
+ },
+ // todo: should be 'POST /errors/server-action'
+ transaction: 'POST *',
+ 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-7-framework/tests/performance/pageload.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/pageload.client.test.ts
new file mode 100644
index 000000000000..c53494c723b4
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/pageload.client.test.ts
@@ -0,0 +1,83 @@
+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';
+ });
+
+ 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.browser',
+ 'sentry.op': 'pageload',
+ 'sentry.source': 'url',
+ },
+ op: 'pageload',
+ origin: 'auto.pageload.browser',
+ },
+ },
+ spans: expect.any(Array),
+ start_timestamp: expect.any(Number),
+ timestamp: expect.any(Number),
+ transaction: '/performance',
+ type: 'transaction',
+ transaction_info: { source: 'url' },
+ 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' },
+ });
+ });
+
+ // todo: this page is currently not prerendered (see react-router.config.ts)
+ test('should send pageload transaction for prerendered pages', async ({ page }) => {
+ const txPromise = waitForTransaction(APP_NAME, async transactionEvent => {
+ return transactionEvent.transaction === '/performance/static';
+ });
+
+ 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.browser',
+ 'sentry.op': 'pageload',
+ 'sentry.source': 'url',
+ },
+ op: 'pageload',
+ origin: 'auto.pageload.browser',
+ },
+ },
+ });
+ });
+});
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/performance.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/performance.server.test.ts
new file mode 100644
index 000000000000..f080d01064ea
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/performance.server.test.ts
@@ -0,0 +1,111 @@
+import { expect, test } from '@playwright/test';
+import { waitForTransaction } from '@sentry-internal/test-utils';
+import { APP_NAME } from '../constants';
+
+test.describe('servery - performance', () => {
+ test('should send server transaction on pageload', async ({ page }) => {
+ const txPromise = waitForTransaction(APP_NAME, async transactionEvent => {
+ // todo: should be GET /performance
+ return transactionEvent.transaction === 'GET *';
+ });
+
+ 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.otel.http',
+ 'sentry.source': 'route',
+ },
+ op: 'http.server',
+ origin: 'auto.http.otel.http',
+ },
+ },
+ spans: expect.any(Array),
+ start_timestamp: expect.any(Number),
+ timestamp: expect.any(Number),
+ // todo: should be GET /performance
+ transaction: 'GET *',
+ 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 => {
+ // todo: should be GET /performance/with/:param
+ return transactionEvent.transaction === 'GET *';
+ });
+
+ 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.otel.http',
+ 'sentry.source': 'route',
+ },
+ op: 'http.server',
+ origin: 'auto.http.otel.http',
+ },
+ },
+ spans: expect.any(Array),
+ start_timestamp: expect.any(Number),
+ timestamp: expect.any(Number),
+ // todo: should be GET /performance/with/:param
+ transaction: 'GET *',
+ 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',
+ },
+ });
+ });
+});
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tsconfig.json b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tsconfig.json
new file mode 100644
index 000000000000..4b7a52f6bddf
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tsconfig.json
@@ -0,0 +1,25 @@
+{
+ "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-7-framework/vite.config.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework/vite.config.ts
new file mode 100644
index 000000000000..68ba30d69397
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/vite.config.ts
@@ -0,0 +1,6 @@
+import { reactRouter } from '@react-router/dev/vite';
+import { defineConfig } from 'vite';
+
+export default defineConfig({
+ plugins: [reactRouter()],
+});