首页/文章/前端监控

前端数据监控平台

2024-08-19
23868 分钟
...

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

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

客户:我就点了一下按钮

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

最后,客户极其不耐烦的地丢过来一张没有缺少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 事件。

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);
  };
}

如果您觉得这篇文章有帮助,请点个赞吧~

分享文章

相关文章

更多文章 →
前端监控2025-05-29
前端错误收集方式
在讲错误收集方式之前,先理清一下前端编码时有那些常见错误 &nbsp; &nbsp; &nbsp; &nbsp; 前端错误收集是保障用户体验、排查线上问题的关键手段。本文将从多个角度详解前端错误收集的各种方式,包括不同框架(如 React、Vue)、JavaScript 执行环境中的同步/异步/Promise 错误、资源加载错误(如图片、script 加载失败)等。以下是全面的错误捕获机制总结: &nbsp; &nbsp; 🌐 一、通...
学习
前端监控2024-10-12
前端小玩具~错误捕获定位工具
正文 我们在平时的工作中,开发功能的同时不可能把场景考虑的面面俱到,而生产环境往往情况是非常复杂的,用户录入进去的数据总是千奇百怪,那如果遇到问题的话,我们又要如何进行排查呢?总不可能让用户录个屏吧哈哈 所以我们就出现了 前端埋点 的操作,不过埋点的方向以及文章都挺多的,也都挺复杂的,这篇文章我们就讲一个比较有趣的 错误捕获思路 。 我们平时在使用框架开发 遇到bug 时,比如 Vue ,如果是在本地环境,我们在控制台可以很容易的找到出...
学习
前端监控2024-09-19
🍻 前端服务监控原理与手写开源监控框架SDK前端服务监控概述
前端监控的目的很明确,无非就是让我们的产品更完善,更符合我们和用户的需求。运营与产品团队需要关注用户在产品内的行为记录,通过用户的行为记录来优化产品,研发与测试团队则需要关注产品的性能以及异常,确保产品的性能体验以及安全迭代。 而一个完整的前端监控平台至少需要包括三个部分: 数据采集与上报、数据整理和存储、数据展示 。算上需要监控的项目的话,也就是说,至少需要4个项目才能完整的记录前端监控的内容。 下图是一个完整的前端监控平台需要处理和...
学习
前端监控2024-02-20
前端实现埋点&监控
实现埋点功能的意义主要体现在以下几个方面: 0. 数据采集:埋点是数据采集领域(尤其是用户行为数据采集领域)的术语,它针对特定用户行为或事件进行捕获、处理和发送的相关技术及其实施过程。通过埋点,可以收集到用户在应用中的所有行为数据,例如页面浏览、按钮点击、表单提交等。 1. 数据分析:采集的数据可以帮助业务人员分析网站或者App的使用情况、用户行为习惯等,是后续建立用户画像、用户行为路径等数据产品的基础。通过数据分析,企业可以更好地了解...
学习
前端监控2023-12-19
前端埋点重要环节:前端如何录制用户的操作过程
前言 大家好,我是林三心,用最通俗易懂的话讲最难的知识点是我的座右铭,基础是进阶的前提是我的初心。 背景 公司有很多的项目,但是并不是每一个项目都很重要,其实重要的项目就那么几个,上面也是很重视这几个项目,尤其是对一些生产问题的关注度很高。 这几天上面交代下来了,需要对这些项目做一些用户行为的记录,主要是为了更好地还原用户在某一个时间点的操作过程 注意点 想要完成这个需求,仔细想了一下,需要注意几个点: 跨框架使用: 这些项目有vue、...
学习
前端监控2023-11-23
Web性能监测的利器Performance Observer
前言 前段时间在研究前端异常监控平台,在思考性能监控时,想到了浏览器自带的观察者以及页面生命周期API 。于是在翻查资料时发现了, API。正好趁着这个机会给大家好好讲讲Performance Observer API Performance Observer(性能观察者) 定义 是一种JavaScript API,用于监测页面性能指标,如资源加载时间、页面渲染时间等。它可以触发回调函数,以便你收集和分析页面性能数据。 优点 1. 性能...
学习

评论

请登录后发表评论

去登录
加载评论中...

目录