前端監控系列2 |聊聊 JS 錯誤監控那些事兒

作者:彭莉,火山引擎 APM 研發工程師。2020年加入位元組,負責前端監控 SDK 的開發維護、平台數據消費的探索和落地。

有必要針對 JS 錯誤做監控嗎?

我們可以先假設不對 JS 錯誤做監控,試想會出現什麼問題?

JS 錯誤可能會導致渲染出錯、用戶操作意外終止,如果沒有 JS 錯誤監控,開發者完全感知不到線上這些異常情況。特別是像電商、支付這類業務,用戶無法下單和付款。即便站點有回饋渠道,但是等到有用戶回饋的時候,說明影響面已經不小了。

因此JS 錯誤監控這類異常監控存在,就是為了及時發現線上問題、幫助快速定位問題,從而提升站點穩定性。

如何監控 JS 錯誤

大多數的 JS 錯誤都是由 JS 引擎自動生成的,比如 TypeError ,常常在值的類型不是預期的類型時觸發、SyntaxError 常常在 JS 引擎解析遇到無效語法時觸發。

對於一些可預見的錯誤,通常可以使用 try/catch 捕獲,而一般不可預見的錯誤都拋到了全局,因此可以通過監聽全局的 error 事件收集到 JS 錯誤。

const handleError = (ev: ErrorEvent) => report(normalizeError(ev))
window.addEventListener('error', handleError)

不過瀏覽器中未處理的 Promise 錯誤比較特殊,它需要額外監聽全局 unhandledrejection 事件來收集。

const handleRejection = (ev: PromiseRejectionEvent) => report(normalizeException(ev))
window.addEventListener('unhandledrejection', handleRejection)

通過上面的全局監聽,我們可以採集到錯誤的基本資訊,包括錯誤類型、錯誤資訊(錯誤的簡短描述)、錯誤的堆棧、引發此錯誤的行列號和文件路徑等資訊。

不過這些資訊都太過簡略,無法幫助定位問題,無法感知到用戶做了怎樣的操作觸發了這個錯誤、用戶當前的瀏覽器型號、系統版本是怎樣的、用戶當前在哪個頁面以及是通過怎樣的方式進到頁面的。

如何幫助定位問題

我們需要收集更多的 JS 錯誤發生時/發生前的上下文,為排查 JS 錯誤提供更多的思路。

怎樣還原 JS 錯誤發生前的用戶操作路徑

用戶從進入頁面到發生 JS 錯誤的所有可能事件都需要記錄下來,比如用戶一開始進到的是哪個頁面、做了哪些操作、發送了什麼請求等等,這涉及到三個方面:交互事件的監聽、請求的監聽、路由的監聽。

監控 SDK 默認會採集 clickkeypress 等交互事件,但是出於安全原因,不會採集事件目標元素的文本內容,而是只記錄元素的 xpath ,輔助定位問題。

請求的監聽主要是通過 hook XHR 和 Fetch 實現,從而獲取包括請求方法、使用的 API 、請求路徑、請求狀態碼等資訊;路由的監聽主要是通過 hook history 相關方法以及監聽路由事件來實現,獲取頁面切換地址、頁面切換方式等資訊。

單純為了還原錯誤發生前的瀏覽器行為,做這麼多事情,成本是不是有些高呢?

對於只做 JS 錯誤監控的 SDK 來講,確實成本不低。但是位元組的前端監控 SDK 本身就會做請求的監控以及路由切換的監控,因此不需要為了監控 JS 錯誤而再額外監聽一次,只通過攔截其他的監聽的上報就可以順帶實現。

比如 SDK 實現關聯 JS 錯誤發生前的請求,只用了下面幾行程式碼。

client.on('report', (ev) => {
  if (ev.ev_type === 'http') {
    addBreadcrumb(ev)
  }
  return ev
})

怎樣採集用戶當前的環境資訊

環境資訊包括用戶當前的瀏覽器類型和版本、系統類型和版本、設備品牌等資訊。監控 SDK 主要是通過採集 UserAgent 來獲取基礎資訊,但是解析出具體瀏覽器、系統和設備品牌其實是十分複雜的,具體的解析工作需要由服務端承擔。

有了這些數據,我們就能很快從單個 JS 錯誤的分布情況判斷出這個 JS 錯誤的影響範圍,特別是如果這個 JS 錯誤是一個兼容性問題,一眼就能看出來,比如它只發生在特定瀏覽器上。

怎樣為堆棧不完整的錯誤補充更多上下文

同步的錯誤通常是帶有完整的堆棧資訊的,但是非同步的堆棧卻只包含極少的堆棧資訊。舉個例子, 在頁面上增加一個按鈕,按鈕的點擊會觸發如下一段程式碼。

const triggerJSError = () => {
  const data = {
    not: { found: 'test' },
  }
  delete data.not
  console.log(data.not.found)
}

這種同步程式碼觸發的 JS 錯誤的堆棧能提供很多資訊。比如在這個例子中,從堆棧中可以看出這個錯誤是經由 click 事件,而後觸發了 triggerJSError 方法,最後發生了這個 JS 錯誤。

但是非同步程式碼的報錯卻很難提供更多的資訊。比如下面這個例子,錯誤由非同步調用觸發,從報錯的堆棧里完全看不出來它是經由 click 事件,也看不出來是觸發了 triggerJSError 方法。

const triggerJSError = () => {
  const data = {
    not: { found: 'test' },
  }
  delete data.not
+ setTimeout(() => {
    console.log(data.not.found)
+ })
}

這是瀏覽器本身的事件循環機制導致的。非同步任務需要等到同步任務執行完成後,再從非同步隊列里取出非同步任務並執行,這個時候是無法沿著調用棧回溯這個非同步任務的創建時的堆棧資訊的。

為了更方便地排查這類錯誤,監控 SDK 會對一些全局的非同步 API 以及全局事件 API 進行 try/catch包裝,捕獲到錯誤時補充 API 調用資訊,再原封不動地將錯誤拋出去。雖然堆棧資訊並沒有填補完整,但是能提供一些輔助資訊,比如 當前這個非同步調用的JS 錯誤是經由哪個 API 調用,最終觸發了這個 JS 錯誤的。

關於採集 JS 錯誤的部分看起來已經結束了? 但當我們看線上真實的JS錯誤時,發現線上錯誤的堆棧難以理解,方法名都被壓縮過了,文件名也變成了打包後的文件名,無法提供有用的資訊。

那麼監控平台是如何做到錯誤一上傳就能顯示原始堆棧的呢?

如何自動解析出原始堆棧

線上的 JS 錯誤堆棧為什麼看不懂

研發編寫的程式碼與線上實際運行的程式碼之間存在著很多處理,比如:

  • 打包並壓縮程式碼。將多個 JS 文件打包成一個 JS 文件來減少資源請求數量;通過縮短變數名、去除空格和其他複雜的壓縮方式來減少資源體積,以便更快的載入 JS 文件;

  • 兼容處理。工程師往往熱衷使用新的 JS 特性。但是由於瀏覽器對這些特性支援度低,在編譯時,往往需要利用 Babel 等工具將這些新特性轉換成更兼容的形式;

  • 從另一種語言編譯成 JS 使用另一種語言編寫,最終編譯成 JS 。比如 TypeScript、PureScript 等等。

這些處理不僅可以提升編碼體驗,還能優化性能、提升用戶體驗。

當然有利就有弊,這也導致線上的程式碼與最初編寫的程式碼相差甚遠,讓排查問題就變得非常棘手,而source map 正是用來解決這個問題的。

什麼是 source map

簡單來說,source map 維護了混淆後的程式碼行列與原程式碼行列的映射關係,就算只知道混淆後的堆棧資訊,也能通過它得到原始堆棧資訊,從而定位到真實的報錯位置。

下方是一個source map 的示例,它通常包含 version / file / sources / mappings 等等欄位,這些欄位里也隱含著它為什麼能反解出原始程式碼的奧秘。

sources 包含轉換前的文件路徑,names 包含轉換前的所有變數名和屬性名,sourcesContent 包含轉換前文件的內容,file 包含轉換後的文件名。

mappings 欄位看起來很神秘,簡而言之,mappings 欄位維護的是壓縮程式碼到源程式碼之間的映射關係,可以映射到源程式碼的任何部分,包括標識符、運算符、函數調用等等。它分為三層, 一層是行對應, 一層是位置對應,還有一層是位置轉換,以 VLQ 編碼表示位置對應的轉換前的源碼位置。這樣就能實現從混淆程式碼到源碼的映射關係,從而實現堆棧反解。

常規的監控平台都會提供自動上傳 source map 的工具,這樣 JS 錯誤上報到平台後就能自動顯示原始錯誤的堆棧。

下面這個截圖就是反解成功後展示的原始堆棧示例,從原始堆棧可以看出,這個 JS 錯誤是因為 250 行的 blankInfo 沒有判空導致。

現在原始堆棧也有了,錯誤的上下文資訊也有了。打開監控平台一看,發現確實監控到了很多的JS錯誤,但是有很多重複的錯誤,一眼望不到頭。

有沒有什麼辦法,能夠只看到不同的錯誤呢?畢竟從研發的角度講,無論一個錯誤上報千次萬次,終究都只是對應一個需要修復的問題。

如何判斷兩個錯誤是否相同

假設能做到這一點,那麼就能將相同的錯誤歸類在一起,研發看到的就是每一個不同的錯誤,就能減少噪音。但是如果聚合的方式有問題,就會導致不同的 JS 錯誤聚合在了一起,這樣可能造成錯誤的遺漏。

那麼怎樣的聚合演算法才是合適的呢?

same name + same message !== same error

在上報的錯誤屬性中,只有 name 和 message 是標準屬性,其他屬性都是非標準屬性,是不是使用這兩個欄位聚合錯誤就可以?

在實際應用中,我們發現僅靠 name 和 message 並不能做到有效聚合錯誤。兩個錯誤 name 和 message 相同,但是可能來源於不同的程式碼段。這樣可能導致我們修復了其中一個錯誤後,誤以為相關的所有錯誤都被修復了,從而遺漏錯誤。

將堆棧資訊納入聚合演算法中

在實際聚合演算法中,我們將反解後的堆棧納入了計算,將堆棧拆分為一系列的 frame, 更細緻的提取堆棧特徵,在每一個 frame 內重點關注其調用函數名、調用文件名以及當前執行的程式碼行,如果這些資訊都相同,可以認為是同一個錯誤。

為了方便識別,我們會利用上述資訊,通過 hash 計算最後生成一個 issueId 作為我們識別相同錯誤的標識。生成 hash 的過程比較複雜,除了常規提取計算外,會針對遞歸調用、匿名路徑、匿名函數等進行跳過,也會避開某些計算開銷過大的 case 。

久而久之,監控平台上出現了很多 JS 錯誤,但是這些錯誤好多都是已經在處理的錯誤,有沒有辦法能只在出現新的 JS 錯誤的時候通知到我呢? 這樣既能及時關注到、又可以做到不遺漏。

如何判斷一個新的錯誤的出現

剛剛提到,我們通過聚合演算法把同類的錯誤聚合在了一起,並且標記成了一個 issueId 。那麼我們就可以通過判斷這個 issueId 是不是一個新的 issueId 來實現目的。如果是的話,就代表有新增的 JS 錯誤。

當然這種新增的思路不僅可以用在 JS 錯誤這種異常數據上,也同樣可以用在其他異常數據上。只要識別到了一個新增的異常,就可以自動發通知,研發就能立即關注並開始處理。

那麼問題來了,所找到的 JS 錯誤出現的原因如果是另一個同事寫的程式碼導致的,應該怎麼辦?

是直接告知他去修復這個問題呢?還是先不管了?或者有沒有辦法,能夠自動把這個「鍋」給到他,這樣尷尬的問題就解決了。

線上 bug 自動分「鍋」

手動指定處理人的方式比較生硬,完全依賴團隊的主動性。實際上,既然已經知道原始堆棧,如果還能知道線上程式碼對應的倉庫,我們就可以做得更細緻一些。比如根據對應報錯的程式碼行,結合 Gitlab / Github 的 open-api ,實現自動分「鍋」。

如何找到某條 JS 錯誤對應的處理人

以 Git Blame 為例,通過下面的命令就能獲取到特定文件對應行的相關 commit 資訊,包括提交者/ 改動內容 / 提交時間,足夠定位誰是處理人。

git blame -L <range> <file>
  • file 對應是文件路徑,也就是解析出來的原始堆棧的文件路徑資訊

  • range 對應的是查找的範圍,也就是解析出來的原始堆棧的行號範圍

分「鍋」不夠准?

默認用來 blame 的文件都是最新版本,但線上跑的不一定是最新版本的程式碼。

我們可以認為一次新的發布就是一個新版本的產生,不同版本的程式碼可能發生行的變動,從而影響實際程式碼的行號。如果無法將線上運行版本和用來 blame 的文件版本對齊,就很有可能突然背「鍋」。

因此我們需要知道兩個問題:線上發生的錯誤是屬於哪個版本的?如何拿到對應版本的倉庫文件程式碼?

問題一比較好實現,在編譯時注入一個版本的環境變數,保證監控時能夠帶上這個資訊就行。

import client from '@apmplus/web'
​
client('init', {
  ...
  release: 'v0.1.10'
  ...
})

問題二不好解決,倉庫程式碼不可能給到監控平台方,更別說拿到對應版本的倉庫程式碼了。

其實不用拿到整個倉庫程式碼,也可以做一些 commits 關聯來實現,通過相關的二進位工具,在程式碼發布前的腳本中,將 commits 關聯上同一個版本號。這樣線上發生 JS 錯誤後,我們就可以通過線上報錯的版本找到原始程式碼文件對應的版本,再通過前面提到的 Gitlab / Github 的 open-api 定位到真正的處理人,就可以直接通知對應的處理人處理問題。

由此,JS 錯誤監控實現了閉環。

歡迎使用

目前位元組的這套前端監控解決方案已同步在火山引擎上,接入即可對 Web 端真實數據進行實時監控、報警歸因、聚類分析和細節定位,解決白屏、性能瓶頸、慢查詢等關鍵問題,歡迎體驗。

評論區留言申請免費使用⬇️