手动埋点还是全扔给自动化?我按三个维度梳理了自定义性能打点的时机与粒度

在性能监控这件事上,「全量自动采集」和「零散手动打点」的争论已经持续了快十年。结论其实很明确:两者不是替代关系,而是分工关系。真正的问题在于分界线划在哪里——我按采集成本、数据精度和长期维护三个维度,把该手动打点的场景和该扔给自动化的场景梳理了一遍。

先给结论:三个维度的分界标准

手动打点和自动化采集的分工,取决于三个核心维度:

  • 采集成本:如果一段逻辑的触发条件复杂、需要上下文信息才能判断是否采集,手动打点成本远低于自动化工具强行覆盖的成本。
  • 数据精度:如果指标本身需要业务语义解释(比如「用户完成了一次有效搜索」),自动化工具只能拿到技术层面的近似值,精度损失不可接受。
  • 长期维护:如果打点逻辑和业务代码强绑定、随功能迭代同步变更,手动维护的成本反而比自动化工具的泛化配置更低。

下面按这三个维度逐一拆解。

采集成本:触发条件越复杂,越该手动打

自动化采集工具(无论是 RUM SDK 还是 APM 探针)的核心思路是「默认全量 + 采样过滤」。它们擅长的是那些触发条件简单、判定逻辑二值化的事情:页面加载完成、HTTP 请求发出、JS 报错抛出。

但一旦到了业务逻辑层面,自动化工具就开始吃力了。举个真实的例子:某电商 App 的结算页,我们想采集「用户从购物车点击结算到收银台可交互」的耗时。这个起点的触发条件是——用户在购物车页、购物车非空、点击结算按钮且该次点击未被防抖拦截。自动化工具如果要在运行时判断这些条件,要么需要注入大量条件表达式(配置成本已经接近写代码),要么只能退而求其次:采集「购物车页最后一次点击到收银台页面 onload」的耗时——但这里面夹杂了用户犹豫、返回修改地址、甚至误触的噪声数据。

这个场景下,手动打一个 performance.mark('checkout_start') 的成本是一次性写 3 行代码,而自动化工具要过滤出有效数据的成本是持续的配置调参 + 数据清洗。成本天平明显倾向手动。

反过来,页面加载性能、资源加载瀑布、接口耗时分布这些场景,触发条件就是浏览器/运行时原生的生命周期节点,自动化工具直接挂载监听即可,零业务侵入。这时候手动打点反而多余——你不可能在每个页面手动埋 performance.mark('page_loaded'),因为 PerformanceObserver 已经能拿到精确到微秒的 Navigation Timing。

判断标准很直接:如果触发条件的判定逻辑超过 3 个 AND 条件,或者需要访问业务状态(store 里的某个字段、页面路由参数),就值得手动打点。 3 个以下纯技术条件(比如「页面可见 + 网络在线 + 无重定向」)的,交给自动化。

数据精度:需要业务语义的指标,自动化不够用

这里有一个很容易被忽视的坑:自动化工具采集的指标,精度再高也是技术精度的「高」,不是业务精度的「准」。

举个例子,某 SaaS 后台的「列表页渲染完成」这个指标。自动化 RUM 工具通常用 Largest Contentful PaintFirst Meaningful Paint 来近似。但 LCP 抓到的「最大内容元素」可能是列表里某张用户头像图片,FMP 的算法在不同浏览器版本里表现不一致。更关键的是,业务方关心的「列表渲染完成」是指「列表中的数据行全部渲染完毕且可交互」——这在虚拟滚动场景下,LCP 和 FMP 根本捕捉不到,因为它们只看首屏可见区域的绘制。

这时候必须手动打点。在虚拟列表组件里,在 scrollTorenderChunk 的最后一帧回调里手动 performance.mark('list_fully_rendered'),然后用 performance.measure 计算从请求发起到这个 mark 的耗时。这个数字精度上可能不如 LCP(LCP 基于浏览器渲染帧,精确到微秒;手动 mark 基于 Date.now()performance.now(),精确到毫秒),但业务语义的准确性碾压自动化指标——业务方看到 2.3 秒就是真实的 2.3 秒,而不是 LCP 的 1.8 秒但实际列表还在异步渲染。

类似的情况还有:

  • 「搜索结果有效性」:自动化工具只能告诉你搜索接口返回了 200,但业务关心的是返回结果里有没有匹配项。
  • 「表单提交成功率」:自动化工具统计的是 HTTP 2xx 比例,但业务定义的「成功」是后端返回 code: 0 且数据落库。
  • 「WebSocket 重连耗时」:自动化工具统计的是 TCP 握手 + TLS 协商,但业务关心的是从断线到业务层收到第一条心跳回包的总时长。

这些场景的共同特征是:指标定义里包含了一个业务语义的判定(「有效」「成功」「完成」),这个判定逻辑只有业务代码自己知道。 自动化工具无论如何配置阈值和过滤规则,都是在技术指标上做近似,精度损失通常在 15%-30% 之间(根据我 2023 年在两个项目里对比手动打点和 RUM 自动采集的数据,列表渲染耗时偏差中位数 22%,搜索有效性偏差更离谱,到了 40% 以上)。

所以数据精度这个维度的判断标准是:如果指标定义中出现了业务术语(有效、成功、可交互、完整),且这个术语的判定需要读取业务状态或后端返回的 business code,那么手动打点是唯一解。 纯技术指标(DNS 耗时、SSL 握手、首字节时间)全扔给自动化。

长期维护:和业务代码共生的打点,手动反而更稳

很多团队选择自动化工具的核心理由是「减少维护成本」。这个逻辑在纯技术指标上成立——页面加载、接口监控、错误捕获这些需求,接入 SDK 后基本零维护,SDK 升级跟着版本走就行。

但在业务指标上,情况刚好相反。自动化工具为了覆盖业务场景,通常提供「自定义事件」或「声明式配置」的能力。比如某 APM 平台支持通过 YAML 配置来定义业务事务:

transaction:
  name: "checkout_flow"
  start_condition: "page.url matches /cart && element.click('#checkout-btn')"
  end_condition: "page.url matches /cashier && element.visible('#payment-form')"

这个配置的问题在于:它和业务代码解耦了,但解耦也意味着它不会跟着业务代码一起重构。当购物车页路由从 /cart 改成 /shopping-cart、结算按钮的 id 从 #checkout-btn 改成 #submit-order、收银台改成异步组件加载时,这个配置就会静默失效——不会报错,不会触发 CI 告警,只是采集到的数据开始出现异常(事务时长骤降、采集量断崖)。而这类失效通常要到月度性能报告出来时才会被发现,此时脏数据已经混入了一整个迭代周期。

反过来,手动打点写进业务代码里:

// checkout.tsx
const handleCheckout = async () => {
  performance.mark('checkout_start');
  await submitOrder();
  router.push('/cashier');
};

// cashier.tsx
useEffect(() => {
  if (paymentFormReady) {
    performance.mark('checkout_end');
    performance.measure('checkout_flow', 'checkout_start', 'checkout_end');
  }
}, [paymentFormReady]);

这段代码在重构时会跟着组件一起被修改——路由改了会改 router.push,状态名改了会改 paymentFormReady。如果打点逻辑失效,往往是代码逻辑也一起失效了,反而容易被功能测试覆盖到。这是一种「被动同步」的维护模式:手动打点因为嵌在业务代码里,它的维护成本实际上摊进了业务功能迭代的成本里,而不是像自动化配置那样成为一个需要单独关注的技术债。

当然,这有一个前提:打点代码必须简洁、零侵入业务逻辑。如果手动打点导致业务代码里散落着十几行 performance.mark 和上报逻辑,那维护成本确实会失控。我的实践经验是:手动打点只做标记(mark/measure),上报逻辑统一收拢到一个独立的 instrumentation 层,通过 PerformanceObserver 监听所有 mark 和 measure 事件,批量上报。这样业务代码里只有一两行的 mark 调用,维护成本可控。

具体场景的分工清单

基于以上三个维度,我整理了常见性能打点场景的分工:

全交给自动化工具的场景:

  • 页面加载性能(DNS、TCP、SSL、TTFB、FCP、LCP、TTI、CLS)——浏览器原生提供,SDK 采集零成本
  • 资源加载瀑布(JS/CSS/图片/字体)——Resource Timing API,全量采集后采样即可
  • HTTP 请求耗时与错误率——XHR/Fetch 拦截,SDK 标配
  • JS 运行时错误与 Promise rejection——全局监听,零侵入
  • 长任务(Long Task)与帧率——PerformanceObserver 原生支持

必须手动打点的场景:

  • 业务流程的端到端耗时(结算、发布、导入导出)——触发条件复杂,需要业务状态判定
  • 任何包含「有效」「成功」等业务语义的指标——自动化工具无法理解业务 code
  • 虚拟滚动/懒加载列表的渲染完成时间——LCP/FMP 无法覆盖
  • WebSocket/SSE 等长连接的业务层耗时——技术层指标(连接建立)和业务层指标(首条消息到达)是两回事
  • 复杂交互的响应时间(拖拽排序、富文本编辑、画布操作)——事件监听无法自动判定交互完成

需要结合的场景(自动化采集 + 手动补充上下文):

  • 接口耗时分布:自动化采集耗时和状态码,手动补充接口的业务参数(如 page_size=100 vs page_size=20)用于分组分析
  • 首屏渲染:自动化采集 FCP/LCP,手动标记「首屏关键模块可见」作为补充
  • 错误监控:自动化捕获堆栈和错误信息,手动补充用户操作路径(面包屑)

落地方案:用 PerformanceObserver 统一收拢

手动打点最容易失控的不是打点本身,而是上报逻辑散落各处。我的做法是:业务代码只负责 performance.mark(),用一个统一的 instrumentation 模块通过 PerformanceObserver 监听并上报。

// instrumentation.ts
const OBSERVED_MEASURES = [
  'checkout_flow',
  'search_response',
  'list_render',
  'file_export',
];

const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.entryType === 'measure' && OBSERVED_MEASURES.includes(entry.name)) {
      reportMetric({
        name: entry.name,
        duration: entry.duration,
        startTime: entry.startTime,
        timestamp: Date.now(),
      });
    }
  }
});

observer.observe({ entryTypes: ['measure'] });

业务代码只需要两行:

performance.mark('checkout_start');
// ... 业务流程
performance.mark('checkout_end');
performance.measure('checkout_flow', 'checkout_start', 'checkout_end');

这个方案的额外好处是:performance.measure 生成的条目可以通过 Chrome DevTools 的 Performance 面板直接可视化,本地调试时不需要上报就能看到耗时瀑布。

常见问题

手动打点和自动化工具的数据怎么统一分析?

两类数据统一用 performance.measure 的时间戳对齐。自动化工具采集的 Navigation Timing 和 Resource Timing 本身就有 startTimeduration,手动打点生成的 measure 也有相同字段。在上报时统一转成毫秒级的时间戳,以页面加载的 navigationStartfetchStart 作为时间原点,就能在同一个时间轴上对齐。Grafana 或自建看板按 timestamp 关联即可。

手动打点多了会不会影响性能?

performance.markperformance.measure 本身是浏览器原生 API,开销在微秒级,单次调用可以忽略。真正有开销的是上报逻辑(序列化、网络请求)。所以关键不是少打点,而是把上报逻辑从业务代码中剥离出来:mark/measure 可以打几十个,但上报只通过一个 PerformanceObserver 回调批量处理,用 requestIdleCallback 或定时器做 debounce,避免在主线程繁忙时抢占资源。

自动化工具的自定义事件和手动打点有什么区别?

大部分 APM SDK 的自定义事件(如 sdk.track('event_name', properties))本质上也是手动打点,只是上报链路用了 SDK 的通道。它和 performance.mark 的核心区别在于:自定义事件走的是 SDK 的私有协议,无法在 Chrome DevTools 的 Performance 面板里直接看到,也无法和其他 performance entry 在浏览器层面对齐时间轴。我的建议是:打点用 W3C 标准的 User Timing API(mark/measure),上报可以用 SDK 的通道,但不要用 SDK 的私有打点方法。这样既能享受 SDK 的采集传输能力,又保留了标准 API 的调试和互操作性。

老项目已经有几百个手动打点,怎么迁移?

不用迁移。手动打点本身不是问题,问题是打点代码和上报代码耦合。重构的方向是把上报逻辑统一抽到 PerformanceObserver 里,原有的 performance.mark 调用保留不动。如果之前用的是自定义上报函数(如 reportTime('checkout', duration)),可以逐步替换为 performance.measure,但不替换也不影响数据质量——只是损失了 DevTools 可视化调试的便利性。优先级应该是:先统一上报链路,再逐步标准化打点 API。