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..477336455452 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,8 @@ 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('other-middleware', 'routes/performance/other-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..4b43ad619901
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes/performance/multi-middleware.tsx
@@ -0,0 +1,34 @@
+import type { Route } from './+types/multi-middleware';
+
+// Multiple middleware functions to test index tracking
+// Using unique names to avoid bundler renaming due to collisions with other routes
+export const middleware: Route.MiddlewareFunction[] = [
+ async function multiAuthMiddleware({ context }, next) {
+ (context as any).auth = true;
+ const response = await next();
+ return response;
+ },
+ async function multiLoggingMiddleware({ context }, next) {
+ (context as any).logged = true;
+ const response = await next();
+ return response;
+ },
+ async function multiValidationMiddleware({ context }, next) {
+ (context as any).validated = true;
+ const response = await next();
+ return response;
+ },
+];
+
+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/app/routes/performance/other-middleware.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes/performance/other-middleware.tsx
new file mode 100644
index 000000000000..7f68cd35e314
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes/performance/other-middleware.tsx
@@ -0,0 +1,23 @@
+import type { Route } from './+types/other-middleware';
+
+// Different middleware to test isolation between routes
+export const middleware: Route.MiddlewareFunction[] = [
+ async function rateLimitMiddleware({ context }, next) {
+ (context as any).rateLimited = false;
+ const response = await next();
+ return response;
+ },
+];
+
+export function loader() {
+ return { message: 'Other middleware route loaded' };
+}
+
+export default function OtherMiddlewarePage() {
+ return (
+
+
Other Middleware Route
+
This route has a different middleware
+
+ );
+}
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..ff9bec3bedd9 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,6 +2,27 @@ import { expect, test } from '@playwright/test';
import { waitForTransaction } from '@sentry-internal/test-utils';
import { APP_NAME } from '../constants';
+interface SpanData {
+ 'sentry.op'?: string;
+ 'sentry.origin'?: string;
+ 'react_router.route.id'?: string;
+ 'react_router.route.pattern'?: string;
+ 'react_router.middleware.name'?: string;
+ 'react_router.middleware.index'?: number;
+}
+
+interface Span {
+ span_id?: string;
+ trace_id?: string;
+ data?: SpanData;
+ description?: string;
+ parent_span_id?: string;
+ start_timestamp?: number;
+ timestamp?: number;
+ op?: string;
+ origin?: string;
+}
+
// 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', () => {
@@ -40,7 +61,7 @@ test.describe('server - instrumentation API middleware', () => {
// Find the middleware span
const middlewareSpan = transaction?.spans?.find(
- (span: { data?: { 'sentry.op'?: string } }) => span.data?.['sentry.op'] === 'function.react_router.middleware',
+ (span: Span) => span.data?.['sentry.op'] === 'function.react_router.middleware',
);
expect(middlewareSpan).toMatchObject({
@@ -49,8 +70,12 @@ test.describe('server - instrumentation API middleware', () => {
data: {
'sentry.origin': 'auto.function.react_router.instrumentation_api',
'sentry.op': 'function.react_router.middleware',
+ 'react_router.route.id': 'routes/performance/with-middleware',
+ 'react_router.route.pattern': '/performance/with-middleware',
+ 'react_router.middleware.name': 'authMiddleware',
+ 'react_router.middleware.index': 0,
},
- description: '/performance/with-middleware',
+ description: 'middleware authMiddleware',
parent_span_id: expect.any(String),
start_timestamp: expect.any(Number),
timestamp: expect.any(Number),
@@ -69,17 +94,168 @@ test.describe('server - instrumentation API middleware', () => {
const transaction = await txPromise;
const middlewareSpan = transaction?.spans?.find(
- (span: { data?: { 'sentry.op'?: string } }) => span.data?.['sentry.op'] === 'function.react_router.middleware',
+ (span: Span) => span.data?.['sentry.op'] === 'function.react_router.middleware',
);
const loaderSpan = transaction?.spans?.find(
- (span: { data?: { 'sentry.op'?: string } }) => span.data?.['sentry.op'] === 'function.react_router.loader',
+ (span: Span) => span.data?.['sentry.op'] === 'function.react_router.loader',
);
expect(middlewareSpan).toBeDefined();
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 and names', 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;
+
+ // Verify the page rendered
+ await expect(page.locator('#multi-middleware-title')).toBeVisible();
+ await expect(page.locator('#multi-middleware-content')).toHaveText('This route has 3 middlewares');
+
+ // Find all middleware spans
+ const middlewareSpans = transaction?.spans?.filter(
+ (span: Span) => span.data?.['sentry.op'] === 'function.react_router.middleware',
+ );
+
+ expect(middlewareSpans).toHaveLength(3);
+
+ // Sort by index to ensure correct order
+ const sortedSpans = [...middlewareSpans!].sort(
+ (a: Span, b: Span) =>
+ (a.data?.['react_router.middleware.index'] ?? 0) - (b.data?.['react_router.middleware.index'] ?? 0),
+ );
+
+ // First middleware: multiAuthMiddleware (index 0)
+ expect(sortedSpans[0]).toMatchObject({
+ data: expect.objectContaining({
+ 'sentry.op': 'function.react_router.middleware',
+ 'react_router.route.id': 'routes/performance/multi-middleware',
+ 'react_router.route.pattern': '/performance/multi-middleware',
+ 'react_router.middleware.name': 'multiAuthMiddleware',
+ 'react_router.middleware.index': 0,
+ }),
+ description: 'middleware multiAuthMiddleware',
+ });
+
+ // Second middleware: multiLoggingMiddleware (index 1)
+ expect(sortedSpans[1]).toMatchObject({
+ data: expect.objectContaining({
+ 'sentry.op': 'function.react_router.middleware',
+ 'react_router.route.id': 'routes/performance/multi-middleware',
+ 'react_router.route.pattern': '/performance/multi-middleware',
+ 'react_router.middleware.name': 'multiLoggingMiddleware',
+ 'react_router.middleware.index': 1,
+ }),
+ description: 'middleware multiLoggingMiddleware',
+ });
+
+ // Third middleware: multiValidationMiddleware (index 2)
+ expect(sortedSpans[2]).toMatchObject({
+ data: expect.objectContaining({
+ 'sentry.op': 'function.react_router.middleware',
+ 'react_router.route.id': 'routes/performance/multi-middleware',
+ 'react_router.route.pattern': '/performance/multi-middleware',
+ 'react_router.middleware.name': 'multiValidationMiddleware',
+ 'react_router.middleware.index': 2,
+ }),
+ description: 'middleware multiValidationMiddleware',
+ });
+
+ // Verify execution order: middleware spans should be sequential
+ expect(sortedSpans[0]!.start_timestamp).toBeLessThanOrEqual(sortedSpans[1]!.start_timestamp!);
+ expect(sortedSpans[1]!.start_timestamp).toBeLessThanOrEqual(sortedSpans[2]!.start_timestamp!);
+ });
+
+ test('should isolate middleware indices between different routes', async ({ page }) => {
+ // First visit the route with different middleware
+ const txPromise1 = waitForTransaction(APP_NAME, async transactionEvent => {
+ return transactionEvent.transaction === 'GET /performance/other-middleware';
+ });
+
+ await page.goto(`/performance/other-middleware`);
+
+ const transaction1 = await txPromise1;
+
+ // Verify the page rendered
+ await expect(page.locator('#other-middleware-title')).toBeVisible();
+
+ // Find the middleware span
+ const middlewareSpan1 = transaction1?.spans?.find(
+ (span: Span) => span.data?.['sentry.op'] === 'function.react_router.middleware',
+ );
+
+ // The other route should have its own middleware with index 0
+ expect(middlewareSpan1).toMatchObject({
+ data: expect.objectContaining({
+ 'sentry.op': 'function.react_router.middleware',
+ 'react_router.route.id': 'routes/performance/other-middleware',
+ 'react_router.route.pattern': '/performance/other-middleware',
+ 'react_router.middleware.name': 'rateLimitMiddleware',
+ 'react_router.middleware.index': 0,
+ }),
+ description: 'middleware rateLimitMiddleware',
+ });
+
+ // Now visit the multi-middleware route
+ const txPromise2 = waitForTransaction(APP_NAME, async transactionEvent => {
+ return transactionEvent.transaction === 'GET /performance/multi-middleware';
+ });
+
+ await page.goto(`/performance/multi-middleware`);
+
+ const transaction2 = await txPromise2;
+
+ // Find all middleware spans
+ const middlewareSpans2 = transaction2?.spans?.filter(
+ (span: Span) => span.data?.['sentry.op'] === 'function.react_router.middleware',
+ );
+
+ // Should have 3 middleware spans with indices 0, 1, 2 (isolated from previous route)
+ expect(middlewareSpans2).toHaveLength(3);
+
+ const indices = middlewareSpans2!.map((span: Span) => span.data?.['react_router.middleware.index']).sort();
+ expect(indices).toEqual([0, 1, 2]);
+ });
+
+ test('should handle visiting same multi-middleware route twice with fresh indices', async ({ page }) => {
+ // First visit
+ const txPromise1 = waitForTransaction(APP_NAME, async transactionEvent => {
+ return transactionEvent.transaction === 'GET /performance/multi-middleware';
+ });
+
+ await page.goto(`/performance/multi-middleware`);
+ await txPromise1;
+
+ // Second visit - indices should reset
+ const txPromise2 = waitForTransaction(APP_NAME, async transactionEvent => {
+ return transactionEvent.transaction === 'GET /performance/multi-middleware';
+ });
+
+ await page.goto(`/performance/multi-middleware`);
+
+ const transaction2 = await txPromise2;
+
+ const middlewareSpans = transaction2?.spans?.filter(
+ (span: Span) => span.data?.['sentry.op'] === 'function.react_router.middleware',
+ );
+
+ expect(middlewareSpans).toHaveLength(3);
+
+ // Indices should be 0, 1, 2 (reset for new request)
+ const indices = middlewareSpans!.map((span: Span) => span.data?.['react_router.middleware.index']).sort();
+ expect(indices).toEqual([0, 1, 2]);
+
+ // Names should still be correct
+ const names = middlewareSpans!.map((span: Span) => span.data?.['react_router.middleware.name']).sort();
+ expect(names).toEqual(['multiAuthMiddleware', 'multiLoggingMiddleware', 'multiValidationMiddleware']);
});
});
diff --git a/packages/react-router/src/client/createClientInstrumentation.ts b/packages/react-router/src/client/createClientInstrumentation.ts
index c465a25dd662..888f9e879416 100644
--- a/packages/react-router/src/client/createClientInstrumentation.ts
+++ b/packages/react-router/src/client/createClientInstrumentation.ts
@@ -19,6 +19,10 @@ 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;
+// Tracks middleware execution index per route, keyed by Request object.
+// Uses WeakMap to isolate counters per navigation and allow GC of cancelled navigations.
+const middlewareCountersMap = new WeakMap