一個完整的前端監控平台包括三個部分:資料采集與上報、資料整理和存儲、資料展示。
本文要講的就是其中的第一個環節——資料采集與上報。下圖是本文要講述内容的大綱,大家可以先大緻了解一下:
僅看理論知識是比較難以了解的,為此我結合本文要講的技術要點寫了一個簡單的簡控 SDK,可以用它來寫一些簡單的 DEMO,幫助加深了解。再結合本文一起閱讀,效果更好。
性能資料采集
chrome 開發團隊提出了一系列用于檢測網頁性能的名額:
- FP(first-paint),從頁面加載開始到第一個像素繪制到螢幕上的時間
- FCP(first-contentful-paint),從頁面加載開始到頁面内容的任何部分在螢幕上完成渲染的時間
- LCP(largest-contentful-paint),從頁面加載開始到最大文本塊或圖像元素在螢幕上完成渲染的時間
- CLS(layout-shift),從頁面加載開始和其生命周期狀态變為隐藏期間發生的所有意外布局偏移的累積分數
這四個性能名額都需要通過 PerformanceObserver 來擷取(也可以通過 performance.getEntriesByName() 擷取,但它不是在事件觸發時通知的)。PerformanceObserver 是一個性能監測對象,用于監測性能度量事件。
簡述
Slardar 前端監控自18年底開始建設以來,從僅僅作為 Sentry 的替代,經曆了一系列疊代和發展,目前做為一個監控解決方案,已經應用到抖音、西瓜、今日頭條等衆多業務線。
據21年下旬統計,Slardar 前端監控(Web + Hybrd) 工作日晚間峰值 qps 300w+,日均處理資料超過千億條。
本文,我将針對在這一系列的發展過程中,位元組内部監控設計和疊代遇到的落地細節設計問題,管中窺豹,向大家介紹我們團隊所思考和使用的解決方案。
他們主要圍繞着前端監控體系建設的一些關鍵問題展開。也許大家對他們的原理早已身經百戰見得多了,不過當實際要落地實作的時候,還是有許多細節可以再進一步琢磨的。
如何做好 JS 異常監控
JS 異常監控本質并不複雜,浏覽器早已提供了全局捕獲異常的方案。
javascript複制代碼window.addEventListener('error', (err) => {
report(normalize(err))
});
window.addEventListener('unhandledrejection', (rejection) => {
report(normalize(rejection))
});
但捕獲到錯誤僅僅隻是相關工作的第一步。在我看來,JS 異常監控的目标是:
- 開發者迅速感覺到 JS 異常發生
- 通過監控平台迅速定位問題
- 開發者能夠高效的處理問題,并統計,追蹤問題的處理進度
在異常捕獲之外,還包括堆棧的反解與聚合,處理人配置設定和報警這幾個方面。
堆棧反解: Sourcemap
大家都知道 Script 腳本在浏覽器中都是以明文傳輸并執行,現代前端開發方案為了節省體積,減少網絡請求數,不暴露業務邏輯,或從另一種語言編譯成 JS。都會選擇将代碼進行混淆和壓縮。在優化性能,提升使用者體驗的同時,也為異常的處理帶來了麻煩。
在本地開發時,我們通常可以清楚的看到報錯的源代碼堆棧,進而快速定位到原始報錯位置。而線上的代碼經過壓縮,可讀性已經變得非常糟糕,上報的堆棧很難對應到原始的代碼中。 Sourcemap 正是用來解決這個問題的。
簡單來說,Sourcemap 維護了混淆後的代碼行列到原代碼行列的映射關系,我們輸入混淆後的行列号,就能夠獲得對應的原始代碼的行列号,結合源代碼檔案便可定位到真實的報錯位置。
Sourcemap 的解析和反解析過程涉及到 VLQ 編碼,它是一種将代碼映射關系進一步壓縮為類base64編碼的優化手段。
在實際應用中,我們可以把它直接當成黑盒,因為業界已經為我們提供了友善的解析工具。下面是一個利用 mozila 的 sourcemap 庫進行反解的例子。
以上代碼執行後通常會得到這樣的結果,實際上我們的線上反解服務也就是這樣實作的
當然,我們不可能在每次異常發生後,才去生成 sourcemap,在本地或上傳到線上進行反解。這樣的效率太低,定位問題也太慢。另一個方案是利用 sourcemappingURL 來制定sourcemap 存放的位置,但這樣等于将頁面邏輯直接暴露給了網站使用者。對于具有一定規模和保密性的項目,這肯定是不能接受的。
ruby複制代碼 //# sourceMappingURL=http://example.com/path/hello.js.map
為了解決這個問題,一個自然的方案便是利用各種打包插件或二進制工具,在建構過程中将生成的 sourcemap 直接上傳到後端。Sentry 就提供了類似的工具,而位元組内部也是使用相似的方案。
通過如上方案,我們能夠讓使用者在發版建構時就可以完成 sourcemap 的上傳工作,而異常發生後,錯誤可以自動完成解析。不需要使用者再操心反解相關的工作了。
堆棧聚合政策
當代碼被成功反解後,使用者已經可以看到這一條錯誤的線上和原始代碼了,但接下來遇到的問題則是,如果我們隻是上報一條存一條,并且給使用者展示一條錯誤,那麼在平台側,我們的異常錯誤清單會被大量的重複上報占滿,
對于錯誤類型進行統計,後續的異常配置設定操作都無法正常進行。
在這種情況下,我們需要對堆棧進行分組和聚合。也就是,将具有相同特征的錯誤上報,歸類為統一種異常,并且隻對使用者暴露這種聚合後的異常。
堆棧怎麼聚合效果才好呢?我們首先可以觀察我們的JS異常所攜帶的資訊,一個異常通常包括以下部分
- name: 異常的 Type,例如 TypeError, SyntaxError, DOMError
- Message:異常的相關資訊,通常是異常原因,例如 a.b is not defined.
- Stack (非标準)異常的上下文堆棧資訊,通常為字元串
那麼聚合的方案自然就出來了,利用某種方式,将 error 相關的資訊利用提取為 fingerprint,每一次上報如果能夠獲得相同的 fingerprint,它們就可以歸為一類。那麼問題進一步細化為:如何利用 Error 來保證 fingerprint 的區分盡量準确呢?
如果跟随标準,我們隻能利用 name + message 作為聚合依據。但在實踐過程中,我們發現這是遠遠不夠的。如上所示,可以看到這兩個檔案發生的位置是完全不同的,來自于不同的代碼段,但由于我們隻按照 name + message 聚合。它們被錯誤聚合到了一起,這樣可能造成我們修複了其中一個錯誤後。誤以為相關的所有異常都被解決。
是以,很明顯我們需要利用非标準的 error.stack 的資訊來幫我們解決問題了。在這裡我們參考了 Sentry 的堆棧聚合政策:
除了正常的 name, message, 我們将反解後的 stacktrace 進一步拆分為一系列的 Frame,每一個 Frame 内我們重點關注其調用函數名,調用檔案名以及目前執行的代碼行(圖中的context_line)。
Sentry 将每一個拆分出的部分都稱為一個 GroupingComponent,當堆棧反解完畢後,我們首先自上而下的遞歸檢測,并自下而上的生成一個個嵌套的 GroupingComponent。最後,在頂層調用 GroupingComponent.getHash() 方法, 得到一個最終的哈希值,這就是我們最終求得的 fingerprint。
相較于message+name, 利用 stacktrace 能夠更細緻的提取堆棧特征,規避了不同檔案下觸發相同 message 的問題。是以獲得的聚合效果也更優秀。這個政策目前在位元組内部的工作效果良好,基本上能夠做到精确的區分各類異常而不會造成混淆和錯誤聚合。
處理人自動配置設定政策
異常已經成功定位後,如果我們可以直接将異常配置設定給這行代碼的書寫者或送出者,可以進一步提升問題解決的效率,這就是處理人自動配置設定所關心的,通常來說,配置設定處理人依賴 git blame 來實作。
一般的團隊或公司都會使用 Gitlab / Github 作為代碼的遠端倉庫。而這些平台都提供了豐富的 open-api 協助使用者進行blame,
我們很自然的會聯想到,當通過 sourcemap 解出原始堆棧路徑後,如果可以結合調用 open-api,獲得這段代碼所在檔案的blame曆史, 我們就有機會直接線上上确定某一行的可能的 author / commitor 究竟是誰。進而将這個異常直接配置設定給他。
思路出來了,那麼實際怎麼落地呢?
我們需要幾個資訊
- 線上報錯的項目對應的源代碼倉庫名,如 toutiao-fe/slardar
- 線上報錯的代碼發生的版本,以及與此版本關聯的 git commit 資訊,為什麼需要這些資訊呢?
預設用來 blame 的檔案都是最新版本,但線上跑的不一定是最新版本的代碼。不同版本的代碼可能發生行的變動,進而影響實際代碼的行号。如果我們無法将線上版本和用來 blame 的檔案劃分在統一範圍内,則很有可能自動定位失敗。
是以,我們必須找到一種方法,确定目前 blame 的檔案和線上報錯的檔案處于同一版本。并且可以直接通過版本定位到關聯的源代碼 commit 起止位置。這樣的操作在 Sentry 的官方工具 Sentry-Cli 中亦有提供。位元組内部同樣使用了這種方案。
通過 相關的 二進制工具,在代碼釋出前的腳本中提供目前将要釋出的項目的版本和關聯的代碼倉庫資訊。同時在資料采集側也攜帶相同的版本,線上異常發生後,我們就可以通過線上報錯的版本找到原始檔案對應的版本,進而精确定位到需要哪個時期的檔案了。
異常報警
當異常已經成功反解和聚合後,當使用者通路監控平台,已經可以觀察并處理相關的錯誤,不過到目前為止,異常的發生還無法觸及開發者,問題的解決依然依靠“走查”行為。這樣的方案對嚴重的線上問題依然是不夠用,是以我們還需要主動通知使用者的手段,這就是異常報警。
在位元組内部,報警可以分為宏觀報警,即針對錯誤名額的數量/比率的報警,以及微觀報警,即針對新增異常的報警。
宏觀報警
宏觀報警是數量/比率報警, 它隻是統計某一類名額是否超出了限定的門檻值,而不關心它具體是什麼。是以預設情況下它并不會告訴你報警的原因。隻有通過歸因次元或者下文會提到的 微觀(新增異常)報警 才能夠知曉引發報警的具體原因
關于宏觀報警,我們有幾個關鍵概念
- 第一是樣本量,使用者數門檻值: 在配置比率名額時。如果上報量過低,可能會造成比率的嚴重波動,例如錯誤率 > 20%, 的報警下,如果 JS 錯誤數從 0 漲到 1, 那就是比率上漲到無限大進而造成沒有意義的誤報。如果不希望被少量波動幹擾,我們設定了針對錯誤上報量和使用者數的最低門檻值,例如隻有當錯誤影響使用者數 > 5 時,才針對錯誤率變化報警。
- 第二是歸因次元: 對于數量,比率報警,僅僅獲得一個異常名額值是沒什麼意義的,因為我們無法快速的定位問題是由什麼因素引發的,是以我們提供了歸因次元配置。例如,通過對 JS 異常報警配置錯誤資訊歸因,我們可以在報警時獲得引發目前報警的 top3 關鍵錯誤和增長最快的 top3 錯誤資訊。
- 第三是時間視窗,報警運作頻率: 如上文所說,報警是數量,比率報警,而數量,比率一定有一個統計範圍,這個就是通過 時間視窗 來确定的。而報警并不是時時刻刻盯着我們的業務資料的,可以了解為利用一個定時器來定期檢查 時間視窗 内的資料是否超出了我們定義的門檻值。而這個定時器的間隔時間,就是 報警運作頻率。通過這種方式,我們可以做到類實時的監測異常資料的變化,但又沒有帶來過大的資源開銷。
微觀報警(新增異常)
相較于在意宏觀數量變化的報警,新增異常在意每一個具體問題,隻要此問題是此前沒有出現過的,就會主動通知使用者。
同時,宏觀報警是針對資料的定時查找,存在運作頻率和時間視窗的限制,實時性有限。微觀報警是主動推送的,具有更高的實時性。
微觀報警适用于發版,灰階等對新問題極其關注,并且不友善在此時專門配置相關數量報警的階段。
如何判斷“新增”?
我們在 異常自動配置設定章節講到了,我們的業務代碼都是可以關聯一個版本概念的。實際上版本不僅和源代碼有關,也可以關聯到某一類錯誤上。
在這裡我們同樣也可以基于版本視角判斷“新增錯誤”。
對于新增異常的判斷,針對兩種不同場景做了區分
- 對于指定版本、最新版本的新增異常報警,我們會分析該報警的 fingerprint 是否為該版本代碼中首次出現。
- 而對于全體版本,我們則将"首次”的範圍增加了時間限制,因為對于某個錯誤,如果在長期沒有出現後又突然出現,他本身還是具有通知的意義的,如果不進行時間限制,這個錯誤就不會通知到使用者,可能會出現資訊遺漏的情況。
如何做好性能監控?
如果說異常處理是前端監控體系60分的分界線,那麼性能度量則是監控體系能否達到90分的關鍵。一個響應遲鈍,點哪兒卡哪兒的頁面,不會比點開到處都是報錯的頁面更加吸引人。頁面的卡頓可能會直接帶來使用者通路量的下降,進而影響背後承載的服務收入。是以,監控頁面性能并提升頁面性能也是非常重要的。針對性能監控,我們主要關注名額選取,品質度量 、瓶頸定位三個關鍵問題。
名額選取
名額選取依然不是我們今天文章分享的重點。網上關于 RUM 名額,Navigation 名額的介紹和采集方式已經足夠清晰。通常分為兩個思路:
- RUM (真實使用者名額) -> 可以通過 Web Vitals (github.com/GoogleChrom…*
- 頁面加載名額 -> NavigationTiming (ResourceTiming + DOM Processing + Load) 可以通過 MDN 相關介紹學習。這裡都不多贅述。
瓶頸定位
收集到名額隻是問題的第一步,接下來的關鍵問題便是,我們應該如何找出影響性能問題的根因,并且針對性的進行修複呢?
慢會話 + 性能時序分析
如果你對“資料洞察/可觀測性”這個概念有所了解,那麼你應該對 Kibana 或 Datadog 這類産品有所耳聞。在 kibana 或 Datadog 中都能夠針對每一條上傳的日志進行詳細的追溯。和詳細的上下文進行關聯,讓使用者的體驗可被觀測,通過多種篩選找到需要使用者的資料。
在位元組前端的内部建設中,我們參考了這類資料洞察平台的消費思路。設計了資料探索能力。通過資料探索,我們可以針對使用者上報的任意次元,對一類日志進行過濾,而不隻是獲得被聚合過的清單資訊資料。這樣的消費方式有什麼好處呢?
- 我們可以直接定位到一條具體日志,找到一個現實的 data point 來分析問題
- 這種視圖的狀态是易于儲存的,我們可以将找到的資料日志通過連結發送給其他人,其他使用者可以直接還原現場。
對于性能瓶頸,在資料探索中,可以輕松通過針對某一類 PV 上報所關聯的性能名額進行數值篩選。也可以按照某個固定時段進行篩選,進而直接獲得響應的慢會話。這樣的優勢在于我們不用預先設定一個“慢會話門檻值”,需要哪個範圍的資料完全由我們自己說了算。例如,通過對 FCP > 3000ms 進行篩選,我們就能夠獲得一系列 FCP > 3s 的 PV 日志現場。
在每次 PV 上報後,我們會為資料采集的 SDK 設定一個全局狀态,比如 view_id, 隻要沒有發生新的頁面切換,目前的 view_id 就會保持不變。
而後續的一系列請求,異常,靜态資源上報就可以通過 view_id 進行後端的時序串聯。形成一張資源加載瀑布圖。在瀑布圖中我們可以觀察到各類性能名額和靜态資源加載,網絡請求的關系。進而檢測出是否是因為某些不必要的或者過大的資源,請求導緻的頁面性能瓶頸。這樣的瀑布圖都是一個個真實的使用者上報形成的,相較于統計值産生的甘特圖,更能幫助我們解決實際問題。
結合Longtask + 使用者行為分析
通過名額過濾慢會話,并且結合性能時序瀑布圖分析,我們能夠判斷出目前頁面中是否存在由于網絡或過大資源因素導緻的頁面加載遲緩問題
但頁面的卡頓不一定全是由網絡因素造成的。一個最簡單的例子。當我在頁面的 head 中插入一段非常耗時的同步腳本(例如 while N 次),則引發頁面卡頓的原因就來自于代碼執行而非資源加載。
針對這種情況,浏覽器同樣提供了 Longtask API 供我們收集這類占據主線程時間過長的任務。
同樣的,我們将這類資訊一并收集,并通過上文提到的 view_id 串聯到一次頁面通路中。使用者就可以觀察到某個性能名額是否受到了繁重的主線程加載的影響。若有,則可利用類似 lighthouse 的合成監控方案集中檢查對應頁面中是否存在相關邏輯了。
受限于浏覽器所收集到的資訊,目前的 longtask 我們僅僅隻能獲得它的執行時間相關資訊。而無法像開發者面闆中的 performance 工具一樣準确擷取這段邏輯是由那段代碼引發的。如果我們能夠在一定程度上收集到longtask觸發的上下文,則可定位到具體的慢操作來源。
此外,頁面的卡頓不一定僅僅發生在頁面加載階段,有時頁面的卡頓會來自于頁面的一次互動,如點選,滾動等等。這類行為造成的卡頓,僅僅依靠 RUM / navigation 名額是無法定位的。如果我們能夠通過某種方式(在PPT中已經說明),對操作行為計時。并将操作計時範圍内觸發的請求,靜态資源和longtask上報以同樣的瀑布圖方式收斂到一起。則可以進一步定位頁面的“慢操作”,進而提升頁面互動體驗。
如下圖所示,我們可以檢查到,點選 slardar_web 這個按鈕 / 标簽帶來了一系列的請求和 longtask,如果這次互動帶來了一定的互動卡頓。我們便可以集中修複觸發這個點選事件所涉及的邏輯來提升頁面性能表現。
品質度量
當我們采集到一個性能名額後,針對這樣一個數字,我們能做什麼?
我們需要結論:好還是不好?
實際上我們通常是以單頁面為次元來判定名額的,以整站視角來評判性能的優劣的置信度會受到諸多因素影響,比如一個站點中包含輕量的登陸頁和功能豐富的中背景,兩者的性能要求和使用者的容忍度是不一緻的,在實際狀況下兩者的絕對性能表現也是不一緻的。而簡單平均隻會讓我們觀察不到重點,頁面存在的問題資料也可能被其他的頁面拉平。
其次,名額隻是冷冰冰的資料,而資料想要發揮作用,一定需要參照系。比如,我僅僅提供 FMP = 4000ms,并不能說明這個頁面的性能就一定需要重點關注,對于邏輯較重的PC頁面,如資料平台,線上遊戲等場景,它可能是符合業務要求的。而一個 FMP = 2000ms的頁面則性能也不一定好,對于已經做了 SSR 等優化的回流頁。這可能遠遠達不到我們的預期。
一個放之四海而皆準的名額定義是不現實的。不同的業務場景有不同的性能基準要求。我們可以把他們轉化為具體的名額基準線。
通過對于現階段線上名額的分布,我們可以可以自由定義目前站點場景下針對某個名額,怎樣的資料是好的,怎樣的資料是差的。
基準線應用後,我們便可以在具體的性能資料産出後,直覺的觀察到,在什麼階段,某些名額的表現是不佳的,并且可以集中針對這段時間的性能資料日志進行排查。
一個頁面總是有多個性能名額的,現在我們已經知道了單個性能名額的優劣情況,如何整體的判斷整個頁面,乃至整個站點的性能狀況,落實到消費側則是,我們如何給一個頁面的性能名額評分?
如果有關注過 lighthouse 的同學應該對這張圖不陌生。
lighthouse 通過 google 采集到的大量線上頁面的性能資料,針對每一個性能名額,通過對數正态分布将其名額值轉化成 百分制分數。再通過給予每個名額一定的權重(随着 lighthouse 版本更疊), 計算出該頁面性能子產品的一個“整體分數”。在即将上線的“品質度量”能力中,我們針對 RUM 名額,異常名額,以及資源加載名額均采取了類似的方案。
我們通常可以給頁面的整體性能分數再制定一個基準分數,當上文所述的性能得分超過分數線,才認為該頁面的性能水準是“達标”的。而整站整體的達标水準,則可以利用整站達标的子頁面數/全站頁面數來計算,也就是達标率,通過達标率,我們可以非常直覺的迅速找到需要優化的性能頁面,讓不熟悉相關技術的營運,産品同學也可以定期巡檢相關頁面的品質狀況。
如何做好請求 / 靜态資源監控?
除了 JS 異常和頁面的性能表現以外,頁面能否正常的響應使用者的操作,資訊能否正确的展示,也和 api 請求,靜态資源息息相關。表現為 SLA,接口響應速度等名額。現在主流的監控方案通常是采用手動 hook相關 api 和利用 resource timing 來采集相關資訊的。
手動打點通常用于請求耗時兜底以及記錄請求狀态和請求響應相關資訊。
- 對于 XHR 請求: 通過 hook XHR 的 open 和 send 方法, 擷取請求的參數,在 onreadystatechange 事件觸發時打點記錄請求耗時。
- javascript複制代碼 // 記錄 method hookObjectProperty(XMLHttpRequest.prototype, 'open', hookXHROpen); // hook onreadystateChange,調用前後打點計算 hookObjectProperty(XMLHttpRequest.prototype, 'send', hookXHRSend);
- 對于fetch請求,則通過 hook Fetch 實作
- csharp複制代碼hookObjectProperty(global, 'fetch', hookFetch)
- 第二種則是 resourceTiming 采集方案
- 靜态資源上報:
- pageLoad 前:通過 performance.getEntriesByType 擷取 resource 資訊
- pageLoad後:通過 PerformanceObserver 監控 entryType 為 resource 的資源
- javascript複制代碼const callback = (val, i, arr, ob) => // ... 略 const observer = new PerformanceObserver((list, ob) => { if (list.getEntries) { list.getEntries().forEach((val, i, arr) => callback(val, i, arr, ob)) } else { onFail && onFail() } // ... }); observer.observe({ type: 'resource', buffered: false })
手動打點的優勢在于無關相容性,采集友善,而 Resource timing 則更精準,并且其記錄中可以避開額外的事件隊列處理耗時
如何了解和使用 resource timing 資料?
我們現在知道 ResourceTiming 是更能夠反映實際資源加載狀況的相關名額,而在工作中,我們經常遇到前端請求上報時間極長而後端對應接口日志卻表現正常的情況。這通常就可能是由使用單純的打點方案計算了太多非服務端因素導緻的。影響一個請求在前端表現的因素除了服務端耗時以外,還包括網絡,前端代碼執行排隊等因素。我們如何從 ResourceTiming 中分離出這些因素,進而更好的對齊後端口徑呢?
第一種是 Chrome 方案(阿裡的 ARMS 也采用的是這種方案):
它通過将線上采集的 ResoruceTiming 和 chrome timing 面闆的名額進行類比還原出一個近似的各部分耗時值。他的簡單計算方式如圖所示。
不過 chrome 實際計算 timing 的方式不明,這種近似的方式不一定能夠和 chrome 的面闆資料對的上,可能會被使用者質疑資料不一緻。
第二種則是标準方案: 規範劃分階段,這種劃分是符合 W3C 規範的格式,其優勢便在于其通用性好,且資料一定是符合要求的而不是 chrome 方案那種“近似計算”。不過它的缺陷是階段劃分還是有點太粗了,比如使用者無法判斷出浏覽器排隊耗時,也無法完全區分網絡下載下傳和下載下傳完成後的資源加載階段。隻是簡單的劃分成了 Request / Response 階段,給使用者了解和分析帶來了一定成本
在位元組内部,我們是以标準方案為主,chrome方案為輔的,使用者可以針對自己喜好的那種統計方式來對齊名額。通常來說,和服務端對齊耗時階段可以利用标準方案的request階段減去severtiming中的cdn,網關部分耗時來确定。
接下來我們再談談采集 SDK 的設計。
SDK 如何降低侵入,減少使用者性能損耗?體積控制和靈活使用可以兼得嗎?
常需要盡早執行,其資源加載通常也會造成一定的性能影響。更大的資源加載可能會導緻更慢的 Load,LCP,TTI 時間,影響使用者體驗。
為了進一步優化頁面加載性能,我們采用了 JS Snippets 來實作異步加載 + 預收集。
- 異步加載主要邏輯
首先,如果通過 JS 代碼建立 script 腳本并追加到頁面中,新增的 script 腳本預設會攜帶 async 屬性,這意味着這這部分代碼将通過async方式延遲加載。下載下傳階段不會阻塞使用者的頁面加載邏輯。進而一定程度的提升使用者的首屏性能表現。
- 預收集
試想一下我們通過 npm 或者 cdn 的方式直接引入監控代碼,script必須置于業務邏輯最前端,這是因為若異常先于監控代碼加載發生,當監控代碼就位時,是沒有辦法捕獲到曆史上曾經發生過的異常的。但将script置于前端将不可避免的對使用者頁面造成一定阻塞,且使用者的頁面可能會是以受到我們監控 sdk 服務可用性的影響。
為了解決這個問題,我們可以同步的加載一段精簡的代碼,在其中啟動 addEventListener 來采集先于監控主要邏輯發生的錯誤。并存儲到一個全局隊列中,這樣,當監控代碼就位,我們隻需要讀取全局隊列中的緩存資料并上報,就不會出現漏報的情況了。
更進一步:事件驅動與插件化
方案1. 2在大部分情況下都已經比較夠用了,但對于位元組的某些特殊場景卻還不夠。由于位元組存在大量的移動端頁面,且這些頁面對性能極為敏感。因而對于第三方庫的首包體積要求非常苛刻,同時,也不希望第三方代碼的執行占據主線程太長時間。
此外,公司内也有部分業務場景特殊,如 node 場景,小程式場景,electron,如果針對每一種場景,都完全重新開發一套新的監控 SDK,有很大的人力重複開發的損耗。
如果我們能夠将 SDK 的架構邏輯做成平台無關的,而各個資料監控,收集方案都隻是以插件形式存在,那麼這個 SDK 完全是可插拔的,類似 Sentry 所使用的 integration 方案。使用者甚至可以完全不使用任何官方插件,而是通過自己實作相關采集方案,來做到項目的定制化。
關于架構設計可以參見下圖
-
我們把整個監控 SDK 看作一條流水線(Client),接受的是使用者配置(config)(通過 ConfigManager),收集和産出的是具體事件(Event, 通過 Plugins)。流水線是平台無關的,它不關心處理的事件是什麼,也不關心事件是從哪來的。它其實是将這一系列的元件互動都抽象為 client 上的事件,進而使得資料采集器能夠介入資料流轉的每個階段
Client 通過 builder 包裝事件後,轉運給 Sender 負責批處理,Sender 最終調用 Transporter 上報。Transporter 是平台強相關的,例如 Web 使用 xhr 或 fetch,node 則使用 request 等。 同時,我們利用生命周期的概念設定了一系列的鈎子,可以讓使用者可以在适當階段處理流水線上的事件。例如利用 beforeSend 鈎子去修改即将被發送的上報内容等。
當整體的架構結構設計完後,我們就可以把視角放到插件上了。由于我們将架構設定為平台無關的,它本身隻是個資料流,有點像是一個精簡版的 Rx.js。而應用在各個平台上,我們隻需要根據各個平台的特性設計其對應的采集或資料處理插件。
插件方案某種意義上實作了 IOC,使用者不需要關心事件怎麼處理,傳入的參數是哪裡來的,隻需要利用傳入的參數去擷取配置,啟動自己的插件等。如下這段JS采集器代碼,開發插件時,我們隻需要關心插件自身相關的邏輯,并且利用傳入 client 約定的相關屬性和方法工作就可以了。不需要關心 client 是怎麼來的,也不用關心 client 什麼時候去執行它。
當我們寫完了插件之後,它要怎麼才能被應用在資料采集和進行中呢?為了達成降低首包大小的目标,我們将插件分為同步和異步兩種加載方式。
- 可以預收集的監控代碼都不需要出現在首包中,以異步插件方式接入
- 無法做到預收集的監控代碼以同步形式和首包打在一起,在源碼中将client傳入,盡早啟動,保證功能穩定。
3. 異步插件采用約定式加載,使用者在使用層面是完全無感的。我們通過主包加載時向在全局初始化系統資料庫和注冊方法,在讀取使用者配置後,拉取遠端插件加載并利用全局注冊方法擷取插件執行個體,最後傳入我們的 client 實作代碼執行。
經過插件化和一系列 SDK 的體積改造後,我們的sdk 首包體積降低到了從63kb 降低到了 34 kb。
FP
FP(first-paint),從頁面加載開始到第一個像素繪制到螢幕上的時間。其實把 FP 了解成白屏時間也是沒問題的。
測量代碼如下:
js複制代碼const entryHandler = (list) => {
for (const entry of list.getEntries()) {
if (entry.name === 'first-paint') {
observer.disconnect()
}
console.log(entry)
}
}
const observer = new PerformanceObserver(entryHandler)
// buffered 屬性表示是否觀察緩存資料,也就是說觀察代碼添加時機比事情觸發時機晚也沒關系。
observer.observe({ type: 'paint', buffered: true })
通過以上代碼可以得到 FP 的内容:
js複制代碼{
duration: 0,
entryType: "paint",
name: "first-paint",
startTime: 359, // fp 時間
}
其中 startTime 就是我們要的繪制時間。
FCP
FCP(first-contentful-paint),從頁面加載開始到頁面内容的任何部分在螢幕上完成渲染的時間。對于該名額,"内容"指的是文本、圖像(包括背景圖像)、<svg>元素或非白色的<canvas>元素。
為了提供良好的使用者體驗,FCP 的分數應該控制在 1.8 秒以内。
測量代碼:
js複制代碼const entryHandler = (list) => {
for (const entry of list.getEntries()) {
if (entry.name === 'first-contentful-paint') {
observer.disconnect()
}
console.log(entry)
}
}
const observer = new PerformanceObserver(entryHandler)
observer.observe({ type: 'paint', buffered: true })
通過以上代碼可以得到 FCP 的内容:
js複制代碼{
duration: 0,
entryType: "paint",
name: "first-contentful-paint",
startTime: 459, // fcp 時間
}
其中 startTime 就是我們要的繪制時間。
LCP
LCP(largest-contentful-paint),從頁面加載開始到最大文本塊或圖像元素在螢幕上完成渲染的時間。LCP 名額會根據頁面首次開始加載的時間點來報告可視區域内可見的最大圖像或文本塊完成渲染的相對時間。
一個良好的 LCP 分數應該控制在 2.5 秒以内。
測量代碼:
js複制代碼const entryHandler = (list) => {
if (observer) {
observer.disconnect()
}
for (const entry of list.getEntries()) {
console.log(entry)
}
}
const observer = new PerformanceObserver(entryHandler)
observer.observe({ type: 'largest-contentful-paint', buffered: true })
通過以上代碼可以得到 LCP 的内容:
js複制代碼{
duration: 0,
element: p,
entryType: "largest-contentful-paint",
id: "",
loadTime: 0,
name: "",
renderTime: 1021.299,
size: 37932,
startTime: 1021.299,
url: "",
}
其中 startTime 就是我們要的繪制時間。element 是指 LCP 繪制的 DOM 元素。
FCP 和 LCP 的差別是:FCP 隻要任意内容繪制完成就觸發,LCP 是最大内容渲染完成時觸發。
LCP 考察的元素類型為:
- <img>元素
- 内嵌在<svg>元素内的<image>元素
- <video>元素(使用封面圖像)
- 通過url()函數(而非使用CSS 漸變)加載的帶有背景圖像的元素
- 包含文本節點或其他行内級文本元素子元素的塊級元素。
CLS
CLS(layout-shift),從頁面加載開始和其生命周期狀态變為隐藏期間發生的所有意外布局偏移的累積分數。
布局偏移分數的計算方式如下:
複制代碼布局偏移分數 = 影響分數 * 距離分數
影響分數測量不穩定元素對兩幀之間的可視區域産生的影響。
距離分數指的是任何不穩定元素在一幀中位移的最大距離(水準或垂直)除以可視區域的最大尺寸次元(寬度或高度,以較大者為準)。
CLS 就是把所有布局偏移分數加起來的總和。
當一個 DOM 在兩個渲染幀之間産生了位移,就會觸發 CLS(如圖所示)。
上圖中的矩形從左上角移動到了右邊,這就算是一次布局偏移。同時,在 CLS 中,有一個叫會話視窗的術語:一個或多個快速連續發生的單次布局偏移,每次偏移相隔的時間少于 1 秒,且整個視窗的最大持續時長為 5 秒。
例如上圖中的第二個會話視窗,它裡面有四次布局偏移,每一次偏移之間的間隔必須少于 1 秒,并且第一個偏移和最後一個偏移之間的時間不能超過 5 秒,這樣才能算是一次會話視窗。如果不符合這個條件,就算是一個新的會話視窗。可能有人會問,為什麼要這樣規定?其實這是 chrome 團隊根據大量的實驗和研究得出的分析結果 Evolving the CLS metric。
CLS 一共有三種計算方式:
- 累加
- 取所有會話視窗的平均數
- 取所有會話視窗中的最大值
累加
也就是把從頁面加載開始的所有布局偏移分數加在一起。但是這種計算方式對生命周期長的頁面不友好,頁面存留時間越長,CLS 分數越高。
取所有會話視窗的平均數
這種計算方式不是按單個布局偏移為機關,而是以會話視窗為機關。将所有會話視窗的值相加再取平均值。但是這種計算方式也有缺點。
從上圖可以看出來,第一個會話視窗産生了比較大的 CLS 分數,第二個會話視窗産生了比較小的 CLS 分數。如果取它們的平均值來當做 CLS 分數,則根本看不出來頁面的運作狀況。原來頁面是早期偏移多,後期偏移少,現在的平均值無法反映出這種情況。
取所有會話視窗中的最大值
這種方式是目前最優的計算方式,每次隻取所有會話視窗的最大值,用來反映頁面布局偏移的最差情況。詳情請看 Evolving the CLS metric。
下面是第三種計算方式的測量代碼:
js複制代碼let sessionValue = 0
let sessionEntries = []
const cls = {
subType: 'layout-shift',
name: 'layout-shift',
type: 'performance',
pageURL: getPageURL(),
value: 0,
}
const entryHandler = (list) => {
for (const entry of list.getEntries()) {
// Only count layout shifts without recent user input.
if (!entry.hadRecentInput) {
const firstSessionEntry = sessionEntries[0]
const lastSessionEntry = sessionEntries[sessionEntries.length - 1]
// If the entry occurred less than 1 second after the previous entry and
// less than 5 seconds after the first entry in the session, include the
// entry in the current session. Otherwise, start a new session.
if (
sessionValue
&& entry.startTime - lastSessionEntry.startTime < 1000
&& entry.startTime - firstSessionEntry.startTime < 5000
) {
sessionValue += entry.value
sessionEntries.push(formatCLSEntry(entry))
} else {
sessionValue = entry.value
sessionEntries = [formatCLSEntry(entry)]
}
// If the current session value is larger than the current CLS value,
// update CLS and the entries contributing to it.
if (sessionValue > cls.value) {
cls.value = sessionValue
cls.entries = sessionEntries
cls.startTime = performance.now()
lazyReportCache(deepCopy(cls))
}
}
}
}
const observer = new PerformanceObserver(entryHandler)
observer.observe({ type: 'layout-shift', buffered: true })
在看完上面的文字描述後,再看代碼就好了解了。一次布局偏移的測量内容如下:
js複制代碼{
duration: 0,
entryType: "layout-shift",
hadRecentInput: false,
lastInputTime: 0,
name: "",
sources: (2) [LayoutShiftAttribution, LayoutShiftAttribution],
startTime: 1176.199999999255,
value: 0.000005752046026677329,
}
代碼中的 value 字段就是布局偏移分數。
DOMContentLoaded、load 事件
當純 HTML 被完全加載以及解析時,DOMContentLoaded 事件會被觸發,不用等待 css、img、iframe 加載完。
當整個頁面及所有依賴資源如樣式表和圖檔都已完成加載時,将觸發 load 事件。
雖然這兩個性能名額比較舊了,但是它們仍然能反映頁面的一些情況。對于它們進行監聽仍然是必要的。
js複制代碼import { lazyReportCache } from '../utils/report'
['load', 'DOMContentLoaded'].forEach(type => onEvent(type))
function onEvent(type) {
function callback() {
lazyReportCache({
type: 'performance',
subType: type.toLocaleLowerCase(),
startTime: performance.now(),
})
window.removeEventListener(type, callback, true)
}
window.addEventListener(type, callback, true)
}
首屏渲染時間
大多數情況下,首屏渲染時間可以通過 load 事件擷取。除了一些特殊情況,例如異步加載的圖檔和 DOM。
html複制代碼<script>
setTimeout(() => {
document.body.innerHTML = `
<div>
<!-- 省略一堆代碼... -->
</div>
`
}, 3000)
</script>
像這種情況就無法通過 load 事件擷取首屏渲染時間了。這時我們需要通過 MutationObserver 來擷取首屏渲染時間。MutationObserver 在監聽的 DOM 元素屬性發生變化時會觸發事件。
首屏渲染時間計算過程:
- 利用 MutationObserver 監聽 document 對象,每當 DOM 元素屬性發生變更時,觸發事件。
- 判斷該 DOM 元素是否在首屏内,如果在,則在 requestAnimationFrame() 回調函數中調用 performance.now() 擷取目前時間,作為它的繪制時間。
- 将最後一個 DOM 元素的繪制時間和首屏中所有加載的圖檔時間作對比,将最大值作為首屏渲染時間。
監聽 DOM
js複制代碼const next = window.requestAnimationFrame ? requestAnimationFrame : setTimeout
const ignoreDOMList = ['STYLE', 'SCRIPT', 'LINK']
observer = new MutationObserver(mutationList => {
const entry = {
children: [],
}
for (const mutation of mutationList) {
if (mutation.addedNodes.length && isInScreen(mutation.target)) {
// ...
}
}
if (entry.children.length) {
entries.push(entry)
next(() => {
entry.startTime = performance.now()
})
}
})
observer.observe(document, {
childList: true,
subtree: true,
})
上面的代碼就是監聽 DOM 變化的代碼,同時需要過濾掉 style、script、link 等标簽。
判斷是否在首屏
一個頁面的内容可能非常多,但使用者最多隻能看見一螢幕的内容。是以在統計首屏渲染時間的時候,需要限定範圍,把渲染内容限定在目前螢幕内。
js複制代碼const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
// dom 對象是否在螢幕内
function isInScreen(dom) {
const rectInfo = dom.getBoundingClientRect()
if (rectInfo.left < viewportWidth && rectInfo.top < viewportHeight) {
return true
}
return false
}
使用 requestAnimationFrame() 擷取 DOM 繪制時間
當 DOM 變更觸發 MutationObserver 事件時,隻是代表 DOM 内容可以被讀取到,并不代表該 DOM 被繪制到了螢幕上。
從上圖可以看出,當觸發 MutationObserver 事件時,可以讀取到 document.body 上已經有内容了,但實際上左邊的螢幕并沒有繪制任何内容。是以要調用 requestAnimationFrame() 在浏覽器繪制成功後再擷取目前時間作為 DOM 繪制時間。
和首屏内的所有圖檔加載時間作對比
js複制代碼function getRenderTime() {
let startTime = 0
entries.forEach(entry => {
if (entry.startTime > startTime) {
startTime = entry.startTime
}
})
// 需要和目前頁面所有加載圖檔的時間做對比,取最大值
// 圖檔請求時間要小于 startTime,響應結束時間要大于 startTime
performance.getEntriesByType('resource').forEach(item => {
if (
item.initiatorType === 'img'
&& item.fetchStart < startTime
&& item.responseEnd > startTime
) {
startTime = item.responseEnd
}
})
return startTime
}
優化
現在的代碼還沒優化完,主要有兩點注意事項:
- 什麼時候上報渲染時間?
- 如果相容異步添加 DOM 的情況?
第一點,必須要在 DOM 不再變化後再上報渲染時間,一般 load 事件觸發後,DOM 就不再變化了。是以我們可以在這個時間點進行上報。
第二點,可以在 LCP 事件觸發後再進行上報。不管是同步還是異步加載的 DOM,它都需要進行繪制,是以可以監聽 LCP 事件,在該事件觸發後才允許進行上報。
将以上兩點方案結合在一起,就有了以下代碼:
js複制代碼let isOnLoaded = false
executeAfterLoad(() => {
isOnLoaded = true
})
let timer
let observer
function checkDOMChange() {
clearTimeout(timer)
timer = setTimeout(() => {
// 等 load、lcp 事件觸發後并且 DOM 樹不再變化時,計算首屏渲染時間
if (isOnLoaded && isLCPDone()) {
observer && observer.disconnect()
lazyReportCache({
type: 'performance',
subType: 'first-screen-paint',
startTime: getRenderTime(),
pageURL: getPageURL(),
})
entries = null
} else {
checkDOMChange()
}
}, 500)
}
checkDOMChange() 代碼每次在觸發 MutationObserver 事件時進行調用,需要用防抖函數進行處理。
接口請求耗時
接口請求耗時需要對 XMLHttpRequest 和 fetch 進行監聽。
監聽 XMLHttpRequest
js複制代碼originalProto.open = function newOpen(...args) {
this.url = args[1]
this.method = args[0]
originalOpen.apply(this, args)
}
originalProto.send = function newSend(...args) {
this.startTime = Date.now()
const onLoadend = () => {
this.endTime = Date.now()
this.duration = this.endTime - this.startTime
const { status, duration, startTime, endTime, url, method } = this
const reportData = {
status,
duration,
startTime,
endTime,
url,
method: (method || 'GET').toUpperCase(),
success: status >= 200 && status < 300,
subType: 'xhr',
type: 'performance',
}
lazyReportCache(reportData)
this.removeEventListener('loadend', onLoadend, true)
}
this.addEventListener('loadend', onLoadend, true)
originalSend.apply(this, args)
}
如何判斷 XML 請求是否成功?可以根據他的狀态碼是否在 200~299 之間。如果在,那就是成功,否則失敗。
監聽 fetch
js複制代碼const originalFetch = window.fetch
function overwriteFetch() {
window.fetch = function newFetch(url, config) {
const startTime = Date.now()
const reportData = {
startTime,
url,
method: (config?.method || 'GET').toUpperCase(),
subType: 'fetch',
type: 'performance',
}
return originalFetch(url, config)
.then(res => {
reportData.endTime = Date.now()
reportData.duration = reportData.endTime - reportData.startTime
const data = res.clone()
reportData.status = data.status
reportData.success = data.ok
lazyReportCache(reportData)
return res
})
.catch(err => {
reportData.endTime = Date.now()
reportData.duration = reportData.endTime - reportData.startTime
reportData.status = 0
reportData.success = false
lazyReportCache(reportData)
throw err
})
}
}
對于 fetch,可以根據傳回資料中的的 ok 字段判斷請求是否成功,如果為 true 則請求成功,否則失敗。
注意,監聽到的接口請求時間和 chrome devtool 上檢測到的時間可能不一樣。這是因為 chrome devtool 上檢測到的是 HTTP 請求發送和接口整個過程的時間。但是 xhr 和 fetch 是異步請求,接口請求成功後需要調用回調函數。事件觸發時會把回調函數放到消息隊列,然後浏覽器再處理,這中間也有一個等待過程。
資源加載時間、緩存命中率
通過 PerformanceObserver 可以監聽 resource 和 navigation 事件,如果浏覽器不支援 PerformanceObserver,還可以通過 performance.getEntriesByType(entryType) 來進行降級處理。
當 resource 事件觸發時,可以擷取到對應的資源清單,每個資源對象包含以下一些字段:
從這些字段中我們可以提取到一些有用的資訊:
js複制代碼{
name: entry.name, // 資源名稱
subType: entryType,
type: 'performance',
sourceType: entry.initiatorType, // 資源類型
duration: entry.duration, // 資源加載耗時
dns: entry.domainLookupEnd - entry.domainLookupStart, // DNS 耗時
tcp: entry.connectEnd - entry.connectStart, // 建立 tcp 連接配接耗時
redirect: entry.redirectEnd - entry.redirectStart, // 重定向耗時
ttfb: entry.responseStart, // 首位元組時間
protocol: entry.nextHopProtocol, // 請求協定
responseBodySize: entry.encodedBodySize, // 響應内容大小
responseHeaderSize: entry.transferSize - entry.encodedBodySize, // 響應頭部大小
resourceSize: entry.decodedBodySize, // 資源解壓後的大小
isCache: isCache(entry), // 是否命中緩存
startTime: performance.now(),
}
判斷該資源是否命中緩存
在這些資源對象中有一個 transferSize 字段,它表示擷取資源的大小,包括響應頭字段和響應資料的大小。如果這個值為 0,說明是從緩存中直接讀取的(強制緩存)。如果這個值不為 0,但是 encodedBodySize 字段為 0,說明它走的是協商緩存(encodedBodySize 表示請求響應資料 body 的大小)。
js複制代碼function isCache(entry) {
// 直接從緩存讀取或 304
return entry.transferSize === 0 || (entry.transferSize !== 0 && entry.encodedBodySize === 0)
}
不符合以上條件的,說明未命中緩存。然後将所有命中緩存的資料/總資料就能得出緩存命中率。
浏覽器往返緩存 BFC(back/forward cache)
bfcache 是一種記憶體緩存,它會将整個頁面儲存在記憶體中。當使用者傳回時可以馬上看到整個頁面,而不用再次重新整理。據該文章 bfcache 介紹,firfox 和 safari 一直支援 bfc,chrome 隻有在高版本的移動端浏覽器支援。但我試了一下,隻有 safari 浏覽器支援,可能我的 firfox 版本不對。
但是 bfc 也是有缺點的,當使用者傳回并從 bfc 中恢複頁面時,原來頁面的代碼不會再次執行。為此,浏覽器提供了一個 pageshow 事件,可以把需要再次執行的代碼放在裡面。
js複制代碼window.addEventListener('pageshow', function(event) {
// 如果該屬性為 true,表示是從 bfc 中恢複的頁面
if (event.persisted) {
console.log('This page was restored from the bfcache.');
} else {
console.log('This page was loaded normally.');
}
});
從 bfc 中恢複的頁面,我們也需要收集他們的 FP、FCP、LCP 等各種時間。
js複制代碼onBFCacheRestore(event => {
requestAnimationFrame(() => {
['first-paint', 'first-contentful-paint'].forEach(type => {
lazyReportCache({
startTime: performance.now() - event.timeStamp,
name: type,
subType: type,
type: 'performance',
pageURL: getPageURL(),
bfc: true,
})
})
})
})
上面的代碼很好了解,在 pageshow 事件觸發後,用目前時間減去事件觸發時間,這個時間內插補點就是性能名額的繪制時間。注意,從 bfc 中恢複的頁面的這些性能名額,值一般都很小,一般在 10 ms 左右。是以要給它們加個辨別字段 bfc: true。這樣在做性能統計時可以對它們進行忽略。
FPS
利用 requestAnimationFrame() 我們可以計算目前頁面的 FPS。
js複制代碼const next = window.requestAnimationFrame
? requestAnimationFrame : (callback) => { setTimeout(callback, 1000 / 60) }
const frames = []
export default function fps() {
let frame = 0
let lastSecond = Date.now()
function calculateFPS() {
frame++
const now = Date.now()
if (lastSecond + 1000 <= now) {
// 由于 now - lastSecond 的機關是毫秒,是以 frame 要 * 1000
const fps = Math.round((frame * 1000) / (now - lastSecond))
frames.push(fps)
frame = 0
lastSecond = now
}
// 避免上報太快,緩存一定數量再上報
if (frames.length >= 60) {
report(deepCopy({
frames,
type: 'performace',
subType: 'fps',
}))
frames.length = 0
}
next(calculateFPS)
}
calculateFPS()
}
代碼邏輯如下:
- 先記錄一個初始時間,然後每次觸發 requestAnimationFrame() 時,就将幀數加 1。過去一秒後用幀數/流逝的時間就能得到目前幀率。
當連續三個低于 20 的 FPS 出現時,我們可以斷定頁面出現了卡頓,詳情請看 如何監控網頁的卡頓。
js複制代碼export function isBlocking(fpsList, below = 20, last = 3) {
let count = 0
for (let i = 0; i < fpsList.length; i++) {
if (fpsList[i] && fpsList[i] < below) {
count++
} else {
count = 0
}
if (count >= last) {
return true
}
}
return false
}
Vue 路由變更渲染時間
首屏渲染時間我們已經知道如何計算了,但是如何計算 SPA 應用的頁面路由切換導緻的頁面渲染時間呢?本文用 Vue 作為示例,講一下我的思路。
js複制代碼export default function onVueRouter(Vue, router) {
let isFirst = true
let startTime
router.beforeEach((to, from, next) => {
// 首次進入頁面已經有其他統計的渲染時間可用
if (isFirst) {
isFirst = false
return next()
}
// 給 router 新增一個字段,表示是否要計算渲染時間
// 隻有路由跳轉才需要計算
router.needCalculateRenderTime = true
startTime = performance.now()
next()
})
let timer
Vue.mixin({
mounted() {
if (!router.needCalculateRenderTime) return
this.$nextTick(() => {
// 僅在整個視圖都被渲染之後才會運作的代碼
const now = performance.now()
clearTimeout(timer)
timer = setTimeout(() => {
router.needCalculateRenderTime = false
lazyReportCache({
type: 'performance',
subType: 'vue-router-change-paint',
duration: now - startTime,
startTime: now,
pageURL: getPageURL(),
})
}, 1000)
})
},
})
}
代碼邏輯如下:
- 監聽路由鈎子,在路由切換時會觸發 router.beforeEach() 鈎子,在該鈎子的回調函數裡将目前時間記為渲染開始時間。
- 利用 Vue.mixin() 對所有元件的 mounted() 注入一個函數。每個函數都執行一個防抖函數。
- 當最後一個元件的 mounted() 觸發時,就代表該路由下的所有元件已經挂載完畢。可以在 this.$nextTick() 回調函數中擷取渲染時間。
同時,還要考慮到一個情況。不切換路由時,也會有變更元件的情況,這時不應該在這些元件的 mounted() 裡進行渲染時間計算。是以需要添加一個 needCalculateRenderTime 字段,當切換路由時将它設為 true,代表可以計算渲染時間了。
錯誤資料采集
資源加載錯誤
使用 addEventListener() 監聽 error 事件,可以捕獲到資源加載失敗錯誤。
js複制代碼// 捕獲資源加載失敗錯誤 js css img...
window.addEventListener('error', e => {
const target = e.target
if (!target) return
if (target.src || target.href) {
const url = target.src || target.href
lazyReportCache({
url,
type: 'error',
subType: 'resource',
startTime: e.timeStamp,
html: target.outerHTML,
resourceType: target.tagName,
paths: e.path.map(item => item.tagName).filter(Boolean),
pageURL: getPageURL(),
})
}
}, true)
js 錯誤
使用 window.onerror 可以監聽 js 錯誤。
js複制代碼// 監聽 js 錯誤
window.onerror = (msg, url, line, column, error) => {
lazyReportCache({
msg,
line,
column,
error: error.stack,
subType: 'js',
pageURL: url,
type: 'error',
startTime: performance.now(),
})
}
promise 錯誤
使用 addEventListener() 監聽 unhandledrejection 事件,可以捕獲到未處理的 promise 錯誤。
js複制代碼// 監聽 promise 錯誤 缺點是擷取不到列資料
window.addEventListener('unhandledrejection', e => {
lazyReportCache({
reason: e.reason?.stack,
subType: 'promise',
type: 'error',
startTime: e.timeStamp,
pageURL: getPageURL(),
})
})
sourcemap
一般生産環境的代碼都是經過壓縮的,并且生産環境不會把 sourcemap 檔案上傳。是以生産環境上的代碼報錯資訊是很難讀的。是以,我們可以利用 source-map 來對這些壓縮過的代碼報錯資訊進行還原。
當代碼報錯時,我們可以擷取到對應的檔案名、行數、列數:
js複制代碼{
line: 1,
column: 17,
file: 'https:/www.xxx.com/bundlejs',
}
然後調用下面的代碼進行還原:
js複制代碼async function parse(error) {
const mapObj = JSON.parse(getMapFileContent(error.url))
const consumer = await new sourceMap.SourceMapConsumer(mapObj)
// 将 webpack://source-map-demo/./src/index.js 檔案中的 ./ 去掉
const sources = mapObj.sources.map(item => format(item))
// 根據壓縮後的報錯資訊得出未壓縮前的報錯行列數和源碼檔案
const originalInfo = consumer.originalPositionFor({ line: error.line, column: error.column })
// sourcesContent 中包含了各個檔案的未壓縮前的源碼,根據檔案名找出對應的源碼
const originalFileContent = mapObj.sourcesContent[sources.indexOf(originalInfo.source)]
return {
file: originalInfo.source,
content: originalFileContent,
line: originalInfo.line,
column: originalInfo.column,
msg: error.msg,
error: error.error
}
}
function format(item) {
return item.replace(/(\.\/)*/g, '')
}
function getMapFileContent(url) {
return fs.readFileSync(path.resolve(__dirname, `./maps/${url.split('/').pop()}.map`), 'utf-8')
}
每次項目打包時,如果開啟了 sourcemap,那麼每一個 js 檔案都會有一個對應的 map 檔案。
arduino複制代碼bundle.js
bundle.js.map
這時 js 檔案放在靜态伺服器上供使用者通路,map 檔案存儲在伺服器,用于還原錯誤資訊。source-map 庫可以根據壓縮過的代碼報錯資訊還原出未壓縮前的代碼報錯資訊。例如壓縮後報錯位置為 1 行 47 列,還原後真正的位置可能為 4 行 10 列。除了位置資訊,還可以擷取到源碼原文。
上圖就是一個代碼報錯還原後的示例。鑒于這部分内容不屬于 SDK 的範圍,是以我另開了一個 倉庫 來做這個事,有興趣可以看看。
Vue 錯誤
利用 window.onerror 是捕獲不到 Vue 錯誤的,它需要使用 Vue 提供的 API 進行監聽。
js複制代碼Vue.config.errorHandler = (err, vm, info) => {
// 将報錯資訊列印到控制台
console.error(err)
lazyReportCache({
info,
error: err.stack,
subType: 'vue',
type: 'error',
startTime: performance.now(),
pageURL: getPageURL(),
})
}
行為資料采集
PV、UV
PV(page view) 是頁面浏覽量,UV(Unique visitor)使用者通路量。PV 隻要通路一次頁面就算一次,UV 同一天内多次通路隻算一次。
對于前端來說,隻要每次進入頁面上報一次 PV 就行,UV 的統計放在服務端來做,主要是分析上報的資料來統計得出 UV。
js複制代碼export default function pv() {
lazyReportCache({
type: 'behavior',
subType: 'pv',
startTime: performance.now(),
pageURL: getPageURL(),
referrer: document.referrer,
uuid: getUUID(),
})
}
頁面停留時長
使用者進入頁面記錄一個初始時間,使用者離開頁面時用目前時間減去初始時間,就是使用者停留時長。這個計算邏輯可以放在 beforeunload 事件裡做。
js複制代碼export default function pageAccessDuration() {
onBeforeunload(() => {
report({
type: 'behavior',
subType: 'page-access-duration',
startTime: performance.now(),
pageURL: getPageURL(),
uuid: getUUID(),
}, true)
})
}
頁面通路深度
記錄頁面通路深度是很有用的,例如不同的活動頁面 a 和 b。a 平均通路深度隻有 50%,b 平均通路深度有 80%,說明 b 更受使用者喜歡,根據這一點可以有針對性的修改 a 活動頁面。
除此之外還可以利用通路深度以及停留時長來鑒别電商刷單。例如有人進來頁面後一下就把頁面拉到底部然後等待一段時間後購買,有人是慢慢的往下滾動頁面,最後再購買。雖然他們在頁面的停留時間一樣,但明顯第一個人更像是刷單的。
頁面通路深度計算過程稍微複雜一點:
- 使用者進入頁面時,記錄目前時間、scrollTop 值、頁面可視高度、頁面總高度。
- 使用者滾動頁面的那一刻,會觸發 scroll 事件,在回調函數中用第一點得到的資料算出頁面通路深度和停留時長。
- 當使用者滾動頁面到某一點時,停下繼續觀看頁面。這時記錄目前時間、scrollTop 值、頁面可視高度、頁面總高度。
- 重複第二點...
具體代碼請看:
js複制代碼let timer
let startTime = 0
let hasReport = false
let pageHeight = 0
let scrollTop = 0
let viewportHeight = 0
export default function pageAccessHeight() {
window.addEventListener('scroll', onScroll)
onBeforeunload(() => {
const now = performance.now()
report({
startTime: now,
duration: now - startTime,
type: 'behavior',
subType: 'page-access-height',
pageURL: getPageURL(),
value: toPercent((scrollTop + viewportHeight) / pageHeight),
uuid: getUUID(),
}, true)
})
// 頁面加載完成後初始化記錄目前通路高度、時間
executeAfterLoad(() => {
startTime = performance.now()
pageHeight = document.documentElement.scrollHeight || document.body.scrollHeight
scrollTop = document.documentElement.scrollTop || document.body.scrollTop
viewportHeight = window.innerHeight
})
}
function onScroll() {
clearTimeout(timer)
const now = performance.now()
if (!hasReport) {
hasReport = true
lazyReportCache({
startTime: now,
duration: now - startTime,
type: 'behavior',
subType: 'page-access-height',
pageURL: getPageURL(),
value: toPercent((scrollTop + viewportHeight) / pageHeight),
uuid: getUUID(),
})
}
timer = setTimeout(() => {
hasReport = false
startTime = now
pageHeight = document.documentElement.scrollHeight || document.body.scrollHeight
scrollTop = document.documentElement.scrollTop || document.body.scrollTop
viewportHeight = window.innerHeight
}, 500)
}
function toPercent(val) {
if (val >= 1) return '100%'
return (val * 100).toFixed(2) + '%'
}
使用者點選
利用 addEventListener() 監聽 mousedown、touchstart 事件,我們可以收集使用者每一次點選區域的大小,點選坐标在整個頁面中的具體位置,點選元素的内容等資訊。
js複制代碼export default function onClick() {
['mousedown', 'touchstart'].forEach(eventType => {
let timer
window.addEventListener(eventType, event => {
clearTimeout(timer)
timer = setTimeout(() => {
const target = event.target
const { top, left } = target.getBoundingClientRect()
lazyReportCache({
top,
left,
eventType,
pageHeight: document.documentElement.scrollHeight || document.body.scrollHeight,
scrollTop: document.documentElement.scrollTop || document.body.scrollTop,
type: 'behavior',
subType: 'click',
target: target.tagName,
paths: event.path?.map(item => item.tagName).filter(Boolean),
startTime: event.timeStamp,
pageURL: getPageURL(),
outerHTML: target.outerHTML,
innerHTML: target.innerHTML,
width: target.offsetWidth,
height: target.offsetHeight,
viewport: {
width: window.innerWidth,
height: window.innerHeight,
},
uuid: getUUID(),
})
}, 500)
})
})
}
頁面跳轉
利用 addEventListener() 監聽 popstate、hashchange 頁面跳轉事件。需要注意的是調用history.pushState()或history.replaceState()不會觸發popstate事件。隻有在做出浏覽器動作時,才會觸發該事件,如使用者點選浏覽器的回退按鈕(或者在Javascript代碼中調用history.back()或者history.forward()方法)。同理,hashchange 也一樣。
js複制代碼export default function pageChange() {
let from = ''
window.addEventListener('popstate', () => {
const to = getPageURL()
lazyReportCache({
from,
to,
type: 'behavior',
subType: 'popstate',
startTime: performance.now(),
uuid: getUUID(),
})
from = to
}, true)
let oldURL = ''
window.addEventListener('hashchange', event => {
const newURL = event.newURL
lazyReportCache({
from: oldURL,
to: newURL,
type: 'behavior',
subType: 'hashchange',
startTime: performance.now(),
uuid: getUUID(),
})
oldURL = newURL
}, true)
}
Vue 路由變更
Vue 可以利用 router.beforeEach 鈎子進行路由變更的監聽。
js複制代碼export default function onVueRouter(router) {
router.beforeEach((to, from, next) => {
// 首次加載頁面不用統計
if (!from.name) {
return next()
}
const data = {
params: to.params,
query: to.query,
}
lazyReportCache({
data,
name: to.name || to.path,
type: 'behavior',
subType: ['vue-router-change', 'pv'],
startTime: performance.now(),
from: from.fullPath,
to: to.fullPath,
uuid: getUUID(),
})
next()
})
}
資料上報
上報方法
資料上報可以使用以下幾種方式:
- sendBeacon
- XMLHttpRequest
- image
我寫的簡易 SDK 采用的是第一、第二種方式相結合的方式進行上報。利用 sendBeacon 來進行上報的優勢非常明顯。
使用 sendBeacon() 方法會使使用者代理在有機會時異步地向伺服器發送資料,同時不會延遲頁面的解除安裝或影響下一導航的載入性能。這就解決了送出分析資料時的所有的問題:資料可靠,傳輸異步并且不會影響下一頁面的加載。
在不支援 sendBeacon 的浏覽器下我們可以使用 XMLHttpRequest 來進行上報。一個 HTTP 請求包含發送和接收兩個步驟。其實對于上報來說,我們隻要確定能發出去就可以了。也就是發送成功了就行,接不接收響應無所謂。為此,我做了個實驗,在 beforeunload 用 XMLHttpRequest 傳送了 30kb 的資料(一般的待上報資料很少會有這麼大),換了不同的浏覽器,都可以成功發出去。當然,這和硬體性能、網絡狀态也是有關聯的。
上報時機
上報時機有三種:
- 采用 requestIdleCallback/setTimeout 延時上報。
- 在 beforeunload 回調函數裡上報。
- 緩存上報資料,達到一定數量後再上報。
建議将三種方式結合一起上報:
- 先緩存上報資料,緩存到一定數量後,利用 requestIdleCallback/setTimeout 延時上報。
- 在頁面離開時統一将未上報的資料進行上報。
總結
本文主要從 JS 異常監控,性能監控和請求,靜态資源監控幾個細節點講述了 Slardar 在前端監控方向所面臨關鍵問題的探索和實踐,希望能夠對大家在前端監控領域或者将來的工作中産生幫助。其實前端監控還有許多方面可以深挖,例如如何利用撥測,線下實驗室資料采集來進一步追溯問題,如何捕獲白屏等類崩潰異常,如何結合研發流程來實作使用者無感覺的接入等等。