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