Skip to content
Merged
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
3 changes: 3 additions & 0 deletions contributors.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
- Artur-
- ashusnapx
- avipatel97
- AviVahl
- awreese
- aymanemadidi
- ayushmanchhabra
Expand Down Expand Up @@ -147,6 +148,7 @@
- fucancode
- fyzhu
- fz6m
- gaoflow
- gaspard
- gatzjames
- gavriguy
Expand Down Expand Up @@ -369,6 +371,7 @@
- raphaelbronsveld
- redabacha
- refusado
- remcohaszing
- remorses
- renyu-io
- restareaByWeezy
Expand Down
157 changes: 131 additions & 26 deletions docs/how-to/instrumentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,11 @@ export const instrumentations = [
async request(handleRequest, { request }) {
let url = `${request.method} ${request.url}`;
console.log(`Request start: ${url}`);
await handleRequest();
console.log(`Request end: ${url}`);
let result = await handleRequest();
let pattern = result.meta?.pattern ?? "unknown";
console.log(
`Request end: ${url} (${result.statusCode} ${pattern})`,
);
},
});
},
Expand Down Expand Up @@ -92,20 +95,24 @@ const instrumentations = [
router.instrument({
// Instrument navigations
async navigate(callNavigate, { currentUrl, to }) {
let nav = `${currentUrl} ${to}`;
let nav = `${currentUrl} -> ${to}`;
console.log(`Navigation start: ${nav}`);
await callNavigate();
console.log(`Navigation end: ${nav}`);
let result = await callNavigate();
console.log(
`Navigation end: ${nav} (${result.meta?.pattern})`,
);
},
// Instrument fetcher calls
async fetch(
callFetch,
{ href, currentUrl, fetcherKey },
) {
let fetch = `${fetcherKey} ${href}`;
let fetch = `${fetcherKey} -> ${href}`;
console.log(`Fetcher start: ${fetch}`);
await callFetch();
console.log(`Fetcher end: ${fetch}`);
let result = await callFetch();
console.log(
`Fetcher end: ${fetch} (${result.meta?.pattern})`,
);
},
});
},
Expand Down Expand Up @@ -160,20 +167,24 @@ const instrumentations = [
router.instrument({
// Instrument navigations
async navigate(callNavigate, { currentUrl, to }) {
let nav = `${currentUrl} ${to}`;
let nav = `${currentUrl} -> ${to}`;
console.log(`Navigation start: ${nav}`);
await callNavigate();
console.log(`Navigation end: ${nav}`);
let result = await callNavigate();
console.log(
`Navigation end: ${nav} (${result.meta?.pattern})`,
);
},
// Instrument fetcher calls
async fetch(
callFetch,
{ href, currentUrl, fetcherKey },
) {
let fetch = `${fetcherKey} ${href}`;
let fetch = `${fetcherKey} -> ${href}`;
console.log(`Fetcher start: ${fetch}`);
await callFetch();
console.log(`Fetcher end: ${fetch}`);
let result = await callFetch();
console.log(
`Fetcher end: ${fetch} (${result.meta?.pattern})`,
);
},
});
},
Expand Down Expand Up @@ -227,7 +238,9 @@ export const instrumentations = [
handler.instrument({
async request(handleRequest, { request, context }) {
// Runs around ALL requests to your app
await handleRequest();
let result = await handleRequest();
let statusCode = result.statusCode;
let routePattern = result.meta?.pattern;
},
});
},
Expand All @@ -248,14 +261,16 @@ export const instrumentations = [
router.instrument({
async navigate(callNavigate, { to, currentUrl }) {
// Runs around navigation operations
await callNavigate();
let result = await callNavigate();
let routePattern = result.meta?.pattern;
},
async fetch(
callFetch,
{ href, currentUrl, fetcherKey },
) {
// Runs around fetcher operations
await callFetch();
let result = await callFetch();
let routePattern = result.meta?.pattern;
},
});
},
Expand Down Expand Up @@ -327,7 +342,7 @@ This ensures that instrumentation is safe to add to production applications and

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.
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 `{ status: "success", error: undefined } | { status: "error", error: Error }`. This ensures your entire instrumentation function runs without needing any try/catch/finally logic to handle application errors.

```tsx
export const instrumentations = [
Expand Down Expand Up @@ -374,6 +389,64 @@ export const instrumentations = [
];
```

### Result Metadata

Some instrumented calls return additional information that is only available after React Router starts processing the request, navigation, or fetcher call.

- Route-level instrumentations (`loader`/`action`/`middleware`) don't include `meta` because metadata is available on the `info` parameter
- Client navigation/fetcher and Server request handler instrumentations return a meta field
- `meta` contains the same values passed to loaders and actions
- `url`: The normalized `URL` for the matched route request
- `pattern`: The matched route pattern, such as `/projects/:id`
- `params`: The matched route params
- `meta` may be `undefined` when React Router does not have route metadata for the instrumented call, such as server manifest requests or numeric POP navigations like `navigate(-1)`
- For client navigations that redirect, `meta` describes the original navigation target instead of the final redirected location.
- Server request handler instrumentations also return the `statusCode` of the response

```tsx
// entry.server.tsx
export const instrumentations = [
{
handler(handler) {
handler.instrument({
async request(handleRequest) {
let result = await handleRequest();

let statusCode = result.statusCode;
let routeUrl = result.meta?.url;
let routePattern = result.meta?.pattern;
let routeParams = result.meta?.params;
},
});
},
},
];

// entry.client.tsx
const instrumentations = [
{
router(router) {
router.instrument({
async navigate(callNavigate) {
let result = await callNavigate();

let routeUrl = result.meta?.url;
let routePattern = result.meta?.pattern;
let routeParams = result.meta?.params;
},
async fetch(callFetch) {
let result = await callFetch();

let routeUrl = result.meta?.url;
let routePattern = result.meta?.pattern;
let routeParams = result.meta?.params;
},
});
},
},
];
```

### Composition

You can compose multiple instrumentations by providing an array:
Expand Down Expand Up @@ -429,8 +502,16 @@ export const instrumentations = [
const logging: ServerInstrumentation = {
handler({ instrument }) {
instrument({
request: (fn, { request }) =>
log(`request ${request.url}`, fn),
async request(fn, { request }) {
let label = `request ${request.url}`;
let start = Date.now();
console.log(`-> ${label}`);
let result = await fn();
let pattern = result.meta?.pattern ?? "";
console.log(
`<- ${label} (${Date.now() - start}ms ${result.statusCode} ${pattern})`,
);
},
});
},
route({ instrument, id }) {
Expand All @@ -447,9 +528,9 @@ async function log(
cb: () => Promise<InstrumentationHandlerResult>,
) {
let start = Date.now();
console.log(`➡️ ${label}`);
console.log(`-> ${label}`);
await cb();
console.log(`⬅️ ${label} (${Date.now() - start}ms)`);
console.log(`<- ${label} (${Date.now() - start}ms)`);
}

export const instrumentations = [logging];
Expand Down Expand Up @@ -523,10 +604,34 @@ export const instrumentations = [otel];
const windowPerf: ClientInstrumentation = {
router({ instrument }) {
instrument({
navigate: (fn, { to, currentUrl }) =>
measure(`navigation:${currentUrl}->${to}`, fn),
fetch: (fn, { href }) =>
measure(`fetcher:${href}`, fn),
async navigate(fn, { to, currentUrl }) {
let label = `navigation:${currentUrl}->${to}`;
performance.mark(`start:${label}`);
let result = await fn();
performance.mark(`end:${label}`);
performance.measure(
label,
`start:${label}`,
`end:${label}`,
);
console.log(
`navigation pattern: ${result.meta?.pattern}`,
);
},
async fetch(fn, { href }) {
let label = `fetcher:${href}`;
performance.mark(`start:${label}`);
let result = await fn();
performance.mark(`end:${label}`);
performance.measure(
label,
`start:${label}`,
`end:${label}`,
);
console.log(
`fetcher pattern: ${result.meta?.pattern}`,
);
},
});
},
route({ instrument, id }) {
Expand Down
12 changes: 9 additions & 3 deletions integration/browser-entry-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,8 +218,12 @@ test("allows users to instrument the client side router via HydratedRouter", asy
router.instrument({
async navigate(impl, info) {
console.log("start navigate", JSON.stringify(Object.entries(info).sort()));
await impl();
console.log("end navigate", JSON.stringify(Object.entries(info).sort()));
let result = await impl();
console.log("end navigate", JSON.stringify(Object.entries(info).sort()), JSON.stringify({
url: result.meta.url,
pattern: result.meta.pattern,
params: result.meta.params,
}));
},
async fetch(impl, info) {
console.log("start fetch", JSON.stringify(Object.entries(info).sort()));
Expand Down Expand Up @@ -300,7 +304,9 @@ test("allows users to instrument the client side router via HydratedRouter", asy
"start loader routes/page /page",
"end loader root /page",
"end loader routes/page /page",
'end navigate [["currentUrl","/"],["to","/page"]]',
expect.stringMatching(
/^end navigate \[\["currentUrl","\/"\],\["to","\/page"\]\] \{"url":"http:\/\/localhost:\d+\/page","pattern":"page","params":\{\}\}$/,
),
]);
logs.splice(0);

Expand Down
54 changes: 53 additions & 1 deletion integration/cli-test.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,60 @@
import { spawnSync } from "node:child_process";
import { existsSync, rmSync } from "node:fs";
import {
copyFileSync,
existsSync,
mkdirSync,
mkdtempSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import * as path from "node:path";
import { fileURLToPath } from "node:url";

import { expect, test } from "@playwright/test";
import dedent from "dedent";
import semver from "semver";

import { createProject } from "./helpers/vite";

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const rootDirectory = path.resolve(__dirname, "..");
const nodeBin = process.argv[0];
const reactRouterBin = "node_modules/@react-router/dev/dist/cli/index.js";
const reactRouterPackageBin = path.join(
rootDirectory,
"packages/react-router-dev/bin.cjs",
);

const run = (command: string[], options: Parameters<typeof spawnSync>[2]) =>
spawnSync(nodeBin, [reactRouterBin, ...command], options);

const getBinNodeEnv = (command: string[]) => {
let cwd = mkdtempSync(path.join(tmpdir(), "react-router-bin-"));
let env = { ...process.env };
delete env.NODE_ENV;

try {
mkdirSync(path.join(cwd, "dist/cli"), { recursive: true });
copyFileSync(reactRouterPackageBin, path.join(cwd, "bin.cjs"));
writeFileSync(
path.join(cwd, "dist/cli/index.js"),
"console.log(process.env.NODE_ENV);",
);

let { stdout, stderr, status } = spawnSync(
nodeBin,
["bin.cjs", ...command],
{ cwd, env },
);
expect(stderr.toString()).toBe("");
expect(status).toBe(0);
return stdout.toString().trim();
} finally {
rmSync(cwd, { recursive: true, force: true });
}
};

const helpText = dedent`
react-router

Expand Down Expand Up @@ -109,6 +150,17 @@ test.describe("cli", () => {
expect(status).toBe(0);
});

test("bin sets NODE_ENV based on the positional command", async () => {
expect(getBinNodeEnv(["dev", "--host", "127.0.0.1"])).toBe("development");
expect(getBinNodeEnv(["--host", "127.0.0.1", "dev"])).toBe("development");
expect(getBinNodeEnv(["build", "--mode", "development"])).toBe(
"production",
);
expect(getBinNodeEnv(["--mode", "development", "build"])).toBe(
"production",
);
});

test("routes", async () => {
const cwd = await createProject();
let { stdout, stderr, status } = run(["routes"], { cwd });
Expand Down
Original file line number Diff line number Diff line change
@@ -1 +1 @@
Use Node's built-in `parseArgs` utility for CLI argument parsing and remove the `arg` dependency.
Use Node's built-in utilities for CLI argument parsing, ANSI-stripping, and child process execution to remove the `arg`, `strip-ansi`, and `execa` dependencies.
Loading