监控数据采集通常分为“自动采集”和“手动采集”两种方式。手动采集很简单,只需要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,这种是绝大部分路由的首选方案,VueRouter和ReactRouter都默认采用了这种方式。另外一种是基于HashChange,VueRouter在浏览器不支持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;
这里,我们只考虑常用的XMLHttpRequest和fetch。
对于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;
});
};
}
注意,我们在重写XMLHttpRequest或fetch时,并没有完整的重新实现这两个函数,而是尽可能的复用了原函数的能力,这样改造的成本更小,也可以避免漏掉某个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方法包装了onLoad和onShow两个生命周期的回调。在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.request或my.request; - 用于上传本地资源到开发者服务器的
wx.uploadFile或my.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方法,用于改写wx或my提供的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) {
// 上报错误信息
});
资源加载错误
暂时没有找到方案实现在小程序中捕获资源加载错误。