前端数据监控平台
比如客户说:你们的页面咋白屏了?
开发:具体可以给我说一下,白屏之前你做了什么吗?
客户:我就点了一下按钮
开发:你能告诉我是哪个页面的哪个按钮吗?
最后,客户极其不耐烦的地丢过来一张没有缺少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",
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",
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",
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);
app.post('/upload', (req, res) => {
const file = req.query.name;
if (!file) {
return;
}
const filename = file?.split(`\\`).pop();
let dir = path?.join(__dirname, "source-map");
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处理。
最终处理后的数据长这样

我们已经找到了具体的错误在哪里。
就是监控我们在页面上常见的事件,点击、滚动、输入、等。
下面这是一个行为监控模板。
function sendData(data) {
}
document.addEventListener('click', function(event) {
let target = event.target;
console.log('我点击了~~',event);
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"] });
}

let lastEvent;
["click", "touchstart", "mousedown", "keydown", "mouseover"].forEach(
(eventType) => {
document.addEventListener(
eventType,
(event) => {
lastEvent = event;
},
{
capture: true,
passive: true,
}
);
}
);
export default function () {
return lastEvent;
}
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);
}
}
export default (time) => new Date(time).getTime();
拿到长任务,我就可以有的放矢的精准命中目标,进行优化了。
性能监控就是我们想办法拿到性能指标:FP,FCP,FMP,LCP 等等。
在页面加载的时候,就是要监听load事件,我们可以通过window.performance.timing拿到具体的事件,可以用指标之间的加减关系,相互换算得出指标数。当然你可以用web-vitals包直接拿到相关数据。

我们用最土的办法如下:
function sendData(data) {
console.log('我才不要每次都触发呢',data);
setTimeout(()=> {
}, 10000)
}
window.addEventListener('load', function() {
const [performanceData] = performance.getEntriesByType("navigation");
let pageLoadTime = performanceData.loadEventEnd - performanceData.domComplete;
const requestResponseTime = performanceData.responseEnd - performanceData.requestStart;
let dnsLookupTime = performanceData.domainLookupEnd - performanceData.domainLookupStart;
let tcpConnectTime = performanceData.connectEnd - performanceData.connectStart;
var whiteScreenTime = performanceData.domInteractive - performanceData.responseStart;
let fcpTime = 0;
const [fcpEntry] = performance.getEntriesByName("first-contentful-paint");
if (fcpEntry) {
fcpTime = fcpEntry.startTime;
}
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;
}
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()) {
if (entry.duration > 50) {
tbt += entry.duration - 50;
}
}
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,
ttfbTime: responseStart - requestStart,
responseTime: responseEnd - responseStart,
parseDOMTime: loadEventStart - domLoading,
domContentLoadedTime:
domContentLoadedEventEnd - domContentLoadedEventStart,
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);
};
}
如果您觉得这篇文章有帮助,请点个赞吧~
评论
请登录后发表评论
去登录