随着业务的快速发展,我们对生产环境下的问题感知能力越来越关注。作为距离用户最近的一层,前端的表现是否可靠、稳定、好用,很大程度上决定着用户对整个产品的体验和感受。因此,对于前端的监控不容忽视。
搭建一套前端监控平台需要考虑的方面很多,比如数据采集、埋点模式、数据处理和分析、报警以及监控平台在具体业务中的应用等等。在这所有环节中,准确、完整、全面的数据采集是一切的前提,也为后续的用户精细化运营提供基础。
前端技术的日新月异给数据采集也带来了变化和挑战,传统的手工打点模式已经不能满足需求。如何在新的技术背景下让前端数据采集工作更加完善、高效,是本文讨论的重点。
前端监控数据采集
在采集数据之前,首先要考虑采集什么样的数据。我们重点关注两类数据,一类是与用户体验相关的,如首屏时间、文件加载时间、页面性能等;另外是帮助我们及时感知产品上线后是否出现异常的,比如资源错误、API 响应时间等。具体来说,我们对前端的数据采集具体主要分为:
- 路由切换 (href、hashchange、pushState)
- JsError
- 性能 (performance)
- 资源错误
- API
- 日志上报
路由切换
Vue、React、Angular 等前端技术的快速发展使单页面应用盛行。我们都知道,传统的页面应用是用一些超链接来实现页面切换和跳转的,而单页面应用是使用各自的路由系统来管理前端的每一个页面切换,例如 vue-router、react-router 等,跳转时仅刷新局部资源 ,js、css 等公共资源只需要加载一次,这就使传统网页进入离开的方式只有***次打开能被记录。单页应用后续所有路由切换的方式有两种,一种是 Hash,一种是 HTML5 推出的 History API。
1. href
href 为页面初始化的***次进入,这里只需要单纯上报「进入页面」事件即可。
2. hashchange
Hash 路由一个明显的标志是带有「 # 」。Hash 的优势是兼容性更好,但问题在于 URL 中一直存在「 # 」并不美观。我们主要通过监听 URL 中的 hashchange 来捕获具体的 hash 值进行检测。
- window.addEventListener('hashchange', function() {
- // 上报【进入页面】事件
- }, true)
需要注意的是,在新版 vue-router 中如果浏览器支持 history,即使 mode 选择 hash 也会优先选择 history 模式,虽然表现形式暂时还是 # 号,但实际上是模拟的,所以千万不要认为自己在 mode 选择了hash 就一定会是 hash。
3. History API
History 利用了 HTML5 History Interface 中新增的 pushState() 和 replaceState() 方法进行路由切换,是目前主流的无刷新切换路由方式。与 hashchange 只能改变 # 后面的代码片段相比,History API (pushState、replaceState) 给了前端完全的自由。
PopState 是浏览器返回事件的回调,但是更新路由的 pushState、replaceState 并没有回调事件,因此,还需要分别在 history.pushState() 和 history.replaceState() 方法里处理 URL 的变化。在这里,我们运用到了一种类似 Java 的 AOP 编程思想,对 pushState 和 replaceState 进行改造。
AOP (Aspect-oriented programming)即面向切面编程,提倡针对同一类问题进行统一处理。AOP 的核心思想是让某个模块能够重用,它采用横向抽取机制,将功能代码从业务逻辑代码中分离出来,扩展功能而不修改源代码,相比封装来说隔离得更加彻底。
下面介绍我们的具体改造方式:
- // ***阶段:我们对原生方法进行包装,调用前执行 dispatchEvent 了一个同样的事件
- function aop (type) {
- var source = window.history[type];
- return function () {
- var event = new Event(type);
- event.arguments = arguments;
- window.dispatchEvent(event);
- var rewrite = source.apply(this, arguments);
- return rewrite;
- };
- }
- // 第二阶段:将 pushState 和 replaceState 进行基于 AOP 思想的代码注入
- window.history.pushState = aop('pushState');
- window.history.replaceState = aop('replaceState'); // 更改路由,不会留下历史记录
- // 第三阶段:捕获pushState 和 replaceState
- window.addEventListener('pushState', function() {
- // 上报【进入页面】事件
- }, true)
- window.addEventListener('replaceState', function() {
- // 上报【进入页面】事件
- }, true)
window.history.pushState 实际调用关系如图:
至此,我们对 pushState、replaceState 改造完毕,实现了有效地捕获路由切换。可以看到,我们在不侵入业务代码的情况下,对 window.history.pushState 进行了扩展,在调用的同时会主动 dispatchEvent 一个 pushState。
但在这里我们也能看到一个弊端,就是如果 AOP 代理函数发生 JS 错误,将会阻断后续的调用关系,使实际的 window.history.pushState 无法被调用。所以在使用此方式的时候,要对 AOP 代理函数的内容做好完善的 try catch,来防止业务上出现异常。
*Tips:想自动捕获页面停留时间只需要在下一个进入页面事件触发时,通过上一个页面的打点时间和当前时间做差值即可,这时候可以上报一个【离开页面】事件。
JsError
前端项目中,由于 JavaScript 本身是一个弱类型语言,加上浏览器环境的复杂性、网络问题等,很容易发生错误。因此做好网页错误监控,不断优化代码,提高代码健壮性是一项很重要的工作。
JsError 的捕获可以帮助我们分析和监控线上问题,它与我们在 Chrome 浏览器的调试工具 Console 中看到的内容一致。
1. window.onerror
我们使用 window.onerror 捕获一般情况下 JS 错误的异常信息。捕获 JS 错误的方式有两种,window.onerror 和 window.addEventListener(‘error’)。一般情况下,捕获 JS 异常不推荐使用 addEventListener(‘error’),主要是因为它没有堆栈信息,而且还需要对捕获到的信息做区分,因为它会将所有异常信息捕获到,包括资源加载错误等。
- window.onerror = function (msg, url, lineno, colno, stack) {
- // 上报 【js错误】事件
- }
2. Uncaught (in promise)
当 Promise 内发生 JS 错误或者 reject 信息未被业务处理的情况时,会抛出一个 unhandledrejection,并且这个错误不会被 window.onerror 以及 window.addEventListener('error') 捕获,这里需要用专门的 window.addEventListener('unhandledrejection') 进行捕获处理:
- window.addEventListener('unhandledrejection', function (e) {
- var reg_url = /\(([^)]*)\)/;
- var fileMsg = e.reason.stack.split('\n')[1].match(reg_url)[1];
- var fileArr = fileMsg.split(':');
- var lineno = fileArr[fileArr.length - 2];
- var colno = fileArr[fileArr.length - 1];
- var url = fileMsg.slice(0, -lno.length - cno.length - 2);}, true);
- var msg = e.reason.message;
- // 上报 【js错误】事件
- }
我们注意到 unhandledrejection 因为继承自 PromiseRejectionEvent,PromiseRejectionEvent 又继承自 Event,所以 msg、url、lineno、colno、stack 以字符串形式放到了 e.reason.stack 中,我们需要解析出来上述参数来和 onerror 参数对齐,为后续监控平台的指标统一化打下基础。
3. 常见问题
- "Script error."
如果出现捕获的 msg 全部为 "Script error." ,问题在于你的 JS 地址和当前网页不在同一个域下。因为我们要经常在线上的版本做静态资源 CDN 化,会导致常访问的页面跟脚本文件来自不同的域名。这时如果没有进行额外的配置,浏览器出于安全方面的设计就容易出现 "Script error."。我们可以利用目前流行的 Webpack 打包工具来处理此类问题。
- // webpack config 配置
- // 处理 html 注入 js 添加跨域标识
- plugins: [
- new HtmlWebpackPlugin({
- filename: 'html/index.html',
- template: HTML_PATH,
- attributes: {
- crossorigin: 'anonymous'
- }
- }),
- new HtmlWebpackPluginCrossorigin({
- inject: true
- })
- ]
- // 处理按需加载的 js 添加跨域标识
- output: {
- crossOriginLoading: true
- }
- SourceMap
大部分场景下,生产环境中的代码都是经过压缩合并的,这使得我们捕获到的错误很难映射到具体的源码,为我们解决问题带来很大困扰,这里简要提出 2 个解决方案的思路。
生产环境我们需要添加 sourceMap 配置,这会导致安全隐患,因为这样外网就可以通过 sourceMap 进行源码映射。为了降低风险,我们可以通过如下方式:
- 将 sourceMap 生成的 .map 文件设置公司内网访问,降低源码安全风险
- 在发布代码到 CDN 的时候,将 .map 文件存储到公司内网下
这时我们已经拥有了 .map 文件,后续要做的就是通过捕获到的 lineno、colno、url 调用 mozilla/source-map 库进行源码映射,即可拿到真实的源码错误信息。
性能
性能指标的获取相对比较简单,在 onload 之后读取 window.performance 即可,里面包含了性能、内存等信息。这部分内容在很多现有的文章中都有介绍,因篇幅所限不在本文做过多展开,之后在相关主题文章中我们会有相关探讨,感兴趣的朋友可以添加「马蜂窝技术」公众号持续关注。
资源错误
首先我们要明确下资源错误捕获的使用场景,更多的是感知 DNS 劫持 及 CDN 节点异常等,具体方式如下:
- window.addEventListener('error', function (e) {
- var target = e.target || e.srcElement;
- if (target instanceof HTMLScriptElement) {
- // 上报 【资源错误】事件
- }
- }, true)
这里只做基本演示,实际环境中我们会关心更多的 Element 错误,如 css、img、woff 等,大家可以根据不同的场景自行添加。
*资源错误的使用场景更多依赖其他几个维度,如:地域、运营商等,后续的篇幅中我们会具体讲解。
API
市面上主流的框架(如 Axios、jQuery.ajax 等)中,基本上所有的 API 请求都是基于xmlHttpRequest 或者 fetch,所以捕获全局接口错误的方式就是封装 xmlHttpRequest 或者 fetch。这里,我们的 SDK 仍然使用到上文提及的 AOP 思想,对 API 进行拦截。
1. XmlHttpRequest
- var xhr = window.XMLHttpRequest;
- var _open = xhr.prototype.open;
- var _send = xhr.prototype.send;
- var attr = {};
- var openReplacement = function (method, url) {
- // 可以存储method、url、时间打点等信息
- attr.duration = new Date().getTime();
- _open.apply(this, arguments);
- }
- var sendReplacement = function () {
- methods.addEvent(this, 'readystatechange', function (attr) {
- // 可以存储response的status、计算客户端实际响应时间
- attr.status = this.status;
- attr.duration = new Date().getTime() - attr.duration;
- // 上报【API】事件
- }.bind(this, , JSON.parse(JSON.stringify(attr))));
- _send.apply(this, arguments);
- }
- xmlhttp.prototype.open = openReplacement;
- xmlhttp.prototype.send = sendReplacement;
2. Fetch
- var _fetch = window.fetch;
- window.fetch = function () {
- var attr = {
- method: arguments[1].method,
- url: arguments[0],
- duration: new Date().getTime()
- };
- return _fetch.apply(this, arguments).then(res => {
- attr.status = res.status;
- attr.duration = new Date().getTime() - attr.duration;
- // 上报【API】事件
- return res;
- });
- }
需要注意的是,API 拦截一定要对 SDK 自己上报的 API 设置好忽略,否则将会导致循环上报问题。
日志上报
为了监控前端应用是否正常运行,通常会在前端收集错误与性能等数据,最终将这些数据上报到服务端。因为日志上报并不是应用的主要功能逻辑,优先级比较低,所以我们在确保日志数据上报更高效的同时,还应该考虑如何尽可能地减少与其他关键操作的资源争抢。
1. sendBeacon
navigator.sendBeacon() 方法主要用于满足统计和诊断代码的需要。这些代码通常会在卸载文档之前,尝试通过 HTTP 将少量数据异步传输到 Web 服务器。它解决了日志上报在 unload 时成功率很低的问题。我们在埋点时有很多对离开页面时上报的需求,因为 SendBeacon 是异步的,不会影响当前页到下一个页面的跳转速度,可以更可靠地保障事件上报成功率,并且不影响路由切换。
- window.navigator.sendBeacon('上报事件的api', '数据参数')
2. img.src
当浏览器不支持 navigator.sendBeacon 时,我们可以采用模拟图片加载的方式发送日志上报事件,且不会存在跨域问题。
- var img = new Image();
- img.src = API + '?' + '数据参数'
3. 关于 XmlHttpRequest
这里不推荐用 XmlHttpRequest。XHR 虽然支持异步请求,直接发送数据到后端,但是会受到跨域和同源的限制。而通过日志上报 API 跟业务是不在一个域下的,如果采用这种模式需要设置 Access-Control-Allow-Origin:* 跨域,非常不方便,并且在 unload 情况下上报发生的丢包率***。
总结来看,日志上报推荐采用 sendBeacon -> img.src。在不影响用户路由切换和阻塞用户的情况下丢包率可以控制在 10%-30%,具体要看用户群体对应的环境。
小结
高效的前端数据采集对于搭建前端监控平台来说非常关键。本文我们分享了马蜂窝在保证数据采集及时、准确、全面等方面的一些思路和实践。需要提示大家注意的是,文中涉及到的演示只做了核心代码的关键描述,不具备生产使用,我们在实际使用中需要做好兼容及容错。
本文也将作为马蜂窝前端监控平台系列文章的开篇,今后还将陆续推出埋点模式、数据处理和分析、报警以及监控平台在具体业务中的应用等内容,欢迎大家持续关注。
本文作者:王峥,马蜂窝大数据平台前端技术专家。
(题图来源于网络)
【本文是51CTO专栏作者马蜂窝技术的原创文章,作者微信公众号马蜂窝技术(ID:mfwtech)】