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/instrument.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/instrument.mjs
index bb1dad2e5da9..0465d1516529 100644
--- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/instrument.mjs
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/instrument.mjs
@@ -1,3 +1,8 @@
+// Register ESM hooks before importing any other modules.
+// This is required on Node 20.19+ and 22.12+ for OTEL module patching to work.
+// Without this, middleware function names won't be captured.
+import '@sentry/react-router/loader';
+
import * as Sentry from '@sentry/react-router';
// Initialize Sentry early (before the server starts)
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..f13c9dd5227c 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,28 @@ 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)
+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;
+}
+
+// Middleware names require ESM loader hook on Node 20.19+/22.12+ - see instrument.mjs for setup.
test.describe('server - instrumentation API middleware', () => {
test('should instrument server middleware with instrumentation API origin', async ({ page }) => {
const txPromise = waitForTransaction(APP_NAME, async transactionEvent => {
@@ -40,23 +60,30 @@ 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).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 }) => {
@@ -69,17 +96,162 @@ 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', 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 (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.index': 0,
+ }),
+ });
+
+ // Second middleware (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.index': 1,
+ }),
+ });
+
+ // Third middleware (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.index': 2,
+ }),
+ });
+
+ // 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!);
+
+ // Verify middleware names are correctly resolved via OTEL patching
+ expect(sortedSpans[0]!.data?.['react_router.middleware.name']).toBe('multiAuthMiddleware');
+ expect(sortedSpans[1]!.data?.['react_router.middleware.name']).toBe('multiLoggingMiddleware');
+ expect(sortedSpans[2]!.data?.['react_router.middleware.name']).toBe('multiValidationMiddleware');
+ });
+
+ // Note: Remaining tests focus on index tracking. Name resolution is verified above.
+ 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.index': 0,
+ }),
+ });
+
+ // 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]);
});
});
diff --git a/dev-packages/rollup-utils/code/sentryNodeEsmLoaderHookTemplate.js b/dev-packages/rollup-utils/code/sentryNodeEsmLoaderHookTemplate.js
index d5167fd15bf9..84f636c923f5 100644
--- a/dev-packages/rollup-utils/code/sentryNodeEsmLoaderHookTemplate.js
+++ b/dev-packages/rollup-utils/code/sentryNodeEsmLoaderHookTemplate.js
@@ -1 +1 @@
-import '@sentry/node/import-hook';
+import '@sentry/node/import';
diff --git a/packages/react-router/package.json b/packages/react-router/package.json
index 7ab6dd30b03b..fe46288657b4 100644
--- a/packages/react-router/package.json
+++ b/packages/react-router/package.json
@@ -39,6 +39,16 @@
"require": "./build/cjs/cloudflare/index.js",
"types": "./build/types/cloudflare/index.d.ts",
"default": "./build/esm/cloudflare/index.js"
+ },
+ "./import": {
+ "import": {
+ "default": "./build/import-hook.mjs"
+ }
+ },
+ "./loader": {
+ "import": {
+ "default": "./build/loader-hook.mjs"
+ }
}
},
"publishConfig": {
diff --git a/packages/react-router/rollup.npm.config.mjs b/packages/react-router/rollup.npm.config.mjs
index 4a52f4ab57a7..10fd9467a79a 100644
--- a/packages/react-router/rollup.npm.config.mjs
+++ b/packages/react-router/rollup.npm.config.mjs
@@ -1,4 +1,4 @@
-import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollup-utils';
+import { makeBaseNPMConfig, makeNPMConfigVariants, makeOtelLoaders } from '@sentry-internal/rollup-utils';
export default [
...makeNPMConfigVariants(
@@ -19,4 +19,5 @@ export default [
},
}),
),
+ ...makeOtelLoaders('./build', 'sentry-node'),
];
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