Instrumentation allows you to add logging, error reporting, and performance tracing to your React Router application without modifying your actual route handlers. This enables comprehensive observability solutions for production applications on both the server and client.
With the React Router Instrumentation APIs, you provide "wrapper" functions that execute around your request handlers, router operations, route middlewares, and/or route handlers. This allows you to:
A key design principle is that instrumentation is read-only - you can observe what's happening but cannot modify runtime application behavior by modifying the arguments passed to, or data returned from your route handlers.
Add instrumentations to your entry.server.tsx
:
export const unstable_instrumentations = [
{
// Instrument the server handler
handler(handler) {
handler.instrument({
async request(handleRequest, { request }) {
let url = `${request.method} ${request.url}`;
console.log(`Request start: ${url}`);
await handleRequest();
console.log(`Request end: ${url}`);
},
});
},
// Instrument individual routes
route(route) {
// Skip instrumentation for specific routes if needed
if (route.id === "root") return;
route.instrument({
async loader(callLoader, { request }) {
let url = `${request.method} ${request.url}`;
console.log(`Loader start: ${url} - ${route.id}`);
await callLoader();
console.log(`Loader end: ${url} - ${route.id}`);
},
// Other available instrumentations:
// async action() { /* ... */ },
// async middleware() { /* ... */ },
// async lazy() { /* ... */ },
});
},
},
];
export default function handleRequest(/* ... */) {
// Your existing handleRequest implementation
}
Add instrumentations to your entry.client.tsx
:
import { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";
import { HydratedRouter } from "react-router/dom";
const unstable_instrumentations = [
{
// Instrument router operations
router(router) {
router.instrument({
// Instrument navigations
async navigate(callNavigate, { currentUrl, to }) {
let nav = `${currentUrl} → ${to}`;
console.log(`Navigation start: ${nav}`);
await callNavigate();
console.log(`Navigation end: ${nav}`);
},
// Instrument fetcher calls
async fetch(
callFetch,
{ href, currentUrl, fetcherKey },
) {
let fetch = `${fetcherKey} → ${href}`;
console.log(`Fetcher start: ${fetch}`);
await callFetch();
console.log(`Fetcher end: ${fetch}`);
},
});
},
// Instrument individual routes (same as server-side)
route(route) {
// Skip instrumentation for specific routes if needed
if (route.id === "root") return;
route.instrument({
async loader(callLoader, { request }) {
let url = `${request.method} ${request.url}`;
console.log(`Loader start: ${url} - ${route.id}`);
await callLoader();
console.log(`Loader end: ${url} - ${route.id}`);
},
// Other available instrumentations:
// async action() { /* ... */ },
// async middleware() { /* ... */ },
// async lazy() { /* ... */ },
});
},
},
];
startTransition(() => {
hydrateRoot(
document,
<StrictMode>
<HydratedRouter
unstable_instrumentations={
unstable_instrumentations
}
/>
</StrictMode>,
);
});
In Data Mode, you add instrumentations when creating your router:
import {
createBrowserRouter,
RouterProvider,
} from "react-router";
const unstable_instrumentations = [
{
// Instrument router operations
router(router) {
router.instrument({
// Instrument navigations
async navigate(callNavigate, { currentUrl, to }) {
let nav = `${currentUrl} → ${to}`;
console.log(`Navigation start: ${nav}`);
await callNavigate();
console.log(`Navigation end: ${nav}`);
},
// Instrument fetcher calls
async fetch(
callFetch,
{ href, currentUrl, fetcherKey },
) {
let fetch = `${fetcherKey} → ${href}`;
console.log(`Fetcher start: ${fetch}`);
await callFetch();
console.log(`Fetcher end: ${fetch}`);
},
});
},
// Instrument individual routes (same as server-side)
route(route) {
// Skip instrumentation for specific routes if needed
if (route.id === "root") return;
route.instrument({
async loader(callLoader, { request }) {
let url = `${request.method} ${request.url}`;
console.log(`Loader start: ${url} - ${route.id}`);
await callLoader();
console.log(`Loader end: ${url} - ${route.id}`);
},
// Other available instrumentations:
// async action() { /* ... */ },
// async middleware() { /* ... */ },
// async lazy() { /* ... */ },
});
},
},
];
const router = createBrowserRouter(routes, {
unstable_instrumentations,
});
function App() {
return <RouterProvider router={router} />;
}
There are different levels at which you can instrument your application. Each instrumentation function receives a second "info" parameter containing relevant contextual information for the specific aspect being instrumented.
Instruments the top-level request handler that processes all requests to your server:
export const unstable_instrumentations = [
{
handler(handler) {
handler.instrument({
async request(handleRequest, { request, context }) {
// Runs around ALL requests to your app
await handleRequest();
},
});
},
},
];
Instruments client-side router operations like navigations and fetcher calls:
export const unstable_instrumentations = [
{
router(router) {
router.instrument({
async navigate(callNavigate, { to, currentUrl }) {
// Runs around navigation operations
await callNavigate();
},
async fetch(
callFetch,
{ href, currentUrl, fetcherKey },
) {
// Runs around fetcher operations
await callFetch();
},
});
},
},
];
// Framework Mode (entry.client.tsx)
<HydratedRouter
unstable_instrumentations={unstable_instrumentations}
/>;
// Data Mode
const router = createBrowserRouter(routes, {
unstable_instrumentations,
});
Instruments individual route handlers:
const unstable_instrumentations = [
{
route(route) {
route.instrument({
async loader(
callLoader,
{ params, request, context, unstable_pattern },
) {
// Runs around loader execution
await callLoader();
},
async action(
callAction,
{ params, request, context, unstable_pattern },
) {
// Runs around action execution
await callAction();
},
async middleware(
callMiddleware,
{ params, request, context, unstable_pattern },
) {
// Runs around middleware execution
await callMiddleware();
},
async lazy(callLazy) {
// Runs around lazy route loading
await callLazy();
},
});
},
},
];
Instrumentations are designed to be observational only. You cannot:
This ensures that instrumentation is safe to add to production applications and cannot introduce bugs in your route logic.
To ensure that instrumentation code doesn't impact the runtime application, errors are caught internally and prevented from propagating outward. This design choice shows up in 2 aspects.
First, if a "handler" function (loader, action, request handler, navigation, etc.) throws an error, that error will not bubble out of the callHandler
function invoked from your instrumentation. Instead, the callHandler
function returns a discriminated union result of type { type: "success", error: undefined } | { type: "error", error: unknown }
. This ensures your entire instrumentation function runs without needing any try/catch/finally logic to handle application errors.
export const unstable_instrumentations = [
{
route(route) {
route.instrument({
async loader(callLoader) {
let { status, error } = await callLoader();
if (status === "error") {
// error case - `error` is defined
} else {
// success case - `error` is undefined
}
},
});
},
},
];
Second, if your instrumentation function throws an error, React Router will gracefully swallow that so that it does not bubble outward and impact other instrumentations or application behavior. In both of these examples, the handlers and all other instrumentation functions will still run:
export const unstable_instrumentations = [
{
route(route) {
route.instrument({
// Throwing before calling the handler - RR will
// catch the error and still call the loader
async loader(callLoader) {
somethingThatThrows();
await callLoader();
},
// Throwing after calling the handler - RR will
// catch the error internally
async action(callAction) {
await callAction();
somethingThatThrows();
},
});
},
},
];
You can compose multiple instrumentations by providing an array:
export const unstable_instrumentations = [
loggingInstrumentation,
performanceInstrumentation,
errorReportingInstrumentation,
];
Each instrumentation wraps the previous one, creating a nested execution chain.
export const unstable_instrumentations = [
{
handler(handler) {
handler.instrument({
async request(handleRequest, info) {
const start = Date.now();
await handleRequest();
const duration = Date.now() - start;
reportPerf(info.request, duration);
},
});
},
route(route) {
route.instrument({
async loader(callLoader, info) {
const start = Date.now();
let { error } = await callLoader();
const duration = Date.now() - start;
reportPerf(info.request, {
routePattern: info.unstable_pattern,
routeId: route.id,
duration,
error,
});
},
});
},
},
];
import { trace, SpanStatusCode } from "@opentelemetry/api";
const tracer = trace.getTracer("my-app");
export const unstable_instrumentations = [
{
handler(handler) {
handler.instrument({
async request(handleRequest, { request }) {
return tracer.startActiveSpan(
"request handler",
async (span) => {
let { error } = await handleRequest();
if (error) {
span.recordException(error);
span.setStatus({
code: SpanStatusCode.ERROR,
});
}
span.end();
},
);
},
});
},
route(route) {
route.instrument({
async loader(callLoader, { routeId }) {
return tracer.startActiveSpan(
"route loader",
{ attributes: { routeId: route.id } },
async (span) => {
let { error } = await callLoader();
if (error) {
span.recordException(error);
span.setStatus({
code: SpanStatusCode.ERROR,
});
}
span.end();
},
);
},
});
},
},
];
const unstable_instrumentations = [
{
router(router) {
router.instrument({
async navigate(callNavigate, { to, currentUrl }) {
let label = `${currentUrl}->${to}`;
performance.mark(`start:${label}`);
await callNavigate();
performance.mark(`end:${label}`);
performance.measure(
`navigation:${label}`,
`start:${label}`,
`end:${label}`,
);
},
});
},
},
];
You can enable instrumentation conditionally based on environment or other factors:
export const unstable_instrumentations =
process.env.NODE_ENV === "production"
? [productionInstrumentation]
: [developmentInstrumentation];
// Or conditionally within an instrumentation
export const unstable_instrumentations = [
{
route(route) {
// Only instrument specific routes
if (!route.id?.startsWith("routes/admin")) return;
// Or, only instrument if a query parameter is present
let sp = new URL(request.url).searchParams;
if (!sp.has("DEBUG")) return;
route.instrument({
async loader() {
/* ... */
},
});
},
},
];