百度360必应搜狗淘宝本站头条
当前位置:网站首页 > 技术文章 > 正文

前端异常埋点系统初探(前端埋点方案)

wxin55 2024-10-25 18:05 12 浏览 0 评论

前言

开发者有时会面临上线的生产环境包出现了异常:bug: ,在长期生产bug并修复bug的循环中总结出一下几个痛点:

无法快速定位到发生错误的代码位置,因为脚手架构建时会用webapck自动帮我们压缩代码,而上线版本又通常不会保留 source map (开源贡献者除外)
无法第一时间通知开发人员异常发生。

不知道用户OS与浏览器版本、请求参数(如页面ID);而对于页面逻辑是否错误问题,通常除了用户OS与浏览器版本外,需要的是报错的堆栈信息及具体报错位置。

错误埋点追踪系统的出现就是为了应对上述问题的解决方案,笔者正好最近接触了不少前端埋点与错误处理的博客内容,按例阶段性产出博客总结一下。

什么是埋点

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

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

大数据时代数据的重要性不言而喻,而其中最重要的就是用户信息的采集。

埋点,无论是项目后期的复盘,还是明确业务价值,还是产品价值的挖掘,都具备很重要的意义。

前端异常捕获

在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

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


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事件中的报错应该考虑防抖般的处理

后记

至此,我们总结了几种异常捕获的做法,并完成了对前端程序异常的上报功能,这对开发和测试人员都有较大的意义,用一句或说便是,要对产品保持敬畏之心,时刻关注存在的缺陷问题。

相关推荐

ES6中 Promise的使用场景?(es6promise用法例子)

一、介绍Promise,译为承诺,是异步编程的一种解决方案,比传统的解决方案(回调函数)更加合理和更加强大在以往我们如果处理多层异步操作,我们往往会像下面那样编写我们的代码doSomething(f...

JavaScript 对 Promise 并发的处理方法

Promise对象代表一个未来的值,它有三种状态:pending待定,这是Promise的初始状态,它可能成功,也可能失败,前途未卜fulfilled已完成,这是一种成功的状态,此时可以获取...

Promise的九大方法(promise的实例方法)

1、promise.resolv静态方法Promise.resolve(value)可以认为是newPromise方法的语法糖,比如Promise.resolve(42)可以认为是以下代码的语...

360前端一面~面试题解析(360前端开发面试题)

1.组件库按需加载怎么做的,具体打包配了什么-按需加载实现:借助打包工具(如Webpack的require.context或ES模块动态导入),在使用组件时才引入对应的代码。例如在V...

前端面试-Promise 的 finally 怎么实现的?如何在工作中使用?

Promise的finally方法是一个非常有用的工具,它无论Promise是成功(fulfilled)还是失败(rejected)都会执行,且不改变Promise的最终结果。它的实现原...

最简单手写Promise,30行代码理解Promise核心原理和发布订阅模式

看了全网手写Promise的,大部分对于新手还是比较难理解的,其中几个比较难的点:状态还未改变时通过发布订阅模式去收集事件实例化的时候通过调用构造函数里传出来的方法去修改类里面的状态,这个叫Re...

前端分享-Promise可以中途取消啦(promise可以取消吗)

传统Promise就像一台需要手动组装的设备,每次使用都要重新接线。而Promise.withResolvers的出现,相当于给开发者发了一个智能遥控器,可以随时随地控制异步操作。它解决了三大...

手写 Promise(手写输入法 中文)

前言都2020年了,Promise大家肯定都在用了,但是估计很多人对其原理还是一知半解,今天就让我们一起实现一个符合PromiseA+规范的Promise。附PromiseA+规范地址...

什么是 Promise.allSettled()!新手老手都要会?

Promise.allSettled()方法返回一个在所有给定的promise都已经fulfilled或rejected后的promise,并带有一个对象数组,每个对象表示对应的pr...

前端面试-关于Promise解析与高频面试题示范

Promise是啥,直接上图:Promise就是处理异步函数的API,它可以包裹一个异步函数,在异步函数完成时抛出完成状态,让代码结束远古时无限回掉的窘境。配合async/await语法糖,可...

宇宙厂:为什么前端离不开 Promise.withResolvers() ?

大家好,很高兴又见面了,我是"高级前端进阶",由我带着大家一起关注前端前沿、深入前端底层技术,大家一起进步,也欢迎大家关注、点赞、收藏、转发。1.为什么需要Promise.with...

Promise 新增了一个超实用的 API!

在JavaScript的世界里,Promise一直是处理异步操作的神器。而现在,随着ES2025的发布,Promise又迎来了一个超实用的新成员——Promise.try()!这个新方法简...

一次搞懂 Promise 异步处理(promise 异步顺序执行)

PromisePromise就像这个词的表面意识一样,表示一种承诺、许诺,会在后面给出一个结果,成功或者失败。现在已经成为了主流的异步编程的操作方式,写进了标准里面。状态Promise有且仅有...

Promise 核心机制详解(promise机制的实现原理)

一、Promise的核心状态机Promise本质上是一个状态机,其行为由内部状态严格管控。每个Promise实例在创建时处于Pending(等待)状态,此时异步操作尚未完成。当异步操作成功...

javascript——Promise(js实现promise)

1.PromiseES6开始支持,Promise对象用于一个异步操作的最终完成(包括成功和失败)及结果值的表示。简单说就是处理异步请求的。之所以叫Promise,就是我承诺,如果成功则怎么处理,失败怎...

取消回复欢迎 发表评论: