之前写了一篇文章 作为前端开发,如何调试线上代码?里面就前端线上或许会出现的问题和应对办法都说了一遍,但是还是不过瘾,因为很多时候,客户提出的问题,描述都不够准确。
比如客户说:你们的页面咋白屏了?
开发:具体可以给我说一下,白屏之前你做了什么吗?
客户:我就点了一下按钮
开发:你能告诉我是哪个页面的哪个按钮吗?
最后,客户极其不耐烦的地丢过来一张没有缺少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 事件。
window.onerror = function(message, source, lineno, colno, error) {
console.log('捕获到异常:', {message, source, lineno, colno, error});
}
它可以捕获异步错误。
3.window.addEventListener
它能捕获:图片、script、css加载错误。
window.addEventListener('error')
来捕获 JS运行异常
;它会比 window.onerror
先触发;
window.onerror能做的事情,window.addEventListener('error')也能做,而且他还会监听静态资源的加载错误。
所以window.addEventListener('error')更靠谱
4.react组件错误:react 通过componentDidCatch,声明一个错误边界的组件,它是高阶组件,只需将子组件传入即可错误兜底。
实际上在我们的项目里面,搜集错误直接用下面这个就能全部扫描到:
import getLastEvent from "../utils/getLastEvent";
import getSelector from "../utils/getSelector";
import tracker from "../utils/tracker";
import axios from 'axios';
export function injectJsError() {
// 监听全局未捕获的错误
window.addEventListener(
"error",
async (event) => {
let lastEvent = getLastEvent(); // 获取到最后一个交互事件
// 脚本加载错误
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 {
//此时你拿到的数据是打包后的报错信息,所以需要转化
const { data } = await axios.get(`/getErrorInfo?filepath=${event.filename}&lineno=${event.lineno}&colno=${event.colno}`);
console.log(data, 99999);
tracker.send({
kind: "stability", // 监控指标的大类,稳定性
type: "error", // 小类型,这是一个错误
errorType: "jsError", // js执行错误
message: event.message, // 报错信息
filename: data.source, // 哪个文件报错了
position: `${data.line}:${data.column}`, // 报错的行列位置
budle: data.budle,
errorName: data.name,
stack: getLines(event.error.stack),
selector: lastEvent ? getSelector(lastEvent.path) : "", // 代表最后一个操作的元素
});
}
},
true
);
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
);
}
function getLines(stack) {
return stack
.split("\n")
.slice(1)
.map((item) => item.replace(/^\s+at\s+/g, ""))
.join("^");
}
我们的包里面只用到了2个就能监控到所有的错误
window.addEventListener("error",()=>{})
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文件,从而映射出来错误的行列。
import fs from 'fs';
import http from 'http';
import path from 'path';
function upload(file) {
return new Promise((resolve) => {
let req = http.request(`http://localhost:3002/upload?name=${file}`, {
method: "POST",
headers: {
"Content-Type": "application/octet-stream",
Connection: "keep-alive",
},
});
let fileStream = fs.createReadStream(file);
fileStream.pipe(req, { end: false });
fileStream.on("end", function () {
req.end();
resolve();
});
});
}
const MyPlugin = () => {
return {
name: 'my-plugin',
async closeBundle() {
let chunks = fs.readdirSync('./dist/assets');
let map_file = chunks.filter((item) => {
return item.match(/\.js\.map$/) !== null;
});
while (map_file.length > 0) {
let file = map_file.shift();
await upload(path.join('./dist/assets', file));
}
},
};
};
export default MyPlugin;
我们写一个node接口来模拟这个过程
import path from "node:path";
import fs from 'fs';
import express from 'express';
const app = express();
import { dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { SourceMapConsumer } from 'source-map';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
//1. 设置文件存放位置
app.post('/upload', (req, res) => { // file 与前端input 的name属性一致
const file = req.query.name;
if (!file) {
return;
}
const filename = file?.split(`\\`).pop();
let dir = path?.join(__dirname, "source-map");
//判断source文件夹是否存在
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir);
}
const filePath = path?.join(dir, filename);
const ws = fs.createWriteStream(filePath);
req.pipe(ws);
});
app.listen(3002, () => {
console.log(`已经启动服务,端口号是3002`);
});
测试看看,首先第一步启动服务
node server.js
npm run build
执行完 npm run build 以后你会发现在项目下面出现一个 source-map 的文件夹,里面会把所有的 map 文件都加入进来。
6.3 接入source-map
现在有了map文件以后,我们拿着监控到错误,去调个接口去找真实文件对应的错误。
import path from "node:path";
import fs from 'fs';
import express from 'express';
const app = express();
import { dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { SourceMapConsumer } from 'source-map';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
app.get("/getErrorInfo", async (req, res) => {
res.setHeader('Access-Control-Allow-Origin', '*');
// 填入错误信息
console.log(req.query, 99);
if (!req.query) {
return;
}
const { filepath, lineno, colno } = req.query;
const filename = filepath.split('/').pop();
const file = path.join(`${__dirname}/source-map`, `${filename}.map`);
const rawSourceMap = fs.readFileSync(file, 'utf-8');
let consumer = await new SourceMapConsumer(rawSourceMap);
let result = await consumer.originalPositionFor({
line: parseInt(lineno),
column: parseInt(colno),
});
res.send({
...result,
budle: `${filename}.map`
});
});
app.listen(3002, () => {
console.log(`已经启动服务,端口号是3002`);
});
当我们监控到的错误是这样的
我们就把 index-CqtqrVqd.js:45:4998
传入接口/getErrorInfo
。然后用工具包:source-map
处理。
最终处理后的数据长这样
我们已经找到了具体的错误在哪里。
二.行为监控
就是监控我们在页面上常见的事件,点击、滚动、输入、等。
下面这是一个行为监控模板。
// 定义一个发送数据的函数
// 这里 后续会改进掉 使用img.src的方式进行数据发送 具体原因也会在后面详细说明
function sendData(data) {
// 在这里,你可以使用AJAX、Fetch或其他方法将数据发送到服务器
// 例如:
// fetch('/api/track', {
// method: 'POST',
// body: JSON.stringify(data),
// headers: {
// 'Content-Type': 'application/json'
// }
// });
}
// 监听点击事件
document.addEventListener('click', function(event) {
// 获取点击的元素
let target = event.target;
console.log('我点击了~~',event);
// 获取元素的相关信息,例如ID、类名等
let id = target.id;
let className = target.className;
// 构造要发送的数据
let data = {
type: 'click',
id: id,
className: className,
// 其它你想要收集的信息
};
// 发送数据
sendData(data);
});
// 监听滚动事件
document.addEventListener('scroll', function(event) {
// 获取滚动位置
let scrollTop = document.documentElement.scrollTop || document.body.scrollTop;
// 构造要发送的数据
let data = {
type: 'scroll',
scrollTop: scrollTop,
// 其它你想要收集的信息
};
// 发送数据
sendData(data);
});
// 监听输入事件
document.addEventListener('input', function(event) {
// 获取输入的元素和值
let target = event.target;
let value = target.value;
// 构造要发送的数据
let data = {
type: 'input',
value: value,
// 其它你想要收集的信息
};
// 发送数据
sendData(data);
});
不过一般情况下,我们只需要监控长任务就好了,不需要所有的事件都监控,所以我们可以对他们进行封装,结果如下:
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"] });
}
//getLastEvent.js
let lastEvent;
["click", "touchstart", "mousedown", "keydown", "mouseover"].forEach(
(eventType) => {
document.addEventListener(
eventType,
(event) => {
lastEvent = event;
},
{
capture: true, // 是在捕获阶段还是冒泡阶段执行
passive: true, // 默认不阻止默认事件
}
);
}
);
export default function () {
return lastEvent;
}
//getSelector.js
function getSelectors(path) {
// 反转 + 过滤 + 映射 + 拼接
return path
.reverse()
.filter((element) => {
return element !== document && element !== window;
})
.map((element) => {
console.log("element", element.nodeName);
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(" ");
}
export default function (pathsOrTarget) {
if (Array.isArray(pathsOrTarget)) {
return getSelectors(pathsOrTarget);
} else {
let path = [];
while (pathsOrTarget) {
path.push(pathsOrTarget);
pathsOrTarget = pathsOrTarget.parentNode;
}
return getSelectors(path);
}
}
//formatTime.js
export default (time) => new Date(time).getTime();
拿到长任务,我就可以有的放矢的精准命中目标,进行优化了。
三.性能监控
性能监控就是我们想办法拿到性能指标:FP,FCP,FMP,LCP 等等。
在页面加载的时候,就是要监听load事件,我们可以通过window.performance.timing拿到具体的事件,可以用指标之间的加减关系,相互换算得出指标数。当然你可以用web-vitals包直接拿到相关数据,你也可以用PerformanceObserver拿到性能实时数据。
你也可以看看这篇文章:zhuanlan.zhihu.com/p/62365390
我们用最土的办法如下:
// 定义一个发送数据的函数
function sendData(data) {
console.log('我才不要每次都触发呢',data);
setTimeout(()=> {
// 在这里,你可以使用AJAX、Fetch或其他方法将数据发送到服务器
// 例如:
// fetch('/api/track', {
// method: 'POST',
// body: JSON.stringify(data),
// headers: {
// 'Content-Type': 'application/json'
// }
// });
}, 10000)
}
// 监听页面加载事件
window.addEventListener('load', function() {
// 获取性能数据
const [performanceData] = performance.getEntriesByType("navigation");
// 即将废弃 推荐上面的PerformanceNavigationTiming写法
// let performanceData = window.performance.timing;
// 计算页面加载时间 (window.performance.timing使用这个)
// let pageLoadTime = performanceData.domContentLoadedEventEnd - performanceData.navigationStart;
// 计算页面加载时间 (performance.getEntriesByType("navigation")的时候使用这个)
let pageLoadTime = performanceData.loadEventEnd - performanceData.domComplete;
// 计算请求响应时间
const requestResponseTime = performanceData.responseEnd - performanceData.requestStart;
// 计算DNS查询时间
let dnsLookupTime = performanceData.domainLookupEnd - performanceData.domainLookupStart;
// 计算TCP连接时间
let tcpConnectTime = performanceData.connectEnd - performanceData.connectStart;
// 计算白屏时间 (老的)
// var whiteScreenTime = performanceData.responseStart - performanceData.navigationStart;
// 计算白屏时间 (当前的)
var whiteScreenTime = performanceData.domInteractive - performanceData.responseStart;
// 获取 FCP 时间
let fcpTime = 0;
const [fcpEntry] = performance.getEntriesByName("first-contentful-paint");
if (fcpEntry) {
fcpTime = fcpEntry.startTime;
}
// 获取 LCP 时间
let lcpTime = 0;
const lcpEntries = performance.getEntriesByType("largest-contentful-paint");
if (lcpEntries.length > 0) {
lcpTime = lcpEntries[lcpEntries.length - 1].renderTime || lcpEntries[lcpEntries.length - 1].loadTime;
}
// Paint Timing
const paintMetrics = performance.getEntriesByType('paint');
paintMetrics.forEach((metric) => {
console.log(metric.name + ': ' + metric.startTime + 'ms');
});
// 监听长任务
let tti = 0;
let tbt = 0;
const observer = new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
// 计算 TBT
if (entry.duration > 50) {
tbt += entry.duration - 50;
}
}
// 计算 TTI
if (tti === 0 && tbt < 50) {
tti = performance.now();
}
});
observer.observe({ entryTypes: ["longtask"] });
// 构造要发送的性能数据
let perfData = {
type: 'performance',
pageLoadTime: pageLoadTime,
dnsLookupTime: dnsLookupTime,
tcpConnectTime: tcpConnectTime,
whiteScreenTime: whiteScreenTime,
requestResponseTime: requestResponseTime,
tbt:tbt,
tti:tti
// 其它你想要收集的信息
};
// 发送性能数据
sendData(perfData);
});
});
再封装一下呗
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);
});
}
其实他就是多加了三个观察者而已,本质是没有变化的。
四.网络监控
网速监控
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
);
}
请求监控
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);
};
}
import tracker from "../utils/tracker";
export function injectFetch() {
let oldFetch = window.fetch;
function hijackFetch(url, options) {
let startTime = Date.now();
return new Promise((resolve, reject) => {
oldFetch.apply(this, [url, options]).then(async response => {
// response 为流数据
const oldResponseJson = response.__proto__.json;
response.__proto__.json = function (...responseRest) {
return new Promise((responseResolve, responseReject) => {
oldResponseJson.apply(this, responseRest).then(result => {
responseResolve(result);
}, (responseRejection) => {
// 接口
sendLogData({
url,
startTime,
statusText: response.statusText,
status: response.status,
eventType: 'error',
response: responseRejection.stack,
options
});
responseReject(responseRejection);
});
});
};
resolve(response);
}, rejection => {
// 连接未连接上
sendLogData({
url,
startTime,
eventType: 'load',
response: rejection.stack,
options
});
reject(rejection);
});
});
}
window.fetch = hijackFetch;
}
const sendLogData = ({
startTime,
statusText = '',
status = '',
eventType,
url,
options,
response,
}) => {
// 持续时间
let duration = Date.now() - startTime;
const { method = 'get', body } = options || {};
tracker.send({
kind: "stability",
type: "fetch",
eventType: eventType,
pathname: url,
status: status + "-" + statusText, // 状态码
duration,
response: response ? JSON.stringify(response) : "", // 响应体
method,
params: body || "", // 入参
});
};