diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/.gitignore b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/.gitignore
new file mode 100644
index 000000000000..ebb991370034
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/.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-custom/.npmrc b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/.npmrc
new file mode 100644
index 000000000000..070f80f05092
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/.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-custom/app/app.css b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/app.css
new file mode 100644
index 000000000000..b31c3a9d0ddf
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/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-custom/app/entry.client.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/entry.client.tsx
new file mode 100644
index 000000000000..925c1e6ab143
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/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-7-framework-custom/app/entry.server.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/entry.server.tsx
new file mode 100644
index 000000000000..97260755da21
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/entry.server.tsx
@@ -0,0 +1,26 @@
+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 = (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-custom/app/root.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/root.tsx
new file mode 100644
index 000000000000..227c08f7730c
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/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-custom/app/routes.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/routes.ts
new file mode 100644
index 000000000000..b412893def52
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/routes.ts
@@ -0,0 +1,21 @@
+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('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'),
+ ]),
+] satisfies RouteConfig;
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/routes/errors/client-action.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/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-custom/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-custom/app/routes/errors/client-loader.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/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-custom/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-custom/app/routes/errors/client-param.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/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-custom/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-custom/app/routes/errors/client.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/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-custom/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-custom/app/routes/errors/server-action.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/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-custom/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-custom/app/routes/errors/server-loader.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/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-custom/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-custom/app/routes/home.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/routes/home.tsx
new file mode 100644
index 000000000000..4498e7a0d017
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/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-custom/app/routes/performance/dynamic-param.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/routes/performance/dynamic-param.tsx
new file mode 100644
index 000000000000..1ac02775f2ff
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/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-7-framework-custom/app/routes/performance/index.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/routes/performance/index.tsx
new file mode 100644
index 000000000000..e5383306625a
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/routes/performance/index.tsx
@@ -0,0 +1,14 @@
+import { Link } from 'react-router';
+
+export default function PerformancePage() {
+ return (
+
+
Performance Page
+
+
+ );
+}
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/routes/performance/server-action.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/routes/performance/server-action.tsx
new file mode 100644
index 000000000000..f149c5466b5a
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/routes/performance/server-action.tsx
@@ -0,0 +1,25 @@
+import { Form } from 'react-router';
+import type { Route } from './+types/server-action';
+import * as Sentry from '@sentry/react-router';
+
+export const action = Sentry.wrapServerAction({}, async ({ 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-7-framework-custom/app/routes/performance/server-loader.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/routes/performance/server-loader.tsx
new file mode 100644
index 000000000000..da688d4dfe3e
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/routes/performance/server-loader.tsx
@@ -0,0 +1,17 @@
+import type { Route } from './+types/server-loader';
+import * as Sentry from '@sentry/react-router';
+
+export const loader = Sentry.wrapServerLoader({}, async ({}: Route.LoaderArgs) => {
+ 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-7-framework-custom/app/routes/performance/ssr.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/routes/performance/ssr.tsx
new file mode 100644
index 000000000000..253e964ff15d
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/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-7-framework-custom/app/routes/performance/static.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/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-custom/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-custom/instrument.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/instrument.mjs
new file mode 100644
index 000000000000..a43afcba814f
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/instrument.mjs
@@ -0,0 +1,13 @@
+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
+ integrations: function (integrations) {
+ return integrations.filter(integration => {
+ return integration.name !== 'ReactRouterServer';
+ });
+ },
+});
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/package.json b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/package.json
new file mode 100644
index 000000000000..6f793c0d20eb
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/package.json
@@ -0,0 +1,67 @@
+{
+ "name": "react-router-7-framework-custom",
+ "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 || *",
+ "@sentry-internal/feedback": "latest || *",
+ "@sentry-internal/replay-canvas": "latest || *",
+ "@sentry-internal/browser-utils": "latest || *",
+ "@sentry/browser": "latest || *",
+ "@sentry/core": "latest || *",
+ "@sentry/node": "latest || *",
+ "@sentry/opentelemetry": "latest || *",
+ "@sentry/react": "latest || *",
+ "@sentry-internal/replay": "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-custom/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/playwright.config.mjs
new file mode 100644
index 000000000000..3ed5721107a7
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/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-custom/public/favicon.ico b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/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-custom/public/favicon.ico differ
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/react-router.config.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/react-router.config.ts
new file mode 100644
index 000000000000..bb1f96469dd2
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/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-7-framework-custom/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/start-event-proxy.mjs
new file mode 100644
index 000000000000..fb8dabc7fcfa
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/start-event-proxy.mjs
@@ -0,0 +1,6 @@
+import { startEventProxyServer } from '@sentry-internal/test-utils';
+
+startEventProxyServer({
+ port: 3031,
+ proxyServerName: 'react-router-7-framework-custom',
+});
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/constants.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/constants.ts
new file mode 100644
index 000000000000..91653303b335
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/constants.ts
@@ -0,0 +1 @@
+export const APP_NAME = 'react-router-7-framework-custom';
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/errors/errors.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/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-custom/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-custom/tests/errors/errors.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/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-custom/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-custom/tests/performance/navigation.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/performance/navigation.client.test.ts
new file mode 100644
index 000000000000..57e3e764d6a8
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/performance/navigation.client.test.ts
@@ -0,0 +1,107 @@
+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';
+ });
+
+ 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': 'url',
+ },
+ 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: 'url' },
+ 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 update navigation transaction for dynamic routes', async ({ page }) => {
+ const txPromise = waitForTransaction(APP_NAME, async transactionEvent => {
+ return transactionEvent.transaction === '/performance/with/:param';
+ });
+
+ 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-7-framework-custom/tests/performance/pageload.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/performance/pageload.client.test.ts
new file mode 100644
index 000000000000..b18ae44e0e71
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/performance/pageload.client.test.ts
@@ -0,0 +1,132 @@
+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' },
+ });
+ });
+
+ test('should update pageload transaction for dynamic routes', async ({ page }) => {
+ const txPromise = waitForTransaction(APP_NAME, async transactionEvent => {
+ return transactionEvent.transaction === '/performance/with/:param';
+ });
+
+ 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.browser',
+ 'sentry.op': 'pageload',
+ 'sentry.source': 'route',
+ },
+ op: 'pageload',
+ origin: 'auto.pageload.browser',
+ },
+ },
+ 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/';
+ });
+
+ 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-custom/tests/performance/performance.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/performance/performance.server.test.ts
new file mode 100644
index 000000000000..abca82a6d938
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/performance/performance.server.test.ts
@@ -0,0 +1,163 @@
+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 => {
+ 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.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),
+ 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.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),
+ 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',
+ },
+ });
+ });
+
+ test('should instrument wrapped server loader', async ({ page }) => {
+ const txPromise = waitForTransaction(APP_NAME, async transactionEvent => {
+ console.log(110, transactionEvent.transaction);
+ return transactionEvent.transaction === 'GET /performance/server-loader';
+ });
+
+ await page.goto(`/performance`);
+ await page.waitForTimeout(500);
+ await page.getByRole('link', { name: 'Server Loader' }).click();
+
+ 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',
+ });
+ });
+
+ test('should instrument a wrapped server action', async ({ page }) => {
+ const txPromise = waitForTransaction(APP_NAME, async transactionEvent => {
+ return transactionEvent.transaction === 'POST /performance/server-action';
+ });
+
+ await page.goto(`/performance/server-action`);
+ await page.getByRole('button', { name: 'Submit' }).click();
+
+ 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-7-framework-custom/tests/performance/trace-propagation.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/performance/trace-propagation.test.ts
new file mode 100644
index 000000000000..7562297b2d4d
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/performance/trace-propagation.test.ts
@@ -0,0 +1,43 @@
+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 *';
+ });
+
+ 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);
+ expect(clientTx.contexts?.trace?.parent_span_id).toBe(serverTx.contexts?.trace?.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-7-framework-custom/tsconfig.json b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tsconfig.json
new file mode 100644
index 000000000000..1b510b528de9
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "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/**/*"],
+ "exclude": ["tests/**/*"]
+}
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/vite.config.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/vite.config.ts
new file mode 100644
index 000000000000..68ba30d69397
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/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/packages/react-router/src/server/index.ts b/packages/react-router/src/server/index.ts
index 67436582aedd..b42b769a78e6 100644
--- a/packages/react-router/src/server/index.ts
+++ b/packages/react-router/src/server/index.ts
@@ -4,3 +4,5 @@ export { init } from './sdk';
// eslint-disable-next-line deprecation/deprecation
export { wrapSentryHandleRequest, sentryHandleRequest, getMetaTagTransformer } from './wrapSentryHandleRequest';
export { createSentryHandleRequest, type SentryHandleRequestOptions } from './createSentryHandleRequest';
+export { wrapServerAction } from './wrapServerAction';
+export { wrapServerLoader } from './wrapServerLoader';
diff --git a/packages/react-router/src/server/wrapServerAction.ts b/packages/react-router/src/server/wrapServerAction.ts
new file mode 100644
index 000000000000..9da0e8d351f8
--- /dev/null
+++ b/packages/react-router/src/server/wrapServerAction.ts
@@ -0,0 +1,70 @@
+import type { SpanAttributes } from '@sentry/core';
+import {
+ getActiveSpan,
+ getRootSpan,
+ parseStringToURLObject,
+ SEMANTIC_ATTRIBUTE_SENTRY_OP,
+ SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
+ SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
+ spanToJSON,
+ startSpan,
+} from '@sentry/core';
+import type { ActionFunctionArgs } from 'react-router';
+import { SEMANTIC_ATTRIBUTE_SENTRY_OVERWRITE } from './instrumentation/util';
+
+type SpanOptions = {
+ name?: string;
+ attributes?: SpanAttributes;
+};
+
+/**
+ * Wraps a React Router server action function with Sentry performance monitoring.
+ * @param options - Optional span configuration options including name, operation, description and attributes
+ * @param actionFn - The server action function to wrap
+ *
+ * @example
+ * ```ts
+ * // Wrap an action function with custom span options
+ * export const action = wrapServerAction(
+ * {
+ * name: 'Submit Form Data',
+ * description: 'Processes form submission data',
+ * },
+ * async ({ request }) => {
+ * // ... your action logic
+ * }
+ * );
+ * ```
+ */
+export function wrapServerAction(options: SpanOptions = {}, actionFn: (args: ActionFunctionArgs) => Promise) {
+ return async function (args: ActionFunctionArgs) {
+ const name = options.name || 'Executing Server Action';
+ const active = getActiveSpan();
+ if (active) {
+ const root = getRootSpan(active);
+ // coming from auto.http.otel.http
+ if (spanToJSON(root).description === 'POST') {
+ const url = parseStringToURLObject(args.request.url);
+ if (url?.pathname) {
+ root.setAttributes({
+ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url',
+ [SEMANTIC_ATTRIBUTE_SENTRY_OVERWRITE]: `${args.request.method} ${url.pathname}`,
+ });
+ }
+ }
+ }
+
+ return startSpan(
+ {
+ name,
+ ...options,
+ attributes: {
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react-router',
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.react-router.action',
+ ...options.attributes,
+ },
+ },
+ () => actionFn(args),
+ );
+ };
+}
diff --git a/packages/react-router/src/server/wrapServerLoader.ts b/packages/react-router/src/server/wrapServerLoader.ts
new file mode 100644
index 000000000000..dda64a1a9204
--- /dev/null
+++ b/packages/react-router/src/server/wrapServerLoader.ts
@@ -0,0 +1,70 @@
+import type { SpanAttributes } from '@sentry/core';
+import {
+ getActiveSpan,
+ getRootSpan,
+ parseStringToURLObject,
+ SEMANTIC_ATTRIBUTE_SENTRY_OP,
+ SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
+ SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
+ spanToJSON,
+ startSpan,
+} from '@sentry/core';
+import type { LoaderFunctionArgs } from 'react-router';
+import { SEMANTIC_ATTRIBUTE_SENTRY_OVERWRITE } from './instrumentation/util';
+
+type SpanOptions = {
+ name?: string;
+ attributes?: SpanAttributes;
+};
+
+/**
+ * Wraps a React Router server loader function with Sentry performance monitoring.
+ * @param options - Optional span configuration options including name, operation, description and attributes
+ * @param loaderFn - The server loader function to wrap
+ *
+ * @example
+ * ```ts
+ * // Wrap a loader function with custom span options
+ * export const loader = wrapServerLoader(
+ * {
+ * name: 'Load Some Data',
+ * description: 'Loads some data from the db',
+ * },
+ * async ({ params }) => {
+ * // ... your loader logic
+ * }
+ * );
+ * ```
+ */
+export function wrapServerLoader(options: SpanOptions = {}, loaderFn: (args: LoaderFunctionArgs) => Promise) {
+ return async function (args: LoaderFunctionArgs) {
+ const name = options.name || 'Executing Server Loader';
+ const active = getActiveSpan();
+ if (active) {
+ const root = getRootSpan(active);
+ // coming from auto.http.otel.http
+ if (spanToJSON(root).description === 'GET') {
+ const url = parseStringToURLObject(args.request.url);
+
+ if (url?.pathname) {
+ root.setAttributes({
+ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url',
+ [SEMANTIC_ATTRIBUTE_SENTRY_OVERWRITE]: `${args.request.method} ${url.pathname}`,
+ });
+ }
+ }
+ }
+ return startSpan(
+ {
+ name,
+ ...options,
+ attributes: {
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react-router',
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.react-router.loader',
+ ...options.attributes,
+ },
+ },
+ () => loaderFn(args),
+ );
+ };
+}
diff --git a/packages/react-router/test/server/wrapServerAction.test.ts b/packages/react-router/test/server/wrapServerAction.test.ts
new file mode 100644
index 000000000000..931e4c72b446
--- /dev/null
+++ b/packages/react-router/test/server/wrapServerAction.test.ts
@@ -0,0 +1,60 @@
+import * as core from '@sentry/core';
+import type { ActionFunctionArgs } from 'react-router';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { wrapServerAction } from '../../src/server/wrapServerAction';
+
+describe('wrapServerAction', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('should wrap an action function with default options', async () => {
+ const mockActionFn = vi.fn().mockResolvedValue('result');
+ const mockArgs = { request: new Request('http://test.com') } as ActionFunctionArgs;
+
+ const spy = vi.spyOn(core, 'startSpan');
+ const wrappedAction = wrapServerAction({}, mockActionFn);
+ await wrappedAction(mockArgs);
+
+ expect(spy).toHaveBeenCalledWith(
+ {
+ name: 'Executing Server Action',
+ attributes: {
+ [core.SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react-router',
+ [core.SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.react-router.action',
+ },
+ },
+ expect.any(Function),
+ );
+ expect(mockActionFn).toHaveBeenCalledWith(mockArgs);
+ });
+
+ it('should wrap an action function with custom options', async () => {
+ const customOptions = {
+ name: 'Custom Action',
+ attributes: {
+ 'sentry.custom': 'value',
+ },
+ };
+
+ const mockActionFn = vi.fn().mockResolvedValue('result');
+ const mockArgs = { request: new Request('http://test.com') } as ActionFunctionArgs;
+
+ const spy = vi.spyOn(core, 'startSpan');
+ const wrappedAction = wrapServerAction(customOptions, mockActionFn);
+ await wrappedAction(mockArgs);
+
+ expect(spy).toHaveBeenCalledWith(
+ {
+ name: 'Custom Action',
+ attributes: {
+ [core.SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react-router',
+ [core.SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.react-router.action',
+ 'sentry.custom': 'value',
+ },
+ },
+ expect.any(Function),
+ );
+ expect(mockActionFn).toHaveBeenCalledWith(mockArgs);
+ });
+});
diff --git a/packages/react-router/test/server/wrapServerLoader.test.ts b/packages/react-router/test/server/wrapServerLoader.test.ts
new file mode 100644
index 000000000000..53fce752286b
--- /dev/null
+++ b/packages/react-router/test/server/wrapServerLoader.test.ts
@@ -0,0 +1,60 @@
+import * as core from '@sentry/core';
+import type { LoaderFunctionArgs } from 'react-router';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { wrapServerLoader } from '../../src/server/wrapServerLoader';
+
+describe('wrapServerLoader', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('should wrap a loader function with default options', async () => {
+ const mockLoaderFn = vi.fn().mockResolvedValue('result');
+ const mockArgs = { request: new Request('http://test.com') } as LoaderFunctionArgs;
+
+ const spy = vi.spyOn(core, 'startSpan');
+ const wrappedLoader = wrapServerLoader({}, mockLoaderFn);
+ await wrappedLoader(mockArgs);
+
+ expect(spy).toHaveBeenCalledWith(
+ {
+ name: 'Executing Server Loader',
+ attributes: {
+ [core.SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react-router',
+ [core.SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.react-router.loader',
+ },
+ },
+ expect.any(Function),
+ );
+ expect(mockLoaderFn).toHaveBeenCalledWith(mockArgs);
+ });
+
+ it('should wrap a loader function with custom options', async () => {
+ const customOptions = {
+ name: 'Custom Loader',
+ attributes: {
+ 'sentry.custom': 'value',
+ },
+ };
+
+ const mockLoaderFn = vi.fn().mockResolvedValue('result');
+ const mockArgs = { request: new Request('http://test.com') } as LoaderFunctionArgs;
+
+ const spy = vi.spyOn(core, 'startSpan');
+ const wrappedLoader = wrapServerLoader(customOptions, mockLoaderFn);
+ await wrappedLoader(mockArgs);
+
+ expect(spy).toHaveBeenCalledWith(
+ {
+ name: 'Custom Loader',
+ attributes: {
+ [core.SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react-router',
+ [core.SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.react-router.loader',
+ 'sentry.custom': 'value',
+ },
+ },
+ expect.any(Function),
+ );
+ expect(mockLoaderFn).toHaveBeenCalledWith(mockArgs);
+ });
+});