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