diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes.ts
index 6bd5b27264eb..f0c389733cfa 100644
--- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes.ts
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes.ts
@@ -10,6 +10,7 @@ export default [
route('server-loader', 'routes/performance/server-loader.tsx'),
route('server-action', 'routes/performance/server-action.tsx'),
route('with-middleware', 'routes/performance/with-middleware.tsx'),
+ route('multi-middleware', 'routes/performance/multi-middleware.tsx'),
route('error-loader', 'routes/performance/error-loader.tsx'),
route('error-action', 'routes/performance/error-action.tsx'),
route('error-middleware', 'routes/performance/error-middleware.tsx'),
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes/performance/multi-middleware.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes/performance/multi-middleware.tsx
new file mode 100644
index 000000000000..1ad5947d8bc9
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes/performance/multi-middleware.tsx
@@ -0,0 +1,26 @@
+import type { Route } from './+types/multi-middleware';
+
+export const middleware: Route.MiddlewareFunction[] = [
+ async function authMiddleware(_args, next) {
+ return next();
+ },
+ async function loggingMiddleware(_args, next) {
+ return next();
+ },
+ async function validationMiddleware(_args, next) {
+ return next();
+ },
+];
+
+export function loader() {
+ return { message: 'Multi-middleware route loaded' };
+}
+
+export default function MultiMiddlewarePage() {
+ return (
+
+
Multi Middleware Route
+
This route has 3 middlewares
+
+ );
+}
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/middleware.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/middleware.server.test.ts
index e99a58a7f57c..c0c2248014a8 100644
--- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/middleware.server.test.ts
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/middleware.server.test.ts
@@ -2,8 +2,6 @@ import { expect, test } from '@playwright/test';
import { waitForTransaction } from '@sentry-internal/test-utils';
import { APP_NAME } from '../constants';
-// Note: React Router middleware instrumentation now works in Framework Mode.
-// Previously this was a known limitation (see: https://github.com/remix-run/react-router/discussions/12950)
test.describe('server - instrumentation API middleware', () => {
test('should instrument server middleware with instrumentation API origin', async ({ page }) => {
const txPromise = waitForTransaction(APP_NAME, async transactionEvent => {
@@ -43,20 +41,27 @@ test.describe('server - instrumentation API middleware', () => {
(span: { data?: { 'sentry.op'?: string } }) => span.data?.['sentry.op'] === 'function.react_router.middleware',
);
+ expect(middlewareSpan).toBeDefined();
expect(middlewareSpan).toMatchObject({
span_id: expect.any(String),
trace_id: expect.any(String),
- data: {
+ data: expect.objectContaining({
'sentry.origin': 'auto.function.react_router.instrumentation_api',
'sentry.op': 'function.react_router.middleware',
- },
- description: '/performance/with-middleware',
+ 'react_router.route.id': 'routes/performance/with-middleware',
+ 'react_router.route.pattern': '/performance/with-middleware',
+ 'react_router.middleware.index': 0,
+ }),
parent_span_id: expect.any(String),
start_timestamp: expect.any(Number),
timestamp: expect.any(Number),
op: 'function.react_router.middleware',
origin: 'auto.function.react_router.instrumentation_api',
});
+
+ // Middleware name is available via OTEL patching of createRequestHandler
+ expect(middlewareSpan!.data?.['react_router.middleware.name']).toBe('authMiddleware');
+ expect(middlewareSpan!.description).toBe('middleware authMiddleware');
});
test('should have middleware span run before loader span', async ({ page }) => {
@@ -80,6 +85,37 @@ test.describe('server - instrumentation API middleware', () => {
expect(loaderSpan).toBeDefined();
// Middleware should start before loader
- expect(middlewareSpan!.start_timestamp).toBeLessThanOrEqual(loaderSpan!.start_timestamp);
+ expect(middlewareSpan!.start_timestamp).toBeLessThanOrEqual(loaderSpan!.start_timestamp!);
+ });
+
+ test('should track multiple middlewares with correct indices', async ({ page }) => {
+ const txPromise = waitForTransaction(APP_NAME, async transactionEvent => {
+ return transactionEvent.transaction === 'GET /performance/multi-middleware';
+ });
+
+ await page.goto(`/performance/multi-middleware`);
+
+ const transaction = await txPromise;
+
+ await expect(page.locator('#multi-middleware-title')).toBeVisible();
+ await expect(page.locator('#multi-middleware-content')).toHaveText('This route has 3 middlewares');
+
+ const middlewareSpans = transaction?.spans?.filter(
+ (span: { data?: { 'sentry.op'?: string } }) => span.data?.['sentry.op'] === 'function.react_router.middleware',
+ );
+
+ expect(middlewareSpans).toHaveLength(3);
+
+ const sortedSpans = [...middlewareSpans!].sort(
+ (a: any, b: any) =>
+ (a.data?.['react_router.middleware.index'] ?? 0) - (b.data?.['react_router.middleware.index'] ?? 0),
+ );
+
+ expect(sortedSpans.map((s: any) => s.data?.['react_router.middleware.index'])).toEqual([0, 1, 2]);
+ expect(sortedSpans.map((s: any) => s.data?.['react_router.middleware.name'])).toEqual([
+ 'authMiddleware',
+ 'loggingMiddleware',
+ 'validationMiddleware',
+ ]);
});
});
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/vite.config.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/vite.config.ts
index 68ba30d69397..4da306d41cc7 100644
--- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/vite.config.ts
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/vite.config.ts
@@ -1,6 +1,11 @@
import { reactRouter } from '@react-router/dev/vite';
+import { sentryReactRouter } from '@sentry/react-router';
import { defineConfig } from 'vite';
-export default defineConfig({
- plugins: [reactRouter()],
-});
+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/client/createClientInstrumentation.ts b/packages/react-router/src/client/createClientInstrumentation.ts
index c465a25dd662..a200ac2fdff5 100644
--- a/packages/react-router/src/client/createClientInstrumentation.ts
+++ b/packages/react-router/src/client/createClientInstrumentation.ts
@@ -19,6 +19,9 @@ const WINDOW = GLOBAL_OBJ as typeof GLOBAL_OBJ & Window;
// Tracks active numeric navigation span to prevent duplicate spans when popstate fires
let currentNumericNavigationSpan: Span | undefined;
+// Per-request middleware counters, keyed by Request
+const middlewareCountersMap = new WeakMap