首页
/ React Router 可观测性实战:Instrumentation 插桩 API 的设计决策与源码实现解析

React Router 可观测性实战:Instrumentation 插桩 API 的设计决策与源码实现解析

2026-09-06 13:06:40作者:虞亚竹Luna

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.serverhandleError 导出;7.8.2 中又补充了客户端对等的 unstable_onError。错误上报至此基本补齐;
  • 日志与性能追踪:一直缺乏官方推荐。Middleware(7.3.0 发布、7.9.0 稳定)虽然能以"包装请求处理函数"的方式在树的任意层级做日志和粗粒度性能追踪,但粒度太粗,无法深入应用到路由、handler 级别。

文档同时指出,社区此前有两个非推荐的替代做法,都问题明显:

  1. 在每个 loader/action 里手写日志/追踪代码——繁琐且易错;
  2. "插桩化"服务端构建(wrap react-router 请求处理器、遍历 build.routes 树包装路由处理器)——这个方案长期被团队私下推荐给 Sentry 等用户,但从未正式文档化,且仅对自定义 server 生效,@react-router/serve 用户无法使用。

决策:两层插桩 + 统一函数形态

决策文档给出的最终方案是:将 Instrumentation 确立为一等公民 API,作为在应用中实现可观测性的推荐方式。插桩分为两个层级:

层级 覆盖范围 特点
Handler(服务端)/ Router(客户端)级 服务端:请求处理器;客户端:导航(navigation)与 fetcher 调用 每次操作只执行一次插桩
Route 级 路由的 loaderactionmiddlewarelazy 同一次操作可触发多次插桩(多个路由、多个 middleware 依次执行)

插桩函数的统一形态

无论插桩对象是服务端请求处理器、客户端导航,还是某个路由的 loader,单个插桩函数都遵循同一种"包裹执行"形态:

function instrumentationFunction(doTheActualThing, info) {
  // 在真正执行前做一些事情

  // 执行被插桩的操作
  await doTheActualThing();

  // 在操作完成后做一些事情
}

这个形态背后有四个明确的设计意图(引自决策文档):

  1. 统一 API:从 handler 到 navigation 到 loader 到 middleware,插桩任何异步操作的方式完全一致;
  2. 只读约束doTheActualThing() 不接受任何参数、也不返回业务数据——插桩代码无法修改 loader 的入参、也无法篡改 loader 的返回值,只能"报告"执行过程,不能改变运行时行为;
  3. info 参数:传入与当前操作相关的只读上下文,如 requestcontextrouteIdparams 等;
  4. 嵌套作用域:插桩函数包裹在单一作用域内执行,天然支持 AsyncLocalStorage 这类上下文执行机制,使嵌套的 OTEL trace 能正确串联父子 span。

服务端:entry.serverinstrumentations 导出

在框架模式下,插桩通过在 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.lazypatchRoutesOnNavigation 动态发现的路由也会被正确插桩。这一能力在仓库中有明确的实现与测试支撑:router.tscreateRouter 内部调用 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.tsxhandleErrorHydratedRouter/RouterProviderunstable_onError 属性——插桩用于日志与追踪,错误上报有专门的通道。

组合、结果元数据与条件插桩

组合(Composition)instrumentations 是数组,多个独立插桩直接并列传入即可,每个插桩包裹前一个,形成嵌套执行链:

let router = createBrowserRouter(routes, {
  instrumentations: [logNavigations, addWindowPerfTraces, addSentryPerfTraces],
});

结果元数据(Result Metadata):落地实现比原始提案更进一步——客户端导航/fetcher 插桩与服务端请求插桩的调用结果会附带 meta 与状态信息,类型见 InstrumentationServerHandlerResult

  • statusCode:服务端请求插桩返回响应的 HTTP 状态码;
  • meta:包含匹配到的路由元数据(url 规范化 URL、pattern 路由模式如 /projects/:idparams 路由参数),仅在路由匹配成功后才有值;manifest 请求、navigate(-1) 这类数字型 POP 导航时 metaundefined;重定向的客户端导航中 meta 描述的是原始导航目标而非最终重定向位置。

这段逻辑由 instrumentationResultMetaContextinstrumentation.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 形态很有价值:

  1. Events 事件 API:最初计划添加事件订阅机制,但它难以做到"包裹(wrap)"逻辑——而包裹恰恰是 OTEL 这类追踪系统正确生成父子 span 的前提,事件模型的解决方案"不可行"或体验不佳;

  2. patchRoutes:客户端也曾考虑用它注入插桩,但 patchRoutes 定位是"添加新路由",对 route.lazy 路由不生效,RSC 场景下也只会更新服务端渲染的 "elements" 部分、不会遍历整棵子树更新 loader,不适合改造整棵路由树;

  3. 朴素函数包装(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 返回的浅只读克隆(只暴露 methodurlheaders.get),context 也只剩 get 方法(ReadonlyContext = Pick<RouterContextProvider, "get">),从类型和运行时双重层面保证插桩代码读得到、改不了;
  • 防重复插桩:包装后的函数通过 UninstrumentedSymbol 记录原始函数引用。当 createRouter 再次处理同一路由(例如静态 loaderlazy() 合并、patchRoutesOnNavigation 二次注册)时,getUninstrumentedHandler 会取回最原始的版本重新包装,避免插桩被叠加多层——测试用例 "does not double-instrument when a static loader is used alongside lazy()"(instrumentation-test.ts L351)专门验证了这一点;
  • 插桩链的聚合getRouteInstrumentationUpdates 把所有插桩函数对同一操作(loader/action/middleware/lazy,包括 lazy 对象形式的 lazy.loader 等)分别聚合成数组,再由 recurseRight 从最后一个插桩向内递归执行——这正是"数组组合即嵌套链"的运行形态;
  • 服务端接入点server.ts L69 与 L314build.entry.module.instrumentations 读取插桩并经 instrumentHandler 包装请求处理器,对应决策文档中"应用于 createRequestHandler" 的设想;
  • 客户端接入点hydrated-router.tsxinstrumentations 属性透传给内部 router 创建流程。

生产环境常用模式

官方 how-to 文档 给出了三类可直接套用的模式,均基于当前仓库已实现的稳定 API:

  1. 请求日志(服务端)handler 层记录请求起止与耗时/状态码,route 层用统一 log(label, fn) 工具函数依次包裹 middlewareloaderaction,形成层级化访问日志;
  2. OpenTelemetry 集成:用 tracer.startActiveSpan 包裹每个插桩点,将 routeIdpattern 作为 span 属性注入;利用 callHandler 返回的 { status, error } 在出错时 span.recordException(error) 并置 SpanStatusCode.ERROR——插桩链的嵌套执行恰好保证了 span 的父子关系正确;
  3. 客户端性能追踪:在 navigate/fetch 插桩中用 performance.mark/performance.measure 打点,路由级 handler 复用同一个 measure 工具,把每次导航、fetcher 调用、loader/action 执行时间落入 Performance Timeline。

文档同时提醒:插桩会增加运行时执行开销,应做好性能测试,并利用条件插桩避免对生产正常流量造成负面体验。

版本演进:从提案到稳定 API

从仓库的 CHANGELOG 可以还原这条 API 的演进轨迹:

  1. unstable_instrumentations 的不稳定形态首次引入(支持 entry.server.tsx 导出、<HydratedRouter unstable_instrumentations> 属性、createBrowserRouter(routes, { unstable_instrumentations }) 三种入口),覆盖路由 loader/action/middleware/lazy、服务端请求处理器与客户端导航/fetcher;
  2. 期间修复了 <HydratedRouter unstable_instrumentations>clientLoader.hydrate = true 属性丢失的问题——对应源码中插桩 loader 时显式保留 hydrate 标志(instrumentation.ts L352-L354);
  3. 随后稳定化:unstable_instrumentations 更名为 instrumentationsunstable_ServerInstrumentationunstable_ClientInstrumentation 等类型去除 unstable_ 前缀;
  4. 最终补充了结果元数据:服务端请求、客户端导航与 fetcher 插桩返回路由 meta 与 HTTP 状态码。

也就是说,当前仓库(react-router 8.x)中决策文档描述的 instrumentations 提案已经全部落地并从 unstable 走向稳定,文中引用的类型(ServerInstrumentationClientInstrumentationInstrumentationHandlerResultInstrumentationResultMeta)均已在 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 }) 三处任选,动态发现的路由(lazypatchRoutesOnNavigation)同样会被插桩;
  • 实战方向:结构化请求日志、OTEL 分布式追踪、Performance Timeline 性能打点是官方推荐的三类模式,可参考 docs/how-to/instrumentation.md 中的完整代码。
登录后查看全文
热门项目推荐
相关项目推荐