前端错误监控从零落地:window.onerror、unhandledrejection 与上报去重
二月底有一天下午,客服转来一张用户截图:订单管理页整个白屏,控制台里一行红色的 TypeError: Cannot read property 'list' of undefined。看时间戳,这个错误在线上已经存在了两天。两天里没有任何人知道——不是没人遇到,是遇到的用户大多刷新一下或者换个页面就绕过去了,只有这位耐心好的用户截了图走客服流程报上来。
后端接口挂了有网关的错误率告警,服务器负载高了运维会收到短信,唯独前端的 JS 跑在用户的浏览器里,挂了就挂了,我们这边一点动静都没有。查下来原因倒不复杂:后端某个接口在特定筛选条件下返回的数据结构少了一层包装,前端没做防御直接取值,渲染函数抛错,Vue 的整棵组件树跟着卸载。修复只花了十分钟,但"线上白屏两天无人知晓"这件事没法当没发生过。资深前端在周会上把话挑明了:接口有监控、服务器有监控,前端代码在用户浏览器里的运行状态是我们唯一的盲区,这个洞得补上。补洞的活落到了我头上。
第一层:window.onerror 能接住什么
JS 运行时错误的全局出口是 window.onerror,这个 API 老得不能再老,IE 时代就有。签名是五个参数:
1window.onerror = function (message, source, lineno, colno, error) { 2 reportError({ 3 type: 'js', 4 message: message, 5 source: source, 6 lineno: lineno, 7 colno: colno, 8 stack: error && error.stack, 9 }); 10 // 返回 true 会阻止错误继续在控制台打印,我们不返回,保留默认行为 11};
前四个参数是历史遗留,真正有价值的是第五个 error 对象,它的 stack 属性里有完整调用栈。早期浏览器只给前四个参数,现在主流浏览器都会把 Error 对象传进来,但写代码时还是要做 error && error.stack 的判空——线上什么浏览器都有,我们的中后台虽然主要跑在 Chrome 上,运营同事的电脑里偶尔还能翻出 IE11。
第一版接进测试环境,很快发现 window.onerror 接不住的东西比我想的多。同步代码抛错、事件回调里抛错、setTimeout 回调里抛错,这些都能接住;但 Promise 里的错误,一个都收不到。
第二层:unhandledrejection 补上 Promise 的漏洞
我们项目里 axios 请求全是 Promise 链,async/await 也已经用得很普遍,Promise 内部抛出且没有被 catch 的错误不走 window.onerror,走的是另一个事件:
1window.addEventListener('unhandledrejection', function (event) { 2 var reason = event.reason; 3 reportError({ 4 type: 'promise', 5 message: reason instanceof Error ? reason.message : String(reason), 6 stack: reason instanceof Error ? reason.stack : '', 7 }); 8});
这里有个容易忽略的点:event.reason 不一定是 Error 对象。Promise.reject('接口超时') 这种直接 reject 一个字符串的写法在老代码里很常见,reason 就是那个字符串,没有 stack。所以上报前要做类型判断,不然监控平台里会出现一堆 undefined 堆栈的记录。
对应地还有一个 rejectionhandled 事件,表示一个之前未处理的 rejection 后来被 catch 了。理论上可以用它把误报的记录撤回,但实现成本和收益不成比例,我们没做——监控宁可多报,不能漏报,误报靠聚合层去消化。
另外 Vue 项目还有一层拦截要注意:Vue 会捕获组件生命周期和渲染函数里的错误,走 Vue.config.errorHandler,默认不会冒泡到 window.onerror。所以这个 handler 也得挂上,拿到错误后转发给统一的上报函数,顺便把组件名带上:
1Vue.config.errorHandler = function (err, vm, info) { 2 reportError({ 3 type: 'vue', 4 message: err.message, 5 stack: err.stack, 6 component: vm && vm.$options.name, 7 hook: info, // 出错的生命周期钩子名 8 }); 9};
info 参数会告诉你错误发生在哪个环节,比如 render、mounted,排查时很有用。开头那次白屏事故的错误,正是发生在 render 里,如果当时有这层监控,两天前就该在群里响铃了。
有一个额外的坑值得记下来:Vue.config.errorHandler 一旦设置,Vue 捕获到的错误就不再往控制台打印了(Vue 2.6 的行为是 handler 存在时吞掉默认输出)。开发环境里错误突然"消失"会让人抓狂,所以我们的 handler 里在非生产环境下补了一句 console.error(err),保持开发时的调试体验不变。
接口报错算不算"前端错误":谁该管这一段
axios 的响应拦截器里其实也能拿到大量"错误"——接口 500、超时、网络断开。做到这里我犹豫过要不要把它们也灌进错误监控。和资深前端讨论后定了归属:接口异常属于业务可用性问题,网关侧已经有完整的错误率监控,前端不重复采集"接口挂了"这个事实本身;前端要采集的是"接口挂了之后,前端代码有没有兜住"。
具体来说,接口返回异常、代码走了 catch 分支、页面展示了错误提示——这是正常的防御路径,不上报;接口返回了 200 但数据结构不符合预期、前端取值抛了 TypeError——这是前端要负责的错误,会自然地被 errorHandler 或 onerror 接住。唯一从 axios 拦截器主动上报的一类,是"请求发出去但没有任何响应"的网络层失败,它带上接口路径和耗时,用来区分"用户网络差"和"某个接口对某批用户系统性不通":
1axios.interceptors.response.use(null, function (error) { 2 if (!error.response) { 3 // 没有 response 说明请求根本没到达或没返回,属于网络层问题 4 reportError({ 5 type: 'network', 6 url: error.config && error.config.url, 7 message: error.message, // 'Network Error' 或 'timeout of 10000ms exceeded' 8 }); 9 } 10 return Promise.reject(error); 11});
这条归属划清楚之后,监控平台里的数据干净了很多。之前试运行时接口错误混进来,一次测试环境后端重启就制造了几百条记录,把真正的 JS 错误淹得看不见。
沿着"什么该报、什么不该报"再往下分了一层错误等级。fatal 级是"页面功能已经不可用":render 抛错、路由组件加载失败、白屏——这类直接触发告警。error 级是"某个功能点坏了但页面还活着":事件回调抛错、手动上报的兜底触发——进平台统计,不实时告警。warning 级是资源 404 这类体验问题,只看趋势。等级字段跟着每条上报走,告警规则只订阅 fatal。分级的目的不是分类学洁癖,是让"半夜响铃"这个动作只留给真正值得半夜爬起来的问题——这套等级怎么定的,就是拿开头那次白屏事故当标尺:像那次一样严重的,才配 fatal。
第三层:资源加载错误要走捕获阶段
JS 错误和 Promise 错误之外,还有一类是资源加载失败——图片 404、CDN 上的 JS 文件被网络劫持、CSS 加载超时。这类错误触发的是资源元素自身的 error 事件,不冒泡,所以 window.onerror 收不到。但它有捕获阶段,在 window 上用捕获模式监听就能拿到:
1window.addEventListener('error', function (event) { 2 var target = event.target || event.srcElement; 3 // 过滤掉 JS 运行时错误(那类 event 没有 target 元素) 4 if (target instanceof HTMLScriptElement || 5 target instanceof HTMLLinkElement || 6 target instanceof HTMLImageElement) { 7 reportError({ 8 type: 'resource', 9 tagName: target.tagName.toLowerCase(), 10 url: target.src || target.href, 11 }); 12 } 13}, true); // 第三个参数 true,捕获阶段
这里要小心一个坑:JS 运行时错误也会触发 window 上的 error 事件,和资源错误混在同一个监听器里。区分方式是看 event.target 是不是资源元素——运行时错误的 target 是 window 本身。不过滤的话同一个 JS 错误会被上报两次。
资源错误上报后马上就体现出价值:接入当天就发现某张商品占位图 404 了三千多次,是上个月改图片目录结构时漏迁了一张。这种问题用户根本不会报障,页面上就是个碎图标,但它一直在消耗请求、拉低体验。
Script error:只有七个字的错误信息
上完监控跑了几天,平台里量最大的一类错误让我犯了难:message 只有 Script error.,没有文件名、没有行号、没有堆栈,什么信息都没有。查了资料才搞清楚,这是浏览器的跨域保护:当错误发生在跨域加载的脚本里时,浏览器会把错误信息抹掉,只留一句 Script error,防止恶意页面通过错误信息探测第三方脚本的内部逻辑。
我们的 JS 是发到 CDN 上的,CDN 域名和页面域名不同,所以自己的代码报错也被当成"跨域脚本错误"处理了。解法是两步配合:
第一步,script 标签加 crossorigin 属性:
1<script src="https://cdn.example.com/app.js" crossorigin="anonymous"></script>
第二步,CDN 的响应头要带上 Access-Control-Allow-Origin。我们的 CDN 控制台里有现成的 CORS 配置项,加上 * 或者指定域名都行。两步缺一不可:只加 crossorigin 不配响应头,脚本会直接加载失败,比拿不到错误信息更糟——这一步我是先在测试环境验证过才敢动线上的。
Vue CLI 打包的 html 模板里,script 标签是插件注入的,好在 html-webpack-plugin 支持配置 crossorigin 属性,vue.config.js 里通过 chainWebpack 改一下插件参数就行。改完之后,Script error 的量从每天几千条降到接近零,剩下的确实是第三方统计脚本的错误,那部分我们管不了,直接在上报前过滤掉。
过滤规则不只这一条。跑了几天数据之后,我整理出一小张"噪音清单",都是采集端要提前拦掉的:source 是 chrome-extension:// 开头的(用户装的浏览器插件在页面里注入脚本报的错,和我们的代码无关)、message 里带 ResizeObserver loop limit exceeded 的(浏览器自己的良性警告,不影响功能)、以及少数几个已知的第三方 SDK 内部错误。过滤逻辑放在 SDK 的 beforeSend 钩子里,规则可配置:
1var IGNORE_RULES = [ 2 /^Script error\.?$/, 3 /chrome-extension:\/\//, 4 /ResizeObserver loop limit exceeded/, 5]; 6 7function shouldIgnore(payload) { 8 var text = payload.message + ' ' + (payload.source || ''); 9 return IGNORE_RULES.some(function (rule) { return rule.test(text); }); 10}
噪音不在采集端拦,就会在告警端报,最后大家学会对告警群免疫——那监控就白做了。这份清单以后肯定还会长,但每加一条都要能说清楚"为什么它是噪音",不能图清净把真错误也过滤掉。
SDK 自身的兼容性:给监控代码定一个更低的基线
还有一条约束是给 SDK 自己的:监控代码的浏览器兼容基线必须低于业务代码。业务代码在 IE11 挂了,正是监控最该工作的时刻,如果 SDK 自己先因为用了 IE 不认识的语法抛错,那最需要监控的环境反而是监控的盲区。所以这个 SDK 是纯手写 ES5——var、function、字符串拼接,不走 Babel(它要内联在 html 里、先于一切执行,不进 webpack 的打包流程),不用 Promise(unhandledrejection 监听本身不需要 SDK 会写 Promise),Object.assign 这类 API 也换成了手写的浅拷贝。写的时候浑身别扭,毕竟平时 ?. 都用上了,但这段代码的运行环境假设必须按最坏的来。写完拿 IE11 的模拟模式过了一遍,真发现一处 addEventListener 的选项对象写法要降级成布尔参数。
聚合与去重:别让监控把自己打挂
上报通道打通之后,下一个问题是量。一个渲染函数里的错误,页面不销毁的话每次重渲染都抛一次;一个在轮询接口回调里的错误,每三秒报一次;碰上高峰期几百个用户同时踩中,上报请求本身就能把收集服务压垮,甚至挤占用户正常请求的带宽。
我在上报层做了三道闸。第一道是单页去重:对每个错误算一个指纹,message 加上堆栈前几行做个简单 hash,同一个指纹在当前页面生命周期内只报第一次,后续只累加计数、定时批量上报计数值。第二道是限流:单个用户单页上报总数封顶,超过阈值直接静默,能触发几十次上报的页面基本已经不可用了,后续的重复信息没有增量价值。第三道是采样开关:预留一个采样率配置,万一哪天出现全网性的错误风暴,可以远程把采样率降下来保护收集端。
1var reportedFingerprints = {}; 2var reportCount = 0; 3var MAX_REPORT = 50; 4 5function reportError(payload) { 6 if (reportCount >= MAX_REPORT) return; 7 var fp = hash(payload.message + '|' + (payload.stack || '').split('\n').slice(0, 3).join('')); 8 if (reportedFingerprints[fp]) { 9 reportedFingerprints[fp]++; 10 return; 11 } 12 reportedFingerprints[fp] = 1; 13 reportCount++; 14 send(payload); 15}
上报方式我选了 navigator.sendBeacon,主要为了覆盖页面卸载时的错误——用户关页面的瞬间用 XHR 发请求很可能发不出去,sendBeacon 由浏览器保证在页面卸载后继续发送。不支持 sendBeacon 的老浏览器降级成 new Image().src 拼 GET 参数,土办法,但可靠:
1function send(payload) { 2 var body = JSON.stringify(payload); 3 if (navigator.sendBeacon) { 4 navigator.sendBeacon(REPORT_URL, body); 5 } else { 6 // IE11 降级:GET 打点,堆栈截断防 URL 超长 7 payload.stack = (payload.stack || '').split('\n').slice(0, 10).join('\n'); 8 new Image().src = REPORT_URL + '.gif?d=' + encodeURIComponent(JSON.stringify(payload)); 9 } 10}
GET 方式有 URL 长度限制,所以降级路径里堆栈只取前十行——对 IE11 来说,能知道"哪个页面哪个错误"已经比之前的一无所知强太多了。
指纹去重在客户端做了一层,服务端还要再做一层归并,因为同一个错误在不同浏览器里的长相不一样。Chrome 的堆栈是 at fn (url:line:col) 格式,Firefox 是 fn@url:line:col,IE11 的又略有出入;同一份代码由于 CDN 域名带了不同的灰度前缀,堆栈里的 URL 还可能不同。服务端聚合的时候,指纹计算要先把堆栈规范化——抽取函数名序列、抹掉 URL 里的域名和查询参数、只保留路径和行列号——不然同一个 bug 会在平台里裂成七八条"不同的错误",看趋势图的时候完全没法看。聚合粒度是错误监控平台的灵魂:聚得太粗,不同的 bug 混成一条;聚得太细,一个 bug 刷出满屏记录。这层规范化逻辑我前后调了三版才让平台里的错误列表看起来像"问题清单"而不是"日志流水"。
还有一类特殊的"错误"我单独立了一个类型:手动上报。SDK 暴露了一个 monitor.capture(err, extra) 方法,给业务代码在 catch 里主动调用——有些错误被 try/catch 兜住了、页面没挂,但那个 catch 分支本身意味着"发生了不该发生的事",值得记录。比如订单金额格式化失败时代码会兜底显示原始值,页面不报错,但这个兜底被触发本身就该有人知道。
上下文与面包屑:让一条错误记录能被排查
除了错误本身,每条上报还要带上下文:页面 URL、userAgent、用户 ID、发生时间,以及一段"行为面包屑"——错误发生前用户做了什么。面包屑的实现是一个定长的环形数组,从几个关键位置往里塞记录:Vue Router 的 afterEach 钩子记路由跳转,axios 拦截器记接口请求和状态码,再用事件委托在 document 上记有意义的点击(只记带 data 属性或按钮类元素的,不然全是噪音):
1var breadcrumbs = []; 2var MAX_CRUMBS = 20; 3 4function addCrumb(type, data) { 5 breadcrumbs.push({ type: type, data: data, ts: Date.now() }); 6 if (breadcrumbs.length > MAX_CRUMBS) breadcrumbs.shift(); 7} 8 9router.afterEach(function (to, from) { 10 addCrumb('route', from.path + ' -> ' + to.path); 11}); 12 13axios.interceptors.response.use(function (res) { 14 addCrumb('http', res.config.method + ' ' + res.config.url + ' ' + res.status); 15 return res; 16});
错误上报时把这二十条一起带走。排查线上问题时,"用户出错前做了什么"往往比堆栈本身更快定位到复现路径——试运行期抓到的一个错误,堆栈指向一个通用的表格组件,光看堆栈完全没头绪,但面包屑显示用户是从搜索页带着一个空的筛选参数跳过来的,复现路径一分钟就找到了。
另一个必带的字段是发布版本号。我们在构建时把 git 的 commit hash 前七位通过 DefinePlugin 注进代码里,随每条上报带上。没有版本字段,"这个错误是新版本引入的还是历史遗留"就只能靠猜;有了它,发版后盯着新版本的错误增量看半小时,比任何灰度观察手段都直接。
上线前先自证:监控系统自己得先被测试
采集 SDK 写完,我给它设计了一个"自检模式":URL 上带 ?__monitor_test=1 时,页面加载完自动依次触发五种错误——同步抛错、Promise reject、加载一张不存在的图片、Vue 组件 render 抛错、发一个必然超时的请求——然后到平台里核对五条记录是否齐全、字段是否完整。听起来有点多余,但第一次跑自检就发现了问题:资源错误的上报丢了,因为监听器注册的时机晚于页面上一张异步插入的碎图触发 error 的时机。监控代码必须是页面里最早执行的一段 JS,我把 SDK 的引入位置提到了所有业务代码和第三方脚本之前,内联在 html 头部,自身用 try/catch 整体包住——监控自己抛错把页面搞挂,就太讽刺了。
上报字段的脱敏:监控不能变成隐私采集器
上报内容定稿前,资深前端提醒了一个我没想到的角度:错误上报很容易变成无意识的隐私泄露通道。页面 URL 的查询参数里可能带着手机号或订单号,面包屑里记录的接口 URL 同理,甚至错误 message 本身都可能包含用户输入(比如 JSON.parse 用户粘贴的内容失败时,message 里会带原文片段)。这些数据进了监控平台,等于把敏感信息复制了一份存到另一个访问控制更松的系统里。
处理方式是在 beforeSend 里统一过一遍脱敏:URL 只保留路径、抹掉查询参数值(保留参数名,排查时知道"带了什么参数"就够了);面包屑里的接口 URL 同样处理;用户标识只上报内部的用户 ID,不带任何联系方式字段。监控数据的原则是"够定位问题就行",多余的信息不是资产,是负债。这层逻辑写死在 SDK 里,不给业务方配置绕过的口子。
自建还是接 Sentry
收集端一开始是我用 Node 写的一个极简服务:收上报、落库、按指纹聚合、错误量超阈值发企业微信告警。跑起来之后越写越觉得这是个无底洞——聚合规则、趋势图、版本对比、告警收敛,每一样都是活。于是认真评估了 Sentry。
Sentry 这几年在前端监控这块已经很成熟,SDK 拿来就能用,上面说的 onerror、unhandledrejection、面包屑它全都封装好了,平台侧的聚合和告警更是甩自建方案几条街。纠结的点在部署方式:SaaS 版数据要出公网,我们的中后台系统对数据出境比较敏感,走采购和安全评审的流程也长;自建版(on-premise)是 Docker 一套全家桶拉起来,试着在测试机上部了一次,内存吃得不少,还带着 Kafka、ClickHouse 一堆依赖,维护成本明显不是"装完就不用管"的级别。
和资深前端商量后定的方案是分两步走:先用我那个简易收集服务扛着,把采集 SDK 这一层按 Sentry 的数据结构对齐,后续无论切 SaaS 还是自建,采集端都不用重写;同时把自建 Sentry 的资源申请报给运维排期。采集层自己写一遍是值得的,每个事件、每个坑都亲手踩过,以后换任何平台都心里有数;平台层没必要重造轮子,那是另一个专业领域。
告警策略也在简易服务里先跑起来了,规则只有两条,都是和资深前端讨论出来的克制版本。第一条是"新错误告警":一个从没出现过的错误指纹第一次出现,立刻通知——新错误大概率和最近的发版有关,值得第一时间看。第二条是"量级突变告警":已知错误的小时错误数超过过去一周同时段均值的五倍才响。故意不做"每个错误都通知",也不做"错误总量阈值"——前者会把群刷炸,后者会被一个高频老错误长期占着阈值,把新问题盖住。告警的目标不是"所有错误都有人看",是"响了就必须有人看",宁可规则保守,也不能让群里的铃声变成背景音。
还差一块:压缩代码的堆栈还原
现在平台里能看到错误了,但堆栈长这样:
TypeError: Cannot read property 'list' of undefined
at a.js:1:23456
at Array.forEach (<anonymous>)
线上代码是 webpack 压缩混淆过的,行号列号指向压缩后的一行天书。要还原成源码位置得靠构建时生成的 SourceMap 反查,这块我还没动——SourceMap 不能直接发到线上(等于源码公开),得改构建配置让 map 文件只产出不发布、在收集端做私有存储和反解服务,还要和发布流程配合把每个版本的 map 归档对齐,牵扯的面不小,是下一阶段要单独解决的事。目前的过渡办法是靠错误 message、面包屑和"哪个页面哪个版本"来缩小范围,多数错误也能定位,只是慢——真遇到难啃的,就本地 checkout 对应版本的 commit 构建一次,拿着行列号手动对,笨办法也是办法。
SDK 本身的上线我也没敢一步到位。它内联在每个页面的头部,理论上一行 bug 就能影响全站,所以走了个土灰度:第一周只挂在两个内部使用频率最高的管理页面上,确认采集正常、上报量符合预期、页面性能没有肉眼可见的影响(SDK 压缩后 6K 出头,初始化耗时毫秒级),第二周才铺到全部页面。上报服务那边同步压了一轮测:按线上 PV 和试运行期的错误率估算峰值 QPS,再乘十倍打压力,确认收集端有余量——错误风暴出现的时机从来不挑收集端的状态好坏。
监控上线三周,抓到了四个用户从没报障过的真实错误,包括一个只在 IE11 上触发的兼容性问题。开头那种"白屏两天靠用户截图才知道"的情况,理论上不会再有了——现在轮到担心另一件事:告警群里每天响的那几声,得有人真的去看。