前端异常埋点

前言

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

  1. 无法快速定位到发生错误的代码位置,因为脚手架构建时会用webapck自动帮我们压缩代码,而上线版本又通常不会保留 source map(开源贡献者除外)
  2. 无法第一时间通知开发人员异常发生
  3. 不知道用户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 未执行完的时候抛出来的,以下都是无法被捕获到的情形。

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

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

全局异常监听window.onerror

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

  • 缺点:
  1. 此方法有一定的浏览器兼容性
  2. 跨域脚本无法准确捕获异常,跨域之后window.onerror捕获不到正确的异常信息,而是统一返回一个Script error,可通过在<script>使用crossorigin属性来规避这个问题
图片

<figcaption style="margin: 5px 0px 0px; padding: 0px; max-width: 100%; box-sizing: border-box !important; word-wrap: break-word !important; text-align: center; color: rgb(221, 165, 45); font-size: 14px;">image.png</figcaption>

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.jsimport { 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内部异常,统一抛给 onerrorwindow.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解析,以避免源代码的泄露造成风险

图片

<figcaption style="margin: 5px 0px 0px; padding: 0px; max-width: 100%; box-sizing: border-box !important; word-wrap: break-word !important; text-align: center; color: rgb(221, 165, 45); font-size: 14px;">image.png</figcaption>

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

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

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

调整 webpack 配置

//vue.config.jslet 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.jsconst 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;复制代码

错误上报

两种方式:

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

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

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

  1. 将异常数据从属性中解构出来,存入一个JSON对象
  2. 将JSON对象转换为字符串
  3. 将字符串转换为Base64

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

图片

<figcaption style="margin: 5px 0px 0px; padding: 0px; max-width: 100%; box-sizing: border-box !important; word-wrap: break-word !important; text-align: center; color: rgb(221, 165, 45); font-size: 14px;">1621581164(1).png</figcaption>

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 = "";});复制代码
图片

<figcaption style="margin: 5px 0px 0px; padding: 0px; max-width: 100%; box-sizing: border-box !important; word-wrap: break-word !important; text-align: center; color: rgb(221, 165, 45); font-size: 14px;">image.png</figcaption>

数据存储 日志可视化

ELK前端日志分析

www.cnblogs.com/xiao9873341

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

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

npm install mongodb --save复制代码

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

// db.jsconst 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 = "";复制代码

数据插入成功🚀
图片

增加一个查询接口:

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

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

图片

<figcaption style="margin: 5px 0px 0px; padding: 0px; max-width: 100%; box-sizing: border-box !important; word-wrap: break-word !important; text-align: center; color: rgb(221, 165, 45); font-size: 14px;">image.png</figcaption>

待完善的点

  1. 应该做错误类型区分,如业务错误与接口错误等
  2. 过多的日志在业务服务器堆积,造成业务服务器的存储空间不够的情况,在迁到mongodb后在考虑不要日志⬆️
  3. 上报频率做限制。如类似mouseover事件中的报错应该考虑防抖般的处理

看小文章看的 谢谢作者大大 还在研究阶段 研究完再分享

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,847评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,208评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,587评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,942评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,332评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,587评论 1 218
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,853评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,568评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,273评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,542评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,033评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,373评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,031评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,073评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,830评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,628评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,537评论 2 269

推荐阅读更多精彩内容