React Router 可观测性实战:Instrumentation 插桩 API 的设计决策与源码实现解析
React Router 自 7.x 起将"插桩(Instrumentation)"提升为一等公民 API,让你无需修改任何 route 处理函数,即可在服务器端请求处理、客户端导航/fetcher 调用、以及每个路由的 loader/action/middleware/lazy 周围包裹日志、错误上报与性能追踪逻辑。本文基于 React Router 官方的架构决策文档 0015-observability.md,结合仓库中的实际实现(instrumentation 核心模块、官方 how-to 文档)与测试用例,完整讲解这一 API 的设计动机、层级划分、错误处理契约、组合方式,以及"插桩绝不能改变业务行为"这一核心约束在源码中是如何被强制保证的。
为什么需要一个一等公民的可观测性 API
决策文档的 Context 部分开宗明义:目标是让为生产环境 React Router 应用添加可观测性变得容易,具体覆盖三个维度——日志(logging)、错误上报(error reporting)、性能追踪(performance tracing),且必须同时覆盖服务端与客户端。
回顾一下此前各维度的能力缺口:
- 错误展示:长期以来有成熟的
ErrorBoundary方案(面向用户展示); - 错误上报:服务端早已有
entry.server的handleError导出;7.8.2中又补充了客户端对等的unstable_onError。错误上报至此基本补齐; - 日志与性能追踪:一直缺乏官方推荐。Middleware(
7.3.0发布、7.9.0稳定)虽然能以"包装请求处理函数"的方式在树的任意层级做日志和粗粒度性能追踪,但粒度太粗,无法深入应用到路由、handler 级别。
文档同时指出,社区此前有两个非推荐的替代做法,都问题明显:
- 在每个 loader/action 里手写日志/追踪代码——繁琐且易错;
- "插桩化"服务端构建(wrap
react-router请求处理器、遍历build.routes树包装路由处理器)——这个方案长期被团队私下推荐给 Sentry 等用户,但从未正式文档化,且仅对自定义 server 生效,@react-router/serve用户无法使用。
决策:两层插桩 + 统一函数形态
决策文档给出的最终方案是:将 Instrumentation 确立为一等公民 API,作为在应用中实现可观测性的推荐方式。插桩分为两个层级:
| 层级 | 覆盖范围 | 特点 |
|---|---|---|
| Handler(服务端)/ Router(客户端)级 | 服务端:请求处理器;客户端:导航(navigation)与 fetcher 调用 | 每次操作只执行一次插桩 |
| Route 级 | 路由的 loader、action、middleware、lazy |
同一次操作可触发多次插桩(多个路由、多个 middleware 依次执行) |
插桩函数的统一形态
无论插桩对象是服务端请求处理器、客户端导航,还是某个路由的 loader,单个插桩函数都遵循同一种"包裹执行"形态:
function instrumentationFunction(doTheActualThing, info) {
// 在真正执行前做一些事情
// 执行被插桩的操作
await doTheActualThing();
// 在操作完成后做一些事情
}
这个形态背后有四个明确的设计意图(引自决策文档):
- 统一 API:从 handler 到 navigation 到 loader 到 middleware,插桩任何异步操作的方式完全一致;
- 只读约束:
doTheActualThing()不接受任何参数、也不返回业务数据——插桩代码无法修改 loader 的入参、也无法篡改 loader 的返回值,只能"报告"执行过程,不能改变运行时行为; info参数:传入与当前操作相关的只读上下文,如request、context、routeId、params等;- 嵌套作用域:插桩函数包裹在单一作用域内执行,天然支持
AsyncLocalStorage这类上下文执行机制,使嵌套的 OTEL trace 能正确串联父子 span。
服务端:entry.server 的 instrumentations 导出
在框架模式下,插桩通过在 entry.server.tsx 中导出 instrumentations 数组生效。构建产物(server build)会在 createRequestHandler 组装请求处理器时应用这些插桩——这意味着即使使用 @react-router/serve 而不写自定义 server,也能享受同样的能力:
// entry.server.tsx
export const instrumentations = [
{
// 包裹请求处理器 - 适用于 RR 处理的_所有_请求,包括:
// - manifest 请求、文档请求、`.data` 请求、resource route 请求
handler(handler) {
handler.instrument({
async request(handleRequest, { request }) {
let start = Date.now();
console.log(`Request start: ${request.method} ${request.url}`);
try {
await handleRequest();
} finally {
let duration = Date.now() - start;
console.log(`Request end: ${request.method} ${request.url} (${duration}ms)`);
}
},
});
},
// 插桩单个路由,可包裹其 middleware/loader/action/lazy
// 还能借此实现"全局 shouldRevalidate"逻辑
route(route) {
// `id` 是路由 id,可用于只插桩部分路由或做路由级定制
if (route.id === "routes/i-dont-care") return;
route.instrument({
async loader(callLoader, { request }) {
let start = Date.now();
console.log(`Loader start: ${request.method} ${request.url}`);
try {
await callLoader();
} finally {
let duration = Date.now() - start;
console.log(`Loader end: ${request.method} ${request.url} (${duration}ms)`);
}
},
// action()、middleware()、lazy() 同理
});
},
},
];
注:决策文档中的原始示例写作
handler({ instrument })的对象解构形式,落地实现采用了handler(handler)+handler.instrument({...})的方法调用形式(见 instrumentation 文档),语义完全一致。
决策文档还记录了两个开放问题:一是可以在构建时(build time)完成插桩包装而非运行时,但团队预期启动开销不大,且构建时插桩"更魔幻"、并与 data mode 的示例不一致;二是也可以把插桩做成 createRequestHandler 的参数,但 react-router-serve 用户仍需 entry.server 的支持,所以两处保持一致更合理。
客户端:createBrowserRouter 选项与 HydratedRouter 属性
客户端的痛点在于:过去想在路由级别插桩只能在调用 createBrowserRouter 之前手动改路由数组,而 router.initialize 没有插桩入口,framework mode 更是完全做不到。最终落地的 API 形态为:
// entry.client.tsx
const instrumentations = [
{
// 插桩 router 操作
router(router) {
router.instrument({
async navigate(callNavigate, info) { /* ... */ },
async fetch(callFetch, info) { /* ... */ },
});
},
route(route) {
route.instrument({
async lazy(callLazy, info) { /* ... */ },
async middleware(callMiddleware, info) { /* ... */ },
async loader(callLoader, info) { /* ... */ },
async action(callAction, info) { /* ... */ },
});
},
},
];
// Data mode
let router = createBrowserRouter(routes, { instrumentations });
// Framework mode
<HydratedRouter instrumentations={instrumentations} />
插桩在 router 创建时即被应用,因此后续通过 route.lazy 或 patchRoutesOnNavigation 动态发现的路由也会被正确插桩。这一能力在仓库中有明确的实现与测试支撑:router.ts 在 createRouter 内部调用 instrumentClientSideRouter,而 instrumentation-test.ts 专门覆盖了 "allows instrumentation of everything for a lazy function route via patchRoutesOnNavigation" 等用例。
错误处理契约:插桩永远不会破坏业务
这是整套 API 中最值得细读的部分,也是决策文档与源码实现完全对齐的地方。契约分两条:
第一条:被插桩的 handler 抛错不会抛出,而是以判别联合返回。 底层 loader/action 抛错时,React Router 会捕获错误并以 { status: "error", error } 的形式返回给插桩函数,你的插桩逻辑保证完整执行到底:
async loader(callLoader) {
let { status, error } = await callLoader();
// status 为 "success" | "error"
// error 仅在 status === "error" 时有值
if (status === "error") {
// 处理错误
}
}
在源码中,这个类型被定义为 InstrumentationHandlerResult,由内部的 getInstrumentationInnerResult 从执行结果转换而来。
第二条:你的插桩函数自身抛错会被优雅吞掉。 无论你在调用 handler 之前还是之后抛错,React Router 都只是 console.error 警告并继续运行,业务 handler 和其余插桩函数照常执行。这一点在 recurseRight 的实现 中可以逐行验证:
// 抛错发生在调用 handler 之前 —— RR 会检测到 handler 未被调用,
// 仍然内部调用它
async loader(callLoader) {
somethingThatThrows();
await callLoader();
}
// 抛错发生在调用 handler 之后 —— 错误被内部捕获
async action(callAction) {
await callAction();
somethingThatThrows();
}
源码中对应三处保障(见 instrumentation.ts L640-L674):
try { await impl(callHandler, info); } catch (e) { console.error(...) }—— 插桩函数抛错被吞掉;if (!handlerPromise) { await callHandler(); }—— 你忘了调用(或在调用前就抛错)handler 时,底层操作仍会被执行;if (handlerPromise) console.error("You cannot call instrumented handlers more than once")—— 重复调用 handler 会被警告拦截。
决策文档同时明确了一个边界:不应该用插桩逻辑来上报错误,错误上报的职责属于 entry.server.tsx 的 handleError 和 HydratedRouter/RouterProvider 的 unstable_onError 属性——插桩用于日志与追踪,错误上报有专门的通道。
组合、结果元数据与条件插桩
组合(Composition):instrumentations 是数组,多个独立插桩直接并列传入即可,每个插桩包裹前一个,形成嵌套执行链:
let router = createBrowserRouter(routes, {
instrumentations: [logNavigations, addWindowPerfTraces, addSentryPerfTraces],
});
结果元数据(Result Metadata):落地实现比原始提案更进一步——客户端导航/fetcher 插桩与服务端请求插桩的调用结果会附带 meta 与状态信息,类型见 InstrumentationServerHandlerResult:
statusCode:服务端请求插桩返回响应的 HTTP 状态码;meta:包含匹配到的路由元数据(url规范化 URL、pattern路由模式如/projects/:id、params路由参数),仅在路由匹配成功后才有值;manifest 请求、navigate(-1)这类数字型 POP 导航时meta为undefined;重定向的客户端导航中meta描述的是原始导航目标而非最终重定向位置。
这段逻辑由 instrumentationResultMetaContext(instrumentation.ts L183-L184)与一个按 router 实例键控的一次性接收器(WeakMap)传递,避免把内部实现泄漏为公开 API。
条件/动态插桩:因为插桩在运行时生效,可以按条件启用。客户端天然简单——页面加载时决定是否传入:
let enableInstrumentation = window.location.search.startsWith("?DEBUG");
let router = createBrowserRouter(routes, {
instrumentations: enableInstrumentation ? [debuggingInstrumentations] : [],
});
服务端则需借助自定义 server,按请求特征在"已插桩 handler"与"剥离了插桩的 handler"之间切换(通过构造 build 时将 entry.module.instrumentations 置为 undefined 实现)。决策文档给出了完整的 Express 中间式示例,how-to 文档 也提供了基于 NODE_ENV 与查询参数的更日常写法。
被否决的替代方案
决策文档的 Alternatives 一节记录了三个被放弃的方向,对理解当前 API 形态很有价值:
-
Events 事件 API:最初计划添加事件订阅机制,但它难以做到"包裹(wrap)"逻辑——而包裹恰恰是 OTEL 这类追踪系统正确生成父子 span 的前提,事件模型的解决方案"不可行"或体验不佳;
-
patchRoutes:客户端也曾考虑用它注入插桩,但patchRoutes定位是"添加新路由",对route.lazy路由不生效,RSC 场景下也只会更新服务端渲染的 "elements" 部分、不会遍历整棵子树更新 loader,不适合改造整棵路由树; -
朴素函数包装(Naive Function wrapping):最早的实现是直接包装 loader 函数本身:
function instrumentRoute(route: RouteModule): RequestHandler { let { loader } = route; let newRoute = { ...route }; if (loader) { newRoute.loader = (args) => { console.log("Loader start"); try { // ⚠️ 用户可以在这里给真正的 loader 传任意参数 return await loader(...args); } finally { console.log("Loader end"); } }; } return newRoute; }问题在于:把被包装函数的参数控制权交给了用户,就可能被滥用修改运行时行为。最终方案因此改为"零参调用函数 + 只读 info"的形态,从 API 层面封死篡改路径。
源码纵深:只读约束与插桩链的实现
阅读 instrumentation.ts 可以确认几个决策文档中的设计承诺是如何被强制执行的:
- 只读 request/context:传给插桩函数的不是原始
Request,而是 getReadonlyRequest 返回的浅只读克隆(只暴露method、url和headers.get),context 也只剩get方法(ReadonlyContext = Pick<RouterContextProvider, "get">),从类型和运行时双重层面保证插桩代码读得到、改不了; - 防重复插桩:包装后的函数通过 UninstrumentedSymbol 记录原始函数引用。当
createRouter再次处理同一路由(例如静态loader与lazy()合并、patchRoutesOnNavigation二次注册)时,getUninstrumentedHandler会取回最原始的版本重新包装,避免插桩被叠加多层——测试用例 "does not double-instrument when a staticloaderis used alongsidelazy()"(instrumentation-test.ts L351)专门验证了这一点; - 插桩链的聚合:getRouteInstrumentationUpdates 把所有插桩函数对同一操作(loader/action/middleware/lazy,包括
lazy对象形式的lazy.loader等)分别聚合成数组,再由recurseRight从最后一个插桩向内递归执行——这正是"数组组合即嵌套链"的运行形态; - 服务端接入点:server.ts L69 与 L314 从
build.entry.module.instrumentations读取插桩并经instrumentHandler包装请求处理器,对应决策文档中"应用于createRequestHandler" 的设想; - 客户端接入点:hydrated-router.tsx 将
instrumentations属性透传给内部 router 创建流程。
生产环境常用模式
官方 how-to 文档 给出了三类可直接套用的模式,均基于当前仓库已实现的稳定 API:
- 请求日志(服务端):
handler层记录请求起止与耗时/状态码,route层用统一log(label, fn)工具函数依次包裹middleware→loader→action,形成层级化访问日志; - OpenTelemetry 集成:用
tracer.startActiveSpan包裹每个插桩点,将routeId、pattern作为 span 属性注入;利用callHandler返回的{ status, error }在出错时span.recordException(error)并置SpanStatusCode.ERROR——插桩链的嵌套执行恰好保证了 span 的父子关系正确; - 客户端性能追踪:在
navigate/fetch插桩中用performance.mark/performance.measure打点,路由级 handler 复用同一个measure工具,把每次导航、fetcher 调用、loader/action 执行时间落入 Performance Timeline。
文档同时提醒:插桩会增加运行时执行开销,应做好性能测试,并利用条件插桩避免对生产正常流量造成负面体验。
版本演进:从提案到稳定 API
从仓库的 CHANGELOG 可以还原这条 API 的演进轨迹:
- 以
unstable_instrumentations的不稳定形态首次引入(支持entry.server.tsx导出、<HydratedRouter unstable_instrumentations>属性、createBrowserRouter(routes, { unstable_instrumentations })三种入口),覆盖路由 loader/action/middleware/lazy、服务端请求处理器与客户端导航/fetcher; - 期间修复了
<HydratedRouter unstable_instrumentations>下clientLoader.hydrate = true属性丢失的问题——对应源码中插桩 loader 时显式保留hydrate标志(instrumentation.ts L352-L354); - 随后稳定化:
unstable_instrumentations更名为instrumentations,unstable_ServerInstrumentation、unstable_ClientInstrumentation等类型去除unstable_前缀; - 最终补充了结果元数据:服务端请求、客户端导航与 fetcher 插桩返回路由
meta与 HTTP 状态码。
也就是说,当前仓库(react-router 8.x)中决策文档描述的 instrumentations 提案已经全部落地并从 unstable 走向稳定,文中引用的类型(ServerInstrumentation、ClientInstrumentation、InstrumentationHandlerResult、InstrumentationResultMeta)均已在 instrumentation.ts 中定义并随包导出。
小结
- 两个插桩层级:handler(服务端请求)/router(客户端导航与 fetcher)级是"每操作一次"的粗粒度入口;route 级可对
loader/action/middleware/lazy逐一包裹,同一次请求会经过多级多次插桩; - 只读是硬约束:零参调用函数 + 只读
info+ 只读Request/Context克隆,从 API 形状上杜绝了插桩代码篡改业务行为的可能; - 错误契约明确:被插桩操作抛错以
{ status, error }返回而非抛出;插桩自身抛错被console.error吞掉且保证底层操作一定执行; - 接入位置:
entry.server.tsx导出instrumentations、<HydratedRouter instrumentations={...}>、createBrowserRouter(routes, { instrumentations })三处任选,动态发现的路由(lazy、patchRoutesOnNavigation)同样会被插桩; - 实战方向:结构化请求日志、OTEL 分布式追踪、Performance Timeline 性能打点是官方推荐的三类模式,可参考 docs/how-to/instrumentation.md 中的完整代码。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0626
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00