導語

前端異常監控已不是什麼特別新鮮的事情了,但其重要性,對於一個站點的質量把控而言卻不容忽視。本文希望借一則案例將前端異常監控拉回人們視野,並對網絡經紀人web站的錯誤信息採集方案做一簡單介紹。

背景

去年年中一次普通的上線過程中,代碼上線後,js error數量異常升高。

代碼是經過測試環境、沙箱環境測試過的。很明顯,異常的起因是由特定瀏覽器、特定數據上下文、特定交互操作順序下的一種沒有被我們考慮到的場景所致。由於Error被定位到房源發佈頁,不敢怠慢,先做了回滾。

但問題還是要查下去。通過上報上來的錯誤日誌拿到了錯誤類型及調用棧(stack trace)信息,也定位到了報錯文件及代碼塊,報錯是從基礎UI庫裏報出來的。但由於調用棧太深,之前也沒有考慮到用Error.stackTraceLimit參數來調大error棧幀數, stack trace默認只有10行,能拿到的錯誤信息又太過於底層,無法有效定位異常根源。因爲對於底層方法而言,外層調用入口很多,沒有足夠的調用鏈信息就無法確定異常調用來源,原本一向無敵的stack trace,那刻卻變得非常雞肋。至於如何拿到混淆代碼stack trace裏的原始內容,請參見https://github.com/mozilla/source-map,這裏不做過多說明。

要解決當下這個js error並不難,加個邊界保護就行。但傳入的數據本不該就以這樣形式出現在那兒,一定是外層業務代碼哪裏調用不當導致了異常,而我們卻因爲沒有抓手,無從知曉其根源。

當然,最後原因還是被查到了。其間各種費時費力,此處不再贅述。

痛點及問題梳理

坦率來說,好久沒有關注過前端異常監控這塊兒了。而這次事件卻擊中了我們的一個痛點,就是在排查一些線上問題上耗費了我們太多時間和精力,往往會因爲要應對一個線上情況,導致項目排期上受到影響。也正是基於這一點,讓我們開始重新審視現有的頁面異常捕獲方案,看看是不是可以做一些調整,來降低排查線上問題的痛苦度。

通過審視和梳理,發現現有頁面異常監控方案,確實存在很多做得不到位的地方。於是把整理後的問題羅列如下:

1、異常場景抓取不夠全面,只用到window.onerror處理了js運行時異常,而對於promise異常、資源加載異常、xhr異常這些都沒處理;

2、對於偶發異常沒有現場還原能力。這類異常往往是在特定瀏覽器下、特定數據條件下,特定交互順序下導致的。由於難以復現,要修復這類問題的成本也最高;

3、部分跨域錯誤遺失。存在個別script標籤遺漏crossdomain屬性,導致Script Error 0 0 null的情況,在跨域情況下丟失了具體報錯信息;

4、沒有可用性自檢能力。有時一些場景沒有error日誌,但卻不能確定到底是一切正常,還是採集腳本在特定瀏覽器環境或特定框架下出現了問題,導致沒有把日誌抓回來所致,無法在線驗證採集器是否正常。

5、數據收集、分類,分析報表以及異常報警這塊還沒去深入做。

總結分析與設計

有了問題,就要考慮解決方案了。調研了業內比較出名的監控方案,有Sentry、ARMS、Fundebug等。一是瞭解一下大致的實現思路,二是評估一下直接接入的可能性。如果要直接接入,那肯定挑一個免費開源的,Sentry當屬首選。但深入調研後,發現要接入並部署Sentry,後端側要做的事情工作量其實更大。當下面臨的情況是業務上等着用,資源投入又不可能太多,於是打算先找一箇中間過渡方案,依託現有錯誤採集服務端的資源,在前端側快速調整一下,來支持線上業務。同時也要爲後續接入Sentry、或是中臺的監控方案預留可擴展性。
基於這種考慮,這次調整的重點放在數據採集上,主要解決採集錯誤信息不夠充分的問題,來緩解問題定位上的痛點。至於數據採集上來後的可視化平臺,數據分析報表等比較重的工作,還是考慮後續通過接入第三方監控系統或中臺的服務來解決。
接下來看一下具體的調整措施:
1、對原有監控探針做一些調整,通過在window上添加監聽和對事件劫持來補全之前漏抓的場景;
2、強化靜態代碼掃描,lint規則中加入針對script標籤crossdomain的檢查。對於被允許CORS域名下的script標籤,如果沒有crossdomain屬性直接lint報錯。暫不對document.createElement("script")做劫持來添加crossdomain屬性,因爲第三方js中可能會用到,且有些域名不允許跨域,可能產生加載報錯,所以只在lint環節打印出來,人工確認一下;
3、針對偶發類錯誤設計一套場景還原方案,通過採集更多維度的信息來輔助還原異常現場,後面會具體講到具體方案;
4、增加採集器自檢能力。在URL中預留一個自檢查開關,默認處於關閉狀態,需要自檢時可手動打開。例如/user/brokerhomeV2?xxx_trace=true。開關打開後會異步執行document.createElement("script")來加載固定地址下的一段腳本,該腳本里是故意設計好的一些報錯用例,方便在線驗證。這個腳本獨立發佈,可以任意修改。同時,開關打開後,日誌上報方法會在控制檯打印上報內容,便於開發人員及時確認。
5、在1的基礎上對監聽和事件劫持做wrap,預留對接入第三方監控的兼容性。
改造前後方案對比:

至於vue或react裏的異常,用window.onerror是捕獲不到的。需要藉助vue的errorHandler,react的ErrorBoundary來處理。以vue爲例,實現思路大致如下:

function wrapErrorHandler(ErrorDetector, Vue) {

Vue = Vue || window.Vue;

if (!Vue || !Vue.config) return;

var _oldOnError = Vue.config.errorHandler;

Vue.config.errorHandler = function VueErrorHandler(error, vm, info) {

var metaData = {};

// vm and lifecycleHook are not always available

if (Object.prototype.toString.call(vm) === '[object Object]') {

metaData.componentName = formatComponentName(vm);

metaData.propsData = vm.$options.propsData;

}

if (typeof info !== 'undefined') {

metaData.lifecycleHook = info;

}

ErrorDetector.captureError(error, {

extra: metaData

});

if (typeof _oldOnError === 'function') {

_oldOnError.call(this, error, vm, info);

}

};

}

以上代碼參考了raven.js中vue integration的實現。Vue對errorHandler的wrap要以es module依賴方式安裝進來,並打包到bundle js中,不能像普通探針那樣直接以script標籤加載。但可以和普通error探針共存,調用探針上captureError方法,從而共享異常處理邏輯和異常上報邏輯。

接下來重點說一下 報錯現場還原 這一塊

做現場還原,目的是要解決在偶發異常(非必現)出現的時候,能夠了解到客戶端究竟發生了什麼,從而爲後續問題的排除、複驗提供抓手和依據。

先來看看現場信息可能包含那些:

終端信息:操作系統(platform),瀏覽器相關(userAgent),顯示器(screen)等;

訪問信息:URL信息,refer,upstream_addr,method,用戶會話ID,地理位置、時間等;

錯誤信息:error基本信息及其錯誤棧信息;

行爲信息:用戶鼠標、鍵盤的操作隊列;

VM快照:Vue或react項目頂層store中數據模型的快照;

屏幕快照:出錯時頁面的截屏。

其中,終端信息、訪問信息、錯誤信息比較好拿,且現有監控已經拿到了。但還有兩點需要完善,一、就是stack trace的擴容(10行不夠)。二、看看有沒有辦法拿到異步方法的stack trace(long stack trace)。至於VM快照,由於房產經紀人頁面還需要兼容低版本瀏覽器的緣故,大多數頁面還沒遷到數據驅動框架下,暫不在採集。而能不能採集到用戶行爲信息和屏幕快照,就成爲能否還原現場的關鍵。接下來具體看一下如何採集信息。

總結

對於頁面錯誤信息,我們把它大致歸爲兩類。一類是頁面加載過程中就會報出來的錯誤,一類是頁面加載完成後,需要用戶在特定操作下,纔會報出的錯誤。第一類錯誤理論上在瀏覽器(UA)確定的情況下,用戶會話ID和URL確定的情況下,該類錯誤絕大多數可以說是必現的。這類錯誤通過現有的UA、用戶會話ID和URL可以直接還原場景。不需要再採集用戶行爲信息和截屏。第二類就是由用戶交互產生的,這類正是場景比較複雜,文章開頭例子中難以復現的那種,需要藉助採集用戶行爲信息和截屏來還原現場。
這兩類信息在採集時需要區分對待,第一類信息只採集終端信息、訪問信息、錯誤信息,第二類信息才需要加上行爲信息和截屏信息作爲補充。對於這塊的處理是通過在window.onload配置一個開關來實現,開關實現如下:

$(window).load(function(){

ErrorDetector.pageLoadComplete = true;

})

關於用戶行爲信息的採集,這裏有二項需要確認。一是怎麼採集到用戶行爲,二是採集來的數據如何進行合理分割。

行爲採集一定是不能侵入到業務代碼中的,所以必須通過監聽和劫持來達成。這個好辦,Sentry不是能採集到嘛,拿來參考一下。以下參考了Sentry中raven.js的採集實現,將下列函數進行了Wrap:

window.setTimeout
window.setInterval
window.requestAnimationFrame
EventTarget.addEventListener
EventTarget.removeEventListener
XMLHTTPRequest.open
XMLHTTPRequest.send
window.fetch
History.pushState
History.replaceState
接下來看一下數據分片
頁面加載完成後,開始錄製用戶行爲,行爲數據以隊列形式存放在監控探針js的全局變量上。這個變量上的內容會在異常發生時,被上報給後臺服務。但這個錄製什麼時候停,什麼時候重新開始錄製,卻需要制定一個策略。我們的方案採取以URL變化和交互error發生兩個時刻來做切割。理由如下:
1、URL變化意味着場景切換,場景切換前上下文的交互細節不需要過多關注。URL、用戶會話ID、瀏覽器(UA)能夠共同確定交互類錯誤發生前的最近上下文環境;
2、同一頁上發生多次異常,沒有必要每個上報內容都包含頁面一進來到報錯時點的全量交互信息,以報錯點切割開就行。每段報錯切片會按時間順序上報給後臺,將它們合在一起可以形成上下文鏈,這個從時間上可以確定。
3、採集數據不是越多越好,對網絡傳輸,數據存儲都會是挑戰,甚至會淹沒有效數據。所以應避免上報過多冗餘數據。
基於以上理由,每次上報的用戶行爲採集切片方案設計如下:

至於SPA類的web,需要監控探針暴露一個方法給到router切換時來調用,以處理清空隊列等動作。
聊完了用戶行爲抓取,再來看屏幕截取。
理論上拿回用戶行爲日誌後,可以在本地瀏覽器環境中注入上下文後,逐一執行來進行回放,從而還原現場。但是,現實情況下要搭建這樣一套交互回放系統,其成本投入卻是現階段不能承受的,所以放棄,還是老老實實截圖去。雖說截圖對於問題定位不是必須的,但有總比沒有好,一圖勝萬言,查問題效率上是有很大提升的。實踐下來,截屏+stack trace絕對是問題定位大殺器。
接下來看看截屏數據採集的具體方式:
截屏是藉助html2canvas插件來實現的。通過將當前頁轉成canvas後再序列化爲base64,base64的內容會和行爲數據隊列序列化後的結果一起上報給服務端。
截屏轉base64代碼如下:

function captureScreen() {

var targetDom = document.querySelector("body");

html2canvas(targetDom, {

useCORS: true,

allowTaint: false

height: targetDom.scrollHeight,

width: targetDom.scrollWidth,

}).then(function(canvas) {

var context = canvas.getContext('2d');

context.mozImageSmoothingEnabled = false;

context.webkitImageSmoothingEnabled = false;

context.msImageSmoothingEnabled = false;

context.imageSmoothingEnabled = false;

var quality = 0.92;

var base64Image = canvas.toDataURL('image/png', quality).substring(22);

ErrorDetector.accidentScene.img = base64Image;

})

}

代碼中imageSmoothEnabled = false是爲了提高圖片清晰度,抗鋸齒用的。由於html2canvas中用到了Promise,對於低版本瀏覽器需要加入promise墊片,這裏用的是promise-polyfill。至於base64是否再要做壓縮,默認圖片質量下實際生成的大小在220~450K之間,在保證清晰度情況下,這個大小範圍是可接受的。

異常上報

現場信息採集到了,上報時如何不給現有錯誤日誌服務帶來太大壓力,這是我們要考慮的。

先來看要不要做節流。去年上半年異常日誌峯值在每分鐘6K左右,日均上報的error量在20萬上下。目前後臺服務完全可以承受這樣的上報量級甚至更高,在項目發佈時我們往往更關注異常返回的及時性,所以暫不考慮用節流方式來降低總請求量。

但是有一個問題,原來單個上報數據量少(1K以內),現在要對現場還原,用戶行爲信息(往大了算10K內)+屏幕截屏(450K內),加一起以上限計算有460K。如果按這個大小每個客戶,每次發生異常都上報,很可能會對現有後臺服務產生影響,而且客戶端網絡上行也會收到一定影響。所以,必須要減少網絡上報數據的傳輸量。

先來看壓縮方案。由於截屏占上報size的大頭,用戶行爲信息幾乎可以忽略,所以壓縮本質可以理解爲對圖片的壓縮。要在現有基礎上壓縮好幾個數量級,不降低清晰度降是不現實的,但降低清晰度有可能導致圖片失去定位問題的作用,單純的圖片壓縮方案有點得不償失。

再看另一條路,在單個上報size不變情況下,把上報次數砍掉,似乎也能達成目標。查了一下單日上報錯誤日誌情況,以總量20萬計,實際按錯誤分類也就40~100之間。刨去頁面剛進入不需要採集場景的這類錯誤類型,那數量就更少了。對於同一個原因導致的問題,後臺只要拿到一次可復現的場景就可以了。同樣的,對於終端瀏覽器而言,同一個原因導致的錯誤現場只上報一次。這樣的話,對現有錯誤蒐集服務來說幾乎沒有新增的壓力。這裏有一點要補充說明一下,這裏同一個原因錯誤上報去重只針對用戶行爲信息和屏幕截屏,終端信息、訪問信息、錯誤信息還是會及時上報,不會做防重,這還是爲了保證項目發佈時異常反饋的及時性。

於是上報方案設計如下:
1、交互場景error只蒐集js運行時這類錯誤,在window.onerror中採集。錯誤唯一性通過message,source,lineno,colno加分隔符後拼接成的字符串作爲唯一標識(unique key)。
2、window.onerror中區分對待頁面加載過程中的錯誤和頁面加載後由用戶行爲操作產生的錯誤。通過在window.onload中設置加載完畢標誌,加載中錯誤走原來通道上報,加載後走場景還原上報通道。這樣可以有效降低圖片上報數量。
3、同一錯誤只在客戶端上報一次。利用客戶端存儲將上報過錯誤的唯一標識存下來,下次上報前比對是否已上報過,已上報過則忽略,否則繼續上報。
4、同一錯誤服務端只採集一次。由於用戶基數大,僅靠客戶端去重是不能有效降低上報總量的。搭建一臺node配redis,用來存儲上報過錯誤的唯一標識。客戶端上報前會先拿唯一標識去服務器端作比對,如果沒有上傳過才上傳,上傳後唯一標識會存入本地客戶端存儲中,下次直接在本地比較時就會攔截,不會給node服務帶來壓力。
爲了在客戶端做一層去重,需要在本地保留上傳過錯誤的摘要。本地存儲採用indexedDB,以隊列存儲,先進先出,大小配30(數字是按線上交互類報錯的單個用戶平均數放大5倍後的預估值)。

實際去做的時候,發現最初的方案有些問題

1、首先,用message+source+lineno+colno來唯一標識特定錯誤是不精確的。一些錯誤是由基礎方法裏拋出來的,報錯位置相同,但產生原因不相同。要唯一標識出特定原因的錯誤還是得用stack trace。
2、存儲用indexeddb的話,有低版本瀏覽器兼容問題。爲了兼容低版本瀏覽器還得單獨實現一套基於localstorage的隊列存儲,做是可以做,但就當下而言不經濟。
針對上面的問題,採取如下調整:
對於第一個問題,把唯一標識換成stack trace。但limit放大後的stack trace太長了,還有格式問題,直接拿來做unique key不太合適。所以對其md5一下後作爲key使用。
至於第二個問題,放棄indexeddb直接用localstorage。但這樣一來,如果還是走每次上報就更新localstorage的模式的話,要做size超限判斷、刪除等操作就很麻煩。思來想去後,決定採用批量更新模式。
每次發生場景上報後不直接寫localstorage,而是放到js內存對象中更新,以map形式存儲。在window.beforeunload時,將map對象序列化後一次性存儲下來。在頁面載入時,監控探針也能一次性從localstorage裏取到map後反序列化到內存變量中,這對於後續的判重來說是比較方便的。至於更新策略,是通過保留引用在隊列中,通過維護隊列的先進先出從而更新map對象裏的內容的。

至此,方案調整完畢。

總結

就目前的監控方案而言,主要是解決了頁面數據採集問題,在業務上補上了之前在監控上遺留下來的一塊短板。在大方向上,後續還是會接入中臺或第三方的監控體系中,因爲整個監控體系極爲複雜龐大,我們沒有必要自己去造這個輪子,借力就好了。

但就這次實踐本身而言,對經紀人前端來說還是有很多收穫。首先,通過這次實踐,實實在在能幫我們緩解查線上問題這個痛點。之前偶發性問題往往需要花費我們2個小時甚至1天來定位問題,現在多數情況15分鐘內可以搞定。其次,由於補全了異常場景,對於監控本身能發揮的作用更有信心了,上新項目也更有底氣了。同時,實踐也爲我們留下不少基礎設施,比如,截圖邏輯是我們特有的,stace trace limit控制也很方便,今後接入其他監控系統後,這些資產仍可以保留下來,爲我所用。

最後,實現監控本身已不是一個大的問題,但如何把監控做得全面、做得到位,並不斷去優化,卻是還是值得我們不斷去思考的。

參考文獻

1、學習 sentry 源碼整體架構,打造屬於自己的前端異常監控SDK

https://juejin.im/post/5dba5a39e51d452a2378348a

2、前端代碼異常監控實戰

https://zhuanlan.zhihu.com/p/32709628

3、前端監控概述

https://www.alibabacloud.com/help/zh/doc-detail/58652.htm?spm=a2c63.p38356.b99.91.46f918f3mekWSB

作者簡介

孫淘熔:HBG二手房技術部資深開發工程師,18年3月加入公司,目前負責中國網絡經紀人、網絡門店、家裝店鋪等前端側工作,對面向B端的前端開發有深入瞭解。

閱讀推薦

相關文章