你知道該如何捕獲js報錯前的用戶行為嗎
拋出問題
我們知道線上環(huán)境復雜多變,不像本地測試的時候那樣順利,經(jīng)常會有各種雜七雜八的問題,要想主動高效的定位這些異常,就得接入監(jiān)控系統(tǒng)啦。作為前端的我們應該或多或少都有所了解,大概就是監(jiān)聽各種 error 事件,然后整理下數(shù)據(jù)并上報,比如下面這樣????:
const handleError = e => { // ... report(); }; const handleRejection = e => { // ... report(); }; window.addEventListener('error', handleError); window.addEventListener('unhandledrejection', handleRejection);
但是有時候這些錯誤并不是那么直觀,也不好復現(xiàn)??,所以要是我們能夠捕獲到異常發(fā)生時的一些上下文信息就好了。??。。。那,這個上下文是指啥呢,讓我們先看看下面這張圖(參考自sentry):
從上圖中可以看出在發(fā)生報錯之前,用戶進行了兩次頁面跳轉(zhuǎn),兩次 xhr 請求,并且進行了幾次點擊操作。于是乎,我們就可以腦補出用戶大概的一個行為路徑了,這樣也許就能復現(xiàn) bug 了,聽起來是不是還有點意思??。所以本篇文章主要就是講解一下這個東西是怎么實現(xiàn)的。
具體思路
收集什么
因為剛開始很容易一頭霧水,所以我們先把上面的示意圖進行一個簡單的轉(zhuǎn)化,它的本質(zhì)就是個普通的數(shù)組,只不過每條數(shù)據(jù)的類型會有所不同,就像下面這樣????:
[ { "type": "dom", "timestamp": "2023-05-27T06:37:41.307522Z", "level": "info", "message": "a.product-thumbnail-item", "category": "ui.click", "data": null }, { "type": "dom", "timestamp": "2023-05-27T06:37:41.692138Z", "level": "info", "message": null, "category": "navigation", "data": { "from": "/shop/", "to": "/shop/products/plant-mood-planter/" } }, { "type": "dom", "timestamp": "2023-05-27T06:37:42.076753Z", "level": "info", "message": "button.add-to-cart", "category": "ui.click", "data": null }, { "type": "http", "timestamp": "2023-05-27T06:37:42.461368Z", "level": "info", "message": null, "category": "xhr", "data": { "method": "POST", "status_code": 200, "url": "/api/0/cart/" } }, { "type": "dom", "timestamp": "2023-05-27T06:37:42.845984Z", "level": "info", "message": "a#view-cart", "category": "ui.click", "data": null }, { "type": "dom", "timestamp": "2023-05-27T06:37:43.230599Z", "level": "info", "message": null, "category": "navigation", "data": { "from": "/shop/products/plant-mood-planter/","to": "/shop/checkout/" } }, { "type": "dom", "timestamp": "2023-05-27T06:37:43.615215Z", "level": "info", "message": "input#zipcode", "category": "ui.click", "data": null }, { "type": "dom", "timestamp": "2023-05-27T06:37:43.999830Z", "level": "info", "message": "button#calculate-shipping", "category": "ui.click", "data": null }, { "type": "http", "timestamp": "2023-05-27T06:37:44.384445Z", "level": "info", "message": null, "category": "xhr", "data": { "method": "POST", "status_code": 200, "url": "/api/0/cart/update-shipping/" } }, { "type": "dom", "timestamp": "2023-05-27T06:37:44.769061Z", "level": "info", "message": "input#card-name", "category": "ui.click", "data": null }, { "type": "dom", "timestamp": "2023-05-27T06:37:45.153677Z", "level": "info", "message": "input#card-number", "category": "ui.click", "data": null }, { "type": "dom", "timestamp": "2023-05-27T06:37:45.538292Z", "level": "info", "message": "input#card-cvv", "category": "ui.click", "data": null }, { "type": "dom", "timestamp": "2023-05-27T06:37:45.922907Z", "level": "info", "message": "input#submit", "category": "ui.click", "data": null } ]
簡單抽離一下它的基本結(jié)構(gòu),大致如下:
interface Breadcrumb { type?: string; // 類型,比如 dom,http category?: string; // 具體分類,比如 dom 下面的 click 和 navigation;http 中的 xhr 和 fetch message?: string; data?: { [key: string]: any }; level?: string; timestamp?: number; }
有同學看到 Breadcrumb 這個單詞,心想說這不是面包屑的嗎,怎么取這個名字。其實它本意就是有跡可循的意思,所以用在這里還是很恰當?shù)摹#??面包屑的起源:從前有兩個孩子在森林中走迷了路,為了找回家,他們在路上散落著面包屑,用以標記自己的行走路徑,最終成功回到家中。)
可以看到,每條數(shù)據(jù)大體會有類型、數(shù)據(jù)、時間戳等幾個重要的部分組成,顯然不同的類型會對應不同的數(shù)據(jù),所以我們只需要知道有哪些類型并記錄相關(guān)信息即可。那怎么確定有哪些類型呢?
想想我們?nèi)绻烙脩魣箦e前的一些信息,肯定不能在報錯的時候才去記錄,那時候已經(jīng)晚了,并且我們也不確定什么時候會報錯。所以...所以在一開始就得進行收集??,如果報錯了就把一路以來收集到的信息上報,如果沒報錯那就不管,這是要先明確的一點。
然后就是確定要收集哪些類型以及怎么收集。這個乍一看也沒什么思緒,好像還得上錄屏。事實上沒這么麻煩,我們可以先想想一般什么情況下做什么操作會導致 js 報錯??。經(jīng)過幾秒鐘短暫的思考后,大概可以羅列出以下幾種情況:
- 頁面跳轉(zhuǎn)
- 接口調(diào)用后
- 點擊某個按鈕
- 鍵盤按下時
- console 打印的信息
- 定時器
- ...(用戶行為 && 瀏覽器行為 && 控制臺行為)
而其中導致報錯概率最大的主要就是發(fā)送請求和點擊事件,所以接下來會以這兩種情況為例子來看看我們是怎么進行收集的??。
初始工作
在此之前,我們先定義一個全局變量,順便簡化一下 interface,就像下面這樣????:
interface Breadcrumb { type?: string; // 類型,比如 fetch、click data?: { [key: string]: any }; // 類型對應的數(shù)據(jù) timestamp?: number; // 觸發(fā)時間 } const breadcrumbs = []; // 所有行為路徑 function addBreadcrumb(breadcrumb) { breadcrumbs.push(breadcrumb); }
之后想要收集的時候只要調(diào)用 addBreadcrumb
方法往 breadcrumbs
里 push
一條條記錄就好啦。
收集 fetch 請求
這里我們就先以收集請求為例進行解釋說明??墒墙涌谡埱竽敲炊?,要是在每個接口都手動加上收集的邏輯會很繁瑣,所以就需要自動的對每個請求進行處理(有點類似手動埋點和全自動埋點)。那怎么進行全量處理并且無感知嘞,就是函數(shù)劫持啦(也可以叫 AOP,面向切面編程),前端最常用的魔改手段之一(此招一出,手動變自動)。通常發(fā)送請求有 xhr 和 fetch 兩種 api,這里我們以 fetch 舉例來看看基本的函數(shù)劫持寫法:
const _fetch = window.fetch; // 緩存原來的方法 window.fetch = function(url, options) { // 發(fā)送請求前可以做點事 const result = _fetch.call(this, url, options); // 執(zhí)行原來的請求邏輯 // 發(fā)送請求后可以做點事 return result; }
想想我們發(fā)送請求的時候需要記錄什么信息呢???好像主要就幾個:接口地址、請求方法、狀態(tài)碼和請求時間?那就先簡單記錄一下它們,就像下面這樣????:
const _fetch = window.fetch; window.fetch = function(url, options) { const breadcrumb = { type: 'fetch', data: { url, method: options.method || 'GET', startTimestamp: Date.now() } }; return _fetch.call(this, url, options).then(response => { breadcrumb.data.response = response; breadcrumb.data.endTimestamp = Date.now(); addBreadcrumb(breadcrumb); return response; }, error => { breadcrumb.data.error = error; breadcrumb.data.endTimestamp = Date.now(); addBreadcrumb(breadcrumb); throw error; }); }
注意到上面的代碼中,我們是在請求返回的時候才添加一條 fetch 面包屑數(shù)據(jù),這是因為我們需要展示接口的狀態(tài)碼或錯誤碼,以及如果我們希望增加一些自定義參數(shù),比如接口中一般會有個 logid 方便后端排查問題,也可以將其帶上,而這些數(shù)據(jù)在發(fā)送請求前是木有的。
此外我們還注意到這里順便記錄了請求發(fā)起和返回的的時間戳,但這并不是添加面包屑的時間戳(雖然和請求返回的時間差不多),并且面包屑的時間戳是每條記錄都有的,所以可以把時間戳的邏輯放在通用方法 addBreadcrumb
里面,就像下面這樣????:
const breadcrumbs = []; function addBreadcrumb(breadcrumb) { breadcrumb.timestamp || (breadcrumb.timestamp = Date.now()); breadcrumbs.push(breadcrumb); }
收集點擊事件
有了上面的基本實踐,接下來我們說說如何記錄點擊事件,看起來也是直接劫持魔改 EventTarget.prototype.addEventListener
這個 api???這當然是沒問題的。不過有個更方便的方法,就是利用點擊事件的特殊性,我們直接在 document
上進行全局監(jiān)聽即可,一行代碼就能輕松搞定,比如這樣:
document.addEventListener('click', e => { console.log(e.target.tagName); });
這樣一來所有點擊事件都會冒泡到 document
上,也就是所有點擊事件都會觸發(fā)上面那段代碼,接下來只要在回調(diào)里面加上需要的面包屑邏輯即可。那點擊事件需要記錄什么信息呢?好像只需要知道點擊哪個元素就可以了??,沒錯,確實是這樣。那怎么標識這個元素呢,除了基本的標簽名外,我們還需要去獲取點擊元素的 class 樣式名(當然 id 和屬性選擇器也是要的,這里就是舉個例子),就像下面這樣:
document.addEventListener('click', e => { const { tagName, className } = e.target; const breadcrumb = { type: 'click', data: { selector: `${tagName.toLowerCase()}.${className.split(' ').join('.')}` // 點擊元素的格式大概長這樣:'tagName#id.class',比如 'button.submit' } }; breadcrumbs.push(breadcrumb); });
這時候就會出現(xiàn)兩個問題,一個問題是有的元素沒有 class 或者同一個 class 的元素有很多個,那也就無法定位出具體是哪個元素被點擊了,為此我們需要把該元素的父元素也記錄下來,然后拼成下面這個樣子:
body > div#app > div.box > ul > li.row.active
這樣一來就基本能定位到是哪個元素了,不過需要一個小小的向上遞歸的過程??。
另一個問題是如果這樣做會不會造成數(shù)據(jù)冗余并且消耗性能。em。。。確實如此,不過我們只需要簡單限制下向上遞歸的次數(shù)就行了,比如四五次就 OK 了,不用一直遍歷到根元素,這里就簡單貼個代碼實現(xiàn)(直接 copy 下面的代碼到瀏覽器運行就能看到效果??):
function getXpath(ele) { const pathArr = []; function helper(ele, depth = 5) { if (!ele || depth < 1) return; const { tagName, className } = ele; const selector = `${tagName.toLowerCase()}.${className.split(' ').join('.')}`; pathArr.push(selector); helper(ele.parentNode, depth - 1); } try { helper(ele); return pathArr.reverse().join(' > '); } catch(e) { return '<unknown>' } } // 如果不想這么麻煩,也可以直接用 element.outerHTML 來表示,舍棄遞歸
不過這樣還是會有問題,比如我們點擊了某個按鈕,按鈕自身阻止了冒泡怎么辦,我們就捕獲不到這個按鈕的點擊事件了。要解決這個問題很簡單,就是將 addEventListener 的第三個參數(shù)設置為 true 就行了,就像下面這樣????:
document.addEventListener('click', e => {}, true);
就這樣簡單的一個操作我們就把冒泡的過程改成了捕獲,也就是所有點擊事件都會先觸發(fā)我們的回調(diào)。
此外我們還可以做一些優(yōu)化,比如多次點擊可以簡單節(jié)個流,包個 throttle 函數(shù)即可;點擊空白處或者點擊了某個元素但不觸發(fā)事件的(比如純文本)也不進行處理,但這個還是比較難辦的,即便可以通過 getEventListeners(ele)
這個方法來判斷某個元素有沒有綁定事件,但是這并不好用,比如我們點擊按鈕內(nèi)部的元素也可以觸發(fā)事件,但是內(nèi)部元素并沒有綁定事件。
小知識:事件的傳播通常有三個階段:捕獲階段、目標階段、冒泡階段。在這三個階段中,事件傳播時所攜帶的信息都是相同的,也就是 event 是相同的。
如果你用的是劫持的方式來處理點擊事件你就要注意,同一個事件可能會被出發(fā)多次,所以需要用一個變量保存最近一次觸發(fā)的事件 lastCapturedEvent
,然后和當前 event 做對比,如果一樣就說明是同樣的事件源,可以跳過。
至此,我們已經(jīng)簡單過了一下兩種面包屑的實現(xiàn),至于其他情況寫法大同小異,都是對相應的 api 進行劫持,只是對應的數(shù)據(jù)不同罷了,比如頁面跳轉(zhuǎn)的 data 就是 { from, to } 即可。最后我們只需要在發(fā)生報錯的時候,把當前的 breadcrumbs
一起上報并做個簡單的可視化就行了??。
一些疑問
我可以對面包屑做一些自定義操作嗎?當然,我們只需要在 push 或者上報的時候加個鉤子即可,就像下面這樣????:
const breadcrumbs = []; const beforeAddBreadcrumb = breadcrumb => { // do something } function addBreadcrumb(breadcrumb) { breadcrumb.timestamp || (breadcrumb.timestamp = Date.now()) beforeAddBreadcrumb(breadcrumb); breadcrumbs.push(breadcrumb); }
通常在一些需要過濾敏感數(shù)據(jù)的情況下,
beforeAddBreadcrumb
這個鉤子就顯得尤為重要,比如海外業(yè)務。面包屑數(shù)據(jù)量不會太大嗎? em。。。確實是會這樣,畢竟我們從頭記到尾,所以可以控制一下
breadcrumbs
的長度,比如控制在 20 條以內(nèi),超出了就把頭部元素刪了,只留下最近的即可;另外還可以控制一下點擊元素的 selector 的長度,當 xpath 過長時也不繼續(xù)遞歸了。我。。??梢杂娩浧羻???當然,錄屏相對面包屑的腦補畫面來說肯定更加直觀,但是錄屏的成本和大小都遠高于面包屑,有條件當然是允許錄屏的(比如 sentry 會通過 rrweb 提供這個功能)。不過錄屏一般用在保險、審核這種需要留存記錄和證據(jù)的地方,對于監(jiān)控來說倒不是剛需。
小結(jié)
通過本文的簡單介紹,想必你對怎么捕獲報錯發(fā)生前的行為應該有所了解??。當然了,這里還得再強調(diào)一下,這個面包屑只是當你對報錯沒有什么頭緒的時候提供一個有跡可循的思路而已,它不是必需品,僅僅是個輔助。
到此這篇關(guān)于該如何捕獲js報錯前的用戶行為的文章就介紹到這了,更多相關(guān)js報錯前用戶行為捕獲內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
深入淺析JavaScript系列(13):This? Yes,this!
在這篇文章里,我們將討論跟執(zhí)行上下文直接相關(guān)的更多細節(jié)。討論的主題就是this關(guān)鍵字。實踐證明,這個主題很難,在不同執(zhí)行上下文中this的確定經(jīng)常會發(fā)生問題2016-01-01Javascript調(diào)試之console對象——你不知道的一些小技巧
這篇文章主要總結(jié)了console對象的一些有用的方法,非常不錯,具有參考借鑒價值,需要的朋友參考下吧2017-07-07JavaScript如何實現(xiàn)數(shù)組按屬性分組
在JavaScript中,有多種方法可以對數(shù)組按屬性進行分組,這篇文章主要為大家至少介紹了6種常見的方法,感興趣的小伙伴可以跟隨小編一起學習一下2023-08-08