比如客户说:你们的页面咋白屏了?

开发:具体可以给我说一下,白屏之前你做了什么吗?

客户:我就点了一下按钮

开发:你能告诉我是哪个页面的哪个按钮吗?

最后,客户极其不耐烦的地丢过来一张没有缺少url的白屏给你看,试图证明他说的是真的,真白屏了。

最后不再理睬你!

开发人员试图找到解决方案,想快速解决线上bug,但是客户却以为你在质疑他。

真的有点秀才遇上兵有理说不清,其实真的没有谁对谁错,只是双方立场不同,作为客户,你的系统bug了,我哪里知道我刚才干了啥成这样了?大家都不是专门的测试人员,肯定不知道如何准确的描述和回溯系统问题。

所以诸多场景证明,我们必须要搭建一个监控平台来帮我们记录错误发生的具体情况。而不是跟在客户的屁股后面,把时间浪费在相互沟通上。

前端监控系统主要包含三个方面:错误监控,性能监控。

错误监控主要包括三个步骤:搜集错误,进行上报,然后对症分析。

任何时候,我要想快速解决问题,一定要问自己几个问题,问题明确之后,相应的解决方案就会自动出现,我最喜欢的就是这 5W1H 思考法。

  1. What,我们遇到了什么问题? 前端页面报错难以追溯,客户无法描述清楚,到底是什么问题。
  2. When,什么时候出现的? 在提供错误信息的时候,最好带上时间戳。
  3. Who,影响了多少用户? 这个错误后面最好带上IP 和 设备信息。
  4. Where,在哪里出现了报错? 最好给他截屏,还要带上url。
  5. Why,为什么报错了?最好能将开发者工具console里面的报错详情发给我,包括错误堆栈、⾏列、SourceMap。
  6. How,我该怎么解决这个问题。

基于上述问题和解决方案,我们搭建一个前端监控平台,项目模型图如下所示:

1.组成部分

整个应用就包含三个部分:

    1. 给咱们的项目接入监控。
    1. 后端进行数据分析。
    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
个人笔记记录 2021 ~ 2025