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

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

vue.config.js配置里通過(guò)屬性productionSourceMap: true可以控制webpack是否生成map文件
webpack自定義插件實(shí)現(xiàn)sourcemap自動(dòng)上傳
為了我們每一次構(gòu)建服務(wù)端能拿到最新的map文件,我們編寫(xiě)一個(gè)插件讓webpack在打包完成后觸發(fā)一個(gè)鉤子實(shí)現(xiàn)文件上傳,在vue.config.js中進(jìn)行配置
調(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ù)制代碼
錯(cuò)誤上報(bào)
兩種方式:
img標(biāo)簽 這種方式無(wú)需加載任何通訊庫(kù),而且頁(yè)面是無(wú)需刷新的,相當(dāng)于get請(qǐng)求,沒(méi)有跨域問(wèn)題。缺點(diǎn)是有url長(zhǎng)度限制,但一般來(lái)講足夠使用了。 ajax 與正常的接口請(qǐng)求無(wú)異,可以用post
這里采用第一種,通過(guò)動(dòng)態(tài)創(chuàng)建一個(gè)img,瀏覽器就會(huì)向服務(wù)器發(fā)送get請(qǐng)求。將需要上報(bào)的錯(cuò)誤數(shù)據(jù)放在url中,利用這種方式就可以將錯(cuò)誤上報(bào)到服務(wù)器了。
確定上報(bào)的內(nèi)容,應(yīng)該包含異常位置(行號(hào),列號(hào)),異常信息,在錯(cuò)誤堆棧中包含了絕大多數(shù)調(diào)試有關(guān)的信息,我們通訊的時(shí)候只能以字符串方式傳輸,我們需要將對(duì)象進(jìn)行序列化處理。
將異常數(shù)據(jù)從屬性中解構(gòu)出來(lái),存入一個(gè)JSON對(duì)象 將JSON對(duì)象轉(zhuǎn)換為字符串 將字符串轉(zhuǎn)換為Base64
后端接收到信息后進(jìn)行對(duì)應(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搭一個(gè)簡(jiǎn)單后臺(tái)服務(wù),代碼比較簡(jiǎn)單,按功能拆開(kāi)來(lái)講
上傳文件接口
文件流寫(xiě)入:
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ù)制代碼
錯(cuò)誤日志
使用log4js記錄我們的錯(cuò)誤日志,這個(gè)也是非常流行的日志插件了,直接貼代碼。
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: {// 錯(cuò)誤日志
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' }
}
});
/**
* 錯(cuò)誤日志記錄方式
* @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ù)制代碼
錯(cuò)誤解析
這個(gè)接口就是對(duì)上報(bào)的錯(cuò)誤信息進(jìn)行解析,得到錯(cuò)誤堆棧對(duì)象
上面我們已經(jīng)拿到colno為2319,lineno為1,接下來(lái)需要安裝一個(gè)插件幫助我們找到對(duì)應(yīng)壓縮前的代碼位置。
npm install source-map -S
復(fù)制代碼
先讀取對(duì)應(yīng)的map文件(按filename對(duì)應(yīng)),然后只需傳入壓縮后的報(bào)錯(cuò)行號(hào)列號(hào)即可,就會(huì)返回壓縮前的錯(cuò)誤信息。打個(gè)比喻:簡(jiǎn)單地說(shuō)相當(dāng)于一本書(shū)的目錄,我們根據(jù)目錄可以快速找到某一部分內(nèi)容的頁(yè)數(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")
);
// 解析原始報(bào)錯(cuò)數(shù)據(jù)
let result = consumer.originalPositionFor({
line: obj.lineno, // 壓縮后的行號(hào)
column: obj.colno, // 壓縮后的列號(hào)
});
// 寫(xiě)入到日志中
obj.lineno = result.line;
obj.colno = result.column;
log4js.logError(JSON.stringify(obj));
ctx.body = "";
});
復(fù)制代碼

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

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