性能监控和错误收集与上报(下)
Last updated
Last updated
上一节课我们学习了性能监控方面的知识。这一节来深入了解关于错误和异常的收集,并学习如何统一将这些信息进行上报。
在此之前,我们先回顾一下这个主题的知识点:
提到错误收集方案,大家应该会首先想到两种:try catch 捕获错误和 window.onerror 监听。
我们先看一下 try catch 方案:
这种方式需要开发者对预估有错误风险的代码进行包裹,这个包裹过程可以手动添加,也可以通过自动化工具或类库完成。自动化方案的基本原理是 AST 技术:比如 UglifyJS 就提供操作 AST 的 API,我们可以对每个函数添加 try catch,社区上 foio 的实现,就是一个很好的例子:
我们从 globalFuncTryCatch 函数的第一个参数中获得目标代码 source,将其转换为 AST:
globalFuncTryCatch 函数的第二个参数为开发者定义的在出现错误时的响应函数,我们将其字符串化并转为 AST,并插入到 catch 块当中:
这样,借助于 globalFuncTryCatch,我们可以对每个函数添加 try catch 语句,并根据 globalFuncTryCatch 的第二个参数,传入自定义的错误处理函数(可以在该函数中进行错误上报):
关键之处在于使用 UglifyJS 的能力,对 AST 语法树进行遍历,并转换:
最终再返回经过处理后的代码:
使用 try catch,我们可以保证页面不崩溃,并对错误进行兜底处理,这是一个非常好的习惯。
但是 try catch 处理异常的能力有限,对于运行时非异步错误,它并没有问题。但是对于:
语法错误
异步错误
try catch 就无法 cover 了。我们来看一个运行时非异步错误:
可以被 try catch 处理。但是,将上述代码改动为语法错误:
就无法捕获。
我们再看一下异步的情况:
也无法捕获。
除非在 setTimeout 中再加一层 try catch:
总结一下, try catch 能力有限,且对于代码的侵入性较强。
我们再看一下 window.onerror 对错误进行处理的方案:开发者只需要给 window 添加 onerror 事件监听,同时 注意需要将 window.onerror 放在所有脚本之前,这样才能对语法异常和运行异常进行处理。
这里的参数较为重要,包含稍后需要上传的信息:
mesage 为错误信息提示
source 为错误脚本地址
lineno 为错误的代码所在行号
colno 为错误的代码所在列号
error 为错误的对象信息,比如 error.stack 获取错误的堆栈信息
window.onerror 这种方式对代码侵入性较小,也就不必涉及 AST 自动插入脚本。除了对语法错误和网络错误(因为 网络请求异常不会事件冒泡 )无能为力以外,无论是异步还是非异步,onerror 都能捕获到运行时错误。
但是需要注意的是,如果想使用 window.onerror 函数消化错误,需要显示返回 true,以保证错误不会向上抛出,控制台也就不会看到一堆错误提示。
千万不要以为掌握了这些,就万事大吉了。现实场景多种多样,比如 一种情况是:加载不同域的 JavaScript 脚本 ,这样的场景较为常见,比如加载第三方内容,以展示广告,进行性能测试、错误统计,或者想用第三方服务等。
对于不同域的 JavaScript 文件,window.onerror 不能保证获取有效信息。由于安全原因,不同浏览器返回的错误信息参数可能并不一致。比如,跨域之后 window.onerror 在很多浏览器中是无法捕获异常信息的,要统一返回 Script error,这就需要 script 脚本设置为:
同时服务器添加 Access-Control-Allow-Origin 以指定允许哪些域的请求访问。
到目前为止,我们已经学习了获取错误信息的“十八般武艺”。但是,如果错误脚本是经过压缩的,那么纵使你有千般本领,也无用武之地了,因为这样捕获到的错误信息的位置(行列号)就会出现较大偏差,错误代码也经过压缩而难以辨认。这时候就需要启用 source map。很多构建工具都支持 source map,比如我们利用 webpack 打包压缩生成的一份对应脚本的 map 文件进行追踪,在 webpack 中开启 source map 功能:
更多 source map 的内容,感兴趣的读者还可以参考以下资料:
Webpack sourcemap 这里不是我们的重点,就不再展开。
我们再来看一下针对 Promise 的错误收集与处理 。我们都提倡养成写 Promise 的时候最后写上 catch 函数的习惯。ESLint 插件 eslint-plugin-promise 会帮我们完成这项工作,使用规则:catch-or-return 来保障代码中所有的 promise(被显式返回的除外)都有相应的 catch 处理。比如这样的写法:
是无法通过代码检查的。
这类 ESLint 插件基于 AST 实现,逻辑也很简单:
如果读者对于 AST 和 ESLint 相关内容感兴趣,请关注课程《代码风格规范和背后技术设计》,会展开分析这方面的话题。
可能大家会想到,promise 实例的 then 方法中的第二个 onRejected 函数也能处理错误,这个和上面提到的 catch 方法有什么差别呢?事实上,我更加推荐 catch 方法,请看下面代码:
输出:rejected,在有 onRejected 的情况下,onRejected 发挥作用,catch 并未被调用。 而当:
输出:VM705:10 Error at Promise.then (<anonymous>:4:9) "catch"
,此时 onRejected 并不能捕获 then 方法中第一个参数 onResolved 函数中的错误。一经对比,也许 catch 是进行错误处理更好的选择。但是,这两种方式各有特点,还是需要读者对 Promise 有较为深入的认识。
除此之外,对于 Promise 的错误处理,我们还可以注册对 Promise 全局异常的捕获事件 unhandledrejection:
这对于集中管理和错误收集更加友好。
前面介绍的处理方式都是对已经在浏览器端的脚本逻辑错误进行的,我们设想用 script 标签,link 标签进行脚本或者其他资源加载时,由于某种原因(可能是服务器错误,也可能是网络不稳定),导致了脚本请求失败,网络加载错误。
为了捕获这些加载异常,我们可以:
除此之外,也可以使用 window.addEventListener('error') 方式对加载异常进行处理,注意这时候我们无法使用 window.onerror 进行处理, 因为 window.onerror 事件是通过事件冒泡获取 error 信息的,而网络加载错误是不会进行事件冒泡的。
这里多提一下, 不支持冒泡的事件还有 :鼠标聚焦 / 失焦(focus / blur)、鼠标移动相关事件(mouseleave / mouseenter)、一些 UI 事件(如 scroll、resize 等)。
因此,我们也就知道 window.addEventListener 不同于 window.onerror,它通过事件捕获获取 error 信息,从而可以对网络资源的加载异常进行处理:
那么,怎么区分网络资源加载错误和其他一般错误呢 ?这里有个小技巧,普通错误的 error 对象中会有一个 error.message 属性,表示错误信息,而资源加载错误对应的 error 对象却没有,因此可以根据下面代码进行判断:
但是,也因为没有 error.message 属性,我们也就没有额外信息获取具体加载的错误细节,现阶段也无法具体区分加载的错误类别:比如是 404 资源不存在还是服务端错误等,只能配合后端日志进行排查。
到这里,我们简单做一个总结,分析 window.onerror 和 window.addEventListener('error') 的区别。
window.onerror 需要进行函数赋值:window.onerror = function() {//...},因此重复声明后会被替换,后续赋值会覆盖之前的值。这是一个弊端。
请看下图示例:
而 window.addEventListener('error') 可以绑定多个回调函数,按照绑定顺序依次执行,请看下图示例:
一个成熟的系统还需要收集崩溃和卡顿,对此我们可以监听 window 对象的 load 和 beforeunload 事件,并结合 sessionStorage 对网页崩溃实施监控:
代码很简单,思路是首先在网页 load 事件的回调里:利用 sessionStorage 记录 good_exit 值为 pending;接下来,在页面无异常退出前,即 beforeunload 事件回调中,修改 sessionStorage 记录的 good_exit 值为 true。因此,如果页面没有崩溃的话,good_exit 值都会在离开前设置为 true,否则就可以通过 sessionStorage.getItem('good_exit') && sessionStorage.getItem('good_exit') !== 'true' 判断出页面崩溃,并进行处理。
如果你的应用部署了 PWA,那么便可以享受 service worker 带来的福利!在这里,可以通过 service worker 来完成网页崩溃的处理工作。基本原理在于:service worker 和网页的主线程独立。因此,即便网页发生了崩溃现象,也不会影响 service worker 所在线程的工作。我们在监控网页的状态时,通过 navigator.serviceWorker.controller.postMessage API 来进行信息的获取和记录。
对于框架来说,React 16 版本之前,使用 unstable_handleError 来处理捕获的错误;16 版本之后,使用著名的 componentDidCatch 来处理错误。Vue 中,提供了 Vue.config.errorHandler 来处理捕获到的错误,如果开发者没有配置 Vue.config.errorHandler,那么捕获到的错误会以 console.error 的方式输出。具体 API 的使用方式和框架特点,这里不再赘述。
上面提到框架会用 console.error 的方法抛出错误,因此可以劫持 console.error,捕获框架中的错误并做出处理:
如下图:
最后总结一下,我们大概处理了以下错误或者异常:
JavaScript 语法错误、代码异常
AJAX 请求异常(xhr.addEventListener('error', function (e) { //... }))
静态资源加载异常
Promise 异常
跨域 Script error
页面崩溃
框架错误
在真实生产环境中,错误和异常多种多样,需要开发者格外留心,并对每一种情况进行覆盖。另外,除了性能和错误信息,一些额外信息,比如页面停留时间、长任务处理耗时等往往对分析网页表现非常重要。所有这些话题,欢迎大家在评论区展开讨论,也可以直接向我提问。对于错误信息采集和处理的介绍到此为止,接下来看一下数据的上报和系统设计。
数据都有了,我们该如何上报呢?可能有的开发者会想:“不就是一个 AJAX 请求吗?”,实际上还真没有这么简单,有一些细节需要考虑。
我们发现,成熟的网站数据上报的域名往往与业务域名并不相同。这样做的好处主要有两点:
使用单独域名,可以防止对主业务服务器的压力,能够避免日志相关处理逻辑和数据在主业务服务器的堆积;
另外,很多浏览器对同一个域名的请求量有并发数的限制,单独域名能够充分利用现代浏览器的并发设置。
对于单独的日志域名,肯定会涉及跨域问题。我们经常发现页面使用“构造空的 Image 对象的方式”进行数据上报。原因是请求图片并不涉及跨域的问题:
我们可以将数据进行序列化,作为 URL 参数传递:
页面加载性能数据可以在页面稳定后进行上报。
一次上报就是一次访问,对于其他错误和异常数据的上报,假设我们的应用日志量很大,则有必要合并日志在统一时间,统一上报。那么什么情况下上报性能数据呢?一般合适的场景为:
页面加载和重新刷新
页面切换路由
页面所在的 Tab 标签重新变得可见
页面关闭
但是,对于越来越多的单页应用来说,需要格外注意数据上报时机,请看下文。
如果切换路由是通过改变 hash 值来实现的,那么只需要监听 hashchange 事件,如果是通过 history API 来改变 URL,那么需要使用 pushState 和 replaceState 事件。当然一劳永逸的做法是进行 monkey patch,结合发布订阅模式,为相关事件的触发添加处理:
我们通过重写 history.pushState 和 history.replaceState 方法,添加并触发 pushState 和 replaceState 事件。这样一来 history.pushState 和 history.replaceState 事件触发时,可以添加订阅函数,进行上报:
如果是在页面离开时进行数据发送,那么在页面卸载期间是否能够安全地发送完数据是一个难题:因为页面跳转,进入下一个页面,就难以保证异步数据的发送了。如果使用同步的 AJAX:
又会对页面跳转流畅程度和用户体验造成影响。
这时候给大家推荐一下 sendBeacon 方法:
navigator.sendBeacon 就是天生来解决“页离开时的请求发送”问题的。它的几个特点决定了对应问题的解决方案:
它的行为是异步的,也就是说请求的发送不会阻塞向下一个页面的跳转,因此可以保证跳转的流畅度;
它在不受到极端“数据 size 和队列总数”的限制下,优先返回 true 以保证请求的发送成功。
目前 Google Analytics 使用 navigator.sendBeacon 来上报数据,请参考:Google Analytics added sendBeacon functionality to Universal Analytics JavaScript API。通过这篇文章,我们看到 Google Analytics 通过动态创建 img 标签,在 img.src 中拼接 URL 的方式发送请求,不存在跨域限制。如果 URL 太长,就会采用 sendBeacon 的方式发送请求,如果 sendBeacon 方法不兼容,则发送 AJAX post 同步请求。类似:
最后,如果网页访问量很大,那么一个错误发送的信息就非常多,我们可以给上报设置一个采集率:
这个采集率当然可以通过具体实际的情况来设定,方法多种多样。
目前为止,我们已经了解了性能监控和错误收集的所有必要知识点。那么根据这些知识点,如何设计一个好的系统方案呢?
首先,这样的系统大致可分为四个阶段:
针对这几个阶段,我们聊一下关键方面的核心细节。
数据上报优化方面
借助 HTTP 2.0 带来的新特性,我们可以持续优化上报性能。比如:采用 HTTP 2.0 头部压缩,以减少数据传送大小;采用 HTTP 2.0 多路复用技术,以充分利用链接资源。
接口和智能化设计方面
我们可以考虑以下方面:
识别周高峰和节假日,动态设置上报采样率;
增强数据清洗能力,提高数据的可用性,对一些垃圾信息进行过滤;
通过配置化,减少业务接入成本;
如果用户一直触发错误,相同的错误内容会不停上报,这时可以考虑是否需要做一个短时间滤重。
实时性方面
目前我们对系统数据的分析都是后置的,如何做到实时提醒呢?这就要依赖后端服务,将超过阈值的情况进行邮件或短信发送。
在这个链路中,所有细节单独拿出来都是一个值得玩味的话题。打个比方,报警阈值如何设定。我们的应用可能在不同的时段和日期,流量差别很大,比如“点评”类应用,或“酒店预订”类应用,在节假日流量远远高于平时。如果报警阈值不做特殊处理,报警过于敏感,也许运维或开发者就要收到“骚扰”。业界上流行 3-sigma 的阈值设置,这是一个统计学概念。它表示对于一个正态分布或近似正态分布来说,数值分布在(μ-3σ,μ+3σ) 中属于正常范围区间。这方面更多内容可以参考:
最后,我收集了业界几个性能监控和错误收集上报系统的分享,这些分享方案有的以 PPT 形式呈现,有的以源码分析实现,希望大家能够继续了解学习:
本节梳理了性能监控和错误收集上报方方面面的内容。前端业务场景和浏览器的兼容性千差万别,因此数据监控上报系统要兼容多种情况。页面生命周期、业务逻辑复杂性也决定了成熟稳定的系统不是一蹴而就的。我们也要持续打磨,结合新技术和老经验,同时对比类似 Sentry 这样的巨型方案,探索更稳定高效的系统。
课程代码仓库:
https://github.com/HOUCe/lucas-gitchat-courses
请大家留言分享「性能监控和错误上报」相关的经验心得。阅读文章过程中有任何疑问随时可以跟其他小伙伴讨论,或者直接向作者 LucasHC 提问。 你的分享不仅帮助他人,更会提升自己。
你也可以说说自己最想了解的主题,课程内容会根据部分读者的意见和建议迭代和完善。
此外,我们为本课程付费读者创建了《前端开发核心知识进阶》微信交流群,以方便更有针对性地讨论课程相关问题。(入群请到第1-2课末尾扫描二维码,若失效请加 GitChat 小助手伽利略的微信,ID 为 GitChatty6,注明「前端核心」。)