转载

前端监控技术架构之数据采集

监控数据采集通常分为“自动采集”和“手动采集”两种方式。手动采集很简单,只需要SDK向外暴露接口,开发人员在需要上报的地方手动调用接口,将数据上报到服务端即可。下面主要解释如何实现自动采集。

Web端采集

所谓的Web端,即指运行在浏览器上的Web应用,这一类应用的共同点是:所有的能力都是基于浏览器。

访问记录

对于现代Web应用,可以分MPA和SPA两种情况来采集访问记录。

对于MPA,情况非常简单,每次进入新页面都会刷新一次页面,此时即可采集到一次页面访问记录。

对于SPA,因为切页面时,并不会触发页面的刷新,所以此时如果想要自动采集到页面的访问记录,一种比较自然的思路就是结合SPA框架的 router 功能来实现。

Vue 为例,我们可以使用以下代码来上报页面访问数据:

const router = new VueRouter({ ... });

router.beforeEach((to, from, next) => {
    this.report({
        from: from.fullPath,
        url: to.fullPath,
    });
});
复制代码

这种方式的优点是简单直接,但是缺点也很明显:

  • 需要业务开发同学手动干预;
  • 强框架结合,不同的路由框架需要不同的处理;
  • 由于嵌套路由的原因,一次页面跳转可能触发多次路由守卫;

那我们能不能从路由底层原理层面来解决这个问题呢?答案是肯定的。

对于前端路由而言,无刷新切换页面无外乎两种技术方案。一种是基于 History API ,这种是绝大部分路由的首选方案, VueRouterReactRouter 都默认采用了这种方式。另外一种是基于 HashChangeVueRouter 在浏览器不支持 History API 时,采用的就是这种方式。

因此,我们可以使用以下代码来自动上报页面访问数据。

const pageChange = function() {
    // 上报访问记录
}

// 判断浏览器是否支持History API
if (pushState()) {
    // replaceState和pushState不会触发popstate事件
    let hooks = ['replaceState', 'pushState'];
    hooks.forEach(function(hook) {
        let method = history[hook];
        history[hook] = function(...args) {
            setTimeout(pageChange, 0); // 路由变化之后再上报
            return method.apply(history, args);
        };
    });
    window.addEventListener('popstate', pageChange, true);
} else {
    window.addEventListener('hashchange', pageChange, true);
}
复制代码

HTTP请求错误

在浏览器环境,发送HTTP请求有以下几种方式:

  • 兼容IE6的 new ActiveXObject("Msxml2.XMLHTTP") 或者 new ActiveXObject("Microsoft.XMLHTTP")
  • 兼容IE8/IE9的 XDomainRequest
  • 兼容IE10及以上或Chrome等现代浏览器的 XMLHttpRequest
  • 兼容现代浏览器的 fetch

除此之外,还有一些非常规方式:

  • 资源加载模拟,如: <script><img> 标签的 src 属性;
  • 新的埋点上报草案 navigator.sendBeacon

这里,我们只考虑常用的 XMLHttpRequestfetch

对于 XMLHttpRequest ,我们可以重写该构造函数。

const _XMLHttpRequest = window.XMLHttpRequest;
if (_XMLHttpRequest) {
    // noinspection JSValidateTypes
    window.XMLHttpRequest = function XMLHttpRequest() {
        const xhr = new _XMLHttpRequest();

        const errorHandler = function() {
            // 上报请求失败
        };

        const timeoutHandler = function() {
            // 上报请求超时
        };

        const readyHandler = function() {
            if (xhr.readyState === xhr.DONE) {
                // 上报请求成功
            }
        };

        xhr.addEventListener('error', errorHandler, true);
        xhr.addEventListener('timeout', timeoutHandler, true);
        xhr.addEventListener('readystatechange', readyHandler, true);

        return xhr;
    };
}
复制代码

对于 fetch ,同样可以通过重写该函数来达到目的。

const _fetch = window.fetch;
if (_fetch) {
    window.fetch = function fetch(url, options = {}) {
        return _fetch.call(window, url, options)
            .then(function(res) {
                // 上报请求成功
                return res;
            })
            .catch(function(err) {
                // 上报请求失败
                throw err;
            });
    };
}
复制代码

注意,我们在重写 XMLHttpRequestfetch 时,并没有完整的重新实现这两个函数,而是尽可能的复用了原函数的能力,这样改造的成本更小,也可以避免漏掉某个API导致引发新的错误。

JS错误

我们可以使用 window.onerror 或者 window.addEventListener 来全局捕获JS错误。

const errorHandler = err => {
    const error = err.error; // error
    const reason = err.reason; // unhandledrejection
    const message = err.message ||
            (error && error.message || JSON.stringify(error)) ||
            (reason && reason.message || JSON.stringify(reason)) ||
            JSON.stringify(err);
    const stack = err.stack || (error && error.stack) || (reason && reason.stack);
    
    // 上报错误信息
};

window.addEventListener('error', errorHandler, false); // 资源加载错误不从此处上报
window.addEventListener('unhandledrejection', errorHandler, false);
复制代码

资源加载错误

静态资源:包括图片、音视频、脚本、外部样式文件等,加载出错时会触发 error 事件,可以通过 document.addEventListener 来捕获。

const errorHandler = e => {
    const el = e.target;
    const source = el.src || el.href || el.data;
    const html = el.outerHTML || '';
    
    // 上报资源加载错误
};

// global listener
document.addEventListener('error', errorHandler, true);
复制代码

对于使用 Image 构造函数加载图片的场景,需要特殊处理:

// hook Image
const _Image = window.Image;
if (_Image) {
    // noinspection JSValidateTypes
    window.Image = function Image(width, height) {
        const image = new _Image(width, height);
        image.onerror = errorHandler;
        return image;
    };
}
复制代码

当然,也有一些场景是暂时无法处理的。例如:CSS样式中的背景图片加载错误,是不会触发错误事件的,这种情况就没办法处理了。

小程序端采集

小程序本身是带有监控功能的,我们可以在小程序管理后台查看错误日志、访问量等信息,其提供的自定义数据上报功能还能支撑更多的自定义分析维度,也可以使用后端API将日志拉取到我们的服务上,做进一步的数据分析。但是,复用其它端上监控的上报路径,使用统一的技术方案,成本更小也更易于理解和使用。所以,我们可以也有必要自己实现小程序端上的日志采集。

小程序虽然也是使用JS,但是底层与Web应用却有很大不同。从 微信,支付宝小程序实现原理概述 一文可知,小程序的JS是执行在单独的引擎上的,所以无法访问BOM(即没有window对象);同时,小程序的网络请求是映射到原生发送而非使用 XMLHttpRequest 或者 fetch 。所以,对于小程序端的采集,只能借用小程序提供的能力来实现。

访问记录

小程序不同的页面会对应到不同的webview,每个页面都需要使用 Page 方法来注册。我们可以通过传入一个 Object 类型参数,指定页面的初始数据、生命周期回调、事件处理函数等。所以我们可以在开发人员传入的参数外包装一次,在生命周期回调中完成访问数据上报。

通过查阅文档可知, onShow 生命周期回调是个好选择,不管是初次页面加载还是从其它页面切回来(此时页面会走缓存,不会触发 onLoad 回调),都会触发该生命周期,正好适合用于统计 PV 。代码如下:

wrapper(options, 'onLoad', function(query) {
    const self = this;
    self.__eye_page_query = query;
});

wrapper(options, 'onShow', function() {
    const self = this;
    const url = `${self.route}${stringifyQuery(self.__eye_page_query)}`;
    // 上报页面访问数据
});
复制代码

上述代码中, options 是开发者定义的 Page 方法的入参,我们通过 wrapper 方法包装了 onLoadonShow 两个生命周期的回调。在 onLoad 时,获取用户打开当前页面路径中的参数(比如扫码进入时,在链接中定义了一些参数表示用户访问来源),这些数据通常需要一并上报,用于后续的访问分析;在 onShow 时,将当前页面的 url 上报给服务端。 wrapper 方法源码如下:

import is from 'web/util/helper/is';

export default function wrapper(obj, key, callback) {
    const fn = obj[key];
    obj[key] = function(...args) {
        const self = this;
        callback.apply(self, args);
        if (is.function(fn)) return fn.apply(self, args);
    };
}
复制代码

HTTP请求错误

在小程序环境,发送HTTP请求有以下几种方式:

  • 用于发起网络请求的 wx.requestmy.request
  • 用于上传本地资源到开发者服务器的 wx.uploadFilemy.uploadFile

所以我们可以通过包装这两个方法,在请求完成时自动上报HTTP请求错误:

const wrapperOptions = function wrapperOptions(options = {}) {
    wrapper(options, 'complete', function(res) {
        let status = res.statusCode || res.status || 0;

        if (status) {
            if (isHttpStatusOK(status)) {
                // 上报请求成功
            } else {
                // 上报请求失败
            }
        } else {
            // res.error 仅支付宝小程序支持
            if (res && res.error === 13) {
                // 上报请求超时
            } else {
                // 上报请求失败
            }
        }
    });
};

const requestHandler = function requestHandler(options = {}) {
    wrapperOptions(options);
    return request(options);
};

const uploadFileHandler = function uploadFileHandler(options = {}) {
    options.method = 'POST';
    wrapperOptions(options);
    return uploadFile(options);
};

define('request', requestHandler);
define('uploadFile', uploadFileHandler);
复制代码

这里有个 define 方法,用于改写 wxmy 提供的 API ,其源码如下:

export default function define(keys, callback) {
    keys = Array.isArray(keys) ? keys : [keys];
    const props = {};
    keys.forEach(function keysLoop(key) {
        props[key] = {
            configurable: true,
            enumerable: true,
            writable: true,
            value: callback,
        };
    });

    Object.defineProperties(global, props);
}
复制代码

JS错误

小程序发生脚本错误或 API 调用报错时会触发在 App 上定义的 onError 回调,所以我们可以在这个回调中统一捕获错误并上报:

wrapper(options, 'onError', function(stack) {
    // 上报错误信息
});
复制代码
原文  https://juejin.im/post/5e04aba2518825121c3326bd
正文到此结束
Loading...