前端监控细分可为俩大类,技术监控和行为监控。
技术准备
此处目的是生成使用的sdk,sdk打包更适合rollup
- npm创建项目
- 安装依赖
"devDependencies": {
"@babel/core": "^7.11.1",
"babel": "^6.23.0",
"cross-env": "^7.0.2",
"rollup": "^2.26.3",
"rollup-plugin-babel": "^4.4.0"
}
- 配置文件
//rollup.config.js
import babel from "rollup-plugin-babel";
let isDev = (process.env.NODE_ENV === 'develop');
let babaelConfig = {
"presets": [
[
"env", {
"modules": false,
"targets": {
"browers": ["chrome>40", "safari>=7"]
}
}
]
]
}
export default {
input: 'index.js',
watch: {
exclude: 'node_modules/**'
},
output: {
file: isDev ? '../website/client/js/eagle-monitor/bundle.umd.js' : '../dist/bundle.umd.js',
name: 'EagleMonitor',
format: 'umd',
sourcemap: true
},
plugin: [
babel({
babelrc:false,
presets:babaelConfig.presets,
plugins:babaelConfig.plugin,
exclude:'node_modules/**'
})
]
}
- 打包入口类
//index.js
import perf from "./perf";
import resource from "./resource";
import xhrHook from "./xhrHook";
import beh from "./beh";
import errorCatch from "./errorCatch";
perf.init(perfData=>{
console.log('perf');
})
resource.init(item=>{
console.log('resource');
})
beh.init(value=>{
console.log('beh');
})
errorCatch.init(errorInfo=>{
console.log('errorInfo');
})
xhrHook.init(xhrInfo=>{
console.log('xhrInfo');
})
技术监控
- 页面性能监控
- performance.timing
export default {
init: cb => {
let isDOMReady=false;
let isOnload=false;
let Util = {
getPerfData: p => {
let data = {
//网络建连
prevPage: p.fetchStart - p.navigationStart,//上一个页面的时间
redirect: p.redirectEnd - p.redirectStart,//重定向时间
dns: p.domainLookupEnd - p.domainLookupStart,//DNS查找时间
connect: p.connectEnd - p.connectStart,//TCP建连时间
network: p.connectEnd - p.navigationStart,//网络总耗时
//网络接收
send: p.responseStart - p.requestStart,//前端从发送到接收的时间
receive: p.responseEnd - p.responseStart,//接收数据用时
request: p.responseEnd - p.requestStart,//请求页面的总耗时
//前端渲染
dom: p.domComplete - p.domLoading,//dom解析时间
loadEvent: p.loadEventEnd - p.loadEventStart,//loadEvent时间
frotend: p.loadEventEnd - p.domLoading,//前端总时间
//关键阶段
load: p.loadEventEnd - p.navigationStart,//页面完全加载的时间
domReady: p.domContentLoadedEventStart - p.navigationStart,//dom准备时间
interactive: p.domInteractive - p.navigationStart,//用户可操作的时间
ttfb: p.responseStart - p.navigationStart //首字节时间
}
return data;
},
//dom解析完成
domready: (callback) => {
if (isDOMReady===true) return
let timer = null;
//之所以这样做,而不是初始时候执行一次,是因为各个属性值计算的时候,可能还没就绪,例如dom相关,很容易出现默认值计算
//然后结果是复数,所以需要这要递归,直到出现真实结果
let runCheck = () => {
if (performance.timing.domInteractive) {
//停止循环检测 然后运行callback
clearTimeout(timer);
callback();
isDOMReady=true
} else {
//再去循环检测
timer = setTimeout(runCheck, 100);
}
}
if (document.readyState === 'interactive') {
callback();
return
}
document.addEventListener('DOMContentLoaded', () => {
//开始循环检测,是否DOMContentLoaded已经完成
runCheck();
})
},
//页面加载完成
onload: (callback) => {
if (isOnload===true) return
let timer = null;
let runCheck = () => {
if (performance.timing.loadEventEnd) {
//停止循环检测 然后运行callback
clearTimeout(timer);
callback();
isOnload=true
} else {
//再去循环检测
timer = setTimeout(runCheck, 100);
}
}
if (document.readyState === 'interactive') {
callback();
return
}
window.addEventListener('load', () => {
//开始循环检测,是否DOMContentLoaded已经完成
runCheck();
})
}
}
let performance = window.performance; //兼容性问题,所以此处只是简化代码
Util.domready(() => {
let perfData = Util.getPerfData(performance.timing);
perfData.type='domready';
cb(perfData);
debugger
})
Util.onload(() => {
let perfData = Util.getPerfData(performance.timing);
perfData.type='onload';
cb(perfData);
debugger
})
// window.addEventListener('load', () => {
// setTimeout(() => {
// console.log(performance.timing);
// let perfData = Util.getPerfData(performance.timing);
// debugger
// }, 100);
// })
}
}
/**
* performance.timing
*
*
含义 默认值
connectEnd: 1597806127336 向服务器建立连接结束 fetchStart
connectStart: 1597806127335 向服务器建立连接开始 fetchStart
domComplete: 0 文档解析完成
domContentLoadedEventEnd: 0 ContentLoaded结束
domContentLoadedEventStart: 0 ContentLoaded开始 备注(只针对dom结构,而不针对里面图片等资源)
domInteractive: 0 解析dom结束 备注:document.readyState为字符串interactive
domLoading: 1597806127367 解析dom开始 备注:document.readyState为字符串loading
domainLookupEnd: 1597806127335 dns查询结束 fetchStart
domainLookupStart: 1597806127335 dns查询开始 fetchStart
fetchStart: 1597806127332 开始请求网页
loadEventEnd: 0 load事件发送后
loadEventStart: 0 load事件发送前
navigationStart: 1597806127331 前一个网页卸载的时间 fetchStart
redirectEnd: 0 重定向结束时间 0 需要同域
redirectStart: 0 重定向开始时间 0 需要同域
requestStart: 1597806127336 向服务器发送请求开始 无默认值
responseEnd: 1597806127361 服务器返回数据结束
responseStart: 1597806127360 服务器返回数据开始
secureConnectionStart: 0 安全握手开始 0 非https的没有
unloadEventEnd: 1597806127365 前一个网页的unload(关掉)时间结束 0
unloadEventStart: 1597806127365 前一个网页的unload(关掉)时间开始 0
*/
总之:注意属性值的触发问题(例如初始化执行时候对应周期还没有,值是默认值导致计算出错,解决方案是递归)
- 静态资源性能监控
//util.js
export default{
onload:(cb)=>{
if ( document.readyState==='complete') {
cb();
return
}
window.addEventListener('load',()=>{
cb();
})
}
}
import Util from "./util";
let resolvePerformanceResource = (resourceData) => {
let r = resourceData;
let o = {
initiatorType: r.initiatorType,
name: r.name,
duration: parseInt(r.duration),
//连接过程
redirect: r.redirectEnd - r.redirectStart,//重定向
dns: r.domainLookupEnd - r.domainLookupStart,//DNS查找时间
connect: r.connectEnd - r.connectStart,//TCP建连时间
network: r.connectEnd - r.startTime,//网络总耗时
//接收过程
send: r.responseStart - r.requestStart,//前端从发送到接收的时间
receive: r.responseEnd - r.responseStart,//接收数据用时
request: r.responseEnd - r.requestStart,//请求页面的总耗时
//核心指标
ttfb: r.responseStart - r.requestStart //首字节时间
}
return o;
}
//帮助我们循环获得每一个资源的性能数据
let resolveEntries = (entries) => entries.map(_ => resolvePerformanceResource(_));
export default {
init: cb => {
//此处意义是使用新的api,通过回调触发,而不是else里面的什么请求都进
if (window.PerformanceObserver) {
//动态获得每一个资源信息
let observer = new window.PerformanceObserver((list) => {
try {
let entries = list.getEntries();
} catch (error) {
console.error(error);
}
});
observer.observe({entryTypes: ['resource']})
} else {
Util.onload(() => {
//在onload之后获得所有的资源信息
let entries = performance.getEntries('resource');
let entriesData = resolveEntries(entries);
cb(entriesData);
// resolvePerformanceResource(entries[0])
debugger
});
}
}
}
总之:监控sdk的js尽量放在所有要加载的link,js,css上面,因为只有注册了才能监控,放在下面的话,可能导致监控不到之前的请求
- 错误监控
- window.onerror
let formatError=errorObj=>{
debugger
let col=errorObj.column||errorObj.columnNumber;//兼容不同浏览器
let row=errorObj.line||errorObj.lineNumber;
let errorType=errorObj.name;
let message=errorObj.message;
let {stack}=errorObj;
if (stack) {
//正则很多,所以理论上应该try不然很容i报错
let matchUrl=stack.match(/https?:\/\/[^\n]+/);
let urlFirstStack=matchUrl?matchUrl[0]:'';
//获取真正的url
let resourceUrl='';
let regUrlCheck=/https?:\/\/(\S)*\.js/;
if (resourceUrl.test(urlFirstStack)) {
resourceUrl=urlFirstStack.match(regUrlCheck)[0];
}
//获取真正的行列信息
let stackCol=null;
let stackRow=null;
//chrome只能通过正则匹配出来行列
let posStack=urlFirstStack.match(/:(\d+):(\d+)/)
if (posStack&&posStack.length>=3) {
[,stack,stackRow]=posStack;
}
return {
content:stack,
col:Number(col||stackCol),
row:Number(row||stackRow),
errorType,
message,
resourceUrl
};
}
}
export default{
init:cb=>{
let _origin_error=window.onerror;
window.onerror=function(message,source,lineno,colno,error) {
/**
colno: 5 列
error: ReferenceError: b is not defined at http://127.0.0.1:3003/:17:5
lineno: 17 行
message: "Uncaught ReferenceError: b is not defined"
source: "http://127.0.0.1:3003/"
*/
//注意:一般项目都会压缩,如果压缩之后可能是第一行,xxxx列,完全无法调试
/**
* 之所以通过stack去解析正则匹配,就是因为比如说react项目,打包之后,直接lineno,colno出来的数据可能不对
* 甚至source:定位报错的js都不对(例如报到vendor.js中的错误,没有任何意义),所以这些数据只是参考,主要还是上面formatError解析之后的信息,一定是正确的
* 而且这些报错都是sourcemap的,需要利用服务端反解,然后返给bug统计界面,说明报错的代码是啥
*/
let errorInfo=formatError(error);
errorInfo._message=message;
errorInfo._source=source;
errorInfo._lineno=lineno;
errorInfo._colno=colno;
errorInfo.type='error';
cb(errorInfo);
_origin_error&&_origin_error.apply(window,arguments);
}
}
}
只有这些不够,因为针对vue,react这种压缩类型的项目,真实运行的都是mapresource资源,所以需要服务端配置,然后解析,最终定位错误代码的上下几行,然后返回给错误监控平台显示
需要注意的是:chrome的报错信息很多其实都是无效数据,因为无法精确定位行数等(例如单页面应用),所以一般都是解析stack的数据正则匹配,这里面取出的才是真实问题所在;还有错误统计代码本身也可能出错,这时候的出错要try处理,同时做特殊上班,例如分类,但是不能不做任何处理,否则直接回出现死循环
//服务端解析source-map
const fs = require('fs');
const path = require('path');
const SourceMap = require('source-map');
let sourceMapFilePath = path.join(__dirname, './main.bundle.js.map');
let sourceFileMap = {};
//替换不规则路径,此处是该map文件内部文件路径的替换
let fixPath = filePath => {
return filePath.replace(/\.[\.\/]+/, '');
}
module.exports = async (ctx, next) => {
//一般是把sourcemap文件在客户端上传上去,然后再此处再反解,
//但是这只是案例,sourcemap文件直接放到服务端,只写反解逻辑
if (ctx.path = '/sourcemap') {
let sourceMapContent = fs.readFileSync(sourceMapFilePath, 'utf-8');
let fileObj = JSON.parse(sourceMapContent);
let { sources } = fileObj;
sources.forEach(item => {
sourceFileMap[fixPath(item)] = item;
})
let column = 554;//此处假设网络请求已经把报错位置上传上来
let line = 17;
const consumer = await new SourceMap.SourceMapConsumer(sourceMapContent);
let result = consumer.originalPositionFor({
line, column
});
/**
* result
* {
* source:"webpack:///react-app.js",
* line:10
* column:6
* name :vue vue就是错误,未定义但是使用了
* }
*/
let originSource = sourceFileMap[result.source];
//originSource:"webpack:///./react-app.js",
//报错的代码-但是基本上是出错js的全部
let sourceContent=fileObj.sourceContent[sources.indexOf(originSource)];
//可以通过这个分行取出所需行数的上下几行,甚至标红出错的行
let sourceContentArr=sourceContent.split('\n');
ctx.body = {sourceContent, sourceContentArr,originSource, result };
}
//此处是koa的中间件,所以这么写,不需要特别在意
return next();
}
- 接口性能监控
核心就是类似代理模式,重写XMLHttpRequest的send和open函数,然后类似于面向切面的形式,在里面实现信息上报
export default {
//TODO 自身SDK请求不需要拦截
init: cb => {
//xhr hook
let xhr = window.XMLHttpRequest;
//避免多次加载该hook,例如用户把sdk引用两次的情况
if (xhr._eagle_monitor_flag === true) {
return
}
xhr._eagle_monitor_flag = true;
let _originOpen = xhr.prototype.open;
//原生xhr有这几个参数
xhr.prototype.open = function (method, url, async, user, password) {
//此处是面向切面编程
this._eagle_xhr_info = {
url, method, status: null
};
return _originOpen.apply(this, arguments);
}
let _originSend = xhr.prototype.send;
xhr.prototype.send = function (value) {
let _self = this;
this._eagle_start_time = Date.now();
//注意此处,是多个箭头的高阶函数
let ajaxEnd = eventType => () => {
if (_self.response) {
let responseSize = null;
switch (_self.responseType) {
case 'json':
responseSize = JSON.stringify(_self.response).length;
break;
case 'arraybuffer':
responseSize = _self.response.byteLength;
break
default:
//注意这里:responseText和response区别
responseSize = _self.responseText.length;
break
}
_self._eagle_xhr_info.event = eventType;
_self._eagle_xhr_info.status = _self.status;
_self._eagle_xhr_info.success = _self.status === 200;
_self._eagle_xhr_info.duration = Date.now() - _self._eagle_start_time;
_self._eagle_xhr_info.responseSize = responseSize;
_self._eagle_xhr_info.requestSize = value ? value.length : 0;
_self._eagle_xhr_info.type = 'xhr';
cb(_self._eagle_xhr_info);
}
//注意:如果sdk也报错,如果不做任何处理会被sdk错误统计上报,然后上报继续报错,不一会CPU就是满,所以sdk错误需要捕获,然后特殊处理
};
//这三种状态都代表着请求已经结束了,需要统计一些信息并上报
this.addEventListener('load', ajaxEnd('load'), false);
this.addEventListener('error', ajaxEnd('error'), false);
this.addEventListener('abort', ajaxEnd('abort'), false); //取消请求
//上面是统计逻辑,这才是真实调用网络请求,例如json请求
return _originSend.apply(this, arguments);
}
//ftech hook
if (window.fetch) {
let _origin_fetch = window.fetch;
window.fetch = function () {
let startTime = Date.now();
let args = [].slice.call(arguments);
let fetchInput = args[0];
let method = 'GET';
let url = null;
if (typeof fetchInput === 'string') {
url = fetchInput
} else if ('Request' in window && fetchInput instanceof window.Request) {
url = fetchInput.url;
if (fetchInput.method) {
method = fetchInput.method;
}
} else {
url = '' + fetchInput;
}
let eagleFetchData = {
method, url, status: null
}
return _origin_fetch.apply(this,args).then(function(response){
eagleFetchData.status=response.status;
eagleFetchData.type='fetch';
eagleFetchData.status=response.status;
eagleFetchData.duration = Date.now() - startTime;
cb(eagleFetchData);
return response;
})
}
}
}
}
- 自身sdk请求不需要拦截,否则死循环
- 此处处理了两个一个是fetch一个是XMLHttpRequest
行为监控
行为监控重点只说一下用户行为路径,其他不做讨论,因为各种方式不同,其中打点监控,例如用户点击一下触发一次行为监控也算,方式很多。
核心是通过xpath形式,统计用户的行为,例如点击了什么
// /html/body/ul[1]/li[1] xpath
/**
* 当然xpath不能直接这么用,因为很多dom点击不需要上传
* 但是比如说支付按钮,则肯定有特殊id或者类名,可以这样过滤需要的
*/
//获取我的元素是兄弟元素的第几个
let getIndex=ele=>{
let children=[].slice.call(ele.parentNode.children);
let myIndex=null;
children=children.filter(node=>node.tagName===ele.tagName);
for (let i = 0; i < children.length; i++) {
if (ele===children[i]) {
myIndex=i;
break
}
}
myIndex= `[${myIndex+1}]`;
let tagName=ele.tagName.toLocaleLowerCase();
let myLabel=tagName+myIndex;
return myLabel;
}
let getXpath=ele=>{
let xpath='';
let currentEle=ele;
while (currentEle!==document.body) {
xpath=getIndex(currentEle)+'/'+xpath;
currentEle=currentEle.parentNode;
}
}
export default{
init:cb=>{
document.addEventListener('click',e=>{
let target=e.target;
let xpath=getXpath(target);
},false);
}
}
全局注册点击监控,然后通过递归遍历,查找到具体是点击什么,形成xpath类似于这种/html/body/ul[1]/li[1],然后可以通过不同类名或者id去过滤需要的行为。例如:支付按钮的样式肯定不同,而且一般不可能统计所有行为,因为数据量太大,如果完全不讲究全部上传也行,那样就需要后端处理逻辑去过滤行为,否则行为统计页面数据量太多,完全无法有效观看。
补充:前端页面打点为什么一般要用gif打点
参考-拷贝: https://blog.csdn.net/weixin_37719279/article/details/103476567?utm_medium=distribute.pc_aggpage_search_result.none-task-blog-2allfirst_rank_v2~rank_v25-7-103476567.nonecase&utm_term=%E6%89%93%E7%82%B9%E7%9A%84%E6%84%8F%E6%80%9D
所谓的前端监控,其实是在满足一定条件后,由Web页面将用户信息(UA/鼠标点击位置/页面报错/停留时长/etc)上报给服务器的过程。一般是将上报数据用url_encode(百度统计/CNZZ)或JSON编码(神策/诸葛io)为字符串,通过url参数传递给服务器,然后在服务器端统一处理。
这套流程的关键在于:
- 能够收集到用户信息;
- 能够将收集到的数据上报给服务器。也就是说,只要能上报数据,无论是请求GIF文件还是请求js文件或者是调用页面接口,服务器端其实并不关心具体的上报方式。
向服务器端上报数据,可以通过请求接口,请求普通文件,或者请求图片资源的方式进行。为什么所有系统都统一使用了请求GIF图片的方式上报数据呢?
- 首先,为什么不能直接用GET/POST/HEAD请求接口进行上报?
这个比较容易想到原因。一般而言,打点域名都不是当前域名,所以所有的接口请求都会构成跨域。而跨域请求很容易出现由于配置不当被浏览器拦截并报错,这是不能接受的。所以,直接排除。
- 其次,为什么不能用请求其他的文件资源(js/css/ttf)的方式进行上报?
这和浏览器的特性有关。通常,创建资源节点后只有将对象注入到浏览器DOM树后,浏览器才会实际发送资源请求。反复操作DOM不仅会引发性能问题,而且载入js/css资源还会阻塞页面渲染,影响用户体验。
但是图片请求例外。构造图片打点不仅不用插入DOM,只要在js中new出Image对象就能发起请求,而且还没有阻塞问题,在没有js的浏览器环境中也能通过img标签正常打点,这是其他类型的资源请求所做不到的。
- 那还剩下最后一个问题,同样都是图片,上报时选用了1x1的透明GIF,而不是其他的PNG/JEPG/BMP文件
首先,1x1像素是最小的合法图片。而且,因为是通过图片打点,所以图片最好是透明的,这样一来不会影响页面本身展示效果,二者表示图片透明只要使用一个二进制位标记图片是透明色即可,不用存储色彩空间数据,可以节约体积。因为需要透明色,所以可以直接排除JEPG(BMP32格式可以支持透明色)。
- 然后还剩下BMP、PNG和GIF,但是为什么会选GIF呢?
因为体积!,gif最小,最小的BMP文件需要74个字节,PNG需要67个字节,而合法的GIF,只需要43个字节。同样的响应,GIF可以比BMP节约41%的流量,比PNG节约35%的流量。这样比较一下,答案就很明显了。
总结
前端监控使用GIF进行上报主要是因为:
- 没有跨域问题;
- 不会阻塞页面加载,影响用户体验;
- 在所有图片中体积最小,相较BMP/PNG,可以节约41%/35%的网络资源。