60

随着业务的快速发展,我们对生产环境下的问题感知能力越来越关注。作为距离用户最近的一层,前端的表现是否可靠、稳定、好用,很大程度上决定着用户对整个产品的体验和感受。因此,对于前端的监控不容忽视。

搭建一套前端监控平台需要考虑的方面很多,比如数据采集、埋点模式、数据处理和分析、报警以及监控平台在具体业务中的应用等等。在这所有环节中,准确、完整、全面的数据采集是一切的前提,也为后续的用户精细化运营提供基础。

前端技术的日新月异给数据采集也带来了变化和挑战,传统的手工打点模式已经不能满足需求。如何在新的技术背景下让前端数据采集工作更加完善、高效,是本文讨论的重点。

前端监控数据采集

在采集数据之前,首先要考虑采集什么样的数据。我们重点关注两类数据,一类是与用户体验相关的,如首屏时间、文件加载时间、页面性能等;另外是帮助我们及时感知产品上线后是否出现异常的,比如资源错误、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 进行源码映射。为了降低风险,我们可以通过如下方式:

  1. 将 sourceMap 生成的 .map 文件设置公司内网访问,降低源码安全风险
  2. 在发布代码到 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

需要注意的是,API 拦截一定要对 SDK 自己上报的 API 设置好忽略,否则将会导致循环上报问题。

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;
    });
}

日志上报

为了监控前端应用是否正常运行,通常会在前端收集错误与性能等数据,最终将这些数据上报到服务端。因为日志上报并不是应用的主要功能逻辑,优先级比较低,所以我们在确保日志数据上报更高效的同时,还应该考虑如何尽可能地减少与其他关键操作的资源争抢。

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%,具体要看用户群体对应的环境。

小结

高效的前端数据采集对于搭建前端监控平台来说非常关键。本文我们分享了马蜂窝在保证数据采集及时、准确、全面等方面的一些思路和实践。需要提示大家注意的是,文中涉及到的演示只做了核心代码的关键描述,不具备生产使用,我们在实际使用中需要做好兼容及容错。

本文也将作为马蜂窝前端监控平台系列文章的开篇,今后还将陆续推出埋点模式、数据处理和分析、报警以及监控平台在具体业务中的应用等内容,欢迎大家持续关注。

本文作者:王峥,马蜂窝大数据平台前端技术专家。

(马蜂窝技术原创内容,转载务必注明出处保存文末二维码图片,谢谢配合。)

关注马蜂窝技术,找到更多你想要的内容


马蜂窝技术
2.3k 声望8.3k 粉丝

欢迎关注马蜂窝技术,我们将定期分享马蜂窝技术团队先进的技术实践,期待您与马蜂窝一起见证技术对行业的赋能和改变。