web异常监控和分析
# 前端错误监控
可以通过web vitals和LightHouse 监控
# 为什么做前端监控
- 更快发现问题和解决问题
- 做产品的决策依据
- 提高稳定性和用户体验
- 为业务扩展提供更多可能性
# 常见的异常
- js错误,js执行错误或者promise异常
- 资源异常 script、link等资源加载异常
- 接口错误 ajax或者fetch请求接口异常
- 白屏或者空白页面
# 用户体验方面
- 加载时间 各个阶段的加载时间
- TTFB(time to first byte)(首字节时间) 是指浏览器发起第一个请求到数据返回第一个字节所消耗的时间,这个时间包含了网络请求时间、后端处理时间
- FP(First Paint)(首次绘制) 首次绘制包括了任何用户自定义的背景绘制,它是将第一个像素点绘制到屏幕的时刻
- FCP(First Content Paint)(首次内容绘制) 首次内容绘制是浏览器将第一个DOM渲染到屏幕的时间,可以是任何文本、图像、SVG等的时间
- FMP(First Meaningful paint)(首次有意义绘制) (opens new window) 首次有意义绘制是页面可用性的量度标准
- FID(First Input Delay)(首次输入延迟) 用户首次和页面交互到页面响应交互的时间
- LCP (Largest Contentful Paint)(最大内容渲染) (opens new window) 代表在viewport中最大的页面元素加载的时间。
2.5s
以内是好的,2.5s到4s需要优化。 - DCL (DomContentLoaded)(DOM加载完成) 当 HTML 文档被完全加载和解析完成之后,DOMContentLoaded 事件被触发,无需等待样式表、图像和子框架的完成加载
- L (onLoad) 当依赖的资源全部加载完毕之后才会触发
- TTI (Time to Interactive) 可交互时间 用于标记应用已进行视觉渲染并能可靠响应用户输入的时间点
- FID First Input Delay(首次输入延迟) 用户首次和页面交互(单击链接,点击按钮等)到页面响应交互的时间。100ms以内是好的,100ms到300ms需要改善。
- TBT(Total blocking time)主线程累计阻塞时间
- CLS(Cummulative layout time)累计布局偏移,小于0.1是好的,0.1s到0.25s需要改善。
- 卡顿 超过50ms的长任务。响应用户交互的响应时间如果大于100ms,用户就会感觉卡顿
- paint timing (opens new window)
# 业务方面
- PV page view 即页面浏览量或点击量
- UV 指访问某个站点的不同IP地址的人数
- 页面的停留时间 用户在每一个页面的停留时间
- 用户行为记录
# 常见的埋点方案
前端监控流程包括:前端埋点 => 数据上报 => 分析和计算数据 将采集到的数据进行加工汇总 => 可视化展示 将数据按各种维度进行展示 => 监控报警 发现问题后按一定的条件触发报警
常见的埋点方案包括:代码埋点、可视化埋点、无痕埋点
# 代码埋点
- 代码埋点,就是以嵌入代码的形式进行埋点,比如需要监控用户的点击事件,会选择在用户点击时,插入一段代码,保存这个监听行为或者直接将监听行为以某一种数据格式直接传递给服务器端
- 优点是可以在任意时刻,精确的发送或保存所需要的数据信息
- 缺点是工作量较大
# 可视化埋点
- 通过可视化交互的手段,代替代码埋点
- 将业务代码和埋点代码分离,提供一个可视化交互的页面,输入为业务代码,通过这个可视化系统,可以在业务代码中自定义的增加埋点事件等等,最后输出的代码耦合了业务代码和埋点代码
- 可视化埋点其实是用系统来代替手工插入埋点代码
# 无痕埋点
- 前端的任意一个事件都被绑定一个标识,所有的事件都别记录下来
- 通过定期上传记录文件,配合文件解析,解析出来我们想要的数据,并生成可视化报告供专业人员分析
- 无痕埋点的优点是采集全量数据,不会出现漏埋和误埋等现象
- 缺点是给数据传输和服务器增加压力,也无法灵活定制数据结构
# 运行时错误
运行时错误的捕获方式
- try catch
- window.onerror(0级dom事件,也可以用二级dom事件),只能捕获即时错误
import tracker from './tracker'; // 后面有写tracker.js
// 捕获全局异常
window.addEventListener('error', function(event){ // 监听全局未捕获的错误
console.log('捕获', event);
let log = {};
if (event.target && (event.target.src || event.target.href)) { // 有src或者href,表示资源加载失败
log = {
kind: 'stability',// 稳定性指标
type: 'error',// resource
errorType: 'resourceError',
filename: event.target.src || event.target.href,// 加载失败的资源
tagName: event.target.tagName,// 标签名
timeStamp: formatTime(event.timeStamp),// 时间
selector: getSelector(event.path || event.target),// 选择器
}
} else {
log = {
"timestamp": event.timeStamp,// "1590815288710",// 访问时间戳
"kind": "stability",// 大类
"type": event.type,// "error",// 小类
"errorType": "jsError",// js执行错误类型
"message": event.message, // "Uncaught TypeError: Cannot set property 'error' of undefined",// 报错信息类型详情
"filename": event.filename,// "http://localhost:8080/",// 访问的文件名
"position": `${event.lineno}:${event.colno}`,// 行列信息
"stack": getLines(event.error.stack),// "btnClick (http://localhost:8080/:20:39)^HTMLInputElement.onclick (http://localhost:8080/:14:72)",// 堆栈信息
"selector": lastEvent?getSelector(lastEvent): '',// "HTML BODY #container .content INPUT"// 选择器
}
}
tracker.send(log);
},true) // 这里一定是true才会捕获。换成冒泡的话是无法捕获到异常的.true代表在捕获阶段调用,false代表在冒泡阶段捕获
function getLines(stack){ // 获取栈信息
return stack.split('\n').slice(1).map(item=>item.replace(/^\s+at\s+/g, '')).join('^');
}
function getSelector(path){ // 获取选择器
if (!Array.isArray(path)){ // 如果是个对象的话转成数组
var paths = [];
var element = pathsOrTarget;
while (element) {
paths.push(element);
element = element.parentNode;
}
path = paths;
}
return path.reverse().filter(element=>element{
return element !== document && element !== window;
}).map(element => {
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(" ");
}
// 当Promise 被 reject 且没有 reject 处理器的时候,会触发 unhandledrejection 事件
window.addEventListener('unhandledrejection',(event) => { // 捕获promise的异常
let lastEvent = getLastEvent();
let message = '';
let line = 0;
let column = 0;
let file = '';
let stack = '';
let reason = event.reason;
if (typeof event.reason === 'string') {
message = event.reason;
} else if (typeof event.reason === 'object') {
message = event.reason.message;
}
if (typeof reason === 'object') { // 是一个错误对象
if (reason.stack) {
var matchResult = reason.stack.match(/at\s+(.+):(\d+):(\d+)/);
if (matchResult) {
file = matchResult[1];
line = matchResult[2];
column = matchResult[3];
}
stack = getLines(reason.stack);
}
}
tracker.send({// 未捕获的promise错误
kind: 'stability',// 稳定性指标 大类型
type: 'error',// jsError
errorType: 'promiseError',// unhandledrejection
message: message,// 标签名
filename: file,
position: line + ':' + column,// 行列
stack,
selector: lastEvent ? getSelector(lastEvent.path || lastEvent.target) : ''
})
},true)
// 存储最后触发的事件
var lastEvent ;
['click','touchstart','mousedown','keydown','mouseover'].forEach(eventType => {
document.addEventListener(eventType,(event) => {
lastEvent = event;
},{
capture: true, // 捕获阶段
passive: true, // 默认不阻止默认事件
})
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
// tracker.js
let host = 'cn-beijing.log.aliyuncs.com'; // 主机名 北京的域名
let project = 'mingjuanmonitor'; // 项目名
let logstore = 'mingjuanmonitor-store'; // 存储名
var 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 = {}, callback){
let extraData = getExtraData();
let logs = {
...extraData,
...data,
}
for(let key in logs){
if(typeof logs[key] === 'number'){
logs[key] = `${logs[key]}`;
}
}
let body = JSON.stringify({
___logs___: [logs]
})
this.xhr.open('POST', this.url, true);
this.xhr.setRequestHeader("Content-Type", "application/json;charset=UTF-8");
this.xhr.setRequestHeader('x-log-apiversion', '0.6.0');
this.xhr.setRequestHeader('x-log-bodyrawsize', body.length);
this.xhr.onload = function () {
if ((this.status >= 200 && this.status <= 300) || this.status == 304) {
callback && callback();
}
}
this.xhr.onerror = function (error) {
console.log('error', error);
}
this.xhr.send(body);
}
}
export default new SendTracker();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
当Promise 被 reject 且没有 reject 处理器的时候,会触发 unhandledrejection 事件;这可能发生在 window 下,但也可能发生在 Worker 中。 这对于调试回退错误处理非常有用。
# 性能监控
- PerformanceTiming (opens new window)对象包含延迟相关的性能信息
- PerformanceObserver.observe (opens new window)方法用于观察传入的参数中指定的性能条目类型的集合。当记录一个指定类型的性能条目时,性能监测对象的回调函数将会被调用
export function timing() {
let FMP, LCP;
if(PerformanceObserver){
new PerformanceObserver((entryList, observer) => {
// dom加上setAttribute('elementtiming','meaningful'); 才会触发表示有意义的绘制
let 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(function (entryList, observer) {
let lastEvent = getLastEvent();
const firstInput = entryList.getEntries()[0];
if (firstInput) {
let inputDelay = firstInput.processingStart - firstInput.startTime;// 处理延迟。processingStart开始处理时间,startTime点击的时间,差值为延迟时间
let duration = firstInput.duration;// 处理持续时间
if (firstInput > 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 }); // 第一次有意义的交互FID
}
onload(function () {
setTimeout(() => {
const {
fetchStart,
connectStart,
connectEnd,
requestStart,
responseStart,
responseEnd,
domLoading,
domInteractive,
domContentLoadedEventStart,
domContentLoadedEventEnd,
loadEventStart } = performance.timing;
tracker.send({
kind: 'experience', // 用户体验指标
type: 'timing', // 统计每个阶段的时间
connectTime: connectEnd - connectStart,// TCP连接耗时
ttfbTime: responseStart - requestStart,// ttfb首字节到达时间
responseTime: responseEnd - responseStart,// Response响应耗时
parseDOMTime: loadEventStart - domLoading,// DOM解析渲染耗时
domContentLoadedTime: domContentLoadedEventEnd - domContentLoadedEventStart,// DOMContentLoaded事件回调耗时(dom完成时间)
timeToInteractive: domInteractive - fetchStart,// 首次可交互时间
loadTime: loadEventStart - fetchStart// 完整的加载时间
});
const FP = performance.getEntriesByName('first-paint')[0];
const FCP = performance.getEntriesByName('first-contentful-paint')[0];
console.log('FP', FP);
console.log('FCP', FCP);
console.log('FMP', FMP);// dom加上setAttribute('elementtiming','meaningful'); 才会触发表示有意义的绘制
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);
});
functin formatTime(time) {
return `${time}`.split(".")[0]
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
# 白屏上报
影响白屏时间的因素: 网络,服务端性能,前端页面结构设计 影响首屏时间的因素: 白屏时间,资源下载执行时间
白屏是通过判断某行某列N个点处的dom元素是否为包裹元素来判断页面中是否有内容。如果没有元素则进行异常上报。
- screen 返回当前window的screen对象,返回当前渲染窗口中和屏幕有关的属性
- innerWidth 只读的 Window 属性 innerWidth 返回以像素为单位的窗口的内部宽度
- innerHeight 窗口的内部高度(布局视口)的高度
- elementsFromPoint方法可以获取到当前视口内指定坐标处,由里到外排列的所有元素
export function blankScreen() {
const wrapperSelectors = ['body', 'html', '#container', '.content'];
let emptyPoints = 0;
function isWrapper(element) {
let selector = getSelector(element);
if (wrapperSelectors.indexOf(selector) >= 0) {
emptyPoints++;
}
}
onload(function () {
let xElements, yElements;
debugger
for (let i = 1; 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) { // 空折点个数大于等于0个时上报
let centerElements = document.elementsFromPoint(window.innerWidth / 2, window.innerHeight / 2)
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 去除工具条与滚动条的窗口高度
function onload(callback) {
if (document.readyState === 'complete') {
callback();
} else {
window.addEventListener('load', callback);
}
};
function getSelector(element) {
var selector;
if (element.id) {
selector = `#${element.id}`;
} else if (element.className && typeof element.className === 'string') {
selector = '.' + element.className.split(' ').filter(function (item) { return !!item }).join('.');
} else {
selector = element.nodeName.toLowerCase();
}
return selector;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
# 跨域的js运行错误捕获
只能捕获错误但不能拿到具体信息
- 在script标签增加crossorigin属性(在客户端做)
- 设置js资源响应头Access-Control-Allow-Origin: * (这里可以是*也可以是域名。需要在服务端做)
# 错误上报
- 采用ajax通信方式上报(一般不用这种方式)
- 利用Image对象上报(如google的gaa,国内的cnzz都是image方式上报)
- 监听请求进行上报时可以重写xmlHttpRequest的方式,重写send函数,执行完上报,在用老的send函数call执行发送请求
export function injectXHR() {
let XMLHttpRequest = window.XMLHttpRequest;
let oldOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function (method, url, async, username, password) {
if (!url.match(/logstores/) && !url.match(/sockjs/)) {
this.logData = {
method, url, async, username, password
}
}
return oldOpen.apply(this, arguments);
}
let oldSend = XMLHttpRequest.prototype.send;
let start;
XMLHttpRequest.prototype.send = function (body) {
if (this.logData) {
start = Date.now();
let handler = (type) => (event) => {
let duration = Date.now() - start;
let status = this.status;
let statusText = this.statusText;
tracker.send({//未捕获的promise错误
kind: 'stability',//稳定性指标
type: 'xhr',//xhr
eventType: type,//load error abort
pathname: this.logData.url,//接口的url地址
status: status + "-" + statusText,
duration: "" + 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);
}
oldSend.apply(this, arguments);
};
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// 利用image方式更简单,一行就可以轻松实现上报
(new Image()).src="http://xxx.com/abc?r=yyy"
1
# 优化
dns预解析
<link rel="dsn-prefetch" href="//example.com">
1
预连接,提前建立连接
<link rel="preconnect" href="//example.com">
1
预获取,获取后不会立即执行
<link rel="prefetch" href="//example.com/script.js" as="script">
1
预渲染,除了会获取还会执行页面进行预渲染
<link rel="prerender" href="//example.com/performance.html">
1
preload的优先级比prefetch更高,浏览器遇到preload后会立刻进行预获取,并存在内存中,资源获取不会影响parse与load事件触发,直到遇到该资源的使用标签时才会触发。
<link rel="preload" href="//example.com/example.css" as="style">
1
# prefetch、preload、async、defer对比
上次更新: 2025/04/07, 01:42:58