zoukankan      html  css  js  c++  java
  • 前端异常埋点系统初探

    什么是埋点

    还不了解的同学可以阅读以下文章:

    前端-埋点-理念-通识-浅谈

    大数据时代数据的重要性不言而喻,而其中最重要的就是用户信息的采集。埋点,无论是项目后期的复盘,还是明确业务价值,还是产品价值的挖掘,都具备很重要的意义。

    前端异常捕获

    在ES3之前js代码执行的过程中,一旦出现错误,整个js代码都会停止执行,这样就显的代码非常的不健壮。从ES3开始,js也提供了类似的异常处理机制,从而让js代码变的更健壮,程序执行的过程中出现了异常,也可以让程序具有了一部分的异常恢复能力。 js异常的特点是,出现不会导致JS引擎崩溃,最多只会终止当前执行的任务。

    回归正题,我们该如何在程序异常发生时捕捉并进行对应的处理呢?在JavaScript中,我们通常有以下两种异常捕获机制。

    基本的try…catch语句

      function errFunc() {
          // eslint-disable-next-line no-undef
          error;
      }
    
      function catchError() {
          try {
              this.errFunc();
          } catch (error) {
              console.log(error);
          }
      }
      catchError()
    

    能捕捉到的异常,必须是线程执行已经进入 try catch 但 try catch 未执行完的时候抛出来的,以下都是无法被捕获到的情形。

    异步任务抛出的异常(执行时try catch已经从执行完了)
    promise(异常内部捕获到了,并未往上抛异常,使用catch处理)
    语法错误(代码运行前,在编译时就检查出来了的错误)
    优点:能够较好地进行异常捕获,不至于使得页面由于一处错误挂掉
    缺点:显得过于臃肿,大多代码使用 try ... catch 包裹,影响代码可读性。
     

    面试官:请用一句话描述 try catch 能捕获到哪些 JS 异常

    全局异常监听window.onerror

    window.onerror 最大的好处就是同步任务、异步任务都可捕获,可以得到具体的异常信息、异常文件的URL、异常的行号与列号及异常的堆栈信息,再捕获异常后,统一上报至我们的日志服务器,而且可以全局监听,代码看起来也简洁很多。

    缺点:

    此方法有一定的浏览器兼容性

    跨域脚本无法准确捕获异常,跨域之后window.onerror捕获不到正确的异常信息,而是统一返回一个Script error,可通过在<script>使用crossorigin属性来规避这个问题

    window.addEventListener('error', function() {
      console.log(error);
      // ...
      // 异常上报
    });
    throw new Error('这是一个错误');
    

    Promise内部异常

    前文已经提到, onerror 以及 try-catch 也无法捕获Promise实例抛出的异常,只能最后在 catch 函数上处理,但是代码写多了就容易糊涂,忘记写 catch。

    如果你的应用用到很多的 Promise 实例的话,特别是在一些基于 promise 的异步库比如 axios 等一定要小心,因为你不知道什么时候这些异步请求会抛出异常而你并没有处理它,所以最好添加一个 Promise 全局异常捕获事件 unhandledrejection 。

    window.addEventListener("unhandledrejection", e => {
     console.log('unhandledrejection',e)
    });
    

    vue工程异常

    window.onerror 并不能捕获.vue文件发生的获取,Vue 2.2.0以上的版本中增加了一个 errorHandle ,使用 Vue.config.errorHandler 这样的Vue全局配置,可以在Vue指定组件的渲染和观察期间未捕获错误的处理函数。这个处理函数被调用时,可获取错误信息和Vue 实例。

    //main.js
    import { createApp } from "vue";
    import App from "./App.vue";
    
    let app = createApp(App);
    app.config.errorHandler = function(e) {
      console.log(e);
      //错误上报...
    };
    app.mount("#app");
    

    Vue项目JS脚本错误捕获

    综上,可以将几种方式有效结合起来,笔者这里是在vue-cli框架中做的处理,其余类似:

    import { createApp } from "vue";
    import App from "./App.vue";
    
    let app = createApp(App);
    
    window.addEventListener(
      "error",
      (e) => {
        console.log(e);
        //TODO:上报逻辑
        return true;
      },
      true
    );
    // 处理未捕获的异常,主要是promise内部异常,统一抛给 onerror
    window.addEventListener("unhandledrejection", (e) => {
      throw e.reason;
    });
    // 框架异常统一捕获
    app.config.errorHandler = function(err, vm, info) {
      //TODO:上报逻辑
      console.log(err, vm, info);
    };
    app.mount("#app");
    

    sourcemap

    生产环境下所有的报错的代码行数都在第一行了,为什么呢?

    通常在该环境下的代码是经过webpack打包后压缩混淆的代码,否则源代码泄漏易造成安全问题,在生产环境下,我们的代码被压缩成了一行。而保留了sourcemap文件就可以利用webpack打包后的生成的一份.map的脚本文件就可以让浏览器对错误位置进行追踪了,但这种做法并不可取,更为推荐的是在服务端使用Node.js对接收到的日志信息时使用source-map解析,以避免源代码的泄露造成风险

    vue.config.js 配置里通过属性 productionSourceMap: true 可以控制webpack是否生成map文件

    webpack自定义插件实现sourcemap自动上传

    为了我们每一次构建服务端能拿到最新的map文件,我们编写一个插件让webpack在打包完成后触发一个钩子实现文件上传,在 vue.config.js 中进行配置

    调整 webpack 配置

    //vue.config.js
    let SourceMapUploader = require("./source-map-upload");
    module.exports = {
        configureWebpack: {
            resolve: {
                alias: {
                    "@": resolve("src"),
                },
            },
            plugins: [
                 new SourceMapUploader({url: "http://localhost:3000/upload"})
            ],
        }
        //   chainWebpack: (config) => {},
    }
    
    //source-map-upload.js
    const fs = require("fs");
    const http = require("http");
    const path = require("path");
    class SourceMapUploader {
      constructor(options) {
        this.options = options;
      }
      /**
       * 用到了hooks,done表示在打包完成之后
       * status.compilation.outputOptions就是打包的dist文件
       */
      apply(compiler) {
        if (process.env.NODE_ENV == "production") {
          compiler.hooks.done.tap("sourcemap-uploader", async (status) => {
            // console.log(status.compilation.outputOptions.path);
            // 读取目录下的map后缀的文件
            let dir = path.join(status.compilation.outputOptions.path, "/js/");
            let chunks = fs.readdirSync(dir);
            let map_file = chunks.filter((item) => {
              return item.match(/.js.map$/) !== null;
            });
            // 上传sourcemap
            while (map_file.length > 0) {
              let file = map_file.shift();
              await this.upload(this.options.url, path.join(dir, file));
            }
          });
        }
      }
      
      //调用upload接口,上传文件
      upload(url, file) {
        return new Promise((resolve) => {
          let req = http.request(`${url}?name=${path.basename(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();
          });
        });
      }
    }
    module.exports = SourceMapUploader;
     

    错误上报

    两种方式:

    img标签 这种方式无需加载任何通讯库,而且页面是无需刷新的,相当于get请求,没有跨域问题。缺点是有url长度限制,但一般来讲足够使用了。

    ajax 与正常的接口请求无异,可以用post

    这里采用第一种,通过动态创建一个img,浏览器就会向服务器发送get请求。将需要上报的错误数据放在url中,利用这种方式就可以将错误上报到服务器了。

    确定上报的内容,应该包含异常位置(行号,列号),异常信息,在错误堆栈中包含了绝大多数调试有关的信息,我们通讯的时候只能以字符串方式传输,我们需要将对象进行序列化处理。

    将异常数据从属性中解构出来,存入一个JSON对象

    将JSON对象转换为字符串

    将字符串转换为Base64

    https://www.98891.com/article-86-1.html

    后端接收到信息后进行对应的反向操作,就可以在日志中记录。

    function uploadErr({ lineno, colno, error: { stack }, message, filename }) {
      let str = window.btoa(
        JSON.stringify({
          lineno,
          colno,
          error: { stack },
          message,
          filename,
        })
      );
      let front_ip = "http://localhost:3000/error";
      new Image().src = `${front_ip}?info=${str}`;
    }
    
     

    后端服务

    用koa搭一个简单后台服务,代码比较简单,按功能拆开来讲

    上传文件接口

    文件流写入:

    router.post("/upload", async (ctx) => {
      const stream = ctx.req;
      const filename = ctx.query.name;
      let dir = path.join(__dirname, "source-map");
      //判断source文件夹是否存在
      if (!fs.existsSync(dir)) {
        fs.mkdirSync(dir);
      }
      let target = path.join(dir, filename);
      const ws = fs.createWriteStream(target);
      stream.pipe(ws);
    });
    

    错误日志

    使用 log4js 记录我们的错误日志,这个也是非常流行的日志插件了,直接贴代码。

    log4js-node
    
    const path = require('path')
    const log4js = require('log4js');
    
    log4js.configure({
      appenders: {
        info: {
          type: "dateFile",
          filename: path.join(__dirname, 'logs', 'info', 'info'),
          pattern: "yyyy-MM-dd.log", 
          encoding: 'utf-8', 
    
          alwaysIncludePattern: true, 
        },
        error: {// 错误日志
          type: 'dateFile',
          filename: path.join(__dirname, 'logs', 'error', 'error'),
          pattern: 'yyyy-MM-dd.log',
          encoding: 'utf-8', 
          alwaysIncludePattern: true
        }
      },
      categories: {
        default: { appenders: ['info'], level: 'info' },
        info: { appenders: ['info'], level: 'info' },
        error: { appenders: ['error'], level: 'error' }
      }
    });
    
    
    /**
     * 错误日志记录方式
     * @param {*} content 日志输出内容
     */
    function logError(content) {
      const log = log4js.getLogger("error");
      log.error(content)
    }
    /**
     * 日志记录方式
     * @param {*} content 日志输出内容
     */
    function logInfo(content) {
      const log = log4js.getLogger("info");
      log.info(content)
    }
    
    module.exports = {
      logError,
      logInfo
    }
    

    错误解析

    这个接口就是对上报的错误信息进行解析,得到错误堆栈对象

    上面我们已经拿到colno为2319,lineno为1,接下来需要安装一个插件帮助我们找到对应压缩前的代码位置。

    npm install source-map -S
    

    先读取对应的map文件(按filename对应),然后只需传入压缩后的报错行号列号即可,就会返回压缩前的错误信息。打个比喻:简单地说相当于一本书的目录,我们根据目录可以快速找到某一部分内容的页数

    router.get("/error", async (ctx) => {
      const errInfo = ctx.query.info;
      // 转码 反序列化
      let obj = JSON.parse(Buffer.from(errInfo, "base64").toString("utf-8"));
    
    
      let fileUrl = obj.filename.split("/").pop() + ".map"; // map文件路径
      // 解析sourceMap
      // 1.sourcemap文件的文件流,我们已经上传 
      // 2.文件编码格式
      let consumer = await new sourceMap.SourceMapConsumer(
        fs.readFileSync(path.join(__dirname, "source-map/" + fileUrl), "utf8")
      );
      // 解析原始报错数据
      let result = consumer.originalPositionFor({
        line: obj.lineno, // 压缩后的行号
        column: obj.colno, // 压缩后的列号
      });
      // 写入到日志中
      obj.lineno = result.line;
      obj.colno = result.column;
      log4js.logError(JSON.stringify(obj));
      ctx.body = "";
    });
    



    数据存储 日志可视化

    ELK前端日志分析

    www.cnblogs.com/xiao9873341…
    

    看了一下许多平台对错误日志的分析和可视化都使用了ELK,ELK在服务器运维界应该是运用的非常成熟了,很多成熟的大型项目都使用ELK来作为前端日志监控、分析的工具。我对运维这一块兴趣不大,有兴趣的可以自行搭建,整出来界面还是挺炫酷的。

    而我又不想每一次都跑去服务器查看日志,于是想到了可以建个表来把错误信息给存起来。用起老三样koa+mongodb+vue,我们这项目就算是齐活了。(mongodb,yyds:fire:,省去了建表许多功夫)

    npm install mongodb --save
    

    新建一个文件db.js封装一下mongo连接,方便复用:

    // db.js
    const MongoClient = require("mongodb").MongoClient;
    const url = "mongodb://localhost:27017/";
    const dbName = "err_db";
    const collectionName = "errList";
    class Db {
      // 单例模式,解决多次实例化时候每次创建连接对象不共享的问题,实现共享连接数据库状态
      static getInstance() {
        if (!Db.instance) {
          Db.instance = new Db();
        }
        return Db.instance;
      }
      constructor() {
        // 属性 存放db对象
        this.dbClient = "";
        // 实例化的时候就连接数据库,增加连接数据库速度
        this.connect();
      }
      // 连接数据库
      connect() {
        return new Promise((resolve, reject) => {
          // 解决数据库多次连接的问题,要不然每次操作数据都会进行一次连接数据库的操作,比较慢
          if (!this.dbClient) {
            // 第一次的时候连接数据库
            MongoClient.connect(
              url,
              { useNewUrlParser: true, useUnifiedTopology: true },
              (err, client) => {
                if (err) {
                  reject(err);
                } else {
                  // 将连接数据库的状态赋值给属性,保持长连接状态
                  this.dbClient = client.db(dbName);
                  resolve(this.dbClient);
                }
              }
            );
          } else {
            // 第二次之后直接返回dbClient
            resolve(this.dbClient);
          }
        });
      }
      
      // 增加一条数据
      insert(json) {
        return new Promise((resolve, reject) => {
          this.connect().then((db) => {
            db.collection(collectionName).insertOne(json, (err, result) => {
              if (err) {
                reject(err);
              } else {
                resolve(result);
              }
            });
          });
        });
      }
      
      //查询 --
      find(query = {}) {
        return new Promise((resolve, reject) => {
          this.connect().then((db) => {
            let res = db.collection(collectionName).find(query);
            res.toArray((e, docs) => {
              if (e) {
                reject(e);
                return;
              }
              resolve(docs);
            });
          });
        });
      }
    }
    
    module.exports = Db.getInstance();
    
    

    然后就可以在项目中愉快使用

     
      let db = require("./db");
      ...
      log4js.logError(JSON.stringify(obj));
      //插入数据
      await db.insert(obj);
      ctx.body = "";
    

    数据插入成功:rocket:

    增加一个查询接口:

    router.get("/errlist", async (ctx) => {
      let res = await db.find({});
      ctx.body = {
        data: res,
      };
    });
    

    为了丰富错误信息,我们还可以在上报的时候增加报错时间,用户浏览器信息,自定义错误类型统计,引入图表可视化展示,更加直观地追踪



    待完善的点

    应该做错误类型区分,如业务错误与接口错误等

    过多的日志在业务服务器堆积,造成业务服务器的存储空间不够的情况,在迁到mongodb后在考虑不要日志:arrow_up:

    上报频率做限制。如类似mouseover事件中的报错应该考虑防抖般的处理

  • 相关阅读:
    使用ROS开源代码和激光雷达进行小车的定位导航——里程计信息的计算
    ROS常用快捷键、基本命令
    机器人关节空间轨迹规划--S型速度规划
    机器人单关节力矩控制(前馈+反馈)
    基于 Mathematica 的机器人仿真环境(机械臂篇)
    ROS2入门教程-windows安装ROS2
    复现一篇深度强化学习论文之前请先看了这篇文章!
    Q学习使用ROS和Gazebo的turtlebot的迷宫导航
    turtlebot2+ROS kinetic+笔记本安装配置(测试成功版)
    视觉机器人+人体姿态识别项目总结
  • 原文地址:https://www.cnblogs.com/xiaonian8/p/14924090.html
Copyright © 2011-2022 走看看