写在前面
字面解释:前端监控就是监控前端所发生的一些行为
我们可能都遇到过代码都上生产了,结果出现了一些未知的问题;我们也可能想通过查看线上的数据,看看哪些需求带来了收益,哪些并没有。基于这些原因,我们就想明白了为什么要前端监控
1、更快的发现问题并解决
2、为产品提需求提供可靠的依据,为业务拓展提供更多的可能性
所以,我们需要这么一个监控系统,去帮助我们~本篇文章就将说关于前端监控的这些事~
搭建前端监控系统
监控目标
JavaScript层
JS执行错误
Promise异常
接口错误
页面空白
用户体验层
加载时间 ----> 各个阶段的加载时间
TTFB(Time To Firstbyte) ----> 首字节时间
指浏览器发起第一个请求到数据返回第一个字节所消耗的时间,这个时间包括了网络请求时间、后端处理时间
FP(First Paint) -----> 首次绘制
首次绘制包括了任何用户自定义的背景绘制,它是将第一个像素点绘制到屏幕的时刻
FCP(First Content Paint) ----> 首次内容绘制
首次内容绘制是浏览器将第一个DOM渲染到屏幕的时间,可以是任何文本、图像、SVG等的时间
FMP(First Meaningful Paint) -----> 首次有意义的绘制
首次有意义的绘制是页面可用性的量度标准
LCP(Large Contentful Paint) -----> 最大内容渲染
代表在viewport中最大的页面元素加载时间
FID(First Input Delay) -----> 首次输入延迟
用户首次和页面交互到页面响应交互的时间
卡顿:超过50ms
业务层面
PV(Page View):页面浏览量或点击量
UV():指访问某个站点的不同ip地址的人数
页面停留时间:用户在每一个页面的停留时间
前端监控的流程
埋点方案
上面说到的流程当中,第一步就是要有埋点,那么常见的埋点方案都有哪些呢
代码埋点
嵌入到代码中进行埋点,比如点击事件
优点:可以在任意时刻,精确发送或保存所需要的数据信息
缺点:工作量大
可视化埋点
通过可视化交互的手段,代替代码埋点,将业务代码和埋点代码分离,提供一个可视化交互的页面,输入为业务代码,通过这个可视化系统,可以在业务代码中自定义的增加埋点事件等等,最后代码输出耦合了业务代码和埋点代码
优点:代替了手工插入埋点
缺点:不灵活
无痕埋点
前端的任意一个事件都被绑定一个标识,所有事件都被记录下来;通过定期上传记录文件,配合文件解析,解析出来我们想要的数据,并生成可视化报告供专业人员分析
优点:采集全量数据,不会出现漏埋和误埋现象
缺点:给数据传输和服务器增加压力,也无法灵活定制数据结构
开始编写
数据统一上报方法
我们会上报很多的数据,但是这些数据都有一个共同的结构,有一个共同的上报方案~
首先我们会准备在阿里云开通日志服务,这是用来存储我们上报的数据的~
阿里云日志服务
在上面这个链接登录然后选到控制台,找到日志服务并开通,最终进入到这个页面就成功啦~
具体教程可以参考这里:日志服务教程
就像我们平时和后端用接口来来回回的传数据一样,上报到阿里云日志的数据也是有接口的,具体可见:putWebtracking
下面我们来写一下代码
let userAgent = require("user-agent");
let host = "cn-beijing.log.aliyuncs.com";
let project = "zyq-monitor";
let logStore = "zyq-monitor-storage";
// 共用数据
function getExtraData() {
return {
title: document.title,
url: location.href,
timestamp: Date.now(),
userAgent: userAgent.parse(navigator.userAgent).name,
}
}
class SendTracker {
constructor() {
this.url = `http://${project}.${host}/logstores/${logStore}/track`; // 上报路径
this.xhr = new XMLHttpRequest;
}
send(data = {}) {
let extraData = getExtraData();
let log = {...extraData, ...data};
// 这里是阿里云要求对象的值不能是数字,所以要转成字符串
for(let key in log) {
if(typeof log[key] === "number") {
log[key] = `${log[key]}`;
}
}
let body = JSON.stringify({
__logs__: [log]
});
this.xhr.open("POST", this.url, true)
this.xhr.setRequestHeader("Content-Type", "application/json");
// 下面两个请求头是阿里云要求的
this.xhr.setRequestHeader("x-log-apiversion", "0.6.0");
this.xhr.setRequestHeader("x-log-bodyrawsize", body.length); // 请求体大小
this.xhr.onload = function() {
// console.log(this.xhr.response);
}
this.xhr.onerror = function(error) {
// console.log(error);
}
this.xhr.send(body);
}
}
export default new SendTracker()
JavaScript层面
JavaScript层主要监听了一些错误的发生,比如说:语法错误、promsie的异常等等,下面我们一点一点说
先大致说一下JavaScript层上报数据需要哪些字段(每个错误上报可能会有不一致的地方)
{
kind, // 监控指标的大类
type, // 小类型 错误
errorType, // js或css加载错误
filename, // 哪个文件报错
tagName, // 报错的标签名
selector, // 代表最后操作的元素
}
JS语法错误
使用window.addEventListener
监听error
事件,获取事件对象,取出对应的值~
let lastEvent = getLastEvent();
window.addEventListener("error", e => {
tracker.send({
kind: "stability",
type: "error",
errorType: "jsError",
message: event.message,
stack: getLines(event.error.stack),
filename: event.filename,
position: `${event.lineno}:${event.colno}`,
selector: lastEvent ? getSelector(lastEvent.path) : "",
})
}, true)
这里面调用了三个方法,一个是getLines()
,另一个是getSelector()
// 获取堆栈信息
function getLines() {
return stack.split("\n").slice(1).map(item => item.replace(/^\s+at\s+/g, "")).join("^");
}
// 获取选择器
function getSelector(pathOrTarget) { // 对象or数组
if(Array.isArray(pathOrTarget)) {
return getSelectors(pathOrTarget)
} else {
let path = [];
while(pathOrTarget) {
path.push(pathOrTarget);
pathOrTarget = pathOrTarget.parentNode;
}
return getSelectors(path)
}
}
// 获取最后一个交互事件
function getLastEvent() {
let lastEvent;
["click", "touchstart", "mousedown", "keydown", "mouseover"].forEach(eventType => {
document.addEventListener(eventType, event => {
lastEvent = event;
}, {
capture: true, // 捕获阶段
passive: true, // 默认不阻止默认事件
});
})
return lastEvent;
}
Promise异常
Promise
异常是通过监听unhandledrejection
事件,获取事件对象~
window.addEventListener("unhandledrejection", function(event) {
let lastEvent = getLastEvent(); //最后一个交互事件
let message;
let filename;
let line = 0;
let col = 0;
let stack = "";
let reason = event.reason;
if(typeof reason === "string") {
message = reason
} else if(typeof reason === "object") {
message = reason.message
if(reason.stack) {
let matchResult = reason.stack.match(/at\s+(.+):(\d+):(\d+)/)
filename = matchResult[1];
line = matchResult[2];
col = matchResult[3];
}
stack = getLines(reason.stack);
}
tracker.send({
kind: "stability",
type: "error",
errorType: "promiseError",
message,
stack,
filename,
position: `${line}:${col}`,
selector: lastEvent ? getSelector(lastEvent.path) : "",
})
}, true)
引入资源异常
通常,引用资源异常为资源找不到或者获取不到资源,它也是监听error
事件,只不过他和JS执行异常有一些区别,我们是通过事件源去判断的
// 监听全局未捕获的错误
window.addEventListener('error', function(event) {
let lastEvent = getLastEvent(); //最后一个交互事件
// 脚本加载错误
if(event.target && (event.target.src || event.target.href)) {
tracker.send({
kind: "stability",
type: "error",
errorType: "resourceError",
filename: event.target.src || event.target.href,
tagName: event.target.tagName,
selector: getSelector(event.target),
})
} else { // JS执行报错
tracker.send({
kind: "stability",
type: "error",
errorType: "jsError",
message: event.message,
stack: getLines(event.error.stack),
filename: event.filename,
position: `${event.lineno}:${event.colno}`,
selector: lastEvent ? getSelector(lastEvent.path) : "",
})
}
}, true)
接口异常
在模拟接口成功或者异常时,我们可以在webpack
中的devServer
加入before
,做一下拦截处理
before(router) {
router.get('/success', function(req, res) {
res.json({id: 1})
})
router.post('/error', function(req, res) {
res.sendStatus(500)
})
}
然后对请求做一下处理
function injectXHR() {
let XMLHttpRequest = window.XMLHttpRequest;
let oldOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function(method, url, async) {
// 这里是避免重复上报数据
if(!url.match(/logstores/)) {
this.logData = {
method,
url,
async,
}
}
return oldOpen.apply(this, arguments);
}
let oldSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.send = function(body) {
if(this.logData) {
let startTime = Date.now(); // 发送之前记录开始时间
let handler = type => event => {
let duration = Date.now() - startTime;
let status = this.status;
let statusText = this.statusText;
tracker.send({
kind: "stability",
type: "xhr",
eventType: event.type,
pathname: this.logData.url,
status: status + '-' + statusText,
duration,
response: this.response ? JSON.stringify(this.response) : "",
params: body || ""
})
}
this.addEventListener("load", handler("load"), false)
this.addEventListener("error", handler("error"), false)
this.addEventListener("abort", handler("abort"), false)
}
return oldSend.apply(this, arguments);
}
}
页面空白
页面空白的原因有很多,比如说一个小小的js
错误就会导致页面空白,对页面空白的处理,我们采用取点的形式,然后看每个点上是否有对应的标签(可配置一下阈值,看多少个点更合适),如果点对应的标签都是body
或者html
的话,那么就可以认为此页面是空白的
function blankScreen() {
let wrapperElements = ["html", "body", "#container", "content.main"];
let emptyPoints = 0;
function getSelector(element) {
if(element.id) {
return '#' + element.id;
} else if(element.className){
return "." + element.className.split(" ").filter(item => !!item).join(".")
} else {
return element.nodeName.toLowerCase();
}
}
function isWrapper(element) {
let selector = getSelector(element);
if(wrapperElements.indexOf(selector) !== -1) {
emptyPoints++;
}
}
onload(function() {
for(let i = 1; i <=9 ; i++) {
let xElements = document.elementsFromPoint(window.innerWidth * i / 10, window.innerHeight / 2)
let yElements = document.elementsFromPoint(window.innerWidth / 2, window.innerHeight * i / 10)
isWrapper(xElements[0]);
isWrapper(yElements[0]);
}
if(emptyPoints >= 16) { // 自定义
let centerElements = document.elementsFromPoint(
window.innerWidth / 2, window.innerHeight / 2
)
tracker.send({
kind: "stability",
type: "blank",
emptyPoints,
screen: window.screen.width + "X" + window.screen.height,
viewPonint: window.innerWidth + "X" + window.innerHeight,
selector: getSelector(centerElements[0])
})
}
})
}
function onload(callback) {
if(document.readyState === "complete") {
callback();
} else {
window.addEventListener("load", callback)
}
}
用户体验层面
对于用户体验层面的一些指标,上面有说,具体做法是监听一些指标以及从performance.timing
对象中获取一些时间,进行上报操作,实际做法为:
function timing() {
let FMP, LCP;
if(PerformanceObserver) {
// 增加一个性能条目的观察者 FMP
new PerformanceObserver((entryList, observer) => {
let perfEntries = entryList.getEntries();
console.log(perfEntries, "perfEntries");
FMP = perfEntries[0];
observer.disconnect(); // 不再观察了
}).observe({entryTypes: ["element"]}); // 观察页面中有意义的元素
// LCP
new PerformanceObserver((entryList, observer) => {
let perfEntries = entryList.getEntries();
LCP = perfEntries[0];
observer.disconnect(); // 不再观察了
}).observe({entryTypes: ["largest-contentful-paint"]}); // 观察页面中有意义的元素
// FID 首次输入延迟
new PerformanceObserver((entryList, observer) => {
let lastEvent = getLastEvent();
let firstInput = entryList.getEntries()[0];
console.log(firstInput, "firstInput");
if(firstInput) {
// 开始处理时间 开始点击时间 差值就是处理的延迟
let inputDelay = firstInput.processingStart - firstInput.startTime;
let duration = firstInput.duration; // 处理耗时
if(inputDelay > 0 || duration > 0) {
tracker.send({
kind: "experience", // 用户体验指标
type: "firstInputDelay", // 首次输入延迟
duration, // 处理时间
inputDelay, // 延时时间
startTime: firstInput.startTime,
selector: lastEvent ? getSelector(lastEvent.path || lastEvent.target) : "",
})
}
}
observer.disconnect(); // 不再观察了
}).observe({type: "first-input", buffered: true}); // 用户第一次交互 点击页面等
}
onload(function() {
setTimeout(() => {
const {
fetchStart,
connectStart,
connectEnd,
requestStart,
responseStart,
responseEnd,
domLoading,
domInteractive,
domContentLoadedEventStart,
domContentLoadedEventEnd,
loadEventStart,
} = performance.timing
tracker.send({
kind: "experience", // 用户体验指标
type: "timing", // 统计每个阶段的时间
connectTiming: connectEnd - connectStart, // 连接时间
ttfbTime: responseStart - requestStart, // 首字节时间
responseTime: responseEnd - responseStart, // 响应读取时间
parseDOMTime: loadEventStart - domLoading, // dom解析时间
domContentLoadedTime: domContentLoadedEventEnd - domContentLoadedEventStart,
timeToInteractive: domInteractive - fetchStart, // 首次可交互时间
loadTime: loadEventStart - fetchStart, // 完整加载时间
})
let FP = performance.getEntriesByName("first-paint")[0]
let FCP = performance.getEntriesByName("first-contentful-paint")[0]
// 开始发送性能指标
tracker.send({
kind: "experience", // 用户体验指标
type: "paint", // 小类绘制
firstPaint: FP.startTime,
firstContentfulPaint: FCP.startTime,
firsMeaningfulPaint: FMP.startTime,
largestContentfulPaint: LCP.startTime,
})
}, 3000)
})
}
卡顿
对于页面卡顿,我们是最熟悉了,看视频时卡,玩游戏时卡等等场景,作为用户,我们遇见卡顿很难受,作为开发人员,我们要努力的去解决页面卡顿问题,所以现在我们需要一个监测页面卡顿的监控。其实监测页面卡顿就是监测页面的FPS
FPS
是在浏览器渲染这些变化时的帧率,帧率越高,网页越流畅,反之则卡顿,最优的帧率是60fps,即为16.67ms左右渲染一次,也就是说,在浏览器显示页面的过程中,处理js以及渲染等,每个执行片段不能超过16.67ms,如超过,则认为卡顿。我们可以通过chrome的一些工具看得见fps(这里不做过多说明),但是在生产环境也就是在用户使用的时候,我们只能通过上报的方式,接下来说下如何通过代码进行上报卡顿
通过浏览器的ewquestAnimationFrame
获取页面的fps
,下面是代码
var lastTime = performance.now();
var frame = 0;
var lastFameTime = performance.now();
var loop = function(time) {
var now = performance.now();
var fs = (now - lastFameTime);
lastFameTime = now;
var fps = Math.round(1000/fs);
frame++;
if (now > 1000 + lastTime) {
var fps = Math.round( ( frame * 1000 ) / ( now - lastTime ) );
frame = 0;
lastTime = now;
};
window.requestAnimationFrame(loop);
}
获取到fps之后,就是监控页面的卡顿问题了,根据参考,我们将fps连续出现低于20三次即认为网页存在卡顿,代码如下
function isCaton(fpsList, below = 20, last = 3) {
var count = 0
for(var i = 0; i < fpsList.length; i++) {
if (fpsList[i] && fpsList[i] < below) {
count++;
} else {
count = 0
}
if (count >= last) {
return true
}
}
return false
}
最后
参考文章
https://zhuanlan.zhihu.com/p/39292837
https://www.cnblogs.com/yincheng/p/avoid-jank.html
本篇文章说了下前端监控上报基础思想以及基础代码编写,在公司的底层建设过程中,可以将这些东西封装成一个库,以供各个业务去使用~这个时候,业务方只需要调用方法以及传送必要的参数即可(当然可能我们并不需要这些个指标,可能还需要更多,可能有的并不需要,这要和业务相吻合)
对于上报后的数据,我们可以拿来再做前端可视化内容,例如曲线等,通过这些内容,我们可以观测到数据的变化,同时更完善一些的话,可以配置报警功能,让开发人员更快的知道哪里出现了问题并及时解决~前端监控不仅仅停留在此篇文章,它仅仅是最最基础的一部分,后边一系列的流程还需要我们去探索~
刚刚接触前端监控两个月,便深深的感受到了它的重要性以及必要性,很多刚刚上线的业务监控报警,能够提醒我及时回滚代码或者修复问题,所以从头学起监控系统,并把知识分享给同样有需要的大家~如有不对之处,欢迎大家指出~
最后,分享下我的公众号「web前端日记」,欢迎小伙伴前来关注~~~