1. 了解前端监控
前端监控的范围很广,如监控行为,监控性能,监控异常,监控告警等一系列的维度来确保我们的页面和功能是正常的,在出现问题时研发可以及时做出响应,及时追踪,定位问题。
对于前端而言,和后端处于同一个监控系统中,前端有自己的监控方案,后端也有自己等监控方案,但两者并不分离,因为一个用户在操作应用过程中如果出现异常,有可能是前端引起,也有可能是后端引起,需要有一个机制,将前后端串联起来,使监控本身统一于监控系统。因此,即使只讨论前端异常监控,其实也不能严格区分前后端界限,而要根据实际系统的设计,在最终的报表中体现出监控对开发和业务的帮助。
2. 前端监控目标
2.1 稳定性指标 stability
- js错误:js执行错误、promise异常
- 资源错误:js、css资源加载异常
- 接口错误:ajax、fetch请求接口异常
- 白屏:页面空白
- 页面加载时长
2.2 用户体验指标 experience
2.3 业务指标 business
- PV:Page View 即页面浏览量或点击量;
- UV:指访问某个站点的不同 IP 地址的人数;
- 页面的停留时间:用户在每一个页面的停留时间。
3. 前端监控流程
- 前端埋点(通过 SDK 给页面的 DOM 都加上标记)
- 数据上报(收集,存储)
- 分析和计算(将采集到的数据进行加工汇总)
- 可视化展示(按照纬度将数据展示)
- 监控报警(发现异常后按一定的条件触发报警/飞书 钉钉机器人群推送)
3.1 前端埋点方案
代码埋点
代码埋点,就是项目中引入埋点 SDK,手动在业务代码中标记,触发埋点事件进行上报。比如页面中的某一个模块的点击事件,会在点击事件的监听中加入触发埋点的代码 this.$track('事件名', { 需要上传的业务数据 }),将数据上报到服务器端。
- 优点:能够在任何时刻,更精确的发送需要的数据信息,上报数据更灵活;
- 缺点:工作量大,代码侵入太强,过于耦合业务代码,一次埋点的更改就要引起发版之类的操作。
这个方案也是我们实际项目中现有的方案。
可视化埋点
通过可视化交互的手段,代替代码埋点,可以新建、编辑、修改埋点。在组件和页面的维度进行埋点的设计。
将业务代码和埋点代码分离,提供一个可视化交互的页面,输入为业务代码,通过这个可视化系统,可以在业务代码中自定义的增加埋点事件,最后输出的代码耦合了业务代码和埋点代码。
这个方案是可以解决第一种代码埋点的痛点,也是我们目前正准备做的方案。
无痕埋点
前端的任意一个事件都被绑定一个标识,所有的事件都被记录下来,通过定期上传记录文件,配合文件解析,解析出来我们想要的数据,并生成可视化报告。
无痕埋点的优点是采集全量数据,不会出现漏埋和误埋等现象。缺点是给数据传输和服务器增加压力,也无法灵活定制数据结构。针对业务数据的准确性不高。
4. 监控脚本
4.1 接入日志系统
- 各公司一般都有自己的日志系统,接收数据上报,例如:阿里云
前端的埋点上报需要存储起来,这个可以使用阿里云的日志服务,不需要投入开发就可以采集。
新建一个项目比如:xxx-monitor
新建一个存储日志,根据阿里云的要求发起请求,携带需要上报的数据:
http://${project}.${host}/logstores/${logStore}/track
代码中调用 Track 上报日志:
日志的上报可以封装成公共的调用方式, monitor/utils/里面放所有的工具方法;
tracker.js 的实现就是按照阿里云的上报格式发送请求,并带上处理好的需要上报的业务数据即可,下面的都是固定的,在日志服务建好:
tracker.js
// 主机
const host = "cn-xxx-log.aliyuncs.com"
// 项目名
const project = "wu-monitor"
// 存储名
const logstore = "wu-monitor-store"
const userAgent = require("user-agent")
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 = { ...data, ...extraData }
// 阿里云要求值不能为数字
for (const key in log) {
if (typeof log[key] === "number") {
log[key] = `${log[key]}`
}
}
console.log("log", log)
// 接入日志系统,此处以阿里云为例
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", "1.0.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()
4.1.1 日志存储
4.2 监控错误
4.2.1 错误分类
- js错误(js执行错误,promise异常)
- 资源加载异常:监听error
4.2.2 数据结构分析
1. jsError
jsError.js
{
"title": "前端监控系统", // 页面标题
"url": "http://localhost:8080/", // 页面URL
"timestamp": "1590815288710", // 访问时间戳
"userAgent": "Chrome", // 用户浏览器类型
"kind": "stability", // 大类
"type": "error", // 小类
"errorType": "jsError", // 错误类型
"message": "Uncaught TypeError: Cannot set property 'error' of undefined", // 类型详情
"filename": "http://localhost:8080/", // 访问的文件名
"position": "0:0", // 行列信息
"stack": "btnClick (http://localhost:8080/:20:39)^HTMLInputElement.onclick (http://localhost:8080/:14:72)", // 堆栈信息
"selector": "HTML BODY #container .content INPUT" // 选择器
}
2. promiseError
jsError.js
tracker.send({
kind: "stability", // 监控指标的大类,稳定性
type: "error", // 小类型,这是一个错误
errorType: "promiseError", // js执行错误
message, // 报错信息
filename, // 哪个文件报错了
position: `${line}:${column}`, // 报错的行列位置
stack,
selector: lastEvent ? getSelector(lastEvent.path) : "", // 代表最后一个操作的元素
})
3. resourceError
jsError.js
tracker.send({
kind: "stability", // 监控指标的大类,稳定性
type: "error", // 小类型,这是一个错误
errorType: "resourceError", // js执行错误
filename: event.target.src || event.target.href, // 哪个文件报错了
tagName: event.target.tagName,
selector: getSelector(event.target), // 代表最后一个操作的元素
})
4.2.3 完整实现
资源加载错误 + js执行错误 + promise异常
jsError.js
import getLastEvent from "../utils/getLastEvent"
import getSelector from "../utils/getSelector"
import tracker from "../utils/tracker"
export function injectJsError() {
// 监听全局未捕获的错误
window.addEventListener(
"error",
(event) => {
console.log("error+++++++++++", event)
let lastEvent = getLastEvent() // 获取到最后一个交互事件
// 1、资源加载错误-----------
if (event.target && (event.target.src || event.target.href)) {
tracker.send({
kind: "stability", // 监控指标的大类,稳定性
type: "error", // 小类型,这是一个错误
errorType: "resourceError", // js执行错误
filename: event.target.src || event.target.href, // 哪个文件报错了
tagName: event.target.tagName,
selector: getSelector(event.target), // 代表最后一个操作的元素
})
} else {
// 2、js执行错误-----------
tracker.send({
kind: "stability", // 监控指标的大类,稳定性
type: "error", // 小类型,这是一个错误
errorType: "jsError", // js执行错误
message: event.message, // 报错信息
filename: event.filename, // 哪个文件报错了
position: `${event.lineno}:${event.colno}`, // 报错的行列位置
stack: getLines(event.error.stack),
selector: lastEvent ? getSelector(lastEvent.path) : "", // 代表最后一个操作的元素
})
}
},
true
)
// 3、promise异常---------------
window.addEventListener(
"unhandledrejection",
(event) => {
console.log("unhandledrejection-------- ", event);
let lastEvent = getLastEvent(); // 获取到最后一个交互事件
let message;
let filename;
let line = 0;
let column = 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];
column = matchResult[3];
}
stack = getLines(reason.stack);
}
tracker.send({
kind: "stability", // 监控指标的大类,稳定性
type: "error", // 小类型,这是一个错误
errorType: "promiseError", // js执行错误
message, // 报错信息
filename, // 哪个文件报错了
position: `${line}:${column}`, // 报错的行列位置
stack,
selector: lastEvent ? getSelector(lastEvent.path) : "", // 代表最后一个操作的元素
})
},
true // true代表在捕获阶段调用,false代表在冒泡阶段捕获,使用true或false都可以
)
}
function getLines(stack) {
return stack
.split("\n")
.slice(1)
.map((item) => item.replace(/^\s+at\s+/g, ""))
.join("^");
}
4.3 接口异常采集脚本
4.3.1 数据设计
success
{
"title": "前端监控系统", //标题
"url": "http://localhost:8080/", //url
"timestamp": "1590817024490", //timestamp
"userAgent": "Chrome", //浏览器版本
"kind": "stability", //大类
"type": "xhr", //小类
"eventType": "load", //事件类型
"pathname": "/success", //路径
"status": "200-OK", //状态码
"duration": "7", //持续时间
"response": "{"id":1}", //响应内容
"params": "" //参数
}
error
{
"title": "前端监控系统",
"url": "http://localhost:8080/",
"timestamp": "1590817025617",
"userAgent": "Chrome",
"kind": "stability",
"type": "xhr",
"eventType": "load",
"pathname": "/error",
"status": "500-Internal Server Error",
"duration": "7",
"response": "",
"params": ""
}
4.3.2 实现
使用webpack devServer模拟请求
- 重写xhr的open、send方法
- 监听load、error、abort事件
xhr.js
import tracker from "../utils/tracker"
export function injectXHR() {
let XMLHttpRequest = window.XMLHttpRequest
let oldOpen = XMLHttpRequest.prototype.open
XMLHttpRequest.prototype.open = function (method, url, async) {
// 把上报接口过滤掉
if (!url.match(/logstores/) && !url.match(/sockjs/)) {
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: 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, false)
this.addEventListener("abort", handler, false)
}
return oldSend.apply(this, arguments)
}
}
4.4 白屏
在页面加载完成之后,如果页面上的空白点很多,就说明页面是白屏的,需要上报,这个上报的时机是:document.readyState === 'complete'
表示文档和所有的子资源已完成加载,表示load(window.addEventListener('load')状态事件即将被触发。
document.readyState 有三个值:loading(document正在加载),interactive(可交互,表示正在加载的状态结束,但是图像,样式和框架之类的子资源仍在加载),complete 就是完成,所以监控白屏需要在文档都加载完成的情况下触发。
onload.js
export default function (callback) {
if (document.readyState === "complete") {
callback()
} else {
window.addEventListener("load", callback)
}
}
4.4.1 数据设计
json
{
"title": "前端监控系统",
"url": "http://localhost:8080/",
"timestamp": "1590822618759",
"userAgent": "chrome",
"kind": "stability", //大类
"type": "blank", //小类
"emptyPoints": "0", //空白点
"screen": "2049x1152", //分辨率
"viewPoint": "2048x994", //视口
"selector": "HTML BODY #container" //选择器
}
4.4.2 实现
- elementsFromPoint方法可以获取到当前视口内指定坐标处,由里到外排列的所有元素
- 根据 elementsFromPoint api,获取屏幕水平中线和竖直中线所在的元素
blankScreen.js
import tracker from "../utils/tracker"
import onload from "../utils/onload"
export function blankScreen() {
let wrapperElements = ["html", "body", "#container", ".content"]
let emptyPoints = 0
function getSelector(element) {
const { id, className, nodeName } = element
if (id) {
return "#" + id
} else if (className) {
// 过滤空白符 + 拼接
return (
"." +
className
.split(" ")
.filter((item) => !!item)
.join(".")
)
} else {
return nodeName.toLowerCase()
}
}
function isWrapper(element) {
let selector = getSelector(element)
if (wrapperElements.indexOf(selector) !== -1) {
emptyPoints++
}
}
// 刚开始页面内容为空,等页面渲染完成,再去做判断
onload(function () {
let xElements, yElements
for (let i = 0; i < 9; i++) {
xElements = document.elementsFromPoint(
(window.innerWidth * i) / 10,
window.innerHeight / 2
)
yElements = document.elementsFromPoint(
window.innerWidth / 2,
(window.innerHeight * i) / 10
)
isWrapper(xElements[0])
isWrapper(yElements[0])
}
// 白屏
if (emptyPoints >= 0) {
const centerElements = document.elementsFromPoint(
window.innerWidth / 2,
window.innerHeight / 2
)
console.log("emptyPoints++++++++++++++", getSelector(centerElements[0]))
tracker.send({
kind: "stability",
type: "blank",
emptyPoints: emptyPoints + "",
screen: window.screen.width + "X" + window.screen.height,
viewPoint: window.innerWidth + "X" + window.innerHeight,
selector: getSelector(centerElements[0]),
})
}
})
}
//screen.width 屏幕的宽度 screen.height 屏幕的高度
//window.innerWidth 去除工具条与滚动条的窗口宽度 window.innerHeight 去除工具条与滚动条的窗口高度
4.5 加载时间
4.5.1 性能指标
字段 | 描述 | 备注 | 计算方式 |
---|---|---|---|
FP | First Paint(首次绘制) | 包括了任何用户自定义的背景绘制,它是首先将像素绘制到屏幕的时刻 | |
FCP | First Content Paint(首次内容绘制) | 是浏览器将第一个 DOM 渲染到屏幕的时间,可能是文本、图像、SVG等,这其实就是白屏时间 | |
FMP | First Meaningful Paint(首次有意义绘制) | 页面有意义的内容渲染的时间 | |
LCP | (Largest Contentful Paint)(最大内容渲染) | 代表在viewport中最大的页面元素加载的时间 | |
DCL | (DomContentLoaded)(DOM加载完成) | 当 HTML 文档被完全加载和解析完成之后, DOMContentLoaded 事件被触发,无需等待样式表、图像和子框架的完成加载 | |
L | (onLoad) | 当依赖的资源全部加载完毕之后才会触发 | |
TTI | (Time to Interactive) 可交互时间 | 用于标记应用已进行视觉渲染并能可靠响应用户输入的时间点 | |
FID | First Input Delay(首次输入延迟) | 用户首次和页面交互(单击链接,点击按钮等)到页面响应交互的时间 |
4.5.2 数据结构
json
{
"title": "前端监控系统",
"url": "http://localhost:8080/",
"timestamp": "1590828364183",
"userAgent": "chrome",
"kind": "experience",
"type": "timing",
"connectTime": "0",
"ttfbTime": "1",
"responseTime": "1",
"parseDOMTime": "80",
"domContentLoadedTime": "0",
"timeToInteractive": "88",
"loadTime": "89"
}
4.5.3 实现
js
import tracker from "../utils/tracker";
import onload from "../utils/onload";
import formatTime from "../utils/formatTime";
import getLastEvent from "../utils/getLastEvent";
import getSelector from "../utils/getSelector";
export function timing() {
let FMP, LCP;
// 增加一个性能条目的观察者
new PerformanceObserver((entryList, observer) => {
const perfEntries = entryList.getEntries();
FMP = perfEntries[0];
observer.disconnect(); // 不再观察了
}).observe({ entryTypes: ["element"] }); // 观察页面中有意义的元素
// 增加一个性能条目的观察者
new PerformanceObserver((entryList, observer) => {
const perfEntries = entryList.getEntries();
const lastEntry = perfEntries[perfEntries.length - 1];
LCP = lastEntry;
observer.disconnect(); // 不再观察了
}).observe({ entryTypes: ["largest-contentful-paint"] }); // 观察页面中最大的元素
// 增加一个性能条目的观察者
new PerformanceObserver((entryList, observer) => {
const lastEvent = getLastEvent();
const firstInput = entryList.getEntries()[0];
if (firstInput) {
// 开始处理的时间 - 开始点击的时间,差值就是处理的延迟
let inputDelay = firstInput.processingStart - firstInput.startTime;
let duration = firstInput.duration; // 处理的耗时
if (inputDelay > 0 || duration > 0) {
tracker.send({
kind: "experience", // 用户体验指标
type: "firstInputDelay", // 首次输入延迟
inputDelay: inputDelay ? formatTime(inputDelay) : 0, // 延迟的时间
duration: duration ? formatTime(duration) : 0,
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,
} = window.performance.timing;
// 发送时间指标
tracker.send({
kind: "experience", // 用户体验指标
type: "timing", // 统计每个阶段的时间
connectTime: connectEnd - connectStart, // TCP连接耗时
ttfbTime: responseStart - requestStart, // 首字节到达时间
responseTime: responseEnd - responseStart, // response响应耗时
parseDOMTime: loadEventStart - domLoading, // DOM解析渲染的时间
domContentLoadedTime:
domContentLoadedEventEnd - domContentLoadedEventStart, // DOMContentLoaded事件回调耗时
timeToInteractive: domInteractive - fetchStart, // 首次可交互时间
loadTime: loadEventStart - fetchStart, // 完整的加载时间
});
// 发送性能指标
let FP = performance.getEntriesByName("first-paint")[0];
let FCP = performance.getEntriesByName("first-contentful-paint")[0];
console.log("FP", FP);
console.log("FCP", FCP);
console.log("FMP", FMP);
console.log("LCP", LCP);
tracker.send({
kind: "experience",
type: "paint",
firstPaint: FP ? formatTime(FP.startTime) : 0,
firstContentPaint: FCP ? formatTime(FCP.startTime) : 0,
firstMeaningfulPaint: FMP ? formatTime(FMP.startTime) : 0,
largestContentfulPaint: LCP
? formatTime(LCP.renderTime || LCP.loadTime)
: 0,
});
}, 3000);
});
}
关键时间节点通过window.performance.timing获取
4.7 卡顿
- 响应用户交互的响应时间如果大于100ms,用户就会感觉卡顿
4.7.1 数据设计 longTask
json
{
"title": "前端监控系统",
"url": "http://localhost:8080/",
"timestamp": "1590828656781",
"userAgent": "chrome",
"kind": "experience",
"type": "longTask",
"eventType": "mouseover",
"startTime": "9331",
"duration": "200",
"selector": "HTML BODY #container .content"
}
4.7.2 实现
- new PerformanceObserver
- entry.duration > 100 判断大于100ms,即可认定为长任务
- 使用 requestIdleCallback上报数据
longTask.js
import tracker from "../utils/tracker";
import formatTime from "../utils/formatTime";
import getLastEvent from "../utils/getLastEvent";
import getSelector from "../utils/getSelector";
export function longTask() {
new PerformanceObserver((list) => {
list.getEntries().forEach((entry) => {
if (entry.duration > 100) {
let lastEvent = getLastEvent();
requestIdleCallback(() => {
tracker.send({
kind: "experience",
type: "longTask",
eventType: lastEvent.type,
startTime: formatTime(entry.startTime), // 开始时间
duration: formatTime(entry.duration), // 持续时间
selector: lastEvent
? getSelector(lastEvent.path || lastEvent.target)
: "",
});
});
}
});
}).observe({ entryTypes: ["longtask"] });
}
4.8 PV、UV、用户停留时间
4.8.1 数据设计 business
json
{
"title": "前端监控系统",
"url": "http://localhost:8080/",
"timestamp": "1590829304423",
"userAgent": "chrome",
"kind": "business",
"type": "pv",
"effectiveType": "4g",
"rtt": "50",
"screen": "2049x1152"
}
4.8.2 PV、UV、用户停留时间
PV(page view) 是页面浏览量,UV(Unique visitor)用户访问量。PV 只要访问一次页面就算一次,UV 同一天内多次访问只算一次。
对于前端来说,只要每次进入页面上报一次 PV 就行,UV 的统计放在服务端来做,主要是分析上报的数据来统计得出 UV。
pv.js
import tracker from "../utils/tracker";
export function pv() {
var connection = navigator.connection;
tracker.send({
kind: "business",
type: "pv",
effectiveType: connection.effectiveType, //网络环境
rtt: connection.rtt, //往返时间
screen: `${window.screen.width}x${window.screen.height}`, //设备分辨率
});
let startTime = Date.now();
window.addEventListener(
"unload",
() => {
let stayTime = Date.now() - startTime;
tracker.send({
kind: "business",
type: "stayTime",
stayTime,
});
},
false
);
}
异常分析
按照 5W1H 法则来分析前端异常,需要知道以下信息
- What,发⽣了什么错误:JS错误、异步错误、资源加载、接口错误等
- When,出现的时间段,如时间戳
- Who,影响了多少用户,包括报错事件数、IP
- Where,出现的页面是哪些,包括页面、对应的设备信息
- Why,错误的原因是为什么,包括错误堆栈、⾏列、SourceMap、异常录屏
- How,如何定位还原问题,如何异常报警,避免类似的错误发生
使用第三方框架
目前市面上成熟的框架有很多,这里罗列一下我目前知道的
可参考文章
# 浅析前端错误监控警报系统Sentry基本使用、私有化部署步骤及部署注意事项、使用webpack插件上传sourceMap