前端监控与可观测性:RUM 采集、Sourcemap 错误还原、性能采样与告警闭环

从零构建前端监控体系:RUM 真实用户指标采集(PerformanceObserver + sendBeacon)、JS 错误上报与 Sourcemap 堆栈还原、性能采样率与分位数聚合设计、Session Replay 实现思路,以及告警闭环与根因分析流程,附可落地的采集 SDK 代码。

一、引言

「后端挂了我们会知道,前端卡了没人察觉」——这是很多团队的真实状态。前端监控(RUM,Real User Monitoring)解决的是真实用户在真实设备上的体验与故障:页面 4 秒才显示、某个老机型用户白屏、生产环境报了一堆 minified 错误却无法定位。这些问题实验室永远测不出来,只有把埋点铺到真实用户身上才能看见。

本文从零构建一套可落地的前端监控体系:RUM 指标采集(PerformanceObserver + sendBeacon)、JS 错误上报与 Sourcemap 堆栈还原、性能采样的统计设计、Session Replay 的实现思路,以及从告警到根因的闭环流程。指标的语义基础可参考 Core Web Vitals 深入,接入层也可用 Vercel Analytics(见 Vercel Analytics & Speed Insights)快速起步,但本文讲的是自己做、可掌控的方案。


二、RUM 采集原理与实现

2.1 采集什么

RUM 采集分四类:

类型数据点来源 API
性能指标LCP / INP / CLS / TTFB / FCPPerformanceObserver + navigation entries
资源加载img/script/fetch 的耗时与失败PerformanceObserver('resource')
错误JS 异常、未捕获 Promise、资源加载失败window.onerror / unhandledrejection
行为路由切换、点击、页面生命周期手动埋点 + pagehide

2.2 核心采集代码

一个最小 RUM SDK(TypeScript):

// rum.ts — 最小可运行 RUM 采集器
const ENDPOINT = 'https://monitor.example.com/collect'

type Metrics = {
  lcp?: number
  cls?: number
  inp?: number
  ttfb?: number
  fp?: number
  url: string
  ua: string
  ts: number
  vitals?: Record<string, string>
}

function report(payload: Record<string, unknown>) {
  // 页面卸载/切后台时也要发出去 → sendBeacon 不被中断
  if (navigator.sendBeacon) {
    navigator.sendBeacon(
      ENDPOINT,
      new Blob([JSON.stringify(payload)], { type: 'application/json' }),
    )
  } else {
    fetch(ENDPOINT, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(payload),
      // keepalive:允许在页面卸载时继续发送
      keepalive: true,
    })
  }
}

// 1) 收集 Web Vitals
let cls = 0
const metrics: Metrics = {
  url: location.href,
  ua: navigator.userAgent,
  ts: Date.now(),
}

new PerformanceObserver((list) => {
  const entries = list.getEntries()
  metrics.lcp = entries[entries.length - 1]?.startTime
  report({ type: 'lcp', ...metrics })
}).observe({ type: 'largest-contentful-paint', buffered: true })

new PerformanceObserver((list) => {
  for (const e of list.getEntries()) {
    if (!e.hadRecentInput) cls += e.value
  }
  metrics.cls = cls
}).observe({ type: 'layout-shift', buffered: true })

// INP 需要等会话结束或兜底上报
window.addEventListener('pagehide', () => {
  report({ type: 'page', ...metrics })
}, { once: true })

// 2) TTFB:navigation entry
const nav = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming
if (nav) {
  metrics.ttfb = nav.responseStart
  report({ type: 'ttfb', ...metrics })
}

// 3) 错误采集
window.addEventListener('error', (ev) => {
  report({ type: 'error', message: ev.message, stack: ev.error?.stack })
}, true) // 捕获阶段捕获资源加载错误

window.addEventListener('unhandledrejection', (ev) => {
  report({ type: 'error', message: String(ev.reason) })
})

2.3 上报时机与体积控制

  • 时机:LCP/CLS 在 observer 回调里即可报;INP 建议页面 pagehide 或 10s 兜底统一报;TTFB 立即报。
  • 合并:把同页面的多条事件合并成一个批次,sendBeacon 一次发完,降低请求量。
  • 体积:每条 ≤ 10KB,压缩 UA、裁剪堆栈(保留前 20 帧)。
  • 去重:同 URL + 同错误签名去重,避免循环报错打爆上报管道。

三、错误上报与 Sourcemap 堆栈还原

3.1 生产环境是 minified 的

生产代码经压缩混淆后,error.stack 长这样:

TypeError: Cannot read properties of undefined (reading 'map')
    at o.ev (https://cdn.example.com/_next/static/chunks/main-abc123.js:1:23456)
    at s.sj (https://cdn.example.com/_next/static/chunks/app.js:1:12345)

没法直接定位源码。要还原,需要 Sourcemap + 后端符号化(symbolication)。

3.2 构建期上传 Sourcemap

关键决策:Sourcemap 只上传给监控平台,绝不下发到浏览器(否则源码裸奔)。Next.js 配置:

// next.config.js
module.exports = {
  productionBrowserSourceMaps: false, // 不给浏览器
  experimental: {
    // 构建后把 map 文件交给脚本上传(如 Sentry/自建符号化服务)
  },
  webpack(config, { isServer, dev }) {
    if (!dev && !isServer) {
      config.devtool = 'hidden-source-map'
    }
    return config
  },
}
# 构建后上传 sourcemap(示例脚本)
npx sentry-cli sourcemaps upload --org=... --project=... ./out/_next/static/chunks

3.3 后端符号化

监控后端拿到堆栈与 sourceMappingURL 后,查 Sourcemap 还原出源码位置 + 原始代码行:

// symbolicate.ts — 用 source-map 库还原堆栈
import { SourceMapConsumer } from 'source-map'

export async function symbolicate(stack: string, sourcemaps: Map<string, Buffer>) {
  const consumer = await new SourceMapConsumer(await sourcemaps.get(url).json())
  return stack.split('\n')
    .map((line) => {
      const m = line.match(/at .* \(.*?:(\d+):(\d+)\)/)
      if (!m) return line
      const pos = consumer.originalPositionFor({
        line: +m[1], column: +m[2],
      })
      if (pos.source && pos.line) {
        const source = pos.source.split('/').pop()
        return `    at ${pos.name ?? '<anonymous>'} (${source}:${pos.line}:${pos.column})`
      }
      return line
    })
    .join('\n')
}

还原后:

TypeError: Cannot read properties of undefined (reading 'map')
    at processList (src/components/List.tsx:42:18)
    at render (src/pages/index.tsx:18:9)

3.4 错误聚合与分级

按「错误签名」聚合,分级处理:

等级示例动作
Critical白屏、主流程抛错、崩溃立即告警 + 追查发布
Warning次级功能异常、资源加载失败日会复盘
Info用户操作路径异常、兼容性错误汇总趋势
// 错误签名:message + 首帧源码文件:行号 → 决定聚合桶
function signature(err: { message: string; stack?: string }) {
  const file = err.stack?.match(/\(?([^:)]+\.(tsx?|jsx?|js)):(\d+):\d+\)?/)
  return `${err.message}|${file?.[1] ?? 'unknown'}:${file?.[3] ?? '0'}`
}

四、性能采样与分位数聚合

4.1 为什么要采样

100% 采集在流量大时是灾难:每秒上万条 beacon 打爆存储与费用。RUM 通常按流量比例采样,但采样必须保证统计代表性。

4.2 采样策略设计

策略适用缺点
固定比例(如 10%)大流量、指标类小流量页面样本不足
自适应采样页面流量不均时实现复杂
错误全采 + 性能采样错误必采、性能抽采需要两套通道
关键页面必采 + 其他抽采转化页、核心漏斗需要页面白名单
// 分布式采样:按 user id / session id 哈希,保证同一用户一致性
const SAMPLE_RATE = 0.1 // 10%
function shouldSample(seed: string) {
  // 简单一致性哈希,同一 seed 始终同结果
  let hash = 0
  for (let i = 0; i < seed.length; i++) {
    hash = (hash * 31 + seed.charCodeAt(i)) | 0
  }
  return (hash & 0xffff) / 0xffff <= SAMPLE_RATE
}

⚠️ 用「随机数」采样会导致同一会话的多个事件被随机切断;用「用户/会话 id 哈希」采样,整个会话一致采或不采,Session Replay 才能完整。

4.3 分位数聚合,不看平均数

平均数被长尾用户拉高,掩盖真实分布。看 P50 / P75 / P95 / P99:

-- ClickHouse 或 PostgreSQL 聚合示例(P75)
SELECT
  url,
  count() AS n,
  quantile(0.75)(lcp) AS lcp_p75,
  quantile(0.90)(inp) AS inp_p90,
  quantile(0.75)(cls) AS cls_p75
FROM rum_events
WHERE ts >= now() - INTERVAL 1 HOUR
GROUP BY url
ORDER BY lcp_p75 DESC
分位代表
P50中位用户体验(正常)
P75谷歌 CWV 官方口径,代表多数用户
P95/P99长尾、弱网、低端设备——最容易出问题的用户

优化目标按 P75 对齐 CWV 阈值,同时用 P95 盯长尾。


五、Session Replay:回放用户真实操作

5.1 原理:事件流重放

Session Replay 不是录屏视频,而是记录 DOM 变异 + 用户输入 + 网络请求的时间流,后端重放:

[记录] [DOM 快照] [click] [input "abc"] [网络 res] [DOM diff] [scroll] ...
        ↓ 重放引擎按时间线逐帧应用
// replay.ts — 最小 DOM 变异采集
import { patch } from 'rrweb' // 业界常用库

patch((event) => {
  // 批量缓存,按时间片打包发送
  buffer.push(event)
  if (buffer.length >= 50) flush()
})

5.2 隐私与成本控制

  • 掩码:输入框、密码、信用卡号用 maskText 脱敏;可用 data-rs-ignore 标记敏感区。
  • 降采样:只对 shouldSample 命中的会话录制,replay 数据量大(一个会话可达 MB 级)。
  • 保留策略:只保留有错误/异常指标的会话(如报错会话、白屏会话),正常会话 24h 后清理。
// 只在出问题时才保留完整 replay
if (errorSignature) {
  persistReplay(sessionId, replayStream) // 标记保留
} else {
  discardReplay(sessionId)
}

Session Replay 的价值在于把「指标差」变成「看得见的过程」:LCP 差在回放里是图片加载、布局抖动还是字体闪烁,一目了然,直接指导优化。


六、告警闭环与根因分析

6.1 告警设计:别把「事件」当「告警」

监控不是「有错就告警」——生产环境每分钟都有噪声。告警应建立在聚合后的异常上:

告警类型触发条件示例
崩溃率突增错误数 / 会话数 比值突增(环比)JS Error 率 > 2%,较昨日翻倍
性能回归核心页面 P75 指标越过阈值LCP P75 > 2.5s 持续 10 分钟
白屏/首屏失败FCP 缺失 或 FP 超阈值FP P95 > 8s
地域/版本异常某版本或地区指标突变Chrome 129 用户 INP 突增
// 自建告警:滑动窗口 + 环比
const ratio = errorsInWindow / sessionsInWindow
if (ratio > 0.02 && ratio > baseline * 2) {
  await notify({ channel: 'oncall', msg: `JS Error 率 ${ratio.toFixed(3)}` })
}

6.2 根因定位流程(闭环)

1. 告警触发 → 看指标 → 是否某个 URL/版本/地区突增
2. 拉该会话 Session Replay → 定位复现路径
3. 错误列表 → Sourcemap 符号化 → 定位源码文件:行
4. 关联发布记录 → 找出对应部署时间戳
5. 回滚 / 修复 → 验证指标回落 → 更新告警基线
# 关联发布:给部署打 tag,监控里按 version 过滤
git tag release-2026-09-27 && git push origin release-2026-09-27
# 前端 SDK 带上版本
navigator.sendBeacon(ENDPOINT, new Blob([JSON.stringify({
  version: __BUILD_VERSION__,  // 构建注入
})]))

6.3 观测数据分层

层工具用途
指标层RUM SDK + 分位数趋势、告警
事件层错误流 + 资源失败问题定位
会话层Session Replay用户视角复现
追踪层前端 trace + 后端 trace 关联端到端耗时(用 traceparent 头透传)
// 把 trace id 透传给后端,打通前后端链路
const traceId = crypto.randomUUID()
fetch(url, {
  headers: { 'x-trace-id': traceId },
})
// 后端日志带同一 trace-id,前端报错也带 → 一次请求全链路可查

七、落地清单与最佳实践

实践理由
错误全采、性能按会话哈希采样成本可控且统计代表
Sourcemap 只传监控平台,不下发浏览器还原堆栈且不泄露源码
指标用 P75/P95,不用平均值长尾可见,对齐 CWV 口径
用 sendBeacon + keepalive 上报页面卸载时不丢数据
会话级采样 + 错误会话保留 replay存储省、问题可回放
告警建立在聚合与环比上过滤噪声,避免告警疲劳
前后端共享 trace id一次故障全链路可查
持续度量,指标不进则退RUM 是系统工程,不是上线一次

八、总结

前端监控体系的本质是把「看不见的用户体验」变成「可度量、可回放、可告警」的工程资产。四件事缺一不可:RUM 指标采集(PerformanceObserver + sendBeacon)告诉你「慢在哪」,错误上报 + Sourcemap 告诉你「坏在哪」,采样与分位数聚合告诉你「多严重」,Session Replay 与告警闭环告诉你「为什么」。配合性能优化的优化动作,形成「度量 → 优化 → 再度量」的正循环——这正是可观测性要解决的问题。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「tools」更多文章

  1. Serverless 冷启动优化:成因拆解、运行时选型、函数合并与预启动策略
  2. 全栈框架深度对比:Next.js vs Nuxt vs Astro vs SvelteKit vs Remix
  3. 边缘缓存策略:Cache-Control 语义、Stale-While-Revalidate 与 CDN 缓存键归一化