前端监控从 Web Vitals 开始:别只在用户投诉后才看性能
前端性能问题最尴尬的地方,是用户已经觉得慢了,开发还没有任何证据。
页面打不开、按钮点了没反应、列表加载很久、移动端滑动卡顿,这些反馈如果只靠用户截图和一句“很慢”,排查效率会非常低。真正可靠的方式,是在问题发生前就把关键指标采集起来。
2024 年做前端项目,我越来越觉得监控不是“大厂才需要”的东西。哪怕是个人博客、管理后台、小型 SaaS,只要有真实用户,就应该知道页面在真实设备上的表现。
一个后台系统在测试环境跑得飞快、Chrome DevTools 里 Lighthouse 也是绿的,并不能说明真实用户那边同样流畅。测试机器配置好、内网直连,跟真实用户手里那台低配办公本、走代理的网络环境完全是两个世界,本地怎么点都不会复现的“卡死了”反馈,原因往往就藏在这个差异里。这也是本地跑分(实验室数据)和真实用户数据(RUM,Real User Monitoring)的根本区别:前者是体检报告,后者才是真实病历,两者不能互相替代。监控要采集的正是后者——只有拿到真实设备、真实网络下的数据,才知道页面在用户手里到底什么样。
先从 Web Vitals 开始
Web Vitals 是浏览器侧最值得优先关注的一组体验指标。
常见指标包括:
- LCP:最大内容绘制,反映主要内容加载速度
- CLS:累计布局偏移,反映页面是否乱跳
- INP:交互到下一次绘制,反映交互响应速度
- FCP:首次内容绘制,反映页面何时开始显示内容
- TTFB:首字节时间,反映服务端响应和网络链路
2024 年很重要的一点是 INP 正式取代 FID 成为 Core Web Vitals 的交互指标。FID 只关注首次输入延迟,INP 更关注整个页面生命周期内的交互响应,对真实体验更接近。
如果只能选三个指标,我会先看:
- LCP:用户多久看到主内容
- CLS:页面有没有跳动
- INP:用户操作后有没有卡顿
这三个指标覆盖了加载、稳定性和交互。
这几个指标在规范层面是怎么定的,知道口径才不会被数字带跑偏。LCP 的“最大内容”并不是按字节算的,而是浏览器在渲染过程中持续比较候选元素(图片、视频首帧、背景图、块级文本节点)的可见面积,取面积最大的那个,它的时间戳就是这个元素被绘制出来的那一刻。关键是它会不断更新——先看到一张占位图,后面又加载出一张更大的主图,LCP 就会跳到后者;但一旦用户发生任何交互(点击、滚动、键盘),浏览器就立刻冻结 LCP,因为再往后的内容变化已经不算“首屏加载体验”了。这条规则能解释一个容易让人费解的现象:同一个页面,有的用户上报 LCP 1.8 秒,有的 4 秒,差异不全是网速,而是手快的用户刚进页面就划了一下,把 LCP 提前定格在了一个较小的元素上。这种“被交互截断”的样本会让 LCP 数据看起来虚低,分析时心里得有数。
CLS 的算法也值得抠一下。它不是简单累加每次位移,而是按“会话窗口(session window)”聚合:连续发生、间隔不超过 1 秒、且总时长不超过 5 秒的一批位移算作一个窗口,最终 CLS 取所有窗口里得分最高的那个,而不是全程总和。每次位移得分 = 影响面积比例 × 距离比例。理解这个口径很重要,因为它意味着“分散在用户停留全程的零星抖动”不会无脏地累加成一个吓人的大数字,真正被惩罚的是“短时间内一连串的剧烈跳动”——通常就是首屏加载那几秒里图片、字体、广告陆续就位时的集中抖动。
刚接入 INP 时,整体分数经常会突然变难看一大截,容易让人怀疑是不是采集逻辑写错了。其实这是两个指标的度量范围不同:FID 只量“首次点击到浏览器开始处理”的那一小段空窗,而 INP 量的是从用户点击/输入,到界面真正画出反馈的整个延迟,包含了事件处理、React 的重渲染、甚至同步的布局计算。也就是说,以前 FID 藏起来的那些“点了之后转半天圈”的卡顿,INP 全给抖出来了。这不是网站退化了,是以前的指标口径太宽容,没把交互后半程的延迟算进去。
真要定位 INP,光知道数值没用,得知道是哪个交互慢。我后来在采集时把 INP 条目里的 target 和 interactionId 也带上,配合长任务(long task)一起看,才能定位到是某个长列表的 onClick 里同步做了重活。CLS 同理,单看一个数字不知道是哪块在跳,得记录 LayoutShift 条目里的 sources,把跳动的 DOM 节点选择器记下来——十有八九是图片没写宽高、或者异步插了一块广告/提示条把内容顶下去了。
把 INP 的“归因”做到能落地,光记 target 还不够,我后来上了 event-timing 这个 entry 类型,它能把一次交互拆成三段,对应排查方向完全不同:
1new PerformanceObserver((list) => { 2 for (const entry of list.getEntries()) { 3 // duration 太短的不关心,只看慢交互 4 if (entry.duration < 100) continue 5 const inputDelay = entry.processingStart - entry.startTime // 事件排队等待主线程的时间 6 const processing = entry.processingEnd - entry.processingStart // 你自己事件处理函数的耗时 7 const presentation = entry.startTime + entry.duration - entry.processingEnd // 处理完到下一帧渲染出来 8 report({ 9 name: 'interaction-debug', 10 eventType: entry.name, // pointerdown / click / keydown ... 11 target: nodeSelector(entry.target), 12 inputDelay, processing, presentation, 13 }) 14 } 15}).observe({ type: 'event', durationThreshold: 16, buffered: true })
这三段一拆,问题立刻收敛:inputDelay 大,说明点击的时候主线程正被别的长任务占着(往往是第三方脚本或一段同步的初始化),要去削长任务;processing 大,是你自己的 onClick 里干了重活,搬到下一帧或 requestIdleCallback 里;presentation 大,是这次交互触发的重渲染/重排太贵,得查是不是动了布局、是不是渲染了一棵巨大的列表。我们那个长列表的卡顿最后定位下来就是典型的 processing 偏高——onClick 里同步算了一遍全量筛选。
用 PerformanceObserver 采集指标
浏览器提供了 PerformanceObserver,可以采集很多性能条目。
一个简化的 LCP 采集示例:
1function observeLcp(report) { 2 const observer = new PerformanceObserver((entryList) => { 3 const entries = entryList.getEntries() 4 const lastEntry = entries[entries.length - 1] 5 6 report({ 7 name: 'LCP', 8 value: lastEntry.startTime, 9 rating: getLcpRating(lastEntry.startTime), 10 }) 11 }) 12 13 observer.observe({ type: 'largest-contentful-paint', buffered: true }) 14} 15 16function getLcpRating(value) { 17 if (value <= 2500) return 'good' 18 if (value <= 4000) return 'needs-improvement' 19 return 'poor' 20}
这段代码能跑,但说实话我现在不会手写这个。LCP 看着简单,真正的边界条件多得吓人:用户切到后台标签页时 LCP 要不要停止计算、bfcache(前进后退缓存)恢复的页面怎么算、软导航(SPA 路由切换)算不算新的一次 LCP……这些坑 web-vitals 这个官方库都替你处理过了,而且它的取值口径和 Chrome 用户体验报告(CrUX)、Search Console 是对齐的。我自己手写过一版,上线两周后发现移动端 LCP 数据偏低,排查半天才知道是没处理标签页可见性,白白浪费时间。
所以实际项目我直接上 web-vitals,自己只负责“拿到值之后怎么上报”这部分:
1import { onLCP, onCLS, onINP, onFCP, onTTFB } from 'web-vitals' 2 3const report = (metric) => { 4 sendMetric({ 5 name: metric.name, 6 value: metric.value, 7 rating: metric.rating, // 库已经按 good/needs-improvement/poor 分好了 8 delta: metric.delta, // 增量,多次回调时只上报变化量 9 id: metric.id, // 同一次页面访问内的唯一 id,用于去重 10 navigationType: metric.navigationType, // navigate / reload / back-forward 11 }) 12} 13 14onLCP(report) 15onCLS(report) 16onINP(report) 17onFCP(report) 18onTTFB(report)
这里有个容易忽略的点:CLS 和 INP 这类指标会随用户停留不断更新,库默认是在页面卸载/隐藏时回调最终值,所以你拿到的是“这次访问最终的体验”。delta 和 id 是为了让你能按需做增量上报又不重复计数,别自己再去做一套 diff。核心思路和上面手写版一样:指标发生时记录数值,再和页面、设备、网络、版本等上下文一起上报。
上报函数要尽量轻,最直接的想法是只用 navigator.sendBeacon 把数据丢出去——它适合页面卸载或后台上报,不会像普通请求一样明显干扰用户操作,哪怕页面正在关闭、标签页正在被销毁,浏览器也会保证把这个请求发出去,而普通 fetch 在 unload 阶段经常被直接掐掉,数据就丢了。但只用它有个局限:它只能发 POST,body 有大小上限(通常 64KB 左右),breadcrumbs 攒多了真能超;而且不支持 sendBeacon 的环境(或超限时)完全没有退路,数据直接丢失。
它返回的也只是“浏览器是否接受了这个发送任务”的布尔值,并不代表服务端真的收到了,所以别指望靠它的返回值做重试。更稳的做法是优先 sendBeacon,超限或不支持时降级到带 keepalive: true 的 fetch:
1function sendMetric(metric) { 2 const body = JSON.stringify({ 3 ...metric, 4 path: location.pathname, 5 connection: navigator.connection?.effectiveType, 6 userAgent: navigator.userAgent, 7 timestamp: Date.now(), 8 }) 9 10 if (navigator.sendBeacon && body.length < 60_000) { 11 navigator.sendBeacon('/api/metrics', body) 12 } else { 13 fetch('/api/metrics', { method: 'POST', body, keepalive: true }) 14 } 15}
另外特别提醒一点:上报的时机别只挂在 beforeunload 或 unload 上。移动端用户切后台、息屏、从多任务里划掉,很多时候根本不会触发 unload,数据就静默丢了。更可靠的是监听 visibilitychange,在 document.visibilityState 变成 hidden 时把待上报的数据 flush 出去——web-vitals 内部也是这么做的。这个细节我是在对比线上 PV 和监控上报量发现两边差了快两成之后才补上的。
补一个更隐蔽的丢数据场景:bfcache。用户点了浏览器的“后退”,页面常常是从前进后退缓存里整页复活的,并不会重新执行你的脚本,也不会触发 load。如果你的上报兜底只挂在 unload,那这类访问从进入到离开你可能一条数据都收不到;更麻烦的是,监听了 unload 或 beforeunload 本身会让页面失去进入 bfcache 的资格,反而拖慢了用户的前进后退体验。所以现在我的收尾逻辑一律走 pagehide + visibilitychange,彻底不碰 unload:
1let flushed = false 2const finalFlush = () => { 3 if (flushed) return 4 flushed = true 5 flush() // 走 sendBeacon 6} 7// 进 bfcache 或真正卸载,都会触发 pagehide 8addEventListener('pagehide', finalFlush) 9// 切后台 / 息屏 / 划走,移动端最常走这条 10addEventListener('visibilitychange', () => { 11 if (document.visibilityState === 'hidden') finalFlush() 12}) 13// 从 bfcache 恢复回来,重置标志,开始新一轮采集 14addEventListener('pageshow', (e) => { 15 if (e.persisted) flushed = false 16})
pageshow 里那个 e.persisted 判断尤其关键,它告诉你“这次是从 bfcache 复活的”,这种访问要不要当成一次新的页面浏览、要不要重新跑一遍指标采集,取决于你的口径,但至少得知道它发生了。我就是没处理 persisted,导致一部分前进后退的用户会话莫名其妙地“短了半截”。
监控不能只看平均值
性能数据最容易骗人的地方是平均值。
假设 LCP 平均 2.4 秒,看起来很好。但可能高端设备 1 秒,低端设备 7 秒,平均后刚好不难看。真正需要关注的是分位数,比如 P75、P90、P95。
Web Vitals 官方也更强调看第 75 百分位。因为产品体验不是服务“平均用户”,而是要避免一大批用户明显难受。
上报后可以按维度拆分:
- 页面路径
- 设备类型
- 网络类型
- 浏览器
- 国家或地区
- 应用版本
- 是否登录
很多性能问题只有拆开后才明显。比如桌面端很好,移动端很差;Wi-Fi 很好,4G 很差;首页很好,详情页很差。
只看整体 P75 很容易被掩盖住局部问题。假设看板上首页 LCP 的 P75 一直稳在 2.3 秒、显示绿色,但某个省份的用户大面积反馈页面慢,按地区拆开看才会发现,那个地区加上 Android 低端机的组合,LCP 直接冲到 6 秒以上。这类问题往往是 CDN 在某个区域缺节点,导致请求回源拉了一张没压缩的首屏大图。在不拆维度的总览里这种问题永远看不见——它被大盘里网络好、设备好的用户稀释掉了。
所以我现在的习惯是:看板默认就按 P75 展示,并且关键维度(路径、设备、网络)必须能下钻。还有一个实用技巧,与其盯着分位数的具体数值,不如直接统计“good / needs-improvement / poor 三档各占多少百分比”。Google 评判一个页面是否通过 Core Web Vitals,标准就是“75% 的访问落在 good”,所以这个占比图比一个干巴巴的 P75 数字更能直接指导你“还差多少人达标”。
错误监控要带上下文
性能之外,前端还需要采集运行时错误。
最基本的两个入口:
1window.addEventListener('error', (event) => { 2 reportError({ 3 type: 'runtime', 4 message: event.message, 5 filename: event.filename, 6 lineno: event.lineno, 7 colno: event.colno, 8 stack: event.error?.stack, 9 }) 10}) 11 12window.addEventListener('unhandledrejection', (event) => { 13 reportError({ 14 type: 'promise', 15 message: String(event.reason?.message || event.reason), 16 stack: event.reason?.stack, 17 }) 18})
但只采集错误信息通常不够。你还需要上下文:
- 当前页面
- 用户操作路径
- 接口请求状态
- 前端版本
- 登录用户的匿名 ID
- 浏览器和设备信息
没有上下文的错误很难复现。比如 “Cannot read properties of undefined” 这种错误,如果不知道用户刚点了哪个按钮、接口返回了什么、页面参数是什么,就只能猜。
还有几个实战中绕不开的坑。第一个是 window.onerror 对跨域脚本会吐出臭名昭著的 “Script error.”,message、行号、堆栈全是空的。要拿到真实信息,得给 <script> 标签加 crossorigin="anonymous",并且 CDN 返回正确的 CORS 头,两者缺一不可——这事我配了三次才记住。
第二个是 source map。线上代码都是压缩混淆的,堆栈里全是 a.b.c is not a function 配上 main.a1b2c3.js:1:88421 这种坐标,肉眼根本读不了。正确做法是把 source map 上传到监控平台(Sentry 之类)做服务端还原,绝对不要把 .map 文件随包发到生产环境的 CDN 上,否则你的源码等于公开了。我们的流程是 CI 构建后单独上传 map、再从产物里删掉。
第三个,React 项目里组件渲染期间抛的错根本不会冒泡到 window.onerror,得靠 Error Boundary 去 catch,再手动调 reportError。所以一个完整的错误采集,至少是“全局监听 + Promise 拒绝 + 框架错误边界”三路一起兜。
错误监控接入之后有两类消息特别容易造成误判。一类是 ResizeObserver loop completed with undelivered notifications:这是浏览器在一帧内反复触发 ResizeObserver 回调时抛出的良性警告,绝大多数情况对用户毫无影响,本质上是浏览器给开发者的提示而非真正的运行时错误,不该进“错误”这个统计池;另一类是 Script error.,这类消息几乎全部来自跨域脚本或用户安装的浏览器扩展注入的代码,没有堆栈也没有文件名,跟自己的业务代码往往没有关系。这两类消息如果不提前过滤,一旦触发的频率突然升高(比如某个浏览器扩展更新后变得更"活跃"),监控大盘会瞬间冒出几万条告警,看起来像是线上出了大问题,实际上只是噪声被放大了。判断一条错误是不是真的值得关注,关键看它有没有堆栈、来源是不是自己的代码——没有这两样的,基本可以先过滤掉再说:
1const IGNORE_PATTERNS = [ 2 /ResizeObserver loop/, // 浏览器良性警告 3 /Script error\.?/, // 跨域脚本无信息,多为扩展注入 4 /Non-Error promise rejection/, // reject 了一个非 Error 对象 5 /Loading chunk \d+ failed/, // 这条要单独处理,见下 6] 7 8function shouldReport(err) { 9 const msg = err.message || '' 10 if (IGNORE_PATTERNS.some((re) => re.test(msg))) { 11 // 注意 Loading chunk failed 不是噪声,要单独归一上报 12 if (/Loading chunk/.test(msg)) reportError({ ...err, type: 'chunk-load' }) 13 return false 14 } 15 // 没有堆栈、又来自匿名脚本的,基本是第三方注入,丢弃 16 if (!err.stack && !err.filename) return false 17 return true 18}
里头特意把 Loading chunk failed 拎出来,是因为它看着像噪声、其实是个高价值信号。它通常意味着用户停在老版本页面上,而新版本已经发布、旧的 chunk 文件名带 hash 已经从 CDN 上被清掉了,于是懒加载一个路由直接 404。这种错误骗不了人——它是真实影响用户的,而且解法很明确:捕获到 chunk 加载失败时,提示用户刷新或自动 location.reload() 一次拿新版本。把它和真噪声区分开之后,“错误”大盘能从几万条噪声降到几百条有效条目,告警才重新变得可信。监控最大的敌人不是没数据,而是满屏都是不用管的数据,久了大家就会对告警脱敏。
接口监控是前端体验的一部分
很多页面慢,不是渲染慢,而是接口慢。前端监控应该记录接口耗时和失败率。
可以在请求层统一打点:
1async function request(url, options = {}) { 2 const start = performance.now() 3 4 try { 5 const response = await fetch(url, options) 6 const duration = performance.now() - start 7 8 reportApiMetric({ 9 url: normalizeUrl(url), 10 method: options.method || 'GET', 11 status: response.status, 12 duration, 13 ok: response.ok, 14 }) 15 16 const body = await response.json().catch(() => null) 17 18 if (!response.ok) { 19 throw normalizeApiError(response.status, body) 20 } 21 22 return body 23 } catch (error) { 24 reportApiMetric({ 25 url: normalizeUrl(url), 26 method: options.method || 'GET', 27 status: 0, 28 duration: performance.now() - start, 29 ok: false, 30 message: error.message, 31 }) 32 33 throw error 34 } 35}
normalizeUrl 很重要。不要把完整 URL 和敏感参数直接上报:
1function normalizeUrl(url) { 2 const parsedUrl = new URL(url, location.origin) 3 return parsedUrl.pathname 4}
否则搜索关键词、手机号、token 之类的信息可能进入监控系统。这不是洁癖:如果没做归一化,把带 ?token=xxx 的导出接口完整上报,监控库本身就会被安全审计标记成敏感数据存储,进而牵出一整套数据合规流程,代价远比多写一个归一化函数大。光去 query 还不够,RESTful 风格的路径里 ID 也得收敛,不然 /api/users/123/orders 会因为 ID 不同裂成成千上万条,根本聚不到一起:
1function normalizeUrl(url) { 2 const { pathname } = new URL(url, location.origin) 3 // 把路径里的纯数字 / uuid 段替换成占位符,便于聚合 4 return pathname 5 .replace(/\/\d+(?=\/|$)/g, '/:id') 6 .replace(/\/[0-9a-f]{8}-[0-9a-f-]{27}(?=\/|$)/gi, '/:uuid') 7}
还有一个比单次耗时更值得看的指标:慢请求率和慢请求在总耗时里的占比。光看平均耗时容易被快请求拉平,我更关心“超过 1 秒的请求占比”这种 SLO 式的数字,它和用户的真实感受更接近。另外别忘了把后端的链路 ID(traceId)也带进上报,前端记一条慢请求,后端能凭这个 ID 直接捞出对应的 span,前后端联调甩锅会快很多——这是我做完前后端打通后最爽的一个改进。
用户行为日志要克制
为了复现问题,记录用户行为很有帮助。例如:
1const breadcrumbs = [] 2 3function addBreadcrumb(event) { 4 breadcrumbs.push({ 5 ...event, 6 time: Date.now(), 7 }) 8 9 if (breadcrumbs.length > 20) { 10 breadcrumbs.shift() 11 } 12} 13 14document.addEventListener('click', (event) => { 15 const target = event.target 16 17 addBreadcrumb({ 18 type: 'click', 19 tag: target.tagName, 20 text: target.textContent?.slice(0, 30), 21 }) 22})
错误发生时,把最近的 breadcrumbs 一起上报,可以大幅提升排查效率。
但这件事必须克制。不要采集输入框内容,不要采集敏感文本,不要记录完整 DOM。监控系统是为了排查问题,不是为了复制用户隐私。
上面那段 target.textContent 我后来其实改掉了——按钮文案还好,但用户点的可能是一条聊天记录、一个带姓名的列表项,截 30 个字符照样能把隐私带出去。更稳妥的做法是优先取语义化标记而不是文本:
1document.addEventListener('click', (event) => { 2 const el = event.target.closest('[data-track], button, a') 3 if (!el) return 4 addBreadcrumb({ 5 type: 'click', 6 // 优先用埋点属性,其次用 aria-label,最后才退而求其次截一点文本 7 label: el.dataset.track || el.getAttribute('aria-label') || el.tagName.toLowerCase(), 8 }) 9})
除了点击,我一般还会把路由变化和接口失败也写进 breadcrumbs,因为复现一个 bug 最关键的往往是“他在哪个页面、调了哪个接口报了 500、然后点了什么”这条时间线。面包屑做成环形缓冲(上面用数组 + shift 那样)就够了,留最近 20~30 条,错误发生时一把带走。一句话原则:能从行为序列里反推出操作,但反推不出用户输入了什么。
采样、限流与批量上报
监控也会带来成本。高流量页面如果每个指标、每个错误、每个接口都全量上报,或者每产生一条数据就立刻发一次请求,很容易把监控接口打爆,甚至监控请求比业务请求还多——监控本身反而成了性能负担。控制上报量主要靠三招:采样、限流、批量,下面依次说。
先是采样:
1function shouldSample(rate) { 2 return Math.random() < rate 3} 4 5if (shouldSample(0.1)) { 6 sendMetric(metric) 7}
但错误日志和性能指标采样策略应该不同。严重错误可以高采样甚至全量,普通性能指标可以低采样。
这里有个我一开始没想明白的坑:采样决定不能在每次上报时各掷一次骰子,否则同一个用户的一次访问里,LCP 被采到了、INP 却被丢了,breadcrumbs 也断了,后面想把一次会话串起来分析就全乱了。正确做法是在进入页面时按 sessionId 决定一次“这次会话采不采”,整次访问统一行动:
1const sessionSampled = hashToUnit(sessionId) < 0.1 // 同一会话稳定命中
hashToUnit 用 sessionId 算个 0~1 之间的稳定值,这样同一会话每次都是同样的采样判定,会话内的数据要么全留要么全丢,链路是完整的。性能指标用这套会话级采样,错误则单独走全量(再叠加下面的去重限流),两条策略互不干扰。
还要做限流,避免同一个错误在一个页面里无限上报:
1const reportedErrors = new Set() 2 3function reportErrorOnce(error) { 4 const key = `${error.message}-${error.stack?.slice(0, 100)}` 5 6 if (reportedErrors.has(key)) return 7 8 reportedErrors.add(key) 9 reportError(error) 10}
采样和限流解决的是“该不该报”,批量解决的是“怎么报更省”:用一个队列缓冲,满 N 条或隔几秒 flush 一次,而不是每条数据都单独发一个请求。
1const queue = [] 2let timer = null 3 4function enqueue(event) { 5 queue.push(event) 6 if (queue.length >= 10) return flush() 7 timer ??= setTimeout(flush, 5000) 8} 9 10function flush() { 11 if (!queue.length) return 12 const body = JSON.stringify(queue.splice(0)) 13 navigator.sendBeacon('/api/metrics', body) 14 clearTimeout(timer) 15 timer = null 16}
flush 这一步不用另起一套监听逻辑,接到前面已经建好的 pagehide + visibilitychange 兜底里就行——把 finalFlush 里那句换成调这里的 flush()。注意 flush 用了 splice(0) 把队列原子地取空,避免边发边进的竞态。批量化之后我们的监控请求数量降了一个量级,而数据完整性几乎没损失。
监控数据要能指导行动
监控不是为了画漂亮图表,而是为了指导修复。
一个有用的性能看板至少应该回答:
- 哪些页面 LCP 最差
- 哪些页面 INP 最差
- 哪些接口失败率最高
- 哪些错误影响用户最多
- 哪个版本引入了退化
如果看板只能展示“今天总共有 1000 个错误”,但不能告诉你哪个错误最该修,那它的价值有限。
我更喜欢按“影响人数”排序,而不是按“发生次数”排序。一个错误在某个用户机器上循环触发 1000 次,不一定比 200 个用户各遇到一次更严重。要做到按人数排,前提是上报里带了稳定的匿名用户 ID,否则你只有 count,没有 unique users,这个排序就无从谈起。
还有一个我现在每次发版都会盯的视图:按版本号拆的指标趋势。这是最便宜的回归预警。有一次我们一个看似无害的依赖升级,把首页 LCP 从 2.2 秒推到了 3.5 秒,灰度阶段就靠版本对比图发现了,回滚的时候底气很足——不是“感觉变慢了”,而是“2024.08.09 这个版本的 P75 比上一版高了 1.3 秒”。把 release 版本号当成一等维度采集,比事后扒 git log 猜原因有用得多。
服务端接收也要统一结构
前端上报的数据最好有统一格式:
1{ 2 "type": "web-vital", 3 "name": "LCP", 4 "value": 2300, 5 "rating": "good", 6 "path": "/blog/react-19", 7 "version": "2024.08.09", 8 "timestamp": 1723161600000 9}
错误可以是:
1{ 2 "type": "error", 3 "message": "Cannot read properties of undefined", 4 "stack": "...", 5 "path": "/dashboard", 6 "breadcrumbs": [], 7 "timestamp": 1723161600000 8}
API 错误也要统一:
1{ 2 "type": "api", 3 "url": "/api/posts", 4 "status": 500, 5 "duration": 842, 6 "ok": false 7}
结构统一后,后续入库、查询、告警都会简单很多。我自己的经验是,三类事件最好共用一组公共字段(type / sessionId / userId / path / version / timestamp),再各自带专属字段。这样存的时候可以进同一张宽表或同一个索引,查询和关联分析时不用 join 来 join 去,告警规则也能用同一套语法写。
字段一旦上线就很难改,所以最好在第一版就预留 type 这种区分字段和一个 extra 自由对象,新增维度往 extra 里塞,而不是动核心结构。监控数据通常量很大、留存时间又长,回头要给几千万条历史历史数据加字段、改类型是非常痛苦的事,schema 设计上的谨慎在这类系统里格外值得。
把三类数据串成一次会话
性能、错误、接口分开看各有各的用,但真正的排查威力来自把它们按一次会话串起来。光知道“某用户 LCP 7 秒”不够,要能顺着同一个 sessionId 往下翻:他这次访问里有没有报错?哪几个接口慢了?卡顿发生在点了哪个按钮之后?这条时间线才是能复现 bug 的东西。
要串得起来,前提是所有上报共享同一组会话字段,而且 sessionId 得有合理的生命周期——不能用一次就变,也不能一年不变。我的做法是把 { id, lastActive } 存在 sessionStorage 里,每次取值时比较 lastActive 和当前时间:超过 30 分钟没活动就生成新 id 换一次会话,没超过就复用旧 id 并刷新 lastActive,读写都封装在一个 getSession() 里,业务代码不用关心过期细节。
userId 我坚持用持久化的匿名 ID(放 localStorage),跟登录态解耦——一个未登录用户后来登录了,我希望还能认出是同一个人,否则“按影响人数排序”会把同一个人算成两个。两个 ID 各司其职:sessionId 串一次访问,userId 跨访问识别人。
还有一个反直觉但很关键的点:监控代码自己绝对不能成为故障源。如果上报函数里有一段 JSON 序列化,碰到一个带循环引用的对象会直接抛异常,而这段代码恰好挂在全局 error 监听器里——错误处理里又抛出新错误,浏览器会把它重新当成一个新错误捕获,于是形成无限循环,几秒钟打出几千条上报,既把监控接口打挂,又拖垮用户页面。这类风险的解法很直接:所有上报逻辑都套一层 try/catch,并且约定好一条纪律:监控可以漏数据,但绝不能因为自己出错而影响业务。
1function safeReport(fn) { 2 return (...args) => { 3 try { fn(...args) } catch { /* 监控自身的错误,吞掉就好,绝不向上抛 */ } 4 } 5}
这个看似多余的包裹,是我对监控系统最重要的一条纪律:它是观察者,不能变成被观察的事故本身。
前端监控的第一步不是买复杂平台,而是把关键体验指标采集起来。Web Vitals 告诉你页面体验,运行时错误告诉你代码稳定性,接口监控告诉你数据链路,用户行为上下文帮助你复现问题。
真正有价值的监控应该能回答“谁受影响、在哪个页面、从哪个版本开始、最可能的原因是什么”。做到这一点后,性能优化就不再靠感觉,线上问题也不会只在用户投诉后才被发现。