比如客户说:你们的页面咋白屏了?
开发:具体可以给我说一下,白屏之前你做了什么吗?
客户:我就点了一下按钮
开发:你能告诉我是哪个页面的哪个按钮吗?
最后,客户极其不耐烦的地丢过来一张没有缺少url的白屏给你看,试图证明他说的是真的,真白屏了。
最后不再理睬你!
开发人员试图找到解决方案,想快速解决线上bug,但是客户却以为你在质疑他。
真的有点秀才遇上兵有理说不清,其实真的没有谁对谁错,只是双方立场不同,作为客户,你的系统bug了,我哪里知道我刚才干了啥成这样了?大家都不是专门的测试人员,肯定不知道如何准确的描述和回溯系统问题。
所以诸多场景证明,我们必须要搭建一个监控平台来帮我们记录错误发生的具体情况。而不是跟在客户的屁股后面,把时间浪费在相互沟通上。
前端监控系统主要包含三个方面:错误监控,性能监控。
错误监控主要包括三个步骤:搜集错误,进行上报,然后对症分析。
任何时候,我要想快速解决问题,一定要问自己几个问题,问题明确之后,相应的解决方案就会自动出现,我最喜欢的就是这 5W1H 思考法。
- What,我们遇到了什么问题? 前端页面报错难以追溯,客户无法描述清楚,到底是什么问题。
- When,什么时候出现的? 在提供错误信息的时候,最好带上时间戳。
- Who,影响了多少用户? 这个错误后面最好带上IP 和 设备信息。
- Where,在哪里出现了报错? 最好给他截屏,还要带上url。
- Why,为什么报错了?最好能将开发者工具console里面的报错详情发给我,包括错误堆栈、⾏列、SourceMap。
- How,我该怎么解决这个问题。
基于上述问题和解决方案,我们搭建一个前端监控平台,项目模型图如下所示:
1.组成部分
整个应用就包含三个部分:
-
- 给咱们的项目接入监控。
-
- 后端进行数据分析。
-
- 在数据监控平台上显示各个监控平台的报警信息。
备注:本文只介绍接入和数据分析,监控平台不做,监控平台就是个管理系统,有了监控数据,剩下的就是表格加echart展示,自己补全!
2.错误类型
前端出现的错误,我们可以把他分为两类,一类是 页面错误, 如页面异常,导致页面白屏的错误;一类是 网络错误,即由于服务端异常所导致的错误,或者不符合既定前后端约束的错误。
具体的错误,我整理为下面两张图:
3.搜集错误
1.try/catch:能捕获常规运行时错误,只捕捉同步错误,不捕捉异步错误。
2.window.onerror
当 JS 运行时错误发生时,window 会触发一个 ErrorEvent 接口的 error 事件。
1window.onerror = function(message, source, lineno, colno, error) {
2 console.log('捕获到异常:', {message, source, lineno, colno, error});
3}
它可以捕获异步错误。
3.window.addEventListener
它能捕获:图片、script、css加载错误。
window.addEventListener('error')
来捕获 JS运行异常
;它会比 window.onerror
先触发;
window.onerror能做的事情,window.addEventListener(‘error’)也能做,而且他还会监听静态资源的加载错误。
所以window.addEventListener(‘error’)更靠谱
4.react组件错误:react 通过componentDidCatch,声明一个错误边界的组件,它是高阶组件,只需将子组件传入即可错误兜底。
实际上在我们的项目里面,搜集错误直接用下面这个就能全部扫描到:
1import getLastEvent from "../utils/getLastEvent";
2import getSelector from "../utils/getSelector";
3import tracker from "../utils/tracker";
4import axios from 'axios';
5
6export function injectJsError() {
7
8 window.addEventListener(
9 "error",
10 async (event) => {
11 let lastEvent = getLastEvent();
12
13 if (event.target && (event.target.src || event.target.href)) {
14 tracker.send({
15 kind: "stability",
16 type: "error",
17 errorType: "resourceError",
18 filename: event.target.src || event.target.href,
19 tagName: event.target.tagName,
20 selector: getSelector(event.target),
21 });
22 } else {
23
24 const { data } = await axios.get(`/getErrorInfo?filepath=${event.filename}&lineno=${event.lineno}&colno=${event.colno}`);
25 console.log(data, 99999);
26
27 tracker.send({
28 kind: "stability",
29 type: "error",
30 errorType: "jsError",
31 message: event.message,
32 filename: data.source,
33 position: `${data.line}:${data.column}`,
34 budle: data.budle,
35 errorName: data.name,
36 stack: getLines(event.error.stack),
37 selector: lastEvent ? getSelector(lastEvent.path) : "",
38 });
39 }
40 },
41 true
42 );
43
44 window.addEventListener(
45 "unhandledrejection",
46 (event) => {
47 console.log("unhandledrejection-------- ", event);
48 let lastEvent = getLastEvent();
49 let message;
50 let filename;
51 let line = 0;
52 let column = 0;
53 let stack = "";
54 let reason = event.reason;
55 if (typeof reason === "string") {
56 message = reason;
57 } else if (typeof reason === "object") {
58 message = reason.message;
59 if (reason.stack) {
60 let matchResult = reason.stack.match(/at\s+(.+):(\d+):(\d+)/);
61 filename = matchResult[1];
62 line = matchResult[2];
63 column = matchResult[3];
64 }
65 stack = getLines(reason.stack);
66 }
67 tracker.send({
68 kind: "stability",
69 type: "error",
70 errorType: "promiseError",
71 message,
72 filename,
73 position: `${line}:${column}`,
74 stack,
75 selector: lastEvent ? getSelector(lastEvent.path) : "",
76 });
77 },
78 true
79 );
80}
81
82function getLines(stack) {
83 return stack
84 .split("\n")
85 .slice(1)
86 .map((item) => item.replace(/^\s+at\s+/g, ""))
87 .join("^");
88}
89
我们的包里面只用到了2个就能监控到所有的错误
1 window.addEventListener("error",()=>{})
2 window.addEventListener("unhandledrejection",()=>{})
使用,在main.jsx里面导入他们。
这样它就会在打包的时候,把我们的搜索错误的两个事件加进去做实时监听。
5.错误上传
我们把错误拿到,还要上报到具体的服务器上,这样才有用对吧
他就是个上传函数,
6.打通sourceMap
你拿到错误了,但是线上是这样的,你咋知道到底是那个文件在报错?所以需要打通source-map。
上面的监控已经能够拿到页面的错误了,但是线上没有.map文件,你咋搞?
6.1. 配置打包工具
将vite.config.js里面的sourcemap配成hidden,这样他会生成map文件但是用户看不到。
直接用live-server启动dist/index.html模拟线上
测试发现没有出现map文件
6.2 上传map文件
开发一个vite插件,要他在执行npm run build 的时候去把map文件存到监控服务器里面去,这样实时监控的时候才能找到具体的map文件,从而映射出来错误的行列。
1import fs from 'fs';
2import http from 'http';
3import path from 'path';
4
5function upload(file) {
6 return new Promise((resolve) => {
7 let req = http.request(`http://localhost:3002/upload?name=${file}`, {
8 method: "POST",
9 headers: {
10 "Content-Type": "application/octet-stream",
11 Connection: "keep-alive",
12 },
13 });
14
15 let fileStream = fs.createReadStream(file);
16 fileStream.pipe(req, { end: false });
17 fileStream.on("end", function () {
18 req.end();
19 resolve();
20 });
21 });
22}
23
24const MyPlugin = () => {
25 return {
26 name: 'my-plugin',
27 async closeBundle() {
28 let chunks = fs.readdirSync('./dist/assets');
29 let map_file = chunks.filter((item) => {
30 return item.match(/\.js\.map$/) !== null;
31 });
32
33 while (map_file.length > 0) {
34 let file = map_file.shift();
35 await upload(path.join('./dist/assets', file));
36 }
37 },
38 };
39};
40
41export default MyPlugin;
42
我们写一个node接口来模拟这个过程
1import path from "node:path";
2import fs from 'fs';
3import express from 'express';
4const app = express();
5import { dirname } from "node:path";
6import { fileURLToPath } from "node:url";
7import { SourceMapConsumer } from 'source-map';
8
9const __filename = fileURLToPath(import.meta.url);
10const __dirname = dirname(__filename);
11
12
13
14app.post('/upload', (req, res) => {
15 const file = req.query.name;
16
17 if (!file) {
18 return;
19 }
20
21 const filename = file?.split(`\\`).pop();
22 let dir = path?.join(__dirname, "source-map");
23
24
25 if (!fs.existsSync(dir)) {
26 fs.mkdirSync(dir);
27 }
28 const filePath = path?.join(dir, filename);
29 const ws = fs.createWriteStream(filePath);
30 req.pipe(ws);
31});
32
33
34app.listen(3002, () => {
35 console.log(`已经启动服务,端口号是3002`);
36});
37
测试看看,首先第一步启动服务
1node server.js
2
3npm run build
执行完 npm run build 以后你会发现在项目下面出现一个 source-map 的文件夹,里面会把所有的 map 文件都加入进来。
6.3 接入source-map
现在有了map文件以后,我们拿着监控到错误,去调个接口去找真实文件对应的错误。
1import path from "node:path";
2import fs from 'fs';
3import express from 'express';
4const app = express();
5import { dirname } from "node:path";
6import { fileURLToPath } from "node:url";
7import { SourceMapConsumer } from 'source-map';
8
9const __filename = fileURLToPath(import.meta.url);
10const __dirname = dirname(__filename);
11
12app.get("/getErrorInfo", async (req, res) => {
13 res.setHeader('Access-Control-Allow-Origin', '*');
14
15 console.log(req.query, 99);
16 if (!req.query) {
17 return;
18 }
19
20 const { filepath, lineno, colno } = req.query;
21
22 const filename = filepath.split('/').pop();
23 const file = path.join(`${__dirname}/source-map`, `${filename}.map`);
24 const rawSourceMap = fs.readFileSync(file, 'utf-8');
25
26 let consumer = await new SourceMapConsumer(rawSourceMap);
27
28 let result = await consumer.originalPositionFor({
29 line: parseInt(lineno),
30 column: parseInt(colno),
31 });
32
33 res.send({
34 ...result,
35 budle: `${filename}.map`
36 });
37});
38
39app.listen(3002, () => {
40 console.log(`已经启动服务,端口号是3002`);
41});
42
当我们监控到的错误是这样的
我们就把 index-CqtqrVqd.js:45:4998
传入接口/getErrorInfo
。然后用工具包:source-map
处理。
最终处理后的数据长这样
我们已经找到了具体的错误在哪里。
就是监控我们在页面上常见的事件,点击、滚动、输入、等。
下面这是一个行为监控模板。
1function sendData(data) {
2
3
4
5
6
7
8
9
10
11}
12
13
14document.addEventListener('click', function(event) {
15
16 let target = event.target;
17 console.log('我点击了~~',event);
18
19
20 let id = target.id;
21 let className = target.className;
22
23
24 let data = {
25 type: 'click',
26 id: id,
27 className: className,
28
29 };
30
31
32 sendData(data);
33});
34
35
36document.addEventListener('scroll', function(event) {
37
38 let scrollTop = document.documentElement.scrollTop || document.body.scrollTop;
39
40
41 let data = {
42 type: 'scroll',
43 scrollTop: scrollTop,
44
45 };
46
47
48 sendData(data);
49});
50
51
52document.addEventListener('input', function(event) {
53
54 let target = event.target;
55 let value = target.value;
56
57
58 let data = {
59 type: 'input',
60 value: value,
61
62 };
63
64
65 sendData(data);
66});
67
不过一般情况下,我们只需要监控长任务就好了,不需要所有的事件都监控,所以我们可以对他们进行封装,结果如下:
1import tracker from "../utils/tracker";
2import formatTime from "../utils/formatTime";
3import getLastEvent from "../utils/getLastEvent";
4import getSelector from "../utils/getSelector";
5
6export function longTask() {
7 new PerformanceObserver((list) => {
8 list.getEntries().forEach((entry) => {
9 if (entry.duration > 100) {
10 let lastEvent = getLastEvent();
11
12 requestIdleCallback(() => {
13 tracker.send({
14 kind: "experience",
15 type: "longTask",
16 eventType: lastEvent.type,
17 startTime: formatTime(entry.startTime),
18 duration: formatTime(entry.duration),
19 selector: lastEvent
20 ? getSelector(lastEvent.path || lastEvent.target)
21 : "",
22 });
23 });
24 }
25 });
26 }).observe({ entryTypes: ["longtask"] });
27}
1let lastEvent;
2
3["click", "touchstart", "mousedown", "keydown", "mouseover"].forEach(
4 (eventType) => {
5 document.addEventListener(
6 eventType,
7 (event) => {
8 lastEvent = event;
9 },
10 {
11 capture: true,
12 passive: true,
13 }
14 );
15 }
16);
17
18export default function () {
19 return lastEvent;
20}
21
1function getSelectors(path) {
2
3 return path
4 .reverse()
5 .filter((element) => {
6 return element !== document && element !== window;
7 })
8 .map((element) => {
9 console.log("element", element.nodeName);
10 let selector = "";
11 if (element.id) {
12 return `${element.nodeName.toLowerCase()}#${element.id}`;
13 } else if (element.className && typeof element.className === "string") {
14 return `${element.nodeName.toLowerCase()}.${element.className}`;
15 } else {
16 selector = element.nodeName.toLowerCase();
17 }
18 return selector;
19 })
20 .join(" ");
21}
22
23export default function (pathsOrTarget) {
24 if (Array.isArray(pathsOrTarget)) {
25 return getSelectors(pathsOrTarget);
26 } else {
27 let path = [];
28 while (pathsOrTarget) {
29 path.push(pathsOrTarget);
30 pathsOrTarget = pathsOrTarget.parentNode;
31 }
32 return getSelectors(path);
33 }
34}
35
1export default (time) => new Date(time).getTime();
拿到长任务,我就可以有的放矢的精准命中目标,进行优化了。
性能监控就是我们想办法拿到性能指标:FP,FCP,FMP,LCP 等等。
在页面加载的时候,就是要监听load事件,我们可以通过window.performance.timing拿到具体的事件,可以用指标之间的加减关系,相互换算得出指标数。当然你可以用web-vitals包直接拿到相关数据。
我们用最土的办法如下:
1function sendData(data) {
2 console.log('我才不要每次都触发呢',data);
3 setTimeout(()=> {
4
5
6
7
8
9
10
11
12
13 }, 10000)
14}
15
16
17window.addEventListener('load', function() {
18
19 const [performanceData] = performance.getEntriesByType("navigation");
20
21
22
23
24
25
26
27 let pageLoadTime = performanceData.loadEventEnd - performanceData.domComplete;
28
29
30 const requestResponseTime = performanceData.responseEnd - performanceData.requestStart;
31
32
33 let dnsLookupTime = performanceData.domainLookupEnd - performanceData.domainLookupStart;
34
35
36 let tcpConnectTime = performanceData.connectEnd - performanceData.connectStart;
37
38
39
40
41
42 var whiteScreenTime = performanceData.domInteractive - performanceData.responseStart;
43
44
45
46 let fcpTime = 0;
47 const [fcpEntry] = performance.getEntriesByName("first-contentful-paint");
48 if (fcpEntry) {
49 fcpTime = fcpEntry.startTime;
50 }
51
52
53 let lcpTime = 0;
54 const lcpEntries = performance.getEntriesByType("largest-contentful-paint");
55 if (lcpEntries.length > 0) {
56 lcpTime = lcpEntries[lcpEntries.length - 1].renderTime || lcpEntries[lcpEntries.length - 1].loadTime;
57 }
58
59
60 const paintMetrics = performance.getEntriesByType('paint');
61 paintMetrics.forEach((metric) => {
62 console.log(metric.name + ': ' + metric.startTime + 'ms');
63 });
64
65
66 let tti = 0;
67 let tbt = 0;
68 const observer = new PerformanceObserver((entryList) => {
69 for (const entry of entryList.getEntries()) {
70
71 if (entry.duration > 50) {
72 tbt += entry.duration - 50;
73 }
74 }
75
76
77 if (tti === 0 && tbt < 50) {
78 tti = performance.now();
79 }
80 });
81 observer.observe({ entryTypes: ["longtask"] });
82
83
84 let perfData = {
85 type: 'performance',
86 pageLoadTime: pageLoadTime,
87 dnsLookupTime: dnsLookupTime,
88 tcpConnectTime: tcpConnectTime,
89 whiteScreenTime: whiteScreenTime,
90 requestResponseTime: requestResponseTime,
91 tbt:tbt,
92 tti:tti
93
94 };
95
96
97
98
99 sendData(perfData);
100 });
101});
102
103
再封装一下呗
1import tracker from "../utils/tracker";
2import onload from "../utils/onload";
3import formatTime from "../utils/formatTime";
4import getLastEvent from "../utils/getLastEvent";
5import getSelector from "../utils/getSelector";
6
7export function timing() {
8 let FMP, LCP;
9
10 new PerformanceObserver((entryList, observer) => {
11 const perfEntries = entryList.getEntries();
12 FMP = perfEntries[0];
13 observer.disconnect();
14 }).observe({ entryTypes: ["element"] });
15
16 new PerformanceObserver((entryList, observer) => {
17 const perfEntries = entryList.getEntries();
18 const lastEntry = perfEntries[perfEntries.length - 1];
19 LCP = lastEntry;
20 observer.disconnect();
21 }).observe({ entryTypes: ["largest-contentful-paint"] });
22
23 new PerformanceObserver((entryList, observer) => {
24 const lastEvent = getLastEvent();
25 const firstInput = entryList.getEntries()[0];
26 if (firstInput) {
27
28 let inputDelay = firstInput.processingStart - firstInput.startTime;
29 let duration = firstInput.duration;
30 if (inputDelay > 0 || duration > 0) {
31 tracker.send({
32 kind: "experience",
33 type: "firstInputDelay",
34 inputDelay: inputDelay ? formatTime(inputDelay) : 0,
35 duration: duration ? formatTime(duration) : 0,
36 startTime: firstInput.startTime,
37 selector: lastEvent
38 ? getSelector(lastEvent.path || lastEvent.target)
39 : "",
40 });
41 }
42 }
43 observer.disconnect();
44 }).observe({ type: "first-input", buffered: true });
45
46
47 onload(function () {
48 setTimeout(() => {
49 const {
50 fetchStart,
51 connectStart,
52 connectEnd,
53 requestStart,
54 responseStart,
55 responseEnd,
56 domLoading,
57 domInteractive,
58 domContentLoadedEventStart,
59 domContentLoadedEventEnd,
60 loadEventStart,
61 } = window.performance.timing;
62
63 tracker.send({
64 kind: "experience",
65 type: "timing",
66 connectTime: connectEnd - connectStart,
67 ttfbTime: responseStart - requestStart,
68 responseTime: responseEnd - responseStart,
69 parseDOMTime: loadEventStart - domLoading,
70 domContentLoadedTime:
71 domContentLoadedEventEnd - domContentLoadedEventStart,
72 timeToInteractive: domInteractive - fetchStart,
73 loadTime: loadEventStart - fetchStart,
74 });
75
76 let FP = performance.getEntriesByName("first-paint")[0];
77 let FCP = performance.getEntriesByName("first-contentful-paint")[0];
78 console.log("FP", FP);
79 console.log("FCP", FCP);
80 console.log("FMP", FMP);
81 console.log("LCP", LCP);
82 tracker.send({
83 kind: "experience",
84 type: "paint",
85 firstPaint: FP ? formatTime(FP.startTime) : 0,
86 firstContentPaint: FCP ? formatTime(FCP.startTime) : 0,
87 firstMeaningfulPaint: FMP ? formatTime(FMP.startTime) : 0,
88 largestContentfulPaint: LCP
89 ? formatTime(LCP.renderTime || LCP.loadTime)
90 : 0,
91 });
92 }, 3000);
93 });
94}
95
其实他就是多加了三个观察者而已,本质是没有变化的。
网速监控
1import tracker from "../utils/tracker";
2
3
4export function pv() {
5 var connection = navigator.connection;
6 tracker.send({
7 kind: "business",
8 type: "pv",
9 effectiveType: connection.effectiveType,
10 rtt: connection.rtt,
11 screen: `${window.screen.width}x${window.screen.height}`,
12 });
13 let startTime = Date.now();
14
15
16 window.addEventListener(
17 "unload",
18 () => {
19 let stayTime = Date.now() - startTime;
20
21 tracker.send({
22 kind: "business",
23 type: "stayTime",
24 stayTime,
25 });
26 },
27 false
28 );
29}
30
请求监控
1import tracker from "../utils/tracker";
2
3export function injectXHR() {
4 let XMLHttpRequest = window.XMLHttpRequest;
5 let oldOpen = XMLHttpRequest.prototype.open;
6 XMLHttpRequest.prototype.open = function (method, url, async) {
7
8 if (!url.match(/logstores/) && !url.match(/sockjs/)) {
9 this.logData = { method, url, async };
10 }
11 return oldOpen.apply(this, arguments);
12 };
13 let oldSend = XMLHttpRequest.prototype.send;
14
15 XMLHttpRequest.prototype.send = function (body) {
16 if (this.logData) {
17 let startTime = Date.now();
18 let handler = (type) => (event) => {
19
20 let duration = Date.now() - startTime;
21 let status = this.status;
22 let statusText = this.statusText;
23 tracker.send({
24 kind: "stability",
25 type: "xhr",
26 eventType: type,
27 pathname: this.logData.url,
28 status: status + "-" + statusText,
29 duration,
30 response: this.response ? JSON.stringify(this.response) : "",
31 params: body || "",
32 });
33 };
34
35 this.addEventListener("load", handler("load"), false);
36 this.addEventListener("error", handler, false);
37 this.addEventListener("abort", handler, false);
38 }
39 return oldSend.apply(this, arguments);
40 };
41}
42