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