Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Next Next commit
feat(react-router): Include middleware function names and indices
  • Loading branch information
onurtemizkan committed Feb 24, 2026
commit b4cd578ed36f449e4c02d9a80160008f28e3687c
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<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
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 @@ -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 }) => {
Expand All @@ -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',
]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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<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 +217,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 +272,24 @@ 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;

await startSpan(
{
name: routePattern,
name: `middleware ${routeId}`,
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,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'react_router.middleware.index': middlewareIndex,
},
},
async span => {
Expand Down
120 changes: 71 additions & 49 deletions packages/react-router/src/server/createServerInstrumentation.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { context } from '@opentelemetry/api';
import { context, createContextKey } from '@opentelemetry/api';
import { getRPCMetadata, RPCType } from '@opentelemetry/core';
import { ATTR_HTTP_ROUTE } from '@opentelemetry/semantic-conventions';
import {
Expand All @@ -17,8 +17,11 @@ import {
import { DEBUG_BUILD } from '../common/debug-build';
import type { InstrumentableRequestHandler, InstrumentableRoute, ServerInstrumentation } from '../common/types';
import { captureInstrumentationError, getPathFromRequest, getPattern, normalizeRoutePath } from '../common/utils';
import { getMiddlewareName } from './serverBuild';
import { markInstrumentationApiUsed } from './serverGlobals';

const MIDDLEWARE_COUNTER_KEY = createContextKey('sentry_react_router_middleware_counter');

// Re-export for backward compatibility and external use
export { isInstrumentationApiUsed } from './serverGlobals';

Expand Down Expand Up @@ -53,61 +56,68 @@ export function createSentryServerInstrumentation(
const activeSpan = getActiveSpan();
const existingRootSpan = activeSpan ? getRootSpan(activeSpan) : undefined;

if (existingRootSpan) {
updateSpanName(existingRootSpan, `${info.request.method} ${pathname}`);
existingRootSpan.setAttributes({
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react_router.instrumentation_api',
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url',
});
const counterStore = { counters: {} as Record<string, number> };
const ctx = context.active().setValue(MIDDLEWARE_COUNTER_KEY, counterStore);

try {
const result = await handleRequest();
if (result.status === 'error' && result.error instanceof Error) {
existingRootSpan.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
captureInstrumentationError(result, captureErrors, 'react_router.request_handler', {
'http.method': info.request.method,
'http.url': pathname,
});
await context.with(ctx, async () => {
if (existingRootSpan) {
updateSpanName(existingRootSpan, `${info.request.method} ${pathname}`);
existingRootSpan.setAttributes({
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react_router.instrumentation_api',
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url',
});

try {
const result = await handleRequest();
if (result.status === 'error' && result.error instanceof Error) {
existingRootSpan.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
captureInstrumentationError(result, captureErrors, 'react_router.request_handler', {
'http.method': info.request.method,
'http.url': pathname,
});
}
} finally {
await flushIfServerless();
}
} finally {
await flushIfServerless();
}
} else {
await startSpan(
{
name: `${info.request.method} ${pathname}`,
forceTransaction: true,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react_router.instrumentation_api',
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url',
'http.request.method': info.request.method,
'url.path': pathname,
'url.full': info.request.url,
} else {
await startSpan(
{
name: `${info.request.method} ${pathname}`,
forceTransaction: true,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react_router.instrumentation_api',
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url',
'http.request.method': info.request.method,
'url.path': pathname,
'url.full': info.request.url,
},
},
},
async span => {
try {
const result = await handleRequest();
if (result.status === 'error' && result.error instanceof Error) {
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
captureInstrumentationError(result, captureErrors, 'react_router.request_handler', {
'http.method': info.request.method,
'http.url': pathname,
});
async span => {
try {
const result = await handleRequest();
if (result.status === 'error' && result.error instanceof Error) {
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
captureInstrumentationError(result, captureErrors, 'react_router.request_handler', {
'http.method': info.request.method,
'http.url': pathname,
});
}
} finally {
await flushIfServerless();
}
} finally {
await flushIfServerless();
}
},
);
}
},
);
}
});
},
});
},

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

route.instrument({
async loader(callLoader, info) {
const urlPath = getPathFromRequest(info.request);
Expand Down Expand Up @@ -168,15 +178,27 @@ export function createSentryServerInstrumentation(
const pattern = getPattern(info);
const routePattern = normalizeRoutePath(pattern) || urlPath;

// Update root span with parameterized route (same as loader/action)
updateRootSpanWithRoute(info.request.method, pattern, urlPath);

const counterStore = context.active().getValue(MIDDLEWARE_COUNTER_KEY) as { counters: Record<string, number> } | undefined;
let middlewareIndex = 0;
if (counterStore) {
middlewareIndex = counterStore.counters[routeId] ?? 0;
counterStore.counters[routeId] = middlewareIndex + 1;
}

const middlewareName = getMiddlewareName(routeId, middlewareIndex);

await startSpan(
{
name: routePattern,
name: `middleware ${middlewareName || routeId}`,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.react_router.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
29 changes: 22 additions & 7 deletions packages/react-router/src/server/instrumentation/reactRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
} from '@sentry/core';
import type * as reactRouter from 'react-router';
import { DEBUG_BUILD } from '../../common/debug-build';
import { isServerBuildLike, setServerBuild } from '../serverBuild';
import { isInstrumentationApiUsed } from '../serverGlobals';
import { getOpName, getSpanName, isDataRequest } from './util';

Expand Down Expand Up @@ -62,8 +63,29 @@ export class ReactRouterInstrumentation extends InstrumentationBase<Instrumentat
if (prop === 'createRequestHandler') {
const original = target[prop];
return function sentryWrappedCreateRequestHandler(this: unknown, ...args: unknown[]) {
// Capture the ServerBuild reference for middleware name lookup
const build = args[0];
if (isServerBuildLike(build)) {
setServerBuild(build);
} else if (typeof build === 'function') {
// Build arg can be a factory function (dev mode HMR). Wrap to capture resolved build.
const originalBuildFn = build as () => unknown;
args[0] = async function sentryWrappedBuildFn() {
const resolvedBuild = await originalBuildFn();
if (isServerBuildLike(resolvedBuild)) {
setServerBuild(resolvedBuild);
}
return resolvedBuild;
};
}

const originalRequestHandler = original.apply(this, args);

// Skip per-request wrapping when instrumentation API is active
if (isInstrumentationApiUsed()) {
return originalRequestHandler;
}
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated

return async function sentryWrappedRequestHandler(request: Request, initialContext?: unknown) {
let url: URL;
try {
Expand All @@ -77,13 +99,6 @@ export class ReactRouterInstrumentation extends InstrumentationBase<Instrumentat
return originalRequestHandler(request, initialContext);
}

// Skip OTEL instrumentation if instrumentation API is being used
// as it handles loader/action spans itself
if (isInstrumentationApiUsed()) {
DEBUG_BUILD && debug.log('Skipping OTEL loader/action instrumentation - using instrumentation API');
return originalRequestHandler(request, initialContext);
}

const activeSpan = getActiveSpan();
const rootSpan = activeSpan && getRootSpan(activeSpan);

Expand Down
Loading