diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/.gitignore b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/.gitignore
new file mode 100644
index 000000000000..ebb991370034
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/.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-instrumentation-dev/app/app.css b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/app/app.css
new file mode 100644
index 000000000000..e78d2096ad20
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/app/app.css
@@ -0,0 +1,5 @@
+body {
+ font-family: system-ui, sans-serif;
+ margin: 0;
+ padding: 20px;
+}
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/app/entry.client.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/app/entry.client.tsx
new file mode 100644
index 000000000000..97539b6fd7cd
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/app/entry.client.tsx
@@ -0,0 +1,33 @@
+import * as Sentry from '@sentry/react-router';
+import { StrictMode, startTransition } from 'react';
+import { hydrateRoot } from 'react-dom/client';
+import { HydratedRouter } from 'react-router/dom';
+
+// Create the tracing integration with useInstrumentationAPI enabled
+// This must be set BEFORE Sentry.init() to prepare the instrumentation
+const tracing = Sentry.reactRouterTracingIntegration({ useInstrumentationAPI: true });
+
+Sentry.init({
+ environment: 'qa', // dynamic sampling bias to keep transactions
+ dsn: 'https://username@domain/123',
+ tunnel: `http://localhost:3031/`, // proxy server
+ integrations: [tracing],
+ tracesSampleRate: 1.0,
+ tracePropagationTargets: [/^\//],
+});
+
+// Get the client instrumentation from the Sentry integration.
+// As of React Router 7.15+, HydratedRouter invokes the client instrumentation hooks (`navigate`
+// and `fetch`) in Framework Mode, so navigation and fetcher spans are created via the
+// instrumentation API (origin `auto.*.react_router.instrumentation_api`). The legacy
+// instrumentHydratedRouter() subscribe still runs to parameterize navigation span names.
+const sentryClientInstrumentation = [tracing.clientInstrumentation];
+
+startTransition(() => {
+ hydrateRoot(
+ document,
+
+
+ ,
+ );
+});
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/app/entry.server.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/app/entry.server.tsx
new file mode 100644
index 000000000000..178a8ed4e377
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/app/entry.server.tsx
@@ -0,0 +1,20 @@
+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 });
+
+export const instrumentations = [Sentry.createSentryServerInstrumentation()];
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/app/root.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/app/root.tsx
new file mode 100644
index 000000000000..bc1b8f1236c0
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/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-7-framework-instrumentation-dev/app/routes.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/app/routes.ts
new file mode 100644
index 000000000000..48c31488de4a
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/app/routes.ts
@@ -0,0 +1,6 @@
+import { type RouteConfig, index, prefix, route } from '@react-router/dev/routes';
+
+export default [
+ index('routes/home.tsx'),
+ ...prefix('performance', [route('with-middleware', 'routes/performance/with-middleware.tsx')]),
+] satisfies RouteConfig;
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/app/routes/home.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/app/routes/home.tsx
new file mode 100644
index 000000000000..7812a9c500d1
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/app/routes/home.tsx
@@ -0,0 +1,10 @@
+export function meta() {
+ return [
+ { title: 'React Router Instrumentation API Test' },
+ { name: 'description', content: 'Testing React Router instrumentation API' },
+ ];
+}
+
+export default function Home() {
+ return home
;
+}
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/app/routes/performance/with-middleware.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/app/routes/performance/with-middleware.tsx
new file mode 100644
index 000000000000..ed4f4713d7b6
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/app/routes/performance/with-middleware.tsx
@@ -0,0 +1,30 @@
+import type { Route } from './+types/with-middleware';
+
+// Middleware runs before loaders/actions on matching routes
+// With future.v8_middleware enabled, we export 'middleware' (not 'unstable_middleware')
+export const middleware: Route.MiddlewareFunction[] = [
+ async function authMiddleware({ context }, next) {
+ // Code runs BEFORE handlers
+ // Type assertion to allow setting custom properties on context
+ (context as any).middlewareCalled = true;
+
+ // Must call next() and return the response
+ const response = await next();
+
+ // Code runs AFTER handlers (can modify response headers here)
+ return response;
+ },
+];
+
+export function loader() {
+ return { message: 'Middleware route loaded' };
+}
+
+export default function WithMiddlewarePage() {
+ return (
+
+
Middleware Route
+
This route has middleware
+
+ );
+}
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/instrument.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/instrument.mjs
new file mode 100644
index 000000000000..bb1dad2e5da9
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/instrument.mjs
@@ -0,0 +1,10 @@
+import * as Sentry from '@sentry/react-router';
+
+// Initialize Sentry early (before the server starts)
+// The server instrumentations are created in entry.server.tsx
+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-7-framework-instrumentation-dev/package.json b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/package.json
new file mode 100644
index 000000000000..82ddbc00177f
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/package.json
@@ -0,0 +1,59 @@
+{
+ "name": "react-router-7-framework-instrumentation-dev",
+ "version": "0.1.0",
+ "type": "module",
+ "private": true,
+ "dependencies": {
+ "@react-router/node": "^7",
+ "@react-router/serve": "^7",
+ "@sentry/react-router": "file:../../packed/sentry-react-router-packed.tgz",
+ "isbot": "^5.1.17",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "react-router": "^7"
+ },
+ "devDependencies": {
+ "@playwright/test": "~1.56.0",
+ "@react-router/dev": "^7",
+ "@sentry-internal/test-utils": "link:../../../test-utils",
+ "@types/node": "^20",
+ "@types/react": "18.3.1",
+ "@types/react-dom": "18.3.1",
+ "typescript": "^5.6.3",
+ "vite": "^5.4.11"
+ },
+ "scripts": {
+ "dev": "NODE_OPTIONS='--import ./instrument.mjs' react-router dev --port 3030",
+ "proxy": "node start-event-proxy.mjs",
+ "typecheck": "react-router typegen && tsc",
+ "clean": "npx rimraf node_modules pnpm-lock.yaml",
+ "test:build": "pnpm install",
+ "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"
+ },
+ "sentryTest": {
+ "optional": true
+ }
+}
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/playwright.config.mjs
new file mode 100644
index 000000000000..c1926d884722
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/playwright.config.mjs
@@ -0,0 +1,11 @@
+import { getPlaywrightConfig } from '@sentry-internal/test-utils';
+
+// This app runs the React Router dev server (`react-router dev`) instead of a production build, so
+// we can verify that server build capture (used for middleware name resolution) works in dev mode.
+const config = getPlaywrightConfig({
+ // `react-router dev` runs the Vite dev server, which ignores PORT - pass the port to the script.
+ startCommand: `pnpm dev`,
+ port: 3030,
+});
+
+export default config;
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/react-router.config.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/react-router.config.ts
new file mode 100644
index 000000000000..b5f863f6052a
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/react-router.config.ts
@@ -0,0 +1,8 @@
+import type { Config } from '@react-router/dev/config';
+
+export default {
+ ssr: true,
+ future: {
+ v8_middleware: true,
+ },
+} satisfies Config;
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/start-event-proxy.mjs
new file mode 100644
index 000000000000..007f80b1aaf5
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/start-event-proxy.mjs
@@ -0,0 +1,6 @@
+import { startEventProxyServer } from '@sentry-internal/test-utils';
+
+startEventProxyServer({
+ port: 3031,
+ proxyServerName: 'react-router-7-framework-instrumentation-dev',
+});
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/tests/constants.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/tests/constants.ts
new file mode 100644
index 000000000000..347234254c25
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/tests/constants.ts
@@ -0,0 +1 @@
+export const APP_NAME = 'react-router-7-framework-instrumentation-dev';
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/tests/performance/middleware.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/tests/performance/middleware.server.test.ts
new file mode 100644
index 000000000000..38f6a014b4ca
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/tests/performance/middleware.server.test.ts
@@ -0,0 +1,27 @@
+import { expect, test } from '@playwright/test';
+import { waitForTransaction } from '@sentry-internal/test-utils';
+import { APP_NAME } from '../constants';
+
+test.describe('server - instrumentation API middleware (dev mode)', () => {
+ test('resolves the middleware name from the server build captured in dev mode', async ({ page }) => {
+ const txPromise = waitForTransaction(APP_NAME, async transactionEvent => {
+ return transactionEvent.transaction === 'GET /performance/with-middleware';
+ });
+
+ await page.goto(`/performance/with-middleware`);
+
+ const transaction = await txPromise;
+
+ const middlewareSpan = transaction?.spans?.find(
+ (span: { data?: { 'sentry.op'?: string } }) => span.data?.['sentry.op'] === 'function.react_router.middleware',
+ );
+
+ expect(middlewareSpan).toBeDefined();
+
+ // The middleware name is only available when the server build was captured. In dev mode this
+ // relies on the Vite plugin injecting the capture snippet for the SSR module (see
+ // makeServerBuildCapturePlugin). Without it, the span would fall back to `middleware `.
+ expect(middlewareSpan!.data?.['react_router.middleware.name']).toBe('authMiddleware');
+ expect(middlewareSpan!.description).toBe('middleware authMiddleware');
+ });
+});
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/tsconfig.json b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/tsconfig.json
new file mode 100644
index 000000000000..a16df276e8bc
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/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-7-framework-instrumentation-dev/vite.config.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/vite.config.ts
new file mode 100644
index 000000000000..4da306d41cc7
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/vite.config.ts
@@ -0,0 +1,11 @@
+import { reactRouter } from '@react-router/dev/vite';
+import { sentryReactRouter } from '@sentry/react-router';
+import { defineConfig } from 'vite';
+
+export default defineConfig(async config => ({
+ plugins: [
+ reactRouter(),
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ ...((await sentryReactRouter({ sourcemaps: { disable: true } }, config)) as any[]),
+ ],
+}));
diff --git a/packages/react-router/src/vite/makeServerBuildCapturePlugin.ts b/packages/react-router/src/vite/makeServerBuildCapturePlugin.ts
index d95129a491ff..b22fc715d782 100644
--- a/packages/react-router/src/vite/makeServerBuildCapturePlugin.ts
+++ b/packages/react-router/src/vite/makeServerBuildCapturePlugin.ts
@@ -7,21 +7,15 @@ const SERVER_BUILD_MODULE_ID = 'virtual:react-router/server-build';
* A Sentry plugin for React Router to capture the server build for middleware name resolution.
*/
export function makeServerBuildCapturePlugin(): Plugin {
- let isSsrBuild = false;
-
return {
name: 'sentry-react-router-server-build-capture',
enforce: 'post',
- configResolved(config) {
- isSsrBuild = !!config.build.ssr;
- },
-
- transform(code, id) {
- // TODO: This only captures the server build for production SSR builds. Dev mode
- // (`react-router dev`) is not covered yet, so middleware names may be missing there - this
- // should be handled for dev too.
- if (!isSsrBuild) {
+ transform(code, id, options) {
+ // Only inject into the server build module when transforming for the SSR environment.
+ // `options.ssr` is set for the SSR module in both dev (`react-router dev`) and production
+ // builds, so this covers both - unlike `config.build.ssr`, which is only true during a build.
+ if (!options?.ssr) {
return null;
}
diff --git a/packages/react-router/test/vite/makeServerBuildCapturePlugin.test.ts b/packages/react-router/test/vite/makeServerBuildCapturePlugin.test.ts
index f76090b60f56..ab77da695dc0 100644
--- a/packages/react-router/test/vite/makeServerBuildCapturePlugin.test.ts
+++ b/packages/react-router/test/vite/makeServerBuildCapturePlugin.test.ts
@@ -11,9 +11,16 @@ describe('makeServerBuildCapturePlugin', () => {
expect(plugin.enforce).toBe('post');
});
- it('should return null for non-SSR builds', () => {
+ it('should return null for non-SSR transforms', () => {
+ const plugin = makeServerBuildCapturePlugin();
+
+ const result = (plugin as any).transform(SERVER_BUILD_CODE, SERVER_BUILD_MODULE_ID, { ssr: false });
+
+ expect(result).toBeNull();
+ });
+
+ it('should return null when no options are passed', () => {
const plugin = makeServerBuildCapturePlugin();
- (plugin as any).configResolved({ build: { ssr: false } });
const result = (plugin as any).transform(SERVER_BUILD_CODE, SERVER_BUILD_MODULE_ID);
@@ -22,18 +29,18 @@ describe('makeServerBuildCapturePlugin', () => {
it('should return null for non-server-build modules in SSR mode', () => {
const plugin = makeServerBuildCapturePlugin();
- (plugin as any).configResolved({ build: { ssr: true } });
- const result = (plugin as any).transform('export function helper() {}', 'src/utils.ts');
+ const result = (plugin as any).transform('export function helper() {}', 'src/utils.ts', { ssr: true });
expect(result).toBeNull();
});
- it('should inject capture snippet into the server build module in SSR mode', () => {
+ // `options.ssr` is true for the SSR module in both dev (`react-router dev`) and production builds,
+ // so a single assertion covers both cases - the plugin cannot (and need not) distinguish them.
+ it('should inject capture snippet into the server build module for SSR transforms', () => {
const plugin = makeServerBuildCapturePlugin();
- (plugin as any).configResolved({ build: { ssr: true } });
- const result = (plugin as any).transform(SERVER_BUILD_CODE, SERVER_BUILD_MODULE_ID);
+ const result = (plugin as any).transform(SERVER_BUILD_CODE, SERVER_BUILD_MODULE_ID, { ssr: true });
expect(result).not.toBeNull();
expect(result.code).toContain(SERVER_BUILD_CODE);