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
@@ -1,3 +1,6 @@
// Required on Node 20.19+/22.12+ for OTEL module patching to work.
import '@sentry/react-router/loader';

import * as Sentry from '@sentry/react-router';

// Initialize Sentry early (before the server starts)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down Expand Up @@ -40,23 +38,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: any) => 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 }) => {
Expand All @@ -69,17 +74,165 @@ 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: any) => 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: any) => 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: any) => span.data?.['sentry.op'] === 'function.react_router.middleware',
);

expect(middlewareSpans).toHaveLength(3);

// Sort by index to ensure correct order
const sortedSpans = [...middlewareSpans!].sort(
(a: any, b: any) =>
(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');
});

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: any) => 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: any) => 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: any) => 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: any) => 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: any) => span.data?.['react_router.middleware.index']).sort();
expect(indices).toEqual([0, 1, 2]);

// Names should still be correct
const names = middlewareSpans!.map((span: any) => span.data?.['react_router.middleware.name']).sort();
expect(names).toEqual(['multiAuthMiddleware', 'multiLoggingMiddleware', 'multiValidationMiddleware']);
});
});
Original file line number Diff line number Diff line change
@@ -1 +1 @@
import '@sentry/node/import-hook';
import '@sentry/node/import';
10 changes: 10 additions & 0 deletions packages/react-router/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
3 changes: 2 additions & 1 deletion packages/react-router/rollup.npm.config.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollup-utils';
import { makeBaseNPMConfig, makeNPMConfigVariants, makeOtelLoaders } from '@sentry-internal/rollup-utils';

export default [
...makeNPMConfigVariants(
Expand All @@ -19,4 +19,5 @@ export default [
},
}),
),
...makeOtelLoaders('./build', 'sentry-node'),
];
50 changes: 49 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,28 @@ export function createSentryClientInstrumentation(
const urlPath = getPathFromRequest(info.request);
const routePattern = normalizeRoutePath(getPattern(info)) || urlPath;

let counters = middlewareCountersMap.get(info.request);
if (!counters) {
counters = {};
middlewareCountersMap.set(info.request, counters);
}

const middlewareIndex = counters[routeId] ?? 0;
counters[routeId] = middlewareIndex + 1;

const middlewareName = getClientMiddlewareName(routeId, middlewareIndex);
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 +347,29 @@ 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];
const clientMiddleware = routeModule?.clientMiddleware;
if (!Array.isArray(clientMiddleware)) return undefined;

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