從0到1,構(gòu)建完整的前端異常監(jiān)控系統(tǒng)
點擊上方 前端瓶子君,關(guān)注公眾號
回復(fù)算法,加入前端編程面試算法每日一題群

來源:violetrosez
https://juejin.cn/post/6965022635470110733
前言
開發(fā)者有時會面臨上線的生產(chǎn)環(huán)境包出現(xiàn)了異常?? ,在長期生產(chǎn)bug并修復(fù)bug的循環(huán)中總結(jié)出一下幾個痛點:
無法快速定位到發(fā)生錯誤的代碼位置,因為腳手架構(gòu)建時會用webapck自動幫我們壓縮代碼,而上線版本又通常不會保留 source map(開源貢獻(xiàn)者除外)無法第一時間通知開發(fā)人員異常發(fā)生 不知道用戶OS與瀏覽器版本、請求參數(shù)(如頁面ID);而對于頁面邏輯是否錯誤問題,通常除了用戶OS與瀏覽器版本外,需要的是報錯的堆棧信息及具體報錯位置。
錯誤埋點追蹤系統(tǒng)的出現(xiàn)就是為了應(yīng)對上述問題的解決方案,筆者正好最近接觸了不少前端埋點與錯誤處理的博客內(nèi)容,按例階段性產(chǎn)出博客總結(jié)一下。
什么是埋點
還不了解的同學(xué)可以閱讀以下文章:
前端-埋點-理念-通識-淺談
大數(shù)據(jù)時代數(shù)據(jù)的重要性不言而喻,而其中最重要的就是用戶信息的采集。埋點,無論是項目后期的復(fù)盤,還是明確業(yè)務(wù)價值,還是產(chǎn)品價值的挖掘,都具備很重要的意義。
前端異常捕獲
在ES3之前js代碼執(zhí)行的過程中,一旦出現(xiàn)錯誤,整個js代碼都會停止執(zhí)行,這樣就顯的代碼非常的不健壯。從ES3開始,js也提供了類似的異常處理機制,從而讓js代碼變的更健壯,程序執(zhí)行的過程中出現(xiàn)了異常,也可以讓程序具有了一部分的異?;謴?fù)能力。js異常的特點是,出現(xiàn)不會導(dǎo)致JS引擎崩潰,最多只會終止當(dāng)前執(zhí)行的任務(wù)。
回歸正題,我們該如何在程序異常發(fā)生時捕捉并進行對應(yīng)的處理呢?在Javascript中,我們通常有以下兩種異常捕獲機制。
基本的try…catch語句
function errFunc() {
// eslint-disable-next-line no-undef
error;
}
function catchError() {
try {
this.errFunc();
} catch (error) {
console.log(error);
}
}
catchError()
復(fù)制代碼
能捕捉到的異常,必須是線程執(zhí)行已經(jīng)進入 try catch 但 try catch 未執(zhí)行完的時候拋出來的,以下都是無法被捕獲到的情形。
異步任務(wù)拋出的異常(執(zhí)行時try catch已經(jīng)從執(zhí)行完了) promise(異常內(nèi)部捕獲到了,并未往上拋異常,使用catch處理) 語法錯誤(代碼運行前,在編譯時就檢查出來了的錯誤)
優(yōu)點:能夠較好地進行異常捕獲,不至于使得頁面由于一處錯誤掛掉 缺點:顯得過于臃腫,大多代碼使用 try ... catch包裹,影響代碼可讀性。
面試官:請用一句話描述 try catch 能捕獲到哪些 JS 異常
全局異常監(jiān)聽window.onerror
window.onerror 最大的好處就是同步任務(wù)、異步任務(wù)都可捕獲,可以得到具體的異常信息、異常文件的URL、異常的行號與列號及異常的堆棧信息,再捕獲異常后,統(tǒng)一上報至我們的日志服務(wù)器,而且可以全局監(jiān)聽,代碼看起來也簡潔很多。
缺點:
此方法有一定的瀏覽器兼容性 跨域腳本無法準(zhǔn)確捕獲異常,跨域之后 window.onerror捕獲不到正確的異常信息,而是統(tǒng)一返回一個Script error,可通過在<script>使用crossorigin屬性來規(guī)避這個問題

window.addEventListener('error', function() {
console.log(error);
// ...
// 異常上報
});
throw new Error('這是一個錯誤');
復(fù)制代碼
Promise內(nèi)部異常
前文已經(jīng)提到,onerror 以及 try-catch 也無法捕獲Promise實例拋出的異常,只能最后在 catch 函數(shù)上處理,但是代碼寫多了就容易糊涂,忘記寫 catch。
如果你的應(yīng)用用到很多的 Promise 實例的話,特別是在一些基于 promise 的異步庫比如 axios 等一定要小心,因為你不知道什么時候這些異步請求會拋出異常而你并沒有處理它,所以最好添加一個 Promise 全局異常捕獲事件 unhandledrejection。
window.addEventListener("unhandledrejection", e => {
console.log('unhandledrejection',e)
});
復(fù)制代碼
vue工程異常
window.onerror并不能捕獲.vue文件發(fā)生的獲取,Vue 2.2.0以上的版本中增加了一個errorHandle,使用Vue.config.errorHandler這樣的Vue全局配置,可以在Vue指定組件的渲染和觀察期間未捕獲錯誤的處理函數(shù)。這個處理函數(shù)被調(diào)用時,可獲取錯誤信息和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");
復(fù)制代碼
Vue項目JS腳本錯誤捕獲
綜上,可以將幾種方式有效結(jié)合起來,筆者這里是在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內(nèi)部異常,統(tǒng)一拋給 onerror
window.addEventListener("unhandledrejection", (e) => {
throw e.reason;
});
// 框架異常統(tǒng)一捕獲
app.config.errorHandler = function(err, vm, info) {
//TODO:上報邏輯
console.log(err, vm, info);
};
app.mount("#app");
復(fù)制代碼
sourcemap
生產(chǎn)環(huán)境下所有的報錯的代碼行數(shù)都在第一行了,為什么呢?
通常在該環(huán)境下的代碼是經(jīng)過webpack打包后壓縮混淆的代碼,否則源代碼泄漏易造成安全問題,在生產(chǎn)環(huán)境下,我們的代碼被壓縮成了一行。而保留了sourcemap文件就可以利用webpack打包后的生成的一份.map的腳本文件就可以讓瀏覽器對錯誤位置進行追蹤了,但這種做法并不可取,更為推薦的是在服務(wù)端使用Node.js對接收到的日志信息時使用source-map解析,以避免源代碼的泄露造成風(fēng)險

vue.config.js配置里通過屬性productionSourceMap: true可以控制webpack是否生成map文件
webpack自定義插件實現(xiàn)sourcemap自動上傳
為了我們每一次構(gòu)建服務(wù)端能拿到最新的map文件,我們編寫一個插件讓webpack在打包完成后觸發(fā)一個鉤子實現(xiàn)文件上傳,在vue.config.js中進行配置
調(diào)整 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) => {},
}
復(fù)制代碼
//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));
}
});
}
}
//調(diào)用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;
復(fù)制代碼
錯誤上報
兩種方式:
img標(biāo)簽 這種方式無需加載任何通訊庫,而且頁面是無需刷新的,相當(dāng)于get請求,沒有跨域問題。缺點是有url長度限制,但一般來講足夠使用了。 ajax 與正常的接口請求無異,可以用post
這里采用第一種,通過動態(tài)創(chuàng)建一個img,瀏覽器就會向服務(wù)器發(fā)送get請求。將需要上報的錯誤數(shù)據(jù)放在url中,利用這種方式就可以將錯誤上報到服務(wù)器了。
確定上報的內(nèi)容,應(yīng)該包含異常位置(行號,列號),異常信息,在錯誤堆棧中包含了絕大多數(shù)調(diào)試有關(guān)的信息,我們通訊的時候只能以字符串方式傳輸,我們需要將對象進行序列化處理。
將異常數(shù)據(jù)從屬性中解構(gòu)出來,存入一個JSON對象 將JSON對象轉(zhuǎn)換為字符串 將字符串轉(zhuǎn)換為Base64
后端接收到信息后進行對應(yīng)的反向操作,就可以在日志中記錄。

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}`;
}
復(fù)制代碼
后端服務(wù)
用koa搭一個簡單后臺服務(wù),代碼比較簡單,按功能拆開來講
上傳文件接口
文件流寫入:
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);
});
復(fù)制代碼
錯誤日志
使用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 日志輸出內(nèi)容
*/
function logError(content) {
const log = log4js.getLogger("error");
log.error(content)
}
/**
* 日志記錄方式
* @param {*} content 日志輸出內(nèi)容
*/
function logInfo(content) {
const log = log4js.getLogger("info");
log.info(content)
}
module.exports = {
logError,
logInfo
}
復(fù)制代碼
錯誤解析
這個接口就是對上報的錯誤信息進行解析,得到錯誤堆棧對象
上面我們已經(jīng)拿到colno為2319,lineno為1,接下來需要安裝一個插件幫助我們找到對應(yīng)壓縮前的代碼位置。
npm install source-map -S
復(fù)制代碼
先讀取對應(yīng)的map文件(按filename對應(yīng)),然后只需傳入壓縮后的報錯行號列號即可,就會返回壓縮前的錯誤信息。打個比喻:簡單地說相當(dāng)于一本書的目錄,我們根據(jù)目錄可以快速找到某一部分內(nèi)容的頁數(shù)
router.get("/error", async (ctx) => {
const errInfo = ctx.query.info;
// 轉(zhuǎn)碼 反序列化
let obj = JSON.parse(Buffer.from(errInfo, "base64").toString("utf-8"));
let fileUrl = obj.filename.split("/").pop() + ".map"; // map文件路徑
// 解析sourceMap
// 1.sourcemap文件的文件流,我們已經(jīng)上傳
// 2.文件編碼格式
let consumer = await new sourceMap.SourceMapConsumer(
fs.readFileSync(path.join(__dirname, "source-map/" + fileUrl), "utf8")
);
// 解析原始報錯數(shù)據(jù)
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 = "";
});
復(fù)制代碼

數(shù)據(jù)存儲 日志可視化
ELK前端日志分析
www.cnblogs.com/xiao9873341…
看了一下許多平臺對錯誤日志的分析和可視化都使用了ELK,ELK在服務(wù)器運維界應(yīng)該是運用的非常成熟了,很多成熟的大型項目都使用ELK來作為前端日志監(jiān)控、分析的工具。我對運維這一塊興趣不大,有興趣的可以自行搭建,整出來界面還是挺炫酷的。
而我又不想每一次都跑去服務(wù)器查看日志,于是想到了可以建個表來把錯誤信息給存起來。用起老三樣koa+mongodb+vue,我們這項目就算是齊活了。(mongodb,yyds??,省去了建表許多功夫)
npm install mongodb --save
復(fù)制代碼
新建一個文件db.js封裝一下mongo連接,方便復(fù)用:
// db.js
const MongoClient = require("mongodb").MongoClient;
const url = "mongodb://localhost:27017/";
const dbName = "err_db";
const collectionName = "errList";
class Db {
// 單例模式,解決多次實例化時候每次創(chuàng)建連接對象不共享的問題,實現(xiàn)共享連接數(shù)據(jù)庫狀態(tài)
static getInstance() {
if (!Db.instance) {
Db.instance = new Db();
}
return Db.instance;
}
constructor() {
// 屬性 存放db對象
this.dbClient = "";
// 實例化的時候就連接數(shù)據(jù)庫,增加連接數(shù)據(jù)庫速度
this.connect();
}
// 連接數(shù)據(jù)庫
connect() {
return new Promise((resolve, reject) => {
// 解決數(shù)據(jù)庫多次連接的問題,要不然每次操作數(shù)據(jù)都會進行一次連接數(shù)據(jù)庫的操作,比較慢
if (!this.dbClient) {
// 第一次的時候連接數(shù)據(jù)庫
MongoClient.connect(
url,
{ useNewUrlParser: true, useUnifiedTopology: true },
(err, client) => {
if (err) {
reject(err);
} else {
// 將連接數(shù)據(jù)庫的狀態(tài)賦值給屬性,保持長連接狀態(tài)
this.dbClient = client.db(dbName);
resolve(this.dbClient);
}
}
);
} else {
// 第二次之后直接返回dbClient
resolve(this.dbClient);
}
});
}
// 增加一條數(shù)據(jù)
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();
復(fù)制代碼
然后就可以在項目中愉快使用
let db = require("./db");
...
log4js.logError(JSON.stringify(obj));
//插入數(shù)據(jù)
await db.insert(obj);
ctx.body = "";
復(fù)制代碼
數(shù)據(jù)插入成功??
增加一個查詢接口:
router.get("/errlist", async (ctx) => {
let res = await db.find({});
ctx.body = {
data: res,
};
});
復(fù)制代碼
為了豐富錯誤信息,我們還可以在上報的時候增加報錯時間,用戶瀏覽器信息,自定義錯誤類型統(tǒng)計,引入圖表可視化展示,更加直觀地追蹤

待完善的點
應(yīng)該做錯誤類型區(qū)分,如業(yè)務(wù)錯誤與接口錯誤等 過多的日志在業(yè)務(wù)服務(wù)器堆積,造成業(yè)務(wù)服務(wù)器的存儲空間不夠的情況,在遷到mongodb后在考慮不要日志?? 上報頻率做限制。如類似mouseover事件中的報錯應(yīng)該考慮防抖般的處理
后記
至此,我們總結(jié)了幾種異常捕獲的做法,并完成了對前端程序異常的上報功能,這對開發(fā)和測試人員都有較大的意義,用一句或說便是,要對產(chǎn)品保持敬畏之心,時刻關(guān)注存在的缺陷問題。代碼中有疑問或者不對的地方歡迎各位批評指正,共同進步。求點贊三連QAQ????
參考鏈接:
