在復雜的網絡環境和瀏覽器環境下,自測、QA測試以及 Code Review 都是不夠的,如果對頁面穩定性和准確性要求較高,就必須有一套完善的代碼異常監控體系,本文從前端代碼異常監控的方法和問題着手,盡量全面地闡述錯誤日志收集各個階段中可能遇到的阻礙和處理方案。
☞ 收集日志的方法
平時收集日志的手段,可以歸類為兩個方面,一個是邏輯中的錯誤判斷,為主動判斷;一個是利用語言給我們提供的捷徑,暴力式獲取錯誤信息,如 try..catch
和 window.onerror
。
1. 主動判斷
我們在一些運算之后,得到一個期望的結果,然而結果不是我們想要的
// test.js function calc(){ // code... return val; } if(calc() !== "someVal"){ Reporter.send({ position: "test.js::<Function>calc" msg: "calc error" }); }
這種屬於邏輯錯誤/狀態錯誤的反饋,在接口 status
判斷中用的比較多。
2. try..catch
捕獲
判斷一個代碼段中存在的錯誤:
try { init(); // code... } catch(e){ Reporter.send(format(e)); }
以 init
為程序的入口,代碼中所有同步執行出現的錯誤都會被捕獲,這種方式也可以很好的避免程序剛跑起來就掛。
3. window.onerror
捕獲全局錯誤:
window.onerror = function() { var errInfo = format(arguments); Reporter.send(errInfo); return true; };
在上面的函數中返回 return true
,錯誤便不會暴露到控制台中。下面是它的參數信息:
/** * @param {String} errorMessage 錯誤信息 * @param {String} scriptURI 出錯的文件 * @param {Long} lineNumber 出錯代碼的行號 * @param {Long} columnNumber 出錯代碼的列號 * @param {Object} errorObj 錯誤的詳細信息,Anything */ window.onerror = function(errorMessage, scriptURI, lineNumber,columnNumber,errorObj) { // code.. }
window.onerror
算是一種特別暴力的容錯手段,try..catch
也是如此,他們底層的實現就是利用 C/C++ 中的 goto
語句實現,一旦發現錯誤,不管目前的堆棧有多深,不管代碼運行到了何處,直接跑到頂層或者 try..catch
捕獲的那一層,這種一腳踢開錯誤的處理方式並不是很好。
☞ 收集日志存在的問題
收集日志的目的是為了及時發現問題,最好日志能夠告訴我們,錯誤在哪里,更優秀的做法是,不僅告訴錯誤在哪里,還告訴我們,如何處理這個錯誤。終極目標是,發現錯誤,自動容錯,這一步是最難的。
1. 無具體報錯信息,Script error.
先看下面的例子,test.html
<!-- http://barret/test.html --> <script> window.onerror = function(){ console.log(arguments); }; </script> <script src="http://barret/test.js"></script>
test.js
// http://barret/test.js function test(){ ver a = 1; return a+1; } test();
我們期望收集到的日志是下面這樣具體的信息:
為了對資源進行更好的配置和管理,我們通常將靜態資源放到異域上
<!-- http://barret/test.html --> <script> window.onerror = function(){ console.log(arguments); }; </script> <script src="http://localhost/test.js"></script>
而拿到的結果卻是:
翻開 Chromium 的 WebCore 源碼,可以看到:
跨域情況下,返回的結果是 Script error.
。
// http://trac.webkit.org/browser/branches/chromium/1453/Source/WebCore/dom/ScriptExecutionContext.cpp#L333 String message = errorMessage; int line = lineNumber; String sourceName = sourceURL; // 已經拿到了所有的錯誤信息,但如果發現是非同源情況,`sanitizeScriptError` 中復寫錯誤信息 sanitizeScriptError(message, line, sourceName, cachedScript);
舊版 的 WebCore 中只判斷了 securityOrigin()->canRequest(targetURL)
,新版中還多了一個 cachedScript
的判斷,可以看出瀏覽器對這方面的限制越來越嚴格。
在本地測試了下:
可見在 file://
協議下,securityOrigin()->canRequest(targetURL)
也是 false
。
☞ 為何Script error.
?
簡單報錯: Script error
,目的是避免數據泄露到不安全的域中,一個簡單的例子:
<script src="bank.com/login.html"></script>
上面我們並沒有引入一個 js 文件,而是一個 html,這個 html 是銀行的登錄頁面,如果你已經登錄了 bank.com
,那 login 頁面就會自動跳轉到 Welcome xxx...
,如果未登錄則跳轉到 Please Login...
,那么 JS 報錯也會是 Welcome xxx... is not defined
,Please Login... is not defined
,通過這些信息可以判斷一個用戶是否登錄他的銀行帳號,給 hacker 提供了十分便利的判斷渠道,這是相當不安全的。
☞ crossOrigin
參數跳過跨域限制
image 和 script 標簽都有 crossorigin 參數,它的作用就是告訴瀏覽器,我要加載一個外域的資源,並且我信任這個資源。
<script src="http://localhost/test.js" crossorigin></script>
然而,卻報錯了:
這是意料之中的錯誤,跨域資源共享策略要求,服務器也設置 Access-Control-Allow-Origin
的響應頭:
header('Access-Control-Allow-Origin: *');
回頭看看我們 CDN 的資源,
Javascript/CSS/Image/Font/SWF 等這些靜態資源其實都已經早早地加上了 CORS 響應頭。
2. 壓縮代碼無法定位到錯誤的具體位置
線上的代碼幾乎都是經過打包壓縮的,幾十上百的文件壓縮后打包成一個,而且只有一行。當我們收到 a is not defined
的時候,如果只在特定場景下才報錯,我們根本無法定位到這個被壓縮的 a
是個什么東西,那么此時的錯誤日志就是無效的。
第一個想到的辦法是利用 sourceMap,利用它可以定位到壓縮代碼某一點在未壓縮代碼的具體位置。下面是 sourceMap 引入的格式,在代碼的最后一行加入:
//# sourceMappingURL=index.js.map
以前使用的是 ‘//@’ 作為開頭,現在使用 ‘//#’,然而對於錯誤上報,這玩意兒沒啥用。JS 不能拿到他真實的行數,只能通過 Chrome DevTools 這樣的工具輔助定位,而且並不是每個線上資源都會添加 sourceMap 文件。sourceMap 的用途目前還只能體現在開發階段。
當然,如果理解了 sourceMap 的 VLQ編碼和位置對應關系,也可以將拿到的日志進行二次解析,映射到真實路徑位置,這個成本比較高,貌似暫時也沒人嘗試過。
那么,有什么辦法,可以定位錯誤的具體位置,或者說有什么辦法可以縮小我們定位問題的難度呢?
可以這樣考慮:打包的時候,在每兩個合並的文件之間加上 1000 個空行,最后上線的文件就會變成
(function(){var longCode.....})(); // file 1 // 1000 個空行 (function(){var longCode.....})(); // file 2 // 1000 個空行 (function(){var longCode.....})(); // file 3 // 1000 個空行 (function(){var longCode.....})(); // file 4 var _fileConfig = ['file 1', 'file 2', 'file 3', 'file 4']
如果報錯在第 3001 行,
window.onerror = function(msg, url, line, col, error){ // line = 3001 var lineNum = line; console.log("錯誤位置:" + _fileConfig[parseInt(lineNum / 1000) - 1]); // -> "錯誤位置:file 3" };
可以計算出,錯誤出現在第三個文件中,范圍就縮小了很多。
3. error 事件的注冊
多次注冊 error 事件,不會重復執行多個回調:
var fn = window.onerror = function() { console.log(arguments); }; window.addEventListener("error", fn); window.addEventListener("error", fn);
觸發錯誤之后,上面代碼的結果為:
window.onerror
和 addEventListener
都執行了,並只執行了一次。
4. 收集日志的量
沒有必要將所有的錯誤信息全部送到 Log 中,這個量太大了。如果網頁 PV 有 1kw,那么一個必現錯誤發送的 log 信息將有 1kw 條,大約一個 G 的日志。我們可以給 Reporter
函數添加一個采樣率:
function needReport (sampling){ // sampling: 0 - 1 return Math.random() <= sampling; } Reporter.send = function(errInfo, sampling) { if(needReport(sampling || 1)){ Reporter._send(errInfo); } };
這個采樣率可以按需求來處理,可以同上,使用一個隨機數,也可以使用 cookie 中的某個字段(如 nickname)的最后一個字母/數字來判定,也可以將用戶的 nickname 進行 hash 計算,再通過最后一位的字母/數字來判斷,總之,方法是很多的。
☞ 收集日志布點位置
為了更加精准的拿到錯誤信息,有效地統計錯誤日志,我們應該更多地采用主動式埋點,比如在一個接口的請求中:
// Module A Get Shops Data $.ajax({ url: URL, dataType: "jsonp", success: function(ret) { if(ret.status === "failed") { // 埋點 1 return Reporter.send({ category: "WARN", msg: "Module_A_GET_SHOPS_DATA_FAILED" }); } if(!ret.data || !ret.data.length) { // 埋點 2 return Reporter.send({ category: "WARN", msg: "Module_A_GET_SHOPS_DATA_EMPTY" }); } }, error: function() { // 埋點 3 Reporter.send({ category: "ERROR", msg: "Module_A_GET_SHOPS_DATA_ERROR" }); } });
上面我們精准地布下了三個點,描述十分清晰,這三個點會對我們后續排查線上問題提供十分有利的信息。
☞ 關於 try..catch
的使用
對於 try..catch
的使用,我的建議是:能不用,盡量不要用。JS代碼都是自己寫出來的,哪里會出現問題,會出現什么問題,心中應該都有個譜,平時用到 try..catch
的一般只有兩個地方:
// JSON 格式不對 try{ JSON.parse(JSONString); }catch(e){} // 存在不可 decode 的字符 try{ decodeComponentURI(string); }catch(e){}
類似這樣的錯誤都是不太可控的。可以在使用到 try..catch
的地方思考是否可以使用其他方式做兼容。感謝 EtherDream 的補充。
☞ 關於 window.onerror
的使用
可以嘗試如下代碼:
// test.js throw new Error("SHOW ME"); window.onerror = function(){ console.log(arguments); // 阻止在控制台中打印錯誤信息 return true; };
上面的代碼直接報錯了,沒有繼續往下執行。頁面中可能有好幾個 script 標簽,但是 window.onerror
這個錯誤監聽一定要放到最前頭!
☞ 錯誤的警報與提示
什么時候該警報?不能有錯就報。上面也說了,因為網絡環境和瀏覽器環境因素,復雜頁面我們允許千分之一的錯誤率。日志處理后的數據圖:
圖中有兩根線,橙色線是今日的數據,淺藍色線是往日平均數據,每隔 10 分鍾產生一條記錄,橫坐標是 0-24 點的時間軸,縱坐標是錯誤量。可以很明顯的看出,在凌晨一兩點左右,服務出現了異常,錯誤信息是平均值的十幾倍,那么這個時候就改報警了。
報警的條件可以設置得嚴苛一點,因為誤報是件很煩人的事情,短信、郵件、軟件等信息轟炸,有的時候還是大半夜。那么,一般滿足如下條件可以報警:
- 錯誤超過閾值,比如 10分鍾最多允許 100 個錯誤,結果超過了 100
- 錯誤超過平均值的 10 倍,超過平均值就報警,這個邏輯顯然不正確,但是超過了平均值的 10 倍,基本可以認定服務出問題了
- 在納入對比之前,要過濾同 IP 出現的錯誤,比如一個錯誤出現在 for 循環或者 while 循環中,再比如一個用戶在蹲點搶購,不停的刷新
☞ 友好的錯誤提示
對比下面兩條日志,catch 的錯誤日志:
Uncaught ReferenceError: vd is not defined
自定義的錯誤日志:
“生日模塊中獲取后端接口信息時,eval 解析出錯,錯誤內容為:vd is not defined.”
該錯誤在最近 10 分鍾內出現 1000 次,這個錯誤往日的平均出錯量是 50 次 / 10 分鍾
☞ 網絡錯誤日志工作草案
W3C Web Performance工作組發布了網絡錯誤日志工作草案。該文檔定義了一個機制,允許Web站點聲明一個網絡錯誤匯報策略,瀏覽器等用戶代理可以利用這一機制,匯報影響資源正確加載的網絡錯誤。該文檔還定義了一個錯誤報告的標准格式及其在瀏覽器和Web服務器之間的傳輸機制。
詳細草案:http://www.w3.org/TR/2015/WD-network-error-logging-20150305/
☞ 小結
功能、測試和監控是程序開發的三板斧,很多工程師可以將功能做的盡善盡美,也了解一些測試方面的知識,可是在監控這個方向上基本處於大腦空白。錯誤日志的收集、整理算是監控的一個小部分,但是它對我們了解網站穩定性至關重要。文中有忽略的地方希望讀者可以補充,錯誤的地方還望斧正。
☞ 拓展閱讀
- 基於window.onerror事件 建立前端錯誤日志 by Dx. Yang
- 構建web前端異常監控系統–FdSafe by 石破
- JavaScript Source Map 詳解 by 阮一峰
- HTML5標准-window.onerror
- MSDN-window.onerror
- MDN-window.onerror
- 網絡錯誤日志