监控数据采集通常分为“自动采集”和“手动采集”两种方式。手动采集很简单,只需要SDK向外暴露接口,开发人员在需要上报的地方手动调用接口,将数据上报到服务端即可。下面主要解释如何实现自动采集。
所谓的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请求有以下几种方式:
new ActiveXObject("Msxml2.XMLHTTP")
或者 new ActiveXObject("Microsoft.XMLHTTP")
; XDomainRequest
; 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导致引发新的错误。
我们可以使用 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请求有以下几种方式:
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); } 复制代码
小程序发生脚本错误或 API
调用报错时会触发在 App
上定义的 onError
回调,所以我们可以在这个回调中统一捕获错误并上报:
wrapper(options, 'onError', function(stack) { // 上报错误信息 }); 复制代码