# 前端监控数据采集
# 错误数据采集
js
代码运行错误、语法错误等- 异步错误
- 静态资源加载错误
- 接口请求错误
# 错误捕获方式
try...catch
:只能捕获代码常规的运行错误,语法错误和异步错误不能捕获到
try {
let a = undefined;
if (a.length) {
console.log('111');
}
} catch (e) {
console.log('捕获到异常:', e);
}
// 语法错误
try {
const notdefined,
} catch(e) {
console.log('捕获不到异常:', 'Uncaught SyntaxError');
}
// 异步错误
try {
setTimeout(() => {
console.log(notdefined);
}, 0)
} catch(e) {
console.log('捕获不到异常:', 'Uncaught ReferenceError');
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
window.onerror
:可以捕获常规错误、异步错误,但不能捕获资源错误
window.onerror = function(message, source, lineno, colno, error) {
console.log("捕获到的错误信息是:", message, source, lineno, colno, error);
};
// 示例1:常规运行时错误,可以捕获 ✅
console.log(notdefined);
// 示例2:语法错误,不能捕获 ❌
const notdefined;
// 示例3:异步错误,可以捕获 ✅
setTimeout(() => {
console.log(notdefined);
}, 0);
// 示例4:资源错误,不能捕获 ❌
let script = document.createElement("script");
script.type = "text/javascript";
script.src = "https://www.test.com/index.js";
document.body.appendChild(script);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
window.addEventListener
:当静态资源加载失败时,会触发error
事件, 此时window.onerror
不能捕获到
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
</head>
<script>
window.addEventListener('error', (error) => {
console.log('捕获到异常:', error);
}, true)
</script>
<!-- 图片、script、css加载错误,都能被捕获 ✅ -->
<img src="https://test.cn/×××.png">
<script src="https://test.cn/×××.js"></script>
<link href="https://test.cn/×××.css" rel="stylesheet" />
<script>
// new Image错误,不能捕获 ❌
// new Image运用的比较少,可以自己单独处理
new Image().src = 'https://test.cn/×××.png'
</script>
</html>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Promise
错误:Promise
中抛出的错误,无法被window.onerror
、try/catch
、error
事件捕获到,可通过unhandledrejection
事件来处理
try {
new Promise((resolve, reject) => {
JSON.parse('');
resolve();
});
} catch (err) {
// try/catch 不能捕获Promise中错误 ❌
console.error('in try catch', err);
}
// error事件 不能捕获Promise中错误 ❌
window.addEventListener(
'error',
(error) => {
console.log('捕获到异常:', error);
},
true
);
// window.onerror 不能捕获Promise中错误 ❌
window.onerror = function (message, source, lineno, colno, error) {
console.log('捕获到异常:', { message, source, lineno, colno, error });
};
// unhandledrejection 可以捕获Promise中的错误 ✅
window.addEventListener('unhandledrejection', function (e) {
console.log('捕获到异常', e);
// preventDefault阻止传播,不会在控制台打印
e.preventDefault();
});
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
# Vue
错误
Vue
项目中,window.onerror
和 error
事件不能捕获到常规的代码错误
// 异常错误
export default {
created() {
let a = null;
if (a.length > 1) {
// ...
}
}
};
// main.js添加错误 控制台会报错,但是 window.onerror 和 error 不能捕获到
window.addEventListener('error', (error) => {
console.log('error', error);
});
window.onerror = function (msg, url, line, col, error) {
console.log('onerror', msg, url, line, col, error);
};
// vue 通过 Vue.config.errorHander 来捕获异常
Vue.config.errorHandler = (err, vm, info) => {
// handleError方法用来处理错误并上报
handleError(err);
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
同 vue
项目的处理类似,react
项目中,可以在 componentDidCatch
中将捕获的错误上报
# 跨域问题
如果当前页面中,引入了其他域名的 JS
资源,如果资源出现错误,error
事件只会监测到一个 script error
的异常。
window.addEventListener(
'error',
(error) => {
console.log('捕获到异常:', error);
},
true
);
// 当前页面加载其他域的资源,如https://www.test.com/index.js
<script src="https://www.test.com/index.js"></script>;
// 加载的https://www.test.com/index.js的代码
function fn() {
JSON.parse('');
}
fn();
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
TIP
原因: 是由于浏览器基于安全考虑,故意隐藏了其它域
JS
文件抛出的具体错误信息,这样可以有效避免敏感信息无意中被第三方(不受控制的)脚本捕获到,因此,浏览器只允许同域下的脚本捕获具体的错误信息。解决办法:
- 前端
script
加crossorigin
,后端配置Access-Control-Allow-Origin
- 如果不能修改服务端的请求头,可以考虑通过使用
try/catch
绕过,将错误抛出
// 1
<script src="https://www.test.com/index.js" crossorigin></script>
// 2
<!doctype html>
<html>
<body>
<script src="https://www.test.com/index.js"></script>
<script>
window.addEventListener("error", error => {
console.log("捕获到异常:", error);
}, true );
try {
// 调用https://www.test.com/index.js中定义的fn方法
fn();
} catch (e) {
throw e;
}
</script>
</body>
</html>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 接口错误
接口监控的实现原理:针对浏览器内置的
XMLHttpRequest
、fetch
对象,利用AOP
切片编程重写该方法,实现对请求的接口拦截,从而获取接口报错的情况并上报
// 拦截 XMLHttpRequest 请求示例:
function xhrReplace() {
if (!('XMLHttpRequest' in window)) {
return;
}
const originalXhrProto = XMLHttpRequest.prototype;
// 重写XMLHttpRequest 原型上的open方法
replaceAop(originalXhrProto, 'open', (originalOpen) => {
return function (...args) {
// 获取请求的信息
this._xhr = {
method: typeof args[0] === 'string' ? args[0].toUpperCase() : args[0],
url: args[1],
startTime: new Date().getTime(),
type: 'xhr'
};
// 执行原始的open方法
originalOpen.apply(this, args);
};
});
// 重写XMLHttpRequest 原型上的send方法
replaceAop(originalXhrProto, 'send', (originalSend) => {
return function (...args) {
// 当请求结束时触发,无论请求成功还是失败都会触发
this.addEventListener('loadend', () => {
const { responseType, response, status } = this;
const endTime = new Date().getTime();
this._xhr.reqData = args[0];
this._xhr.status = status;
if (['', 'json', 'text'].indexOf(responseType) !== -1) {
this._xhr.responseText =
typeof response === 'object' ? JSON.stringify(response) : response;
}
// 获取接口的请求时长
this._xhr.elapsedTime = endTime - this._xhr.startTime;
// 上报xhr接口数据
reportData(this._xhr);
});
// 执行原始的send方法
originalSend.apply(this, args);
};
});
}
/**
* 重写指定的方法
* @param { object } source 重写的对象
* @param { string } name 重写的属性
* @param { function } fn 拦截的函数
*/
function replaceAop(source, name, fn) {
if (source === undefined) return;
if (name in source) {
var original = source[name];
var wrapped = fn(original);
if (typeof wrapped === 'function') {
source[name] = wrapped;
}
}
}
// 拦截 fetch 请求示例:
function fetchReplace() {
if (!('fetch' in window)) {
return;
}
// 重写fetch方法
replaceAop(window, 'fetch', (originalFetch) => {
return function (url, config) {
const sTime = new Date().getTime();
const method = (config && config.method) || 'GET';
let handlerData = {
type: 'fetch',
method,
reqData: config && config.body,
url
};
return originalFetch.apply(window, [url, config]).then(
(res) => {
// res.clone克隆,防止被标记已消费
const tempRes = res.clone();
const eTime = new Date().getTime();
handlerData = {
...handlerData,
elapsedTime: eTime - sTime,
status: tempRes.status
};
tempRes.text().then((data) => {
handlerData.responseText = data;
// 上报fetch接口数据
reportData(handlerData);
});
// 返回原始的结果,外部继续使用then接收
return res;
},
(err) => {
const eTime = new Date().getTime();
handlerData = {
...handlerData,
elapsedTime: eTime - sTime,
status: 0
};
// 上报fetch接口数据
reportData(handlerData);
throw err;
}
);
};
});
}
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
112
113
# 性能数据采集
谈到性能数据采集,就会提及加载过程模型图:
以 Spa
页面来说,页面的加载过程大致是这样的:
包括 dns
查询、建立 tcp
连接、发送 http
请求、返回 html
文档、html
文档解析等阶段
最初,可以通过 window.performance.timing
来获取加载过程模型中各个阶段的耗时数据
// window.performance.timing 各字段说明
{
navigationStart, // 同一个浏览器上下文中,上一个文档结束时的时间戳。如果没有上一个文档,这个值会和 fetchStart 相同。
unloadEventStart, // 上一个文档 unload 事件触发时的时间戳。如果没有上一个文档,为 0。
unloadEventEnd, // 上一个文档 unload 事件结束时的时间戳。如果没有上一个文档,为 0。
redirectStart, // 表示第一个 http 重定向开始时的时间戳。如果没有重定向或者有一个非同源的重定向,为 0。
redirectEnd, // 表示最后一个 http 重定向结束时的时间戳。如果没有重定向或者有一个非同源的重定向,为 0。
fetchStart, // 表示浏览器准备好使用 http 请求来获取文档的时间戳。这个时间点会在检查任何缓存之前。
domainLookupStart, // 域名查询开始的时间戳。如果使用了持久连接或者本地有缓存,这个值会和 fetchStart 相同。
domainLookupEnd, // 域名查询结束的时间戳。如果使用了持久连接或者本地有缓存,这个值会和 fetchStart 相同。
connectStart, // http 请求向服务器发送连接请求时的时间戳。如果使用了持久连接,这个值会和 fetchStart 相同。
connectEnd, // 浏览器和服务器之前建立连接的时间戳,所有握手和认证过程全部结束。如果使用了持久连接,这个值会和 fetchStart 相同。
secureConnectionStart, // 浏览器与服务器开始安全链接的握手时的时间戳。如果当前网页不要求安全连接,返回 0。
requestStart, // 浏览器向服务器发起 http 请求(或者读取本地缓存)时的时间戳,即获取 html 文档。
responseStart, // 浏览器从服务器接收到第一个字节时的时间戳。
responseEnd, // 浏览器从服务器接受到最后一个字节时的时间戳。
domLoading, // dom 结构开始解析的时间戳,document.readyState 的值为 loading。
domInteractive, // dom 结构解析结束,开始加载内嵌资源的时间戳,document.readyState 的状态为 interactive。
domContentLoadedEventStart, // DOMContentLoaded 事件触发时的时间戳,所有需要执行的脚本执行完毕。
domContentLoadedEventEnd, // DOMContentLoaded 事件结束时的时间戳
domComplete, // dom 文档完成解析的时间戳, document.readyState 的值为 complete。
loadEventStart, // load 事件触发的时间。
loadEventEnd; // load 时间结束时的时间。
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
后来
window.performance.timing
被废弃,通过PerformanceObserver
来获取。旧的api
,返回的是一个UNIX
类型的绝对时间,和用户的系统时间相关,分析的时候需要再次计算。而新的api
,返回的是一个相对时间,可以直接用来分析。
# 性能指标计算web-vitals (opens new window)
关于
FP、FCP、LCP、CLS、TTFB、FID
等性能指标的含义和计算方式
性能指标:
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)
:首次输入延迟。用户首次和页面交互(单击链接、点击按钮等)到页面响应交互的时间。
用户体验:
TTFB
(Time To First Byte
首字节时间)。是指浏览器发起第一个请求到数据返回第一个字节所消耗的时间,这个时间包含了网络请求时间、后端处理时间。
FP
(First Paint
首次绘制)。首次绘制包括了任何用户自定义的背景绘制,它是将第一个像素点绘制到屏幕的时间。
FCP
(First Content Paint
首次内容绘制)。首次内容绘制是浏览器将第一个 DOM
渲染到屏幕的时间,可以是任何文本、图像、SVG
等的时间。
FMP
(First Meaningful Paint
首次有意义绘制)。 首次有意义绘制是页面可用性的量度标准。
FID
(First Input Delay
首次输入延迟)。用户首次和页面交互到页面响应交互的时间。
业务:
PV
:page view
即页面浏览量或点击量
UV
:指访问某个站点的不同 IP
地址的人数。
# 用户行为数据采集
用户行为包括:页面路由变化、鼠标点击、资源加载、接口调用、代码报错等行为
# 设计思路
1、通过 Breadcrumb
类来创建用户行为的对象,来存储和管理所有的用户行为
2、通过重写或添加相应的事件,完成用户行为数据的采集
// 创建用户行为类
class Breadcrumb {
// maxBreadcrumbs控制上报用户行为的最大条数
maxBreadcrumbs = 20;
// stack 存储用户行为
stack = [];
constructor() {}
// 添加用户行为栈
push(data) {
if (this.stack.length >= this.maxBreadcrumbs) {
// 超出则删除第一条
this.stack.shift();
}
this.stack.push(data);
// 按照时间排序
this.stack.sort((a, b) => a.time - b.time);
}
}
let breadcrumb = new Breadcrumb();
// 添加一条页面跳转的行为,从home页面跳转到about页面
breadcrumb.push({
type: "Route",
form: '/home',
to: '/about'
url: "http://localhost:3000/index.html",
time: "1668759320435"
});
// 添加一条用户点击行为
breadcrumb.push({
type: "Click",
dom: "<button id='btn'>按钮</button>",
time: "1668759620485"
});
// 添加一条调用接口行为
breadcrumb.push({
type: "Xhr",
url: "http://10.105.10.12/monitor/open/pushData",
time: "1668760485550"
});
// 上报用户行为
reportData({
uuid: "a6481683-6d2e-4bd8-bba1-64819d8cce8c",
stack: breadcrumb.getStack()
});
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
# 页面跳转
通过监听路由的变化来判断页面跳转,路由有history
、hash
两种模式,history
模式可以监听popstate
事件,hash
模式通过重写 pushState
和 replaceState
事件。vue
项目中不能通过 hashchange
事件来监听路由变化,vue-router
底层调用的是 history.pushState
和 history.replaceState
,不会触发 hashchange
//通过重写 pushState、replaceState 事件来监听路由变化
// lastHref 前一个页面的路由
let lastHref = document.location.href;
function historyReplace() {
function historyReplaceFn(originalHistoryFn) {
return function (...args) {
const url = args.length > 2 ? args[2] : undefined;
if (url) {
const from = lastHref;
const to = String(url);
lastHref = to;
// 上报路由变化
reportData('routeChange', {
from,
to
});
}
return originalHistoryFn.apply(this, args);
};
}
// 重写pushState事件
replaceAop(window.history, 'pushState', historyReplaceFn);
// 重写replaceState事件
replaceAop(window.history, 'replaceState', historyReplaceFn);
}
function replaceAop(source, name, fn) {
if (source === undefined) return;
if (name in source) {
var original = source[name];
var wrapped = fn(original);
if (typeof wrapped === 'function') {
source[name] = wrapped;
}
}
}
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
# 用户点击
给
document
对象添加click
事件,并上报
function domReplace() {
document.addEventListener(
'click',
({ target }) => {
const tagName = target.tagName.toLowerCase();
if (tagName === 'body') {
return null;
}
let classNames = target.classList.value;
classNames = classNames !== '' ? ` class="${classNames}"` : '';
const id = target.id ? ` id="${target.id}"` : '';
const innerText = target.innerText;
// 获取包含id、class、innerTextde字符串的标签
let dom = `<${tagName}${id}${classNames !== '' ? classNames : ''}>${innerText}</${tagName}>`;
// 上报
reportData({
type: 'Click',
dom
});
},
true
);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23