前端监控系统总结,一步一步从零复盘(一) | 青训营笔记
这是我参与「第四届青训营 」笔记创作活动的的第16天 之前有一篇笔记死活通不过,拿这个来凑个数,ahahah
一、 意义
相信很多的开发者都遇到过突然代码跑不起来的,重启了电脑甚至重启了ide就解决了百思不得其解bug,想像你发布在线上运行的项目突然有一天收到用户的反馈说突然运行不起来了,你跟他说清理下缓存或者重新登陆一下,然后用户的问题就解决了,然而当你想追根溯源时却发现自己不知道该如何去复现,你真知不知道它究竟报了个什么错,因此监控系统应运而生
而随着前端技术的不断发展,前端作为最接近客户的一部分变得更加花里胡哨,也变得越来越复杂,因此前端监控也百年的越来越被广大开发者重视,在我认为前端监控最重要的作用就是帮助开发者快速定位错误,同时能够监控各种运营指标与性能检测,帮助开发者更快定位优化方向。市面上已经有了很多第三方前端监控平台和搭建前端监控的分享博客,这也能够帮助我们减少学习的成本,在能够完成市面上监控系统基础上我们能够自定义我们的监控系统,这也是我们自研的最终目的。
二、第一步
首先大致来看一下我们前端架空要做些什么(这张图都被用烂了)
埋点说的就是我们的各种监听方式,触发我们的监听事件后,调用回调去做数据采集、存储、传输等工作,传输完以后就时后端跟可视化那边的事了,然而要搭建自己的前端监控系统,第一步当然是建立一个入口,准备一个用于外部入的类Moniter,他在未来需要实现我们前端监控系统的基本的配置,控制功能的开关,因为是刚开始这个类暂时还什么都不需要做,但是当我们写了一些监控代码后,要暴露出来然后在这里引入,做一些配置操作。参考代码如下
type configType={
//...
}
class Monitor{
config:configType={
pid:'',
production:'dev'
//...
}
constructor(){}
setconfig(config:configType){
this.config=Object.assign(this.config,config)
}
useMonitor(){
//启动监控,做一些配置
}
}
export default new Monitor();
我们成功迈出了搭建系统的第一步,然后就是开始实现我们前端监控的各个功并装载到我们的Moniter类上,再实现之前我们还需要来做一些准备工作——为我们的系统准备些轮子
三、造轮子
其实就是准备一下我们在监控的过程中可以灵活使用的方法,比如数据上报、日期格式化、获取最后的交互元素、事件等等等等
获取浏览器、系统等基本信息
我们把浏览器信息,系统信息作为公共字段上报,这样我们就可以知道上报信息发生在什么浏览器,什么系统,方便快速定位问题,我们可以用全局apinavigator获取到我们想要的内容,不过还需要利用正则匹配等a方法做一步数据过滤,参考代码如下
// 各主流浏览器
export function getBrowser():string {
var u = navigator.userAgent;//获取信息
//字典
var bws = [{
name: 'sgssapp',
it: /sogousearch/i.test(u)
}, {
name: 'wechat',
it: /MicroMessenger/i.test(u)
}, {
name: 'weibo',
it: !!u.match(/Weibo/i)
}, {
name: 'uc',
it: !!u.match(/UCBrowser/i) || u.indexOf(' UBrowser') > -1
}, {
name: 'sogou',
it: u.indexOf('MetaSr') > -1 || u.indexOf('Sogou') > -1
}, {
name: 'xiaomi',
it: u.indexOf('MiuiBrowser') > -1
}, {
name: 'baidu',
it: u.indexOf('Baidu') > -1 || u.indexOf('BIDUBrowser') > -1
}, {
name: '360',
it: u.indexOf('360EE') > -1 || u.indexOf('360SE') > -1
}, {
name: '2345',
it: u.indexOf('2345Explorer') > -1
}, {
name: 'edge',
it: u.indexOf('Edge') > -1
}, {
name: 'ie11',
it: u.indexOf('Trident') > -1 && u.indexOf('rv:11.0') > -1
}, {
name: 'ie',
it: u.indexOf('compatible') > -1 && u.indexOf('MSIE') > -1
}, {
name: 'firefox',
it: u.indexOf('Firefox') > -1
}, {
name: 'safari',
it: u.indexOf('Safari') > -1 && u.indexOf('Chrome') === -1
}, {
name: 'qqbrowser',
it: u.indexOf('MQQBrowser') > -1 && u.indexOf(' QQ') === -1
}, {
name: 'qq',
it: u.indexOf('QQ') > -1
}, {
name: 'chrome',
it: u.indexOf('Chrome') > -1 || u.indexOf('CriOS') > -1
}, {
name: 'opera',
it: u.indexOf('Opera') > -1 || u.indexOf('OPR') > -1
}];
//匹配浏览器名称
for (var i = 0; i < bws.length; i++) {
if (bws[i].it) {
return bws[i].name;
}
}
return 'other';
}
// 系统区分
export function getOS():string {
var u = navigator.userAgent;
if (!!u.match(/compatible/i) || u.match(/Windows/i)) {
return 'windows';
} else if (!!u.match(/Macintosh/i) || u.match(/MacIntel/i)) {
return 'macOS';
} else if (!!u.match(/iphone/i) || u.match(/Ipad/i)) {
return 'ios';
} else if (!!u.match(/android/i)) {
return 'android';
} else {
return 'other';
}
}
数据上报
最重要的就是数据上报,每次需要上报我们都只需要统一调用同一个方法,这里我们用的是简单的xhr请求尽享上报,还需要做跨越的处理,当然我们可以选择更好的GIF打点方式,再后期我也会做分享,xhr上报参考代码如下,其中公共字段uuid用于uv数据识别用户,我们这里选择用本地存储的方法处理,可选择的方法还可以有很多,甚至可以个性化为自己项目中的识别用户的方法,这就表现出了我们自研监控项目的意义
//公共字段
function getExtraData(this: any) {
return {
title: document.title,
url: location.href,
timestamp: Date.now(),
browser:getBrowser(),
OS:getOS()
};
}
class SendTracker {
pid:string='';
uuid:string|null='';//用户标识
url:string=`/upload`;//上报接口
xhr = new XMLHttpRequest();
production = "";
constructor() {
// 上报的路径
//获取本地用户标识
if(window.localStorage){
if(window.localStorage.getItem('m-uuid')!==null){
this.uuid=window.localStorage.getItem('m-uuid')
}else{
this.uuid=getUuid();
window.localStorage.setItem('m-uuid',this.uuid);
}
}
}
setPid(pid:string){
this.pid=pid;
}
setProduction(production : string){
this.production=production;
}
send(data = {}) {
let extraData = getExtraData.apply(this);
let log = { ...data, ...extraData };
let mXhr=this.xhr
let body = JSON.stringify(log);
mXhr.open("POST", this.url, true);
mXhr.setRequestHeader("Content-Type", "application/json");
mXhr.setRequestHeader("x-log-apiversion", "1.0.0");
mXhr.setRequestHeader("x-log-bodyrawsize", body.length.toString());
mXhr.onload = function () {
console.log(mXhr.responseText);
};
mXhr.onerror = function (error) {
console.log(error);
};
mXhr.send(body);
}
}
export default new SendTracker();
获取最后触发的事件
顾名思义,我们做一个用于获取最后触发的事件的api,添加多个事件的监听,出发任意监听事件后保存事件信息,因为是枚举的方法,所以这个方法并不能获取到所有的事件,如果有更好的方法可以留言
let lastEvent: Event;
["click", "touchstart", "mousedown", "keydown", "mouseover"].forEach(
(eventType) => {
document.addEventListener(
eventType,
(event) => {
lastEvent = event;
},
{
capture: true, // 是在捕获阶段还是冒泡阶段执行
passive: true, // 默认不阻止默认事件
}
);
}
);
export default function () {
return lastEvent;
}
获取最后触发的元素
同样顾名思义,我们做一个用于获取最后触发的元素的api,我们有时候可能会直接获取到事件对象的元素,也可能是再冒泡过程中触发的事件,因此我们要获取到的是过滤掉不需要的节点的真正触发事件的路径,并返回能够定位到该元素的路径,理解起来可能有点困难,参考代码如下
function getSelectors(path: any[]) {
// 反转 + 过滤 + 映射 + 拼接
return path
.reverse()
.filter((element: Document | (Window & typeof globalThis)) => {
return element !== document && element !== window;
})
.map((element: { nodeName: string; id: any; className: any; }) => {
console.log("element", element.nodeName);
let selector = "";
if (element.id) {
return `${element.nodeName.toLowerCase()}#${element.id}`;
} else if (element.className && typeof element.className === "string") {
return `${element.nodeName.toLowerCase()}.${element.className}`;
} else {
selector = element.nodeName.toLowerCase();
}
return selector;
})
.join(" ");
}
export default function (pathsOrTarget: { parentNode: any; }) {
if (Array.isArray(pathsOrTarget)) {
return getSelectors(pathsOrTarget);
} else {
let path = [];
while (pathsOrTarget) {
path.push(pathsOrTarget);
pathsOrTarget = pathsOrTarget.parentNode;
}
return getSelectors(path);
}
}
总结
基本的轮子都造完了,至于其他更基本的例如获取uuid啊,时间格式化这些轮子我们就不再这里多做赘述,良好的开端是成功的一半,下一步我们就可以真正开始编写监控代码了