天天看點

#穩定性-異常上報 前端異常埋點系統初探

點選上方關注 前端技術江湖,一起學習,天天進步

前言

開發者有時會面臨上線的生産環境包出現了異常???? ,在長期生産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

    屬性來規避這個問題
#穩定性-異常上報 前端異常埋點系統初探

image.png

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解析,以避免源代碼的洩露造成風險

#穩定性-異常上報 前端異常埋點系統初探

image.png

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;

複制代碼
           

錯誤上報

兩種方式:

  1. img标簽 這種方式無需加載任何通訊庫,而且頁面是無需重新整理的,相當于get請求,沒有跨域問題。缺點是有url長度限制,但一般來講足夠使用了。
  2. ajax 與正常的接口請求無異,可以用post

這裡采用第一種,通過動态建立一個img,浏覽器就會向伺服器發送get請求。将需要上報的錯誤資料放在url中,利用這種方式就可以将錯誤上報到伺服器了。

确定上報的内容,應該包含異常位置(行号,列号),異常資訊,在錯誤堆棧中包含了絕大多數調試有關的資訊,我們通訊的時候隻能以字元串方式傳輸,我們需要将對象進行序列化處理。

  1. 将異常資料從屬性中解構出來,存入一個JSON對象
  2. 将JSON對象轉換為字元串
  3. 将字元串轉換為Base64

後端接收到資訊後進行對應的反向操作,就可以在日志中記錄。

#穩定性-異常上報 前端異常埋點系統初探

1621581164(1).png

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 = "";
});
複制代碼
           
#穩定性-異常上報 前端異常埋點系統初探

image.png

資料存儲 日志可視化

ELK前端日志分析
www.cnblogs.com/xiao9873341…

看了一下許多平台對錯誤日志的分析和可視化都使用了ELK,ELK在伺服器運維界應該是運用的非常成熟了,很多成熟的大型項目都使用ELK來作為前端日志監控、分析的工具。我對運維這一塊興趣不大,有興趣的可以自行搭建,整出來界面還是挺炫酷的。

而我又不想每一次都跑去伺服器檢視日志,于是想到了可以建個表來把錯誤資訊給存起來。用起老三樣koa+mongodb+vue,我們這項目就算是齊活了。(mongodb,yyds????,省去了建表許多功夫)

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 = "";
複制代碼
           

資料插入成功????

#穩定性-異常上報 前端異常埋點系統初探

增加一個查詢接口:

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

為了豐富錯誤資訊,我們還可以在上報的時候增加報錯時間,使用者浏覽器資訊,自定義錯誤類型統計,引入圖表可視化展示,更加直覺地追蹤

#穩定性-異常上報 前端異常埋點系統初探

image.png

待完善的點

  1. 應該做錯誤類型區分,如業務錯誤與接口錯誤等
  2. 過多的日志在業務伺服器堆積,造成業務伺服器的存儲空間不夠的情況,在遷到mongodb後在考慮不要日志⬆️
  3. 上報頻率做限制。如類似mouseover事件中的報錯應該考慮防抖般的處理

後記

至此,我們總結了幾種異常捕獲的做法,并完成了對前端程式異常的上報功能,這對開發和測試人員都有較大的意義,用一句或說便是,要對産品保持敬畏之心,時刻關注存在的缺陷問題。代碼中有疑問或者不對的地方歡迎各位批評指正,共同進步。求點贊三連QAQ????????

參考連結:

從0到1,Vue大牛的前端搭建——異常監控系統
  • 本文作者:violetrosez
  • 本文連結:https://juejin.cn/post/6965022635470110733

The End

歡迎自薦投稿到《前端技術江湖》,如果你覺得這篇内容對你挺有啟發,記得點個 「在看」哦

點個『在看』支援下 

#穩定性-異常上報 前端異常埋點系統初探