Skip to content
Open
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,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 multiAuthMiddleware(_args, next) {
return next();
},
async function multiLoggingMiddleware(_args, next) {
return next();
},
async function multiValidationMiddleware(_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([
'multiAuthMiddleware',
'multiLoggingMiddleware',
'multiValidationMiddleware',
]);
});
});
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { reactRouter } from '@react-router/dev/vite';
import { sentryReactRouter } from '@sentry/react-router';
import { defineConfig } from 'vite';

export default defineConfig({
plugins: [reactRouter()],
});
export default defineConfig(async config => ({
plugins: [
reactRouter(),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
...((await sentryReactRouter({ sourcemaps: { disable: true } }, config)) as any[]),
],
}));
19 changes: 18 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,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,
'react_router.middleware.index': middlewareIndex,
},
},
async span => {
Expand Down
122 changes: 73 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,29 @@ 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
32 changes: 24 additions & 8 deletions packages/react-router/src/server/instrumentation/reactRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ import {
} from '@sentry/core';
import type * as reactRouter from 'react-router';
import { DEBUG_BUILD } from '../../common/debug-build';
import { isInstrumentationApiUsed } from '../serverGlobals';
import { isServerBuildLike, setServerBuild } from '../serverBuild';
import { isInstrumentationApiUsed, isOtelDataLoaderSpanCreationEnabled } from '../serverGlobals';
import { getOpName, getSpanName, isDataRequest } from './util';

type ReactRouterModuleExports = typeof reactRouter;
Expand Down Expand Up @@ -62,9 +63,31 @@ 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);

return async function sentryWrappedRequestHandler(request: Request, initialContext?: unknown) {
// Skip OTEL span creation when instrumentation API is active or when span creation is not enabled.
// Checked per-request (not at handler-creation time) because in dev, createRequestHandler runs before entry.server.tsx.
if (isInstrumentationApiUsed() || !isOtelDataLoaderSpanCreationEnabled()) {
return originalRequestHandler(request, initialContext);
}

let url: URL;
try {
url = new URL(request.url);
Expand All @@ -77,13 +100,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
Loading