Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<h1 id="multi-middleware-title">Multi Middleware Route</h1>
<p id="multi-middleware-content">This route has 3 middlewares</p>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<h1 id="other-middleware-title">Other Middleware Route</h1>
<p id="other-middleware-content">This route has a different middleware</p>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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({
Expand All @@ -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),
Expand All @@ -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']);
});
});
56 changes: 55 additions & 1 deletion packages/react-router/src/client/createClientInstrumentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<object, Record<string, number>>();

const SENTRY_CLIENT_INSTRUMENTATION_FLAG = '__sentryReactRouterClientInstrumentationUsed';
// Intentionally never reset - once set, instrumentation API handles all navigations for the session.
const SENTRY_NAVIGATE_HOOK_INVOKED_FLAG = '__sentryReactRouterNavigateHookInvoked';
Expand Down Expand Up @@ -214,6 +218,8 @@ export function createSentryClientInstrumentation(
},

route(route: InstrumentableRoute) {
const routeId = route.id;

route.instrument({
async loader(callLoader, info) {
const urlPath = getPathFromRequest(info.request);
Expand Down Expand Up @@ -267,12 +273,33 @@ export function createSentryClientInstrumentation(
const urlPath = getPathFromRequest(info.request);
const routePattern = normalizeRoutePath(getPattern(info)) || urlPath;

// Get or create counters for this navigation's Request
let counters = middlewareCountersMap.get(info.request);
if (!counters) {
counters = {};
middlewareCountersMap.set(info.request, counters);
}

// Get middleware index and increment for next middleware
const middlewareIndex = counters[routeId] ?? 0;
counters[routeId] = middlewareIndex + 1;

// Try to get the actual middleware function name
const middlewareName = getClientMiddlewareName(routeId, middlewareIndex);

// Build display name: prefer function name, fallback to routeId
const displayName = middlewareName || routeId;

await startSpan(
{
name: routePattern,
name: `middleware ${displayName}`,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.react_router.client_middleware',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.react_router.instrumentation_api',
'react_router.route.id': routeId,
'react_router.route.pattern': routePattern,
...(middlewareName && { 'react_router.middleware.name': middlewareName }),
'react_router.middleware.index': middlewareIndex,
},
},
async span => {
Expand Down Expand Up @@ -325,3 +352,30 @@ export function isClientInstrumentationApiUsed(): boolean {
export function isNavigateHookInvoked(): boolean {
return !!GLOBAL_WITH_FLAGS[SENTRY_NAVIGATE_HOOK_INVOKED_FLAG];
}

interface RouteModule {
[key: string]: unknown;
clientMiddleware?: Array<{ name?: string }>;
}

interface GlobalObjWithRouteModules {
__reactRouterRouteModules?: Record<string, RouteModule>;
}

/**
* Get client middleware function name from __reactRouterRouteModules.
* @internal
*/
function getClientMiddlewareName(routeId: string, index: number): string | undefined {
const globalWithModules = GLOBAL_OBJ as typeof GLOBAL_OBJ & GlobalObjWithRouteModules;
const routeModules = globalWithModules.__reactRouterRouteModules;
if (!routeModules) return undefined;

const routeModule = routeModules[routeId];
// Client middleware is exposed as clientMiddleware in route modules
const clientMiddleware = routeModule?.clientMiddleware;
if (!Array.isArray(clientMiddleware)) return undefined;

const middlewareFn = clientMiddleware[index];
return middlewareFn?.name || undefined;
}
Loading
Loading