前段时间线上又出了两次说不清的问题。一次是用户反馈页面白了,测试环境怎么点都是好的;一次是某个列表页偶发卡一下,自己电脑上飞快。控制台能看到的,多半是一句 Script error.,堆栈是空的。Sentry 能用,但项目多、字段要对齐自己的上报网关,有的页面还得把某一类采集关掉。
与其每个项目临时加几个 window.onerror,不如收成一个 SDK。Vue 项目里 init 一下,错误、性能、白屏按需打开。
先定监控范围
我把自己要的收成三类,再多先不做。
- 错误:JS 运行时、资源加载失败、未捕获的 Promise、Vue 组件里抛出来的错
- 性能:FP、FCP、LCP、页面加载耗时、PV,再加上慢接口
- 异常:白屏、卡顿
点击埋点先留一个口子,业务要用的时候自己调 report。SDK 负责采集和组装,真正把数据发出去交给项目自己。上报地址、鉴权、要不要 sendBeacon,每个项目都不一样,写死在 SDK 里以后改一次疼一次。
SDK 长什么样
入口做成 Vue 插件就行。装的时候给上报地址,再按项目打开要的采集,不必一次全开。
import { createApp } from 'vue' |
Vue 错误,别覆盖原来的 errorHandler
Vue 3 挂在 app.config.errorHandler,Vue 2 挂在 Vue.config.errorHandler。写法一样,区别只是你把谁传进来。
最容易犯的错是直接赋值,把业务原来的处理盖掉。组件里本来会弹个提示,接了监控之后提示没了,这种锅后面很难查。正确做法是包一层:先走原来的 handler,再上报。
const hooked = new WeakSet() |
有两个细节别省。
err 不一定是 Error。Vue 2 里有人会 throw '出错了',你直接读 err.message,监控自己又炸一次,而这次发生在 errorHandler 里面,Vue 会当成已经处理过,页面上什么都不剩。
另外这个函数得幂等。热更新、重复 init,再包一层就会连环上报。打个标记,第二次进来直接返回。
组件名尽量带上。线上同一句 Cannot read properties of undefined 能出现在十几个页面,没有组件名基本等于没用。
JS、资源和 Promise
全局错误用捕获阶段听,冒泡阶段听不到资源加载失败。
window.addEventListener( |
资源错误别把整个 outerHTML 传上去。img 的 src 如果是一段 data URI,一条日志能有几 MB。截断到几百个字符,再带上标签名和 DOM 路径就够定位。
Script error. 单独算一类。这不是业务写错了,是脚本跨域又没加 crossorigin,堆栈拿不到。上报的时候标成 cors,别和普通 JS 错误混在一个桶里,不然告警全是这句,真正的错误被淹没。
堆栈我只留前 5 帧:文件名、函数名、行号、列号。再多对定位帮助不大,体积倒是上去了。
性能不要自己用 Date 减
FP、FCP、LCP 用 PerformanceObserver,自己记 performance.now() 对不齐浏览器的定义,后面和 Lighthouse 对不上还得解释。
let lcp = 0 |
LCP 有个麻烦:它不是一次就算死的。用户还没滚动、图片还在加载,候选值会一直变。我的做法是:
- 设一个超时,比如 5 秒,到点用当前候选值上报
- 路由切换、页面进后台、用户第一次点击,立刻结算,别把上一个页面的 LCP 记到下一个路由上
buffered: true能拿到注册前的记录,但旧记录不要重复报
CLS、INP 同理,都是“一段时间里的汇总值”,路由一切就要结算本段,不然整站只有一个数字,看不出是哪个页面在抖。
PV 在 SPA 里不能只听 load。首次加载报一次;hash 模式听 hashchange;history 模式把 pushState / replaceState 包一层,再听 popstate。同一个路径不要因为 query 变了再计一次。销毁的时候把原生方法还回去,不然后面别的库会怪。
慢接口劫持 XMLHttpRequest.prototype 就行,axios 走的也是这个,不用专门适配。耗时超过阈值的报慢请求,status === 0 和 5xx 报失败,4xx 多半是业务校验,先别报,不然登录失败能把监控打满。
白屏和卡顿
白屏不要拿“根节点没有子元素”来判断。现在的页面骨架屏、loading 容器都算有 DOM,用户看到的还是一片空白。
我的判断是:页面上取十几个采样点,用 document.elementsFromPoint 看命中的是不是容器或者骨架屏。全部命中,并且持续超过 6 秒,才记一次白屏。同一个 URL 报过就别再报。
采样可以先密后疏。前 30 秒每 2 秒看一次,白屏大多发生在这一段;页面稳了以后降到 30 秒一次。一直 2 秒轮询,监控自己也会吃主线程。
根节点不叫 #app 的项目,把实际容器选进判断里,不然正常页面也会被当成白屏。
卡顿用 requestAnimationFrame 算 FPS,低于 24 记一笔,几秒内最多报一次。这个数不用很准,它是用来告诉你“这个页面当时在掉帧”,不是用来替代 Performance 面板。
上报,才是容易写烂的地方
采集写完只是一半。上报写得糙,要么把用户网络打满,要么关键那条日志在关页面时丢了。
我用内存里一个数组接着。凑满 5 条立刻发,没凑满就等 requestIdleCallback,没有这个 API 就 setTimeout。浏览器忙的时候别跟业务抢。
相同错误要去重。用 子类型 + message + 文件 做一个短哈希当 errId,60 秒内同一个只走一次。去重标记有个顺序问题:等数据真正交到上报函数之后再记。如果进缓存就标记,发送失败的那条会被当成“已经报过”,窗口期内再也出不去。
页面关掉之前把缓存冲掉。pagehide 比 beforeunload 稳,移动端和往返缓存都认它,两个都注册一下。
const queue = [] |
还有一个自己打自己的问题。接口监控如果包了 XHR,上报请求自己也会被记成一条慢接口,然后这条再触发上报。上报地址直接排除掉,不要靠“发的时候加个标志”去赌时序。
接到 Vue 项目里
放在 main.js,new Vue / createApp 之前。用户 id 登录之后才有的话,先传空,登录成功再补一次配置,别为了等用户把整页监控推迟。
// main.js |
// monitor.js 里跟一下路由,SPA 不能只在 load 时记一次 PV |
Vue 2 没有 app.config,errorHandler 在 Vue.config 上,把上面的 app 换成 Vue 构造函数即可。
本地怎么验,比接生产重要。我一般在 demo 里故意做几件事:组件里抛个错、请求一个 500、把根节点选择器配错看会不会误报白屏、来回切两次路由看 PV 和 LCP 有没有串页。这四条过了,再谈采样率。
做的时候记住这几条
- 上报地址没配就不要假装监控已经接上。安装时缺
endpoint直接在控制台提醒,别默默丢掉。 - 模块顶层不要碰
window、navigator。Nuxt 这类项目 import 的时候还在服务端,一碰就炸。采集放到init之后。 - 劫持过的
pushState、XMLHttpRequest,销毁时还回去。页面卸载也要自动销毁,刷新残留的定时器会在下一次进来重复上报。 - 白屏、卡顿、错误去重都要节流。监控最怕的不是漏一条,是把上报服务打挂。
- 先把错误和慢接口做准。LCP、INP 这些可以后补,但一条带组件名和堆栈的 Vue 错误,比一堆看不懂的性能分有用。
监控不是为了收集更多数字。它要回答的是:这个错出在哪个页面、哪个组件、用户当时在干什么。这三样对上了,后面的看板怎么做都好说。