講透前端錯誤監(jiān)控,看這篇文章就夠了
點(diǎn)擊上方 前端瓶子君,關(guān)注公眾號
回復(fù)算法,加入前端編程面試算法每日一題群
○ 一、背景
痛點(diǎn)
某?天產(chǎn)品:xxx?告主反饋我們的??注冊不了!??天運(yùn)營:這個活動在xxx媒體上掛掉了!
在我司線上運(yùn)行的是近億級別的廣告頁面,這樣線上如果裸奔,出現(xiàn)了什么問題不知道,后置在業(yè)務(wù)端發(fā)現(xiàn),被業(yè)務(wù)方詢問,這種場景很尷尬。
選擇
公司存在四個事業(yè)部,而每個事業(yè)部不下于3個項(xiàng)目,這里至少12個項(xiàng)目,這里作為伏筆,業(yè)務(wù)線多。
我們是選擇自己做呢,還是選第三方的呢。我們比較一項(xiàng)幾款常見第三方。
-
Fundebug:付費(fèi)版 159元/月起,數(shù)據(jù)存在第三方,而數(shù)據(jù)自我保存需要 30 萬/年。還是很貴的。 -
FrontJS,F(xiàn)rontJS 高級版 899/月,專業(yè)版是 2999/月。 -
Sentry,80 美金/月。
以Sentry為計(jì)費(fèi),對這12個項(xiàng)目計(jì)算一下。12個項(xiàng)目一年將近10萬。而大致估算過需要2人1.5月即90人日,能完成MVP版本,按每人1.5萬工資/月計(jì)算,總共花費(fèi)4.5萬,而且是一勞永逸的。
因此從成本角度我們會選擇自研,但除了成本外,還有其他原因。例如我們會基于這套系統(tǒng)做一些自定義功能,與公司權(quán)限用戶系統(tǒng)打通,再針對用戶進(jìn)行Todo管理,對用戶進(jìn)行錯誤排行等。
還有基于業(yè)務(wù)數(shù)據(jù)的安全,我們希望自我搭建一個系統(tǒng)。
所以從成本、安全、擴(kuò)展性角度,我們選擇了自己研發(fā)。
○ 二、產(chǎn)品設(shè)計(jì)
我們要什么樣的一個產(chǎn)品呢,根據(jù)第一性原理,解決關(guān)鍵問題“怎么定位問題”。通過5W1H法我們來分析,我們想要知道些什么信息呢?
錯誤信息
其實(shí)錯誤監(jiān)控說簡單就一句話可以描述,搜集頁面錯誤,進(jìn)行上報(bào),然后對癥分析。
按照5W1H法則進(jìn)行分析這句話,可以發(fā)現(xiàn)有幾項(xiàng)需要我們關(guān)注。
-
What,發(fā)?了什么錯誤:邏輯錯誤、數(shù)據(jù)錯誤、?絡(luò)錯誤、語法錯誤等。 -
When,出現(xiàn)的時(shí)間段,如時(shí)間戳。 -
Who,影響了多少用戶,包括報(bào)錯事件數(shù)、IP、設(shè)備信息。 -
Where,出現(xiàn)的頁面是哪些,包括頁面、廣告位(我司)、媒體(我司)。 -
Why,錯誤的原因是為什么,包括錯誤堆棧、?列、SourceMap。 -
How,怎么定位解決問題,我們還需要收集系統(tǒng)等信息。
架構(gòu)層次
首先我們需要梳理下,我們需要一些哪些功能。
那我們怎么得到上面的信息進(jìn)行最終錯誤的定位呢。
首先我們肯定需要對錯誤進(jìn)行搜集,然后用戶設(shè)備頁面端的錯誤我們怎么才能感知到呢,這就需要進(jìn)行上報(bào)。那么第一層就展現(xiàn)出來了,我們需要一個搜集上報(bào)端。
那怎么才能進(jìn)行上報(bào)呢,和后端協(xié)作那么久,肯定知道的吧?? ,你需要一個接口。那就需要一個服務(wù)器來進(jìn)行對于上報(bào)的錯誤進(jìn)行采集,對于錯誤進(jìn)行篩選聚合。那么第二層也知道了啊,我們需要一個采集聚合端。
我們搜集到了我們足夠的物料信息了,那接下來要怎么用起來呢,我們需要把它們按照我們的規(guī)則進(jìn)行整理。如果每次又是通過寫類SQL進(jìn)行整理查詢效率會很低,因此我們需要一個可視化的平臺進(jìn)行展示。因此有了第三層,可視化分析端。
感覺好像做完啦,想必大家都這么想,一個錯誤監(jiān)控平臺做完了,?? 。如果是這樣你會發(fā)現(xiàn)一個現(xiàn)象,每次上線和上線后一段時(shí)間,開發(fā)同學(xué)都一直盯著屏幕看,這是在干嘛,人形眼動觀察者模式嗎。因此我們需要通過代碼去解決,自然而然,第四層,監(jiān)控告警端應(yīng)運(yùn)而生。
所以請大聲說出來我們需要什么?? ,搜集上報(bào)端,采集聚合端,可視分析端,監(jiān)控告警端。
○ 三、系統(tǒng)設(shè)計(jì)
如函數(shù)一樣,定義好每個環(huán)節(jié)的輸入和輸出,且核心需要處理的功能。
下面我們看看上述所說的四個端怎么去實(shí)現(xiàn)呢。
搜集上報(bào)端(SDK)
這個環(huán)節(jié)主要輸入是所有錯誤,輸出是捕獲上報(bào)錯誤。核心是處理不同類型錯誤的搜集工作。其他是一些非核心但必要的工作。
錯誤類型
先看看我們需要處理哪些錯誤類型。
常見JS執(zhí)行錯誤
-
SyntaxError
解析時(shí)發(fā)生語法錯誤
// 控制臺運(yùn)行
const xx,
復(fù)制代碼
window.onerror捕獲不到SyntxError,一般SyntaxError在構(gòu)建階段,甚至本地開發(fā)階段就會被發(fā)現(xiàn)。
-
TypeError
值不是所期待的類型
// 控制臺運(yùn)行
const person = void 0
person.name
復(fù)制代碼
-
ReferenceError
引用未聲明的變量
// 控制臺運(yùn)行
nodefined
復(fù)制代碼
-
RangeError
當(dāng)一個值不在其所允許的范圍或者集合中
(function fn ( ) { fn() })()
復(fù)制代碼
網(wǎng)絡(luò)錯誤
-
ResourceError
資源加載錯誤
new Image().src = '/remote/image/notdeinfed.png'
復(fù)制代碼
-
HttpError
Http請求錯誤
// 控制臺運(yùn)行
fetch('/remote/notdefined', {})
復(fù)制代碼
搜集錯誤
所有起因來源于錯誤,那我們?nèi)绾芜M(jìn)行錯誤捕獲。
try/catch
能捕獲常規(guī)運(yùn)行時(shí)錯誤,語法錯誤和異步錯誤不行
// 常規(guī)運(yùn)行時(shí)錯誤,可以捕獲 ?
try {
console.log(notdefined);
} catch(e) {
console.log('捕獲到異常:', e);
}
// 語法錯誤,不能捕獲 ?
try {
const notdefined,
} catch(e) {
console.log('捕獲到異常:', e);
}
// 異步錯誤,不能捕獲 ?
try {
setTimeout(() => {
console.log(notdefined);
}, 0)
} catch(e) {
console.log('捕獲到異常:',e);
}
復(fù)制代碼
try/catch有它細(xì)致處理的優(yōu)勢,但缺點(diǎn)也比較明顯。
window.onerror
pure js錯誤收集,window.onerror,當(dāng) JS 運(yùn)行時(shí)錯誤發(fā)生時(shí),window 會觸發(fā)一個 ErrorEvent 接口的 error 事件。
/**
* @param {String} message 錯誤信息
* @param {String} source 出錯文件
* @param {Number} lineno 行號
* @param {Number} colno 列號
* @param {Object} error Error對象
*/
window.onerror = function(message, source, lineno, colno, error) {
console.log('捕獲到異常:', {message, source, lineno, colno, error});
}
復(fù)制代碼
先驗(yàn)證下幾個錯誤是否可以捕獲。
// 常規(guī)運(yùn)行時(shí)錯誤,可以捕獲 ?
window.onerror = function(message, source, lineno, colno, error) {
console.log('捕獲到異常:',{message, source, lineno, colno, error});
}
console.log(notdefined);
// 語法錯誤,不能捕獲 ?
window.onerror = function(message, source, lineno, colno, error) {
console.log('捕獲到異常:',{message, source, lineno, colno, error});
}
const notdefined,
// 異步錯誤,可以捕獲 ?
window.onerror = function(message, source, lineno, colno, error) {
console.log('捕獲到異常:',{message, source, lineno, colno, error});
}
setTimeout(() => {
console.log(notdefined);
}, 0)
// 資源錯誤,不能捕獲 ?
<script>
window.onerror = function(message, source, lineno, colno, error) {
console.log('捕獲到異常:',{message, source, lineno, colno, error});
return true;
}
</script>
<img src="https://yun.tuia.cn/image/kkk.png">
復(fù)制代碼
window.onerror 不能捕獲資源錯誤怎么辦?
window.addEventListener
當(dāng)一項(xiàng)資源(如圖片或腳本)加載失敗,加載資源的元素會觸發(fā)一個 Event 接口的 error 事件,這些 error 事件不會向上冒泡到 window,但能被捕獲。而window.onerror不能監(jiān)測捕獲。
// 圖片、script、css加載錯誤,都能被捕獲 ?
<script>
window.addEventListener('error', (error) => {
console.log('捕獲到異常:', error);
}, true)
</script>
<img src="https://yun.tuia.cn/image/kkk.png">
<script src="https://yun.tuia.cn/foundnull.js"></script>
<link href="https://yun.tuia.cn/foundnull.css" rel="stylesheet"/>
// new Image錯誤,不能捕獲 ?
<script>
window.addEventListener('error', (error) => {
console.log('捕獲到異常:', error);
}, true)
</script>
<script>
new Image().src = 'https://yun.tuia.cn/image/lll.png'
</script>
// fetch錯誤,不能捕獲 ?
<script>
window.addEventListener('error', (error) => {
console.log('捕獲到異常:', error);
}, true)
</script>
<script>
fetch('https://tuia.cn/test')
</script>
復(fù)制代碼
new Image運(yùn)用的比較少,可以單獨(dú)自己處理自己的錯誤。
但通用的fetch怎么辦呢,fetch返回Promise,但Promise的錯誤不能被捕獲,怎么辦呢?
Promise錯誤
-
普通Promise錯誤
try/catch不能捕獲Promise中的錯誤
// try/catch 不能處理 JSON.parse 的錯誤,因?yàn)樗?nbsp;Promise 中
try {
new Promise((resolve,reject) => {
JSON.parse('')
resolve();
})
} catch(err) {
console.error('in try catch', err)
}
// 需要使用catch方法
new Promise((resolve,reject) => {
JSON.parse('')
resolve();
}).catch(err => {
console.log('in catch fn', err)
})
復(fù)制代碼
-
async錯誤
try/catch不能捕獲async包裹的錯誤
const getJSON = async () => {
throw new Error('inner error')
}
// 通過try/catch處理
const makeRequest = async () => {
try {
// 捕獲不到
JSON.parse(getJSON());
} catch (err) {
console.log('outer', err);
}
};
try {
// try/catch不到
makeRequest()
} catch(err) {
console.error('in try catch', err)
}
try {
// 需要await,才能捕獲到
await makeRequest()
} catch(err) {
console.error('in try catch', err)
}
復(fù)制代碼
-
import chunk錯誤
import其實(shí)返回的也是一個promise,因此使用如下兩種方式捕獲錯誤
// Promise catch方法
import(/* webpackChunkName: "incentive" */'./index').then(module => {
module.default()
}).catch((err) => {
console.error('in catch fn', err)
})
// await 方法,try catch
try {
const module = await import(/* webpackChunkName: "incentive" */'./index');
module.default()
} catch(err) {
console.error('in try catch', err)
}
復(fù)制代碼
小結(jié):全局捕獲Promise中的錯誤
以上三種其實(shí)歸結(jié)為Promise類型錯誤,可以通過unhandledrejection捕獲
// 全局統(tǒng)一處理Promise
window.addEventListener("unhandledrejection", function(e){
console.log('捕獲到異常:', e);
});
fetch('https://tuia.cn/test')
復(fù)制代碼
為了防止有漏掉的 Promise 異常,可通過unhandledrejection用來全局監(jiān)聽Uncaught Promise Error。
Vue錯誤
由于Vue會捕獲所有Vue單文件組件或者Vue.extend繼承的代碼,所以在Vue里面出現(xiàn)的錯誤,并不會直接被window.onerror捕獲,而是會拋給Vue.config.errorHandler。
/**
* 全局捕獲Vue錯誤,直接扔出給onerror處理
*/
Vue.config.errorHandler = function (err) {
setTimeout(() => {
throw err
})
}
復(fù)制代碼
React錯誤
react 通過componentDidCatch,聲明一個錯誤邊界的組件
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error) {
// 更新 state 使下一次渲染能夠顯示降級后的 UI
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
// 你同樣可以將錯誤日志上報(bào)給服務(wù)器
logErrorToMyService(error, errorInfo);
}
render() {
if (this.state.hasError) {
// 你可以自定義降級后的 UI 并渲染
return <h1>Something went wrong.</h1>;
}
return this.props.children;
}
}
class App extends React.Component {
render() {
return (
<ErrorBoundary>
<MyWidget />
</ErrorBoundary>
)
}
}
復(fù)制代碼
但error boundaries并不會捕捉以下錯誤:React事件處理,異步代碼,error boundaries自己拋出的錯誤。
跨域問題
一般情況,如果出現(xiàn) Script error 這樣的錯誤,基本上可以確定是出現(xiàn)了跨域問題。
如果當(dāng)前投放頁面和云端JS所在不同域名,如果云端JS出現(xiàn)錯誤,window.onerror會出現(xiàn)Script Error。通過以下兩種方法能給予解決。
-
后端配置Access-Control-Allow-Origin、前端script加crossorigin。
<script src="http://yun.tuia.cn/test.js" crossorigin></script>
const script = document.createElement('script');
script.crossOrigin = 'anonymous';
script.src = 'http://yun.tuia.cn/test.js';
document.body.appendChild(script);
復(fù)制代碼
-
如果不能修改服務(wù)端的請求頭,可以考慮通過使用 try/catch 繞過,將錯誤拋出。
<!doctype html>
<html>
<head>
<title>Test page in http://test.com</title>
</head>
<body>
<script src="https://yun.dui88.com/tuia/cdn/remote/testerror.js"></script>
<script>
window.onerror = function (message, url, line, column, error) {
console.log(message, url, line, column, error);
}
try {
foo(); // 調(diào)用testerror.js中定義的foo方法
} catch (e) {
throw e;
}
</script>
</body>
</html>
復(fù)制代碼
會發(fā)現(xiàn)如果不加try catch,console.log就會打印script error。加上try catch就能捕獲到。
我們捋一下場景,一般調(diào)用遠(yuǎn)端js,有下列三種常見情況。
-
調(diào)用遠(yuǎn)端JS的方法出錯 -
遠(yuǎn)端JS內(nèi)部的事件出問題 -
要么在setTimeout等回調(diào)內(nèi)出錯
調(diào)用方法場景
可以通過封裝一個函數(shù),能裝飾原方法,使得其能被try/catch。
<!doctype html>
<html>
<head>
<title>Test page in http://test.com</title>
</head>
<body>
<script src="https://yun.dui88.com/tuia/cdn/remote/testerror.js"></script>
<script>
window.onerror = function (message, url, line, column, error) {
console.log(message, url, line, column, error);
}
function wrapErrors(fn) {
// don't wrap function more than once
if (!fn.__wrapped__) {
fn.__wrapped__ = function () {
try {
return fn.apply(this, arguments);
} catch (e) {
throw e; // re-throw the error
}
};
}
return fn.__wrapped__;
}
wrapErrors(foo)()
</script>
</body>
</html>
復(fù)制代碼
大家可以嘗試去掉wrapErrors感受下。
事件場景
可以劫持原生方法。
<!doctype html>
<html>
<head>
<title>Test page in http://test.com</title>
</head>
<body>
<script>
const originAddEventListener = EventTarget.prototype.addEventListener;
EventTarget.prototype.addEventListener = function (type, listener, options) {
const wrappedListener = function (...args) {
try {
return listener.apply(this, args);
}
catch (err) {
throw err;
}
}
return originAddEventListener.call(this, type, wrappedListener, options);
}
</script>
<div style="height: 9999px;">http://test.com</div>
<script src="https://yun.dui88.com/tuia/cdn/remote/error_scroll.js"></script>
<script>
window.onerror = function (message, url, line, column, error) {
console.log(message, url, line, column, error);
}
</script>
</body>
</html>
復(fù)制代碼
大家可以嘗試去掉封裝EventTarget.prototype.addEventListener的那段代碼,感受下。
上報(bào)接口
為什么不能直接用GET/POST/HEAD請求接口進(jìn)行上報(bào)?
這個比較容易想到原因。一般而言,打點(diǎn)域名都不是當(dāng)前域名,所以所有的接口請求都會構(gòu)成跨域。
為什么不能用請求其他的文件資源(js/css/ttf)的方式進(jìn)行上報(bào)?
創(chuàng)建資源節(jié)點(diǎn)后只有將對象注入到瀏覽器DOM樹后,瀏覽器才會實(shí)際發(fā)送資源請求。而且載入js/css資源還會阻塞頁面渲染,影響用戶體驗(yàn)。
構(gòu)造圖片打點(diǎn)不僅不用插入DOM,只要在js中new出Image對象就能發(fā)起請求,而且還沒有阻塞問題,在沒有js的瀏覽器環(huán)境中也能通過img標(biāo)簽正常打點(diǎn)。
使用new Image進(jìn)行接口上報(bào)。最后一個問題,同樣都是圖片,上報(bào)時(shí)選用了1x1的透明GIF,而不是其他的PNG/JEPG/BMP文件。
首先,1x1像素是最小的合法圖片。而且,因?yàn)槭峭ㄟ^圖片打點(diǎn),所以圖片最好是透明的,這樣一來不會影響頁面本身展示效果,二者表示圖片透明只要使用一個二進(jìn)制位標(biāo)記圖片是透明色即可,不用存儲色彩空間數(shù)據(jù),可以節(jié)約體積。因?yàn)樾枰该魃钥梢灾苯优懦齁EPG。
同樣的響應(yīng),GIF可以比BMP節(jié)約41%的流量,比PNG節(jié)約35%的流量。GIF才是最佳選擇。
-
可以進(jìn)行跨域 -
不會攜帶cookie -
不需要等待服務(wù)器返回?cái)?shù)據(jù)
使用1\*1的gif[1]
非阻塞加載
盡量避免SDK的js資源加載影響。
通過先把window.onerror的錯誤記錄進(jìn)行緩存,然后異步進(jìn)行SDK的加載,再在SDK里面處理錯誤上報(bào)。
<!DOCTYPE html>
<html lang="en">
<head>
<script>
(function(w) {
w._error_storage_ = [];
function errorhandler(){
// 用于記錄當(dāng)前的錯誤
w._error_storage_&&w._error_storage_.push([].slice.call(arguments));
}
w.addEventListener && w.addEventListener("error", errorhandler, true);
var times = 3,
appendScript = function appendScript() {
var sc = document.createElement("script");
sc.async = !0,
sc.src = './build/skyeye.js', // 取決于你存放的位置
sc.crossOrigin = "anonymous",
sc.onerror = function() {
times--,
times > 0 && setTimeout(appendScript, 1500)
},
document.head && document.head.appendChild(sc);
};
setTimeout(appendScript, 1500);
})(window);
</script>
</head>
<body>
<h1>這是一個測試頁面(new)</h1>
</body>
</html>
復(fù)制代碼
采集聚合端(日志服務(wù)器)
這個環(huán)節(jié),輸入是借口接收到的錯誤記錄,輸出是有效的數(shù)據(jù)入庫。核心功能需要對數(shù)據(jù)進(jìn)行清洗,順帶解決了過多的服務(wù)壓力。另一個核心功能是對數(shù)據(jù)進(jìn)行入庫。
總體流程可以看為錯誤標(biāo)識 -> 錯誤過濾 -> 錯誤接收 -> 錯誤存儲。
錯誤標(biāo)識(SDK配合)
聚合之前,我們需要有不同維度標(biāo)識錯誤的能力,可以理解為定位單個錯誤條目,單個錯誤事件的能力。
單個錯誤條目
通過date和隨機(jī)值生成一條對應(yīng)的錯誤條目id。
const errorKey = `${+new Date()}@${randomString(8)}`
function randomString(len) {
len = len || 32;
let chars = 'ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678';
let maxPos = chars.length;
let pwd = '';
for (let i = 0; i < len; i++) {
pwd += chars.charAt(Math.floor(Math.random() * maxPos));
}
return pwd;
}
復(fù)制代碼
單個錯誤事件
首先需要有定位同個錯誤事件(不同用戶,發(fā)生相同錯誤類型、錯誤信息)的能力。
通過message、colno與lineno進(jìn)行相加計(jì)算阿斯克碼值,可以生成錯誤的errorKey。
const eventKey = compressString(String(e.message), String(e.colno) + String(e.lineno))
function compressString(str, key) {
let chars = 'ABCDEFGHJKMNPQRSTWXYZ';
if (!str || !key) {
return 'null';
}
let n = 0,
m = 0;
for (let i = 0; i < str.length; i++) {
n += str[i].charCodeAt();
}
for (let j = 0; j < key.length; j++) {
m += key[j].charCodeAt();
}
let num = n + '' + key[key.length - 1].charCodeAt() + m + str[str.length - 1].charCodeAt();
if(num) {
num = num + chars[num[num.length - 1]];
}
return num;
}
復(fù)制代碼
如下圖,一個錯誤事件(事件列表),下屬每條即為實(shí)際的錯誤條目。
錯誤過濾(SDK配合)
域名過濾
過濾本頁面script error,可能被webview插入其他js。
我們只關(guān)心自己的遠(yuǎn)端JS問題,因此做了根據(jù)本公司域名進(jìn)行過濾。
// 偽代碼
if(!e.filename || !e.filename.match(/^(http|https):\/\/yun./)) return true
復(fù)制代碼
重復(fù)上報(bào)
怎么避免重復(fù)的數(shù)據(jù)上報(bào)?根據(jù)errorKey來進(jìn)行緩存,重復(fù)的錯誤避免上報(bào)的次數(shù)超過閾值。
// 偽代碼
const localStorage = window.localStorage;
const TIMES = 6; // 緩存條數(shù)
export function setItem(key, repeat) {
if(!key) {
key = 'unknow';
}
if (has(key)) {
const value = getItem(key);
// 核心代碼,超過條數(shù),跳出
if (value >= repeat) {
return true;
}
storeStorage[key] = {
value: value + 1,
time: Date.now()
}
} else {
storeStorage[key] = {
value: 1,
time: Date.now()
}
}
return false;
}
復(fù)制代碼
錯誤接收
在處理接收接口的時(shí)候,注意流量的控制,這也是后端開發(fā)需要投入最多精力的地方,處理高并發(fā)的流量。
錯誤記錄
接收端使用Koa,簡單的實(shí)現(xiàn)了接收及打印到磁盤。
// 偽代碼
module.exports = async ctx => {
const { query } = ctx.request;
// 對于字段進(jìn)行簡單check
check([ 'mobile', 'network', 'ip', 'system', 'ua', ......], query);
ctx.type = 'application/json';
ctx.body = { code: '1', msg: '數(shù)據(jù)上報(bào)成功' };
// 進(jìn)行日志記錄到磁盤的代碼,根據(jù)自己的日志庫選擇
};
復(fù)制代碼
削峰機(jī)制
比如每秒設(shè)置2000的閾值,然后根據(jù)請求量減少上限,定時(shí)重置上限。
// 偽代碼
// 1000ms
const TICK = 1000;
// 1秒上限為2000
const MAX_LIMIT = 2000;
// 每臺服務(wù)器請求上限值
let maxLimit = MAX_LIMIT;
/**
* 啟動重置函數(shù)
*/
const task = () => {
setTimeout(() => {
maxLimit = MAX_LIMIT;
task();
}, TICK);
};
task();
const check = () => {
if (maxLimit <= 0) {
throw new Error('超過上報(bào)次數(shù)');
}
maxLimit--;
// 執(zhí)行業(yè)務(wù)代碼。。。
};
復(fù)制代碼
采樣處理
超過閾值,還可以進(jìn)行采樣收集。
// 只采集 20%
if(Math.random() < 0.2) {
collect(data) // 記錄錯誤信息
}
復(fù)制代碼
錯誤存儲
對于打印在了磁盤的日志,我們怎么樣才能對于其進(jìn)行聚合呢,這里得考慮使用存儲方案。
一般選擇了存儲方案后,設(shè)置好配置,存儲方案就可以通過磁盤定時(shí)周期性的獲取數(shù)據(jù)。因此我們需要選擇一款存儲方案。
對于存儲方案,我們對比了日常常見方案,阿里云日志服務(wù) - Log Service(SLS)、ELK(Elastic、Logstash、Kibana)、Hadoop/Hive(將數(shù)據(jù)存儲在 Hadoop,利用 Hive 進(jìn)行查詢) 類方案的對比。
從以下方面進(jìn)行了對比,最終選擇了Log Service,主要考慮為無需搭建,成本低,查詢功能滿足。
| 功能項(xiàng) | ELK 類系統(tǒng) | Hadoop + Hive | 日志服務(wù) |
|---|---|---|---|
| 日志延時(shí) | 1~60 秒 | 幾分鐘~數(shù)小時(shí) | 實(shí)時(shí) |
| 查詢延時(shí) | 小于 1 秒 | 分鐘級 | 小于 1 秒 |
| 查詢能力 | 好 | 好 | 好 |
| 擴(kuò)展性 | 提前預(yù)備機(jī)器 | 提前預(yù)備機(jī)器 | 秒級 10 倍擴(kuò)容 |
| 成本 | 較高 | 較低 | 很低 |
日志延時(shí):日志產(chǎn)生后,多久可查詢。查詢延時(shí):單位時(shí)間掃描數(shù)據(jù)量。查詢能力:關(guān)鍵詞查詢、條件組合查詢、模糊查詢、數(shù)值比較、上下文查詢。擴(kuò)展性:快速應(yīng)對百倍流量上漲。成本:每 GB 費(fèi)用。
具體API使用,可查看日志服務(wù)[2]。
可視分析端(可視化平臺)
這個環(huán)節(jié),輸入是借口接收到的錯誤記錄,輸出是有效的數(shù)據(jù)入庫。核心功能需要對數(shù)據(jù)進(jìn)行清洗,順帶解決了過多的服務(wù)壓力。另一個核心功能是對數(shù)據(jù)進(jìn)行入庫。
主功能
這部分主要是產(chǎn)品功能的合理設(shè)計(jì),做到小而美,具體的怎么聚合,參考阿里云SLS就可以。
-
首頁圖表,可選1天、4小時(shí)、1小時(shí)等等,聚合錯誤數(shù),根據(jù)1天切分24份來聚合。 -
首頁列表,聚合選中時(shí)間內(nèi)的數(shù)據(jù),展示錯誤文件、錯誤key、事件數(shù)、錯誤類型、時(shí)間、錯誤信息。 -
錯誤詳情,事件列表、基本信息、設(shè)備信息、設(shè)備占比圖表(見上面事件列表的圖)。
排行榜
剛開始做了待處理錯誤列表、我的錯誤列表、已解決列表,錯誤與人沒有綁定關(guān)系,過于依賴人為主動,需要每個人主動到平臺上處理,效果不佳。
后面通過錯誤作者排行榜,通過釘釘日報(bào)來提醒對應(yīng)人員處理。緊急錯誤,通過實(shí)時(shí)告警來責(zé)任到人,后面告警會說。
具體原理:
-
webpack打包通過git命令把作者和作者郵箱、時(shí)間打包在頭部。 -
在可視化服務(wù)中,去請求對應(yīng)的報(bào)錯url匹配到對應(yīng)作者,返回給展示端。
SourceMap
利用webpack的hidden-source-map構(gòu)建。與 source-map 相比少了末尾的注釋,但 output 目錄下的 index.js.map 沒有少。線上環(huán)境避免source-map泄露。
webpackJsonp([1],[
function(e,t,i){...},
function(e,t,i){...},
function(e,t,i){...},
function(e,t,i){...},
...
])
// 這里沒有生成source-map的鏈接地址
復(fù)制代碼
根據(jù)報(bào)錯文件的url,根據(jù)團(tuán)隊(duì)內(nèi)部約定好的目錄和規(guī)則,定位之前打包上傳的sourceMap地址。
const sourcemapUrl = ('xxxfolder/' + url + 'xxxHash' +'.map')
復(fù)制代碼
獲取上報(bào)的line、column、source,利用第三方庫sourceMap進(jìn)行定位。
const sourceMap = require('source-map')
// 根據(jù)行數(shù)獲取源文件行數(shù)
const getPosition = async(map, rolno, colno) => {
const consumer = await new sourceMap.SourceMapConsumer(map)
const position = consumer.originalPositionFor({
line: rolno,
column: colno
})
position.content = consumer.sourceContentFor(position.source)
return position
}
復(fù)制代碼
感興趣SourceMap原理的,可以繼續(xù)深入,SourceMap 與前端異常監(jiān)控[3]。
錯誤報(bào)警
報(bào)警設(shè)置
-
每條業(yè)務(wù)線設(shè)置自己的閾值、錯誤時(shí)間跨度,報(bào)警輪詢間隔 -
通過釘釘hook報(bào)警到對應(yīng)的群 -
通過日報(bào)形式報(bào)出錯誤作者排行榜
○ 四、擴(kuò)展
行為搜集
通過搜集用戶的操作,可以明顯發(fā)現(xiàn)錯誤為什么產(chǎn)生。
分類
-
UI行為:點(diǎn)擊、滾動、聚焦/失焦、長按 -
瀏覽器行為:請求、前進(jìn)/后退、跳轉(zhuǎn)、新開頁面、關(guān)閉 -
控制臺行為:log、warn、error
搜集方式
-
點(diǎn)擊行為
使用addEventListener監(jiān)聽全局上的click事件,將事件和DOM元素名字收集。與錯誤信息一起上報(bào)。
-
發(fā)送請求
監(jiān)聽XMLHttpRequest的onreadystatechange回調(diào)函數(shù)
-
頁面跳轉(zhuǎn)
監(jiān)聽window.onpopstate,頁面進(jìn)行跳轉(zhuǎn)時(shí)會觸發(fā)。
-
控制臺行為
重寫console對象的info等方法。
有興趣可以參考行為監(jiān)控[4]。
遇到的問題
由于涉及到一些隱私,下述會做脫敏處理。
空日志問題
上線灰度運(yùn)行后,我們發(fā)現(xiàn)SLS日志存在一些空日志?? ,??,這是發(fā)生了啥?
首先我們回憶下這個鏈路上有哪些環(huán)節(jié)可能存在問題。
排查鏈路,SLS采集環(huán)節(jié)之前有磁盤日志收集,服務(wù)端接收,SDK上報(bào),那我們依次排查。
往前一步,發(fā)現(xiàn)磁盤日志就已經(jīng)存在空日志,那剩下就得看一下接收端、SDK端。
開始利用控制變量法,先在SDK端進(jìn)行空判斷,防止空日志上報(bào)。結(jié)果:發(fā)現(xiàn)無效??。
再繼續(xù)對Node接收端處理,對接收到的數(shù)據(jù)進(jìn)行判空,如果為空不進(jìn)行日志打印,結(jié)果:依然無效??。
所以開始定位是不是日志打印本身出了什么問題?研究了下日志第三方日志庫的API,進(jìn)行了各種嘗試,發(fā)現(xiàn)依舊沒用,我臉黑了??。
什么情況,“遇事不決”看源碼。排查下日志庫源碼存在什么問題。對于源碼的主調(diào)用流程走了一遍,并沒有發(fā)現(xiàn)什么問題,一頭霧水??。
整個代碼邏輯很正常,這讓我們開始懷疑難道是數(shù)據(jù)的問題,于是開始縮減上報(bào)的字段,最終定義為了一個字段。發(fā)現(xiàn)上線后沒有問題了??。
難道是有些字段存儲的數(shù)據(jù)過長導(dǎo)致的?但從代碼邏輯、流程日志中并沒有反應(yīng)這個錯誤的可能性。
因此我們利用二分法,二分地增加字段,最終定位到了某個字段。如果存在某個字段上報(bào)就會出現(xiàn)問題。這很出乎人的意料。
我們再想了下鏈路,除了日志庫,其他代碼基本都是我們自己的邏輯,所以對日志庫進(jìn)行了排查,懷疑其對某個字段做了什么處理。
于是通過搜索,定位到了日志庫在仆從模式(可以了解下Node的主從模式)下會使用某個字段來表意,導(dǎo)致和我們上報(bào)的字段沖突,因此丟失了??。
日志丟失問題
解決了上個問題,開心了,一股成就感涌上心頭。但馬上就被當(dāng)頭一棒,我發(fā)現(xiàn)我高興的太早了??。
團(tuán)隊(duì)的某同學(xué)在本地測試的時(shí)候,由于玩的很開心,一直去刷新頁面去上報(bào)當(dāng)前頁面的錯誤。但他發(fā)現(xiàn)本地上報(bào)的條數(shù)和實(shí)際日志服務(wù)里的條數(shù)對不上,日志服務(wù)里的少了很多。
由于之前自身剛畢業(yè)時(shí)候做過2年多后端開發(fā),對于IO操作丟失數(shù)據(jù)還是有點(diǎn)敏感。直覺上就感覺可能是多進(jìn)程方向的問題。懷疑是多進(jìn)程導(dǎo)致的文件死鎖問題。
那我們?nèi)サ舳嗑€程,通過單線程,我們?nèi)ブ貜?fù)原先復(fù)現(xiàn)問題的步驟。發(fā)現(xiàn)沒有遺漏??。
我們發(fā)現(xiàn)能進(jìn)行配置Cluster(主從模式)的地方有兩處,日志庫和部署工具。
觀察日志庫默認(rèn)使用的主從進(jìn)程模式,而部署工具沒有主從模式的概念,勢必會導(dǎo)致寫入IO的死鎖問題,導(dǎo)致日志丟失。于是在想社區(qū)有沒有可以有解決此問題的第三方支持。
然后通過谷歌搜索,很快就找到了對應(yīng)的第三方庫,它能提供主人進(jìn)程和仆從進(jìn)程之間的消息溝通。原理是主人進(jìn)程負(fù)責(zé)所有消息寫入log,而仆從進(jìn)程通過消息傳遞給主人進(jìn)程。
關(guān)于本文
來源:羽飛
https://juejin.cn/post/6987681953424080926
