天天看點

前端監控系列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 錯誤提供更多的思路。

怎樣采集使用者目前的環境資訊

環境資訊包括使用者目前的浏覽器類型和版本、系統類型和版本、裝置品牌等資訊。監控 SDK 主要是通過采集 UserAgent 來擷取基礎資訊,但是解析出具體浏覽器、系統和裝置品牌其實是十分複雜的,具體的解析工作需要由服務端承擔。

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

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

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

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

這種同步代碼觸發的 JS 錯誤的堆棧能提供很多資訊。比如在這個例子中,從堆棧中可以看出這個錯誤是經由

click

事件,而後觸發了

triggerJSError

方法,最後發生了這個 JS 錯誤。

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

但是異步代碼的報錯卻很難提供更多的資訊。比如下面這個例子,錯誤由異步調用觸發,從報錯的堆棧裡完全看不出來它是經由

click

事件,也看不出來是觸發了

triggerJSError

方法。

const triggerJSError = () => {
  const data = {
    not: { found: 'test' },
  }
  delete data.not
+ setTimeout(() => {
    console.log(data.not.found)
+ })
}
           
前端監控系列2 | 聊聊 JS 錯誤監控那些事兒

**這是浏覽器本身的事件循環機制導緻的。**異步任務需要等到同步任務執行完成後,再從異步隊列裡取出異步任務并執行,這個時候是無法沿着調用棧回溯這個異步任務的建立時的堆棧資訊的。

為了更友善地排查這類錯誤,監控 SDK 會對一些全局的異步 API 以及全局事件 API 進行

try/catch

包裝,捕獲到錯誤時補充 API 調用資訊,再原封不動地将錯誤抛出去。雖然堆棧資訊并沒有填補完整,但是能提供一些輔助資訊,比如 目前這個異步調用的JS 錯誤是經由哪個 API 調用,最終觸發了這個 JS 錯誤的。

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

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

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

如何自動解析出原始堆棧

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

研發編寫的代碼與線上實際運作的代碼之間存在着很多處理,比如:

  • 打包并壓縮代碼。将多個 JS 檔案打包成一個 JS 檔案來減少資源請求數量;通過縮短變量名、去除空格和其他複雜的壓縮方式來減少資源體積,以便更快的加載 JS 檔案;
  • 相容處理。工程師往往熱衷使用新的 JS 特性。但是由于浏覽器對這些特性支援度低,在編譯時,往往需要利用 Babel 等工具将這些新特性轉換成更相容的形式;
  • 從另一種語言編譯成 JS 。使用另一種語言編寫,最終編譯成 JS 。比如 TypeScript、PureScript 等等。

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

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

什麼是 source map

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

下方是一個source map 的示例,它通常包含 version / file / sources / mappings 等等字段,這些字段裡也隐含着它為什麼能反解出原始代碼的奧秘。

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

sources 包含轉換前的檔案路徑,names 包含轉換前的所有變量名和屬性名,sourcesContent 包含轉換前檔案的内容,file 包含轉換後的檔案名。

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

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

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

blankInfo

沒有判空導緻。

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

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

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

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

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

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

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

same name + same message !== same error

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

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

将堆棧資訊納入聚合算法中

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

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

為了友善識别,我們會利用上述資訊,通過 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 端真實資料進行實時監控、報警歸因、聚類分析和細節定位,解決白屏、性能瓶頸、慢查詢等關鍵問題,歡迎體驗。

::: hljs-center

掃描下方二維碼,立即申請免費使用⬇️

繼續閱讀