跳到主内容

前端监控 SDK 从零实现:错误采集、Web Vitals 指标与上报策略的完整落地 ​

线上出了白屏,用户在客服那边描述了十分钟,研发这边却连一条报错都找不到——这是没有监控体系的团队的日常。等第三方监控的商业报价摆上桌,或者数据合规要求日志不出内网时,「自己写一个监控 SDK」就成了必选项。好消息是,SDK 的核心链路并不神秘:错误采集、性能指标、上报策略三块拼图,每块都有浏览器标准 API 支撑。

本文带你把这三块拼完整,产出的是一个单文件、无依赖、可直接嵌入业务的 SDK 骨架。写完你会发现:难点从来不是 API 不熟悉,而是「采到之后怎么办」——跨域静默错误、上报风暴、数据丢失,这些坑我们逐个填平。

错误采集:四条捕获通道一个都不能少 ​

前端错误有四种形态,各有专属的捕获通道,缺一条就有盲区:同步 JS 错误走 window.onerror;未处理的 Promise 拒绝走 unhandledrejection;资源加载失败(图片、脚本、字体)要靠捕获阶段的 error 事件;接口异常则需要在请求库层面统一拦截。核心实现如下:

js
// monitor-sdk.js:错误采集核心(可直接引入运行)
const sdk = { queue: [], appId: 'demo' }

// 通道一:同步运行时错误
window.addEventListener('error', (event) => {
  // event.target 有值说明是资源加载错误,不是脚本错误,分流处理
  if (event.target && (event.target.src || event.target.href)) {
    report({
      type: 'resource-error',
      url: event.target.src || event.target.href,
      tagName: event.target.tagName
    })
    return
  }
  report({
    type: 'js-error',
    message: event.message,
    file: event.filename,
    line: event.lineno,
    col: event.colno,
    stack: event.error?.stack?.slice(0, 2000)  // 截断超长堆栈,控制上报体量
  })
}, true)  // 第三参数 true:必须在捕获阶段监听,资源错误不冒泡

// 通道二:未处理的 Promise 拒绝
window.addEventListener('unhandledrejection', (event) => {
  report({
    type: 'promise-error',
    message: String(event.reason?.message ?? event.reason).slice(0, 500)
  })
})

// 通道三:接口异常——在请求封装层统一埋点(示例为 fetch 包装)
const rawFetch = window.fetch
window.fetch = async (...args) => {
  const start = performance.now()
  try {
    const res = await rawFetch(...args)
    if (!res.ok) report({ type: 'api-error', url: args[0], status: res.status, cost: performance.now() - start })
    return res
  } catch (err) {
    report({ type: 'api-error', url: args[0], status: 0, message: String(err) })
    throw err
  }
}

三处细节决定采集质量。第一,资源错误监听必须传 true 用捕获阶段——error 事件在资源元素上不冒泡,普通监听会漏掉全部资源错误。第二,堆栈要做截断与清洗,长堆栈既浪费流量又可能带出内部路径。第三,包装 fetch 时要保持原函数行为(参数透传、异常继续抛出),否则 SDK 自己就成了业务故障源——监控代码的第一原则是不引入新问题。

性能指标:直接用 web-vitals 官方库,不要手搓 ​

Core Web Vitals(LCP、CLS、INP)的采集逻辑极其讲究:INP 需要统计整个页面生命周期内所有交互的耗时取最差值,LCP 依赖 PaintTiming 与元素检测,自己实现大概率与真实口径有偏差。官方的 web-vitals 库(npm 包,约 1KB+)是唯一正解,SDK 里直接消费它的回调:

js
// 性能指标采集:官方 web-vitals 库,口径与 Google 一致
import { onLCP, onCLS, onINP, onTTFB } from 'web-vitals'

const metrics = {}
onLCP(m => (metrics.lcp = m.value))   // 最大内容绘制
onCLS(m => (metrics.cls = m.value))   // 累积布局偏移
onINP(m => (metrics.inp = m.value))   // 交互到下一帧绘制(2024 年起取代 FID)
onTTFB(m => (metrics.ttfb = m.value)) // 首字节时间

// 页面隐藏时把累积指标一并上报,避免「指标采到了但没发出去」
document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') report(metrics, true)
})

除指标值之外,web-vitals 的 attribution 构建(web-vitals/attribution)还能给出 INP 对应的交互元素与 LCP 对应的资源 URL,定位「哪个按钮拖慢了整个页面」这类问题时是关键证据,建议一并启用。

上报策略:批量合并 + sendBeacon + 采样率 ​

采到的数据不能一条一发——高流量页面一天能产生百万级事件,把上报请求本身变成性能事故。成熟的上报策略是三层漏斗:内存队列批量合并(比如每 10 秒或满 10 条触发一次)、页面卸载时用 navigator.sendBeacon 兜底(浏览器保证在页面关闭后仍完成发送)、再叠加采样率控制总量:

js
// 上报模块:批量合并 + sendBeacon 优先 + img 兜底
let timer = null

function report(data, immediate = false) {
  // 采样率控制:随机数大于阈值则丢弃,控制上报总量(示例 20%)
  if (!immediate && Math.random() > 0.2) return

  sdk.queue.push({ ...data, ts: Date.now(), appId: sdk.appId, page: location.pathname })

  if (immediate) return flush()
  clearTimeout(timer)
  timer = setTimeout(flush, 10000)  // 批量窗口:10 秒内的数据合并成一次请求
}

function flush() {
  if (sdk.queue.length === 0) return
  const body = JSON.stringify(sdk.queue.splice(0))
  const url = 'https://monitor.internal/api/collect'

  // sendBeacon:页面关闭也不中断发送,且不阻塞卸载流程;但单次有约 64KB 上限
  if (navigator.sendBeacon && new Blob([body]).size < 64000) {
    navigator.sendBeacon(url, new Blob([body], { type: 'application/json' }))
  } else {
    // 兜底方案:keepalive fetch 或 1x1 gif 打点,保证极端环境也有出路
    fetch(url, { method: 'POST', body, keepalive: true }).catch(() => {})
  }
}

服务端收到的是「设备指纹 + 会话 id + 事件序列」,配合 SourceMap 还原服务(sourcemap 文件只留在内网服务,绝不随产物发布)即可把压缩后的堆栈翻译回源码行号,错误定位从「猜」变成「看」。

采到只是起点:消费侧的三块拼图 ​

数据采集只是 SDK 的上半场,消费侧的设计决定它的最终价值。第一块是错误聚类去重:同一堆栈前缀归并为一个 issue、按版本与页面维度聚合计数,否则一次发布引发的报错会把告警群刷成瀑布,真正的新问题反而被淹没。第二块是行为轨迹:错误发生时带上最近若干次用户操作(点击的按钮、路由跳转序列)与基础环境(UA、屏幕尺寸、网络类型),排障就能从「有个 TypeError」推进到「用户在订单页点击导出后必现」,定位效率完全不同。第三块是版本对齐:每条上报都标记前端构建版本,SourceMap 按「版本 + 文件哈希」归档,灰度期的错误能精确归属到某一次发布。这三件事都放在服务端完成,SDK 采集端保持轻量——采集端做减法、分析端做加法,是监控体系分工的基本盘。

SDK 自身的版本治理与心跳观测 ​

最后补一个容易被忽略的维度:SDK 的版本治理。监控 SDK 是全站强制引入的基础依赖,一旦出问题就是全站事故,因此发布节奏必须比业务代码保守——语义化版本、破坏性变更走 major、旧版本至少维护两个迭代窗口;同时 SDK 要上报「运行状态心跳」(初始化成功、采集量、上报成功率),让「SDK 挂了没人知道」这个最隐蔽的风险也有观测手段。我们吃过一次亏:某版本在低版本 WebView 上初始化抛错,业务代码因加载顺序幸免,但监控整整盲了两周才被发现。心跳机制上线后,同类问题的发现时间从「周」级降到「分钟」级——监控自己的监控,是监控体系真正的最后一环。

避坑总结 ​

  1. Script error 静默化要先配 CORS。跨域脚本报错时浏览器只给 Script error. 两个字,根因是安全策略;处理方式是 CDN 响应加 Access-Control-Allow-Origin,同时 script 标签加 crossorigin 属性,两者缺一不可,漏一步就回到静默。
  2. sendBeacon 不是万能投递。单次负载约 64KB 上限、部分老环境不支持、失败无回调。大 payload 切片发送,不支持的环境走 keepalive fetch 或 gif 打点兜底,并把「上报失败计数」本身也监控起来。
  3. 采样率必须分级。错误类事件低采样甚至全量(错误本来就少),性能指标中采样,行为日志高采样。一刀切 100% 全量会在流量高峰把后端打挂,一刀切 1% 又会让偶发错误永远采不到。
  4. SDK 初始化要隔离自身异常。采集代码全部包 try-catch,SDK 报错绝不能波及业务;初始化时机放在业务脚本最前面,否则页面早期错误全部漏采。
  5. 无痕模式与隐私合规要前置考虑。localStorage 在无痕模式可能受限、设备指纹类信息受隐私法规约束;采集字段建立白名单制度,用户标识优先用服务端会话而非永久指纹,合规评审先于上线。

写在最后 ​

自建监控 SDK 的完整链路是:四条通道采错误、官方库采指标、三层漏斗管上报,再加服务端 SourceMap 还原闭环。第一版不必追求大而全,先把「错误 + 页面性能」两条主线跑通,就能解决 80% 的线上排障需求;埋点规范、告警策略、看板这些「采后工程」随着使用反馈逐步补齐。监控的本质是给线上系统装上神经系统——感知先于修复,看见先于解决。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。