Google Analytics 源代码里的小技巧之 JS 错误监控

2,144 阅读7分钟
原文链接: github.com

作者前段时间在做类似Google Analytics(以下简称GA)的第三方监控脚本。所以对GA的前端代码做过调研,对GA的压缩后代码做了一定程度上的人肉美化。这里美化的是analytics.js的j41版本,本文提到的小技巧也是基于这个版本的js。

GA的JS代码是运行在开发者网站中的,如果GA本身出现了bug那么将会影响到很多的网站。除了发布前做好检查之外,还需要对线上GA的JS代码做好出错监控,确保线上错误出现时能够及时修复问题。

onerror事件

在Web前端领域做JS错误监控的常用方法就是重写window.onerror方法然后获取出错的信息,但是这同时也会监控到页面上其他的JS代码的错误。当然我们可以根据第二个参数source来获取仅仅属于GA自己的错误信息。示例代码如下:

window.onerror = function (msg, url, lineNo, columnNo, error) {
    if (~source.indexOf('analytics.js')) {
        // ... handle error ...
    }

    return false;
}

不过这个方法也有两个致命确定。

  1. 兼容性问题,尽管主流浏览器的最新版基本都已经支持了onerror事件,但是一些旧版浏览器并不支持它
  2. 对于JS文件和网页域名不同的情况,非IE浏览器有同源策略的限制。需要设置其特殊的HTTP头部和script标签的crossorigin属性才能够得到更加完整的错误信息。当然crossorigin属性也是有 兼容性问题

GA的JS代码所在的域名一般都放在开发者的网页上,所以受到同源策略的限制。而且GA对监控脚本的兼容性要求较高希望支持更多的浏览器。所以重写window.onerror的方法并不是很合适。

try catch方案

它采用了更为稳妥的try catch方案。对所有的对外接口做了一层包装,当接口调用出错时发送错误日志给后端。示例代码如下:

/**
 * 对接口做一个包装并返回
 *
 *      wrap('functionName', function() {});
 *      var context = {
 *          name: function() {}
 *      };
 *      wrap('name', context);
 *
 * @param {string} methodName 函数在对象上的属性名
 * @param {Object} context 函数所在的对象
 * @param {Function} method 函数
 * @param {number} idx 函数所在的对象
 */
function wrapApi(methodName, context, method, idx) {
    context[methodName] = function() {
        try {
            idx && reg(idx);
            return method.apply(this, arguments)
        } catch (e) {
            // 发送错误日志
            errorPing('exc', methodName, e && e.name);
            throw e;
        }
    }
};

频率控制

有意思的是GA并没有每次出错都发日志来记录问题,而是只取其中的1%来发送日志,也就是99%的概率会直接放过。这么设计的原因本人猜想有三点:

  1. 重复的错误日志并没有提供更多的信息可以用于帮助修复错误,反而会多占用一些资源。以GA的量级,这应该不是一个可以忽略的数字
  2. 考虑到在迭代中多次调用GA接口出错的情况,很可能一次访问就会触发大量的日志。这些重复日志数据并没有多大意义
  3. 一定程度上过滤开发者在开发调试阶段因为调用接口出错(例如输入参数错误)导致的GA报错

错误的定位

当GA的码农们接收到错误告警邮件之后如何去定位这个错误呢?首先他们会找到出错的网站和浏览器,然后打开对应的浏览器和网站地址去复现错误。

如果错误比较常见,那么很容易就能复现。如果是登陆后的网站地址呢?如果是在用户的机器下才能复现呢?如果用户通过修改了hosts文件造了个假域名来访问本地文件呢?如果……

比较简单的方法就是去联系用户QQ远程协助看看出问题的原因。前提是你开发的是普通的网站且能找到用户联系方式且用户的协助意愿比较强,那么可以这么做。GA的报错是发生在非自己可控的网站上,更拿不到大多数用户的联系信息。

GA采用另一种方法,通过“打点”来记录运行环境和执行位置。打点信息会被传给后端用于还原错误产生时候的状态,分析可能的原因并尝试解决。

打点

GA通过一个数字ID来给每个重要的代码标示位标记,如果代码进入了这个标示位或者触发了这个标示位那么就将对应的数字ID传给服务器。如果没触发则不发。

服务器通过传过来的ID,便能知道哪些逻辑被执行过,依次来实现一定程度上的场景还原,帮助定位问题。上面的代码中reg(idx);这行便是用来记录代码标识位的操作,idx则是标识位。

一般来说标识位主要用在两种地方:

  1. 重要的代码执行前后
  2. 分支逻辑

第一点相对容易理解,比如之前的代码就是在包装过的API方法执行之前做一次打点。然后服务器便可以知道API方法是否已经执行过。以此来大概判断出错位置。

第二个点则是用来判断一些特殊条件,例如:

if (noCookie) {
    reg(20);
    // 分支逻辑
}

// 正常逻辑

例子中当浏览器没有开启cookie支持时,要执行特殊的分支逻辑。这些地方很可能是开发者在平时很少测试到的逻辑,比较容易出现线上bug。所以要增加打点,帮助定位当时浏览器的运行环境,确定哪些特殊分支逻辑被执行了。

依靠打点GA开发者基本上就可以确定哪些逻辑被执行过了,但并不能定位到执行的次数。对于GA的情况执行的次数可能并不是一个重要的信息,所以没有记录。

打点的传输

打点的数据本质上是一个数字数字,例如:

[1, 4, 10, 14, 15, 18, 23,  26, 28, 30, 32, 35, 38, 40, 41, 43, 45, 48]

数字并不一定是连续的。相对普通的传输方式是arr.join('.')之后变成1.4.10.14.15.18.23.26.28.30.32.35.38.40.41.43.45.48传给后端。当打点很多时传输的字符数据会太多。我们之前已经讨论过:传输数据的长度是有限制的。所以要尽量压缩字符长度。GA采取了一种有趣的压缩算法,大致代码如下:

function encode(data) {
    var arr = [];
    for (var i = 0; i < data.length; i++) {
        if (data[i]) {
            // `1 << x` === `Math.pow(2, x)`
            arr[Math.floor(i / 6)] ^= 1 << (i % 6);
        }
    }
    for (i = 0; i < arr.length; i++) {
        arr[i] = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'.charAt(arr[i] || 0);
    }
    return arr.join('') + '~';
}

大致的逻辑是:

  1. 输入数据为数组[true, true, false, true, false, ......]。数组的每个值代表的对应标示位是否开启。例如第一个值为true,那么标示位0打开
  2. 6个标示位一组压缩到一个数字中,每个二进制位表示一个标示位。例如[true, true, false, true, false, true]被转换成二进制数101011
  3. 因为6位二进制数最大是63,所以可以将二进制数字转换成字符串ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_的索引值
  4. 于是便可以将6个数字压缩成一个字符,将所有字符合并成字符串,并在最后加上~

这样就可以尽量压缩传输数据的字符长度了,只需要在服务器端解码即可。

总结

GA先通过try catch来捕捉到JS错误,同时使用打点来记录JS的执行情况。然后将压缩后的打点数据和错误信息一起传输给后端记录,以便之后分析问题。