<ruby id="bdb3f"></ruby>

    <p id="bdb3f"><cite id="bdb3f"></cite></p>

      <p id="bdb3f"><cite id="bdb3f"><th id="bdb3f"></th></cite></p><p id="bdb3f"></p>
        <p id="bdb3f"><cite id="bdb3f"></cite></p>

          <pre id="bdb3f"></pre>
          <pre id="bdb3f"><del id="bdb3f"><thead id="bdb3f"></thead></del></pre>

          <ruby id="bdb3f"><mark id="bdb3f"></mark></ruby><ruby id="bdb3f"></ruby>
          <pre id="bdb3f"><pre id="bdb3f"><mark id="bdb3f"></mark></pre></pre><output id="bdb3f"></output><p id="bdb3f"></p><p id="bdb3f"></p>

          <pre id="bdb3f"><del id="bdb3f"><progress id="bdb3f"></progress></del></pre>

                <ruby id="bdb3f"></ruby>

                ??碼云GVP開源項目 12k star Uniapp+ElementUI 功能強大 支持多語言、二開方便! 廣告
                &emsp;&emsp;目前市面上有許多成熟的前端監控系統,但我們沒有選擇成品,而是自己動手研發。這里面包括多個原因: * 填補H5日志的空白 * 節約公司費用支出 * 可靈活地根據業務自定義監控 * 回溯時間能更長久 * 反哺運營和產品,從而優化產品質量 * 一次難得的練兵機會 &emsp;&emsp;前端監控地基本目的:了解當前項目實際使用的情況,有哪些異常,在追蹤到后,對其進行分析,并提供合適的解決方案。 &emsp;&emsp;其實也可以說是防患于未來,因為根據海恩法則可知: &emsp;&emsp;每一起嚴重事故的背后,必然有 29 次輕微事故和 300 起未遂先兆以及 1000 起事故隱患。 &emsp;&emsp;前端監控地終極目標: 1 分鐘感知、5 分鐘定位、10 分鐘恢復。目前是初版,離該目標還比較遙遠。 &emsp;&emsp;SDK(采用ES5語法)取名為 [shin.js](https://github.com/pwstrick/shin-admin/blob/main/public/shin.js),其作用就是將數據通過 JavaScript 采集起來,統一發送到后臺,采集的方式包括監聽或劫持原始方法,獲取需要上報的數據,并通過 gif 傳遞數據。 &emsp;&emsp;整個系統大致的運行流程如下: :-: ![](https://img.kancloud.cn/19/6b/196bbdb90ddb8e4f33453369c606b128_627x381.jpg =400x) &emsp;&emsp;2023-01-16 經過 TypeScript 整理重寫后,正式將監控系統的腳本開源,命名為?[shin-monitor](https://github.com/pwstrick/shin-monitor)。 ## 一、異常捕獲 &emsp;&emsp;異常包括運行時錯誤、Promise錯誤、框架錯誤等。 &emsp;&emsp;2022-08-10 錯誤日志在提交時附帶版本號,版本號是在初始化時手動輸入的,格式是年月日時分加上名字。 ~~~ shin.setParam({ version: '202208091830-strick', }); ~~~ &emsp;&emsp;版本號的作用是為了在發生錯誤時,能追蹤到什么時刻上線的代碼。有時候可能因為上了新代碼發生了錯誤,那就得找出新代碼的位置。 ~~~ /** * 上報錯誤 * @param {Object} errorLog 錯誤日志 */ function handleError(errorLog) { // 推送版本號 shin.param.version && (errorLog.version = shin.param.version); shin.send({ category: ACTION_ERROR, data: errorLog }); } ~~~ &emsp;&emsp;有了版本號大致能知道代碼提交的時間范圍,如果要與 Git 提交操作做精準關聯的話,可以將此版本號作為 commit 的備注添加進來。 **1)error事件** &emsp;&emsp;為 window 注冊[error](https://developer.mozilla.org/zh-CN/docs/Web/API/Window/error_event)事件,捕獲全局錯誤,過濾掉與業務無關的錯誤,例如“Script error.”、JSBridge告警等,還需統一資源載入和運行時錯誤的數據格式。 ~~~ // 定義的錯誤類型碼 var ERROR_RUNTIME = "runtime"; var ERROR_SCRIPT = "script"; var ERROR_STYLE = "style"; var ERROR_IMAGE = "image"; var ERROR_AUDIO = "audio"; var ERROR_VIDEO = "video"; var ERROR_PROMISE = "promise"; var ERROR_VUE = "vue"; var ERROR_REACT = "react"; var LOAD_ERROR_TYPE = { SCRIPT: ERROR_SCRIPT, LINK: ERROR_STYLE, IMG: ERROR_IMAGE, AUDIO: ERROR_AUDIO, VIDEO: ERROR_VIDEO }; /** * 監控腳本運行時的異常 */ window.addEventListener( "error", function (event) { var errorTarget = event.target; // 過濾掉與業務無關的錯誤 if (event.message === "Script error.") { return; } if ( errorTarget !== window && errorTarget.nodeName && LOAD_ERROR_TYPE[errorTarget.nodeName.toUpperCase()] ) { handleError(formatLoadError(errorTarget)); } else { // 過濾無效錯誤 event.message && handleError( formatRuntimerError( event.message, event.filename, event.lineno, event.colno, event.error ) ); } }, true //捕獲 ); /** * 生成 runtime 錯誤日志 * @param {String} message 錯誤信息 * @param {String} filename 出錯文件的URL * @param {Long} lineno 出錯代碼的行號 * @param {Long} colno 出錯代碼的列號 * @param {Object} error 錯誤信息Object * https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Error */ function formatRuntimerError(message, filename, lineno, colno, error) { return { type: ERROR_RUNTIME, lineno, colno, desc: { prompt: message + " at " + filename + ":" + lineno + ":" + colno, url: location.href } // stack: error && (error.stack ? error.stack : "no stack") // IE <9, has no error stack }; } /** * 生成 load 錯誤日志 * 需要加載資源的元素 */ function formatLoadError(errorTarget) { return { type: LOAD_ERROR_TYPE[errorTarget.nodeName.toUpperCase()], desc: { url: errorTarget.baseURI, src: errorTarget.src || errorTarget.href } }; } ~~~ &emsp;&emsp;2022-12-14 在 error 事件中,調用 formatRuntimerError() 前,增加 event.message 的判斷,用于過濾一些無效的錯誤,例如 undefined at undefined:undefined:undefined。 &emsp;&emsp;2022-12-16 為 formatRuntimerError() 中的 desc 增加當前出錯頁面的地址,便于日志檢索。 ~~&emsp;&emsp;得用[performance.getEntriesByType("resource")](https://developer.mozilla.org/zh-CN/docs/Web/API/Performance/getEntriesByType)讀取到資源列表(由[PerformanceResourceTiming](https://developer.mozilla.org/zh-CN/docs/Web/API/PerformanceResourceTiming)組成),然后循環列表。 &emsp;&emsp;當數據項的decodedBodySize屬性為0時,就可判斷無法讀取這個資源;或者沒有該屬性,可認為當前資源緩存在瀏覽器中。 &emsp;&emsp;這種判斷的條件不夠全,也不夠精確,后面就用比較簡單粗暴的方式來做判斷依據,那就是 duration 大于20秒,就認為請求超時了。 &emsp;&emsp;在日志中會將各個階段的時間參數都保存,便于后期的校驗。~~ &emsp;&emsp;2023-06-27 媒體資源在出現錯誤時,會在 errorTarget 包含一個錯誤對象,包括 code 和 message。? &emsp;&emsp;不過 message 我讀取時都是空字符串,下面是一段來自于[shin-monitor](https://github.com/pwstrick/shin-monitor)的 TS 代碼,修改了 formatLoadError 中的邏輯。 ~~~ private formatLoadError(errorTarget: TypeEventTarget): TypeErrorData { const desc: TypeResourceDesc = { url: errorTarget.baseURI, src: errorTarget.src || errorTarget.href }; /** * 對于媒體資源 errorTarget 會包含 error 屬性,其 code 包含 4 個值 * MEDIA_ERR_ABORTED:表示由于用戶取消操作而引發的錯誤(數值為 1) * MEDIA_ERR_NETWORK:表示由于網絡錯誤而引發的錯誤(數值為 2) * MEDIA_ERR_DECODE:表示由于解碼錯誤而引發的錯誤(數值為 3) * MEDIA_ERR_SRC_NOT_SUPPORTED:表示由于不支持媒體資源格式而引發的錯誤(數值為 4) */ if(errorTarget.error) { const MEDIA_ERR = { 1: '用戶取消操作', 2: '網絡錯誤', 3: '解碼錯誤', 4: '不支持的媒體資源格式' }; const { code } = errorTarget.error; code && (desc.message = MEDIA_ERR[code]); } return { type: CONSTANT.LOAD_ERROR_TYPE[errorTarget.nodeName.toUpperCase()], desc // stack: "no stack" }; } ~~~ &emsp;&emsp;2022-12-13 在將 error 事件處理程序中的第一個 if 判斷條件中的 !event.filename 去掉后,就能監控到腳本、樣式和圖像等靜態資源的異常了。 &emsp;&emsp;所以下面這段代碼現在可以全部注釋掉了,但是這段代碼可以提供一些對異常資源處理的思路,例如根據參數計算出資源是 404 還是超時等問題。 ~~~ /** * 監控資源異常,即無法響應的資源 */ window.addEventListener( "load", function () { // 羅列資源列表,PerformanceResourceTiming類型 var resources = performance.getEntriesByType("resource"); // 映射initiatorType和錯誤類型 var hashError = { script: ERROR_SCRIPT, link: ERROR_STYLE // img: ERROR_IMAGE }; resources && resources.forEach(function (value) { var type = hashError[value.initiatorType]; /** * 非監控資源、響應時間在20秒內、監控資源是ma.gif或shin.js,則結束當前循環 */ if ( !type || // 非監控資源 value.duration < 20000 || // 20秒內 value.name.indexOf("ma.gif") >= 0 || value.name.indexOf("shin.js") >= 0 ) { return; } /** * 若是CSS文件,則過濾腳本文件 * 但是Vue會通過link元素預請求腳本 */ // if (type === ERROR_STYLE // && value.name.indexOf('.js') >= 0) { // return; // } handleError({ type, desc: handleNumber(value.toJSON()) }); }); }, false ); ~~~ &emsp;&emsp;其實主要是為了監控腳本文本的響應,因為有時候會由于腳本沒響應而導致頁面空白,直接影響到業務,業務人員也不可能一直盯著頁面的,為了避免這種情況,就需要實時監控資源的響應狀態。 &emsp;&emsp;在監控到圖像的異常后,就發現有兩三千個 404 請求,一部分是因為默認給 img 元素的 src 屬性賦空字符串導致的。 &emsp;&emsp;另一部分是真的請求不到資源,那么當圖像請求不到時,為了能有更好的體驗,可以將裂圖替換成某一張默認圖。 &emsp;&emsp;2022-12-15 在優化后,仍然有三千多個圖像錯誤的記錄,于是做了進一步的分析。 &emsp;&emsp;發現這些圖像都是用戶的頭像,雖然在活動頁中無法訪問,但是在管理后臺卻能訪問。 &emsp;&emsp;經過比對發現,是域名的差異,一個域名可以訪問,而另一個不能訪問,所以需要將域名替換掉,而路徑可以保持不變。 &emsp;&emsp;由于之前頭像使用了統一的組件,所以只需在一處做替換即可。 &emsp;&emsp;2023-06-19 對一個常規活動做了一次優化,但是上線后收到大量的 webp 圖像錯誤,并且清一色都來自于 iOS 系統。 &emsp;&emsp;經過搜索了解到 iOS14 以上才支持 webp 格式,因此需要對此類系統做兼容處理。 :-: ![](https://img.kancloud.cn/d5/9e/d59e29c8e7dac2202792b1dd35b726a2_1329x836.png =600x) &emsp;&emsp;2022-12-30 突然發現 shin.js 腳本內部報的錯誤,并不會上報到后臺,將線上文件通過 Charles 映射到本地,發現報的錯誤是 Script error。 &emsp;&emsp;Script error 是一種跨域錯誤,而我的 shin.js 與當前網頁是不同域名,因此腳本中的錯誤就都變成了 Script error。 &emsp;&emsp;因此需要為 shin.js 的請求加上跨域首部:Access-Control-Allow-Origin: \*,并且為 script 增加[crossorigin](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/crossorigin)屬性。 ~~~html <script src="https://www.xxx.com/js/shin.js" crossorigin="anonymous"></script> ~~~ &emsp;&emsp;還有一種辦法就是重寫 addEventListener,但只能捕獲事件回調程序中的錯誤。在調試的時候,出現了死循環,調查發現和 React 有關,[參考此文](https://ost.51cto.com/posts/33)。 ~~~ const originAddEventListener = EventTarget.prototype.addEventListener; EventTarget.prototype.addEventListener = function (type, listener, options) { const wrappedListener = function (...args) { try { return listener.apply(this, args); } catch (err) { throw err; } } return originAddEventListener.call(this, type, wrappedListener, options); } ~~~ **2)unhandledrejection事件** &emsp;&emsp;為 window 注冊[unhandledrejection](https://developer.mozilla.org/zh-CN/docs/Web/API/Window/unhandledrejection_event)事件,捕獲未處理的 Promise 錯誤,當 Promise 被 reject 且沒有 reject 處理器時觸發。 ~~~ window.addEventListener( "unhandledrejection", function (event) { //處理響應數據,只抽取重要信息 var response = event.reason.response || response.status; //若無響應,則不監控 if (!response) { return; } var desc = response.request.ajax; desc.status = event.reason.status; handleError({ type: ERROR_PROMISE, desc: desc }); }, true ); ~~~ &emsp;&emsp;Promise 常用于異步通信,例如[axios](https://github.com/axios/axios)庫,當響應異常通信時,就能借助該事件將其捕獲,得到的結果如下。 ~~~ { "type": "promise", "desc": { "response": { "data": "Error occured while trying to proxy to: localhost:8000/monitor/performance/statistic", "status": 504, "statusText": "Gateway Timeout", "headers": { "connection": "keep-alive", "date": "Wed, 24 Mar 2021 07:53:25 GMT", "transfer-encoding": "chunked", "x-powered-by": "Express" }, "config": { "transformRequest": {}, "transformResponse": {}, "timeout": 0, "xsrfCookieName": "XSRF-TOKEN", "xsrfHeaderName": "X-XSRF-TOKEN", "maxContentLength": -1, "headers": { "Accept": "application/json, text/plain, */*", }, "method": "get", "url": "/api/monitor/performance/statistic" }, "request": { "ajax": { "type": "GET", "url": "/api/monitor/performance/statistic", "status": 504, "endBytes": 0, "interval": "13.15ms", "network": { "bandwidth": 0, "type": "4G" }, "response": "Error occured while trying to proxy to: localhost:8000/monitor/performance/statistic" } } }, "status": 504 }, "stack": "Error: Gateway Timeout at handleError (http://localhost:8000/umi.js:18813:15)" } ~~~ &emsp;&emsp;這樣就能分析出 500、502、504 等響應碼所占通信的比例,當高于日常數量時,就得引起注意,查看是否在哪塊邏輯出現了問題。 &emsp;&emsp;500 是代碼報錯,502 是沒有找到對應的服務,504 是服務響應過慢導致超時,例如超過 60 秒沒有得到服務的響應就會報 504。 &emsp;&emsp;有一點需要注意,上面的結構中包含響應信息,這是需要對[Error](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Error)做些額外擴展的,如下所示。 ~~~ import fetch from 'axios'; function handleError(errorObj) { const { response } = errorObj; if (!response) { const error = new Error('你的網絡有點問題'); error.response = errorObj; error.status = 504; throw error; } const error = new Error(response.statusText); error.response = response; error.status = response.status; throw error; } export default function request(url, options) { return fetch(url, options) .catch(handleError) .then((response) => { return { data: response.data }; }); } ~~~ &emsp;&emsp;2022-11-22 最近發現前端監控中的 504 數量,要比 Nginx 中的 504 日志數量多。 &emsp;&emsp;經過排查發現是因為有些接口的 response 是空字符串,在 handleError() 函數中,就會給 error.status 賦值 504。 &emsp;&emsp;經查,這些接口中都不會包含 req-id 的記錄,也就是說在服務端的日志中,沒有留下記錄,服務器沒有對其進行處理。 &emsp;&emsp;這類接口占比很少(0.001% 左右),懷疑是 Nginx 因為某種原因沒有轉發到對應的服務中,需要運維配合調查。 &emsp;&emsp;為了能區分是服務器返回的 504,還是因為空字符串定義的 504,對 handleError() 函數做改造。 &emsp;&emsp;將 status 聲明成 512,之所以選這個狀態碼,是因為在它之前的狀態碼都有明確的含義。 ~~~ function handleError(errorObj) { // ... if (!response) { // ... error.status = 512; // 自定義response為空時的錯誤狀態碼 throw error; } // ... } ~~~ &emsp;&emsp;2022-11-08 在?unhandledrejection?事件中,可以增加一些需要過濾的通信異常,例如登錄信息超時、埋點請求等,如下所示。 &emsp;&emsp;如果做的通用點,還可以在此處預留一個可配置的鉤子函數。 ~~~ if(desc.status == 401 || // 過濾管理后臺登錄信息超時的異常 desc.url.indexOf('reports/logs') >= 0 // 過濾埋點異常 ) { return; } ~~~ &emsp;&emsp;公司中有一套項目依賴的是 jQuery 庫,因此要監控此處的異常通信,需要做點改造。 &emsp;&emsp;好在所有的通信都會請求一個通用函數,那么只要修改此函數的邏輯,就能覆蓋到項目中的所有頁面。 &emsp;&emsp;搜索了API資料,以及研讀了 jQuery 中通信的源碼后,得出需要聲明一個 xhr() 函數,在函數中初始化 XMLHttpRequest 對象,從而才能監控它的實例。 &emsp;&emsp;并且在 error 方法中需要手動觸發?unhandledrejection 事件。 ~~~ $.ajax({ url, method, data, success: (res) => { success(res); }, xhr: function () { this.current = new XMLHttpRequest(); return this.current; }, error: function (res) { error(res); Promise.reject({ status: res.status, response: { request: { ajax: this.current.ajax } } }).catch((error) => { throw error; }); } }); ~~~ &emsp;&emsp;2023-09-25 在我當前公司中,Nginx 也可以監控 HTTP 的異常請求(例如 5XX 的請求),實踐下來比較精確,而網頁監控到的錯誤數量與 Nginx 監控的數量往往不同。 &emsp;&emsp;有時候網頁多,有時候網頁少,很有可能受外部環境的影響,例如網絡波動、瀏覽器關閉等,導致兩邊統計的不同。 &emsp;&emsp;其實還有一類響應異常,這類接口的請求是 200,但是由于業務邏輯異常,在返回響應時,會給一個異常的狀態碼,例如下面是一段正常的 JSON 響應。 ~~~ { code: 0, msg: "", data: {} } ~~~ &emsp;&emsp;當 code 為非 0 時,就認為是一次異常的請求。如果公司對 JSON 響應的格式做了標準化,那就比較容易做區分。 &emsp;&emsp;像我當前公司,沒有統一,存在多種格式,例如有些接口中 status 為 1 是正常的,或者 code 為 200 是正常的。 &emsp;&emsp;所以不能簡單的來做甄別,為此,將響應狀態碼特地存儲在數據庫表的 message_code 字段中,方便自己分析。 **3)框架錯誤** &emsp;&emsp;框架是指目前流行的React、Vue等,我只對公司目前使用的這兩個框架做了監控。 &emsp;&emsp;React 需要在項目中創建一個[ErrorBoundary](https://react.docschina.org/docs/error-boundaries.html)類,捕獲錯誤。 ~~~ import React from 'react'; export default class ErrorBoundary extends React.Component { constructor(props) { super(props); this.state = { hasError: false }; } componentDidCatch(error, info) { this.setState({ hasError: true }); // 將component中的報錯發送到后臺 shin && shin.reactError(error, info); } render() { if (this.state.hasError) { return null // 也可以在出錯的component處展示出錯信息 // return <h1>出錯了!</h1>; } return this.props.children; } } ~~~ &emsp;&emsp;其中 reactError() 方法在組裝錯誤信息。 ~~~ /** * 處理 React 錯誤(對外) */ shin.reactError = function (err, info) { handleError({ type: ERROR_REACT, desc: { prompt: err.toString(), // 描述 url: location.href }, stack: info.componentStack }); }; ~~~ &emsp;&emsp;如果要對 Vue 進行錯誤捕獲,那么就得重寫[Vue.config.errorHandler()](https://cn.vuejs.org/v2/api/index.html#errorHandler),其參數就是 Vue 對象。 ~~~ /** * Vue.js 錯誤劫持(對外) */ shin.vueError = function (vue) { var _vueConfigErrorHandler = vue.config.errorHandler; vue.config.errorHandler = function (err, vm, info) { handleError({ type: ERROR_VUE, desc: { prompt: err.toString(), // 描述 url: location.href } stack: err.stack      //堆棧 }); // 控制臺打印錯誤 if ( typeof console !== "undefined" && typeof console.error !== "undefined" ) { console.error(err); } // 執行原始的錯誤處理程序 if (typeof _vueConfigErrorHandler === "function") { _vueConfigErrorHandler.call(err, vm, info); } }; }; ~~~ &emsp;&emsp;如果 Vue 是被模塊化引入的,那么就得在模塊的某個位置調用該方法,因為此時 Vue 不會綁定到 window 中,即不是全局變量。 &emsp;&emsp;2022-12-16 為 React 和 Vue 在上報的 desc 中增加當前頁面的地址,在日志查詢時就能通過 URL 路徑來查了。 **4)難點** &emsp;&emsp;雖然把錯誤都搜集起來了,但是現代化的前端開發,都會做一次代碼合并壓縮混淆,也就是說,無法定位錯誤的真正位置。 &emsp;&emsp;為了能轉換成源碼,就需要引入自動堆棧映射([SourceMap](https://github.com/mozilla/source-map)),[webpack](https://www.webpackjs.com/configuration/devtool/)默認就帶了此功能,只要聲明相應地關鍵字開啟即可。 &emsp;&emsp;我選擇了 devtool: "hidden-source-map",生成完成的原始代碼,并在腳本中隱藏Source Map路徑。 ~~~ //# sourceMappingURL=index.bundle.js.map ~~~ &emsp;&emsp;在生成映射文件后,就需要讓運維配合,編寫一個腳本(在發完代碼后觸發),將這些文件按年月日小時分鐘的格式命名(例如 202103041826.js.map),并遷移到指定目錄中,用于后期的映射。 &emsp;&emsp;之所以沒有到秒是因為沒必要,在執行發代碼的操作時,發布按鈕會被鎖定,其他人無法再發。 &emsp;&emsp;映射的邏輯是用 Node.js 實現的,會在后文中詳細講解。注意,必須要有列號,才能完成代碼還原。 ## 二、行為搜集 &emsp;&emsp;將行為分成:用戶行為、瀏覽器行為、控制臺打印行為。監控這些主要是為了在排查錯誤時,能還原用戶當時的各個動作,從而能更好的找出問題出錯的原因。 **1)用戶行為** &emsp;&emsp;2022-12-29 觀察了下公司網頁的布局,里面充斥著 div 元素,沒有實現語義化的布局,并且經常拿 div 當按鈕使用。 &emsp;&emsp;之前也沒約定樣式規則,例如按鈕樣式加 btn- 前綴,所以現在也無法區分哪些 div 是按鈕,哪些是容器。 &emsp;&emsp;一般的話,a、button、li 等元素大部分都會綁定交互事件或默認行為,所以它們是有意義。 &emsp;&emsp;而隨意的點下背景圖、列表等位置,在本處是不需要記錄的,因為這里不是用作埋點的功能,可理解為記錄關鍵的影響布局的行為。 &emsp;&emsp;為了避免大量無意義的點擊上報,就需要過濾出綁定點擊事件的元素,想到一個方法是讀取 node.onclick 屬性。 &emsp;&emsp;但是發現,并不都能返回值,因此這種判斷方式不準確。并且當點擊的元素并 &emsp;&emsp;然后試圖找到一個方法,可以讀取元素綁定的事件名稱,網上有個非標準的方法 getEventListeners(),但是有兼容性問題。 &emsp;&emsp;又想到一個方法,那就是記錄 HTML 的結構,比對兩次是否相同,不同時就記錄。 &emsp;&emsp;但是實際操作時,發現很多情況下都會改變 HTML 結構,選中一個文本框、點擊表格的一列等,都不是有記錄意義的點擊。 &emsp;&emsp;現在還沒想到比較好的辦法,就只能先通過特征慢慢完善需要記錄的判斷條件了,例如 CSS 類中包含 tabs 字符串的,可認為是一個菜單欄,點擊是有意義的。 &emsp;&emsp;在下面的代碼中 window.onclick 支持 IE9+,若要支持 IE8 瀏覽器,可以改成 document.onclick。 ~~~ /** * 全局監聽事件 */ function _eventHandle(eventType, detect) { return function (e) { if (!detect(e)) { return; } handleAction(ACTION_EVENT, { type: eventType, desc: _removeQuote(e.target.outerHTML) // 去除雙引號 }); }; } /** * 監聽點擊事件 * window.onclick 支持 IE9+,若要支持 IE8 瀏覽器,可以改成 document.onclick */ window.addEventListener("click", _eventHandle("click", function (e) { var node = e.target; var nodeName = node.nodeName.toLowerCase(); // 若是 body 元素,則不記錄 if (nodeName === "body") { return false; } // 白名單 if ( nodeName !== "a" && nodeName !== "button" && nodeName !== "li" && // 先判斷是否包含 indexOf 方法,再根據樣式特征判斷,例如菜單欄樣式 (node.className.indexOf && node.className.indexOf('tabs') === -1)) { return false; } return true; }), false ); ~~~ &emsp;&emsp;2024-03-01 handleAction() 方法用于將數據整理好后,發送到后臺。handleNumber() 會遞歸的將數字四舍五入小數點后兩位,性能參數計算后會有大量小數,在此處可以統一處理。 ~~~ private handleAction(type: string, data: any): void { this.http.send({ category: type, data: this.handleNumber(data) }); } /** * 遞歸的將數字四舍五入小數點后兩位 */ private handleNumber(obj: any): any { const type = typeof obj; // 若 obj 是 null,則 typeof null 也是 object if (type === 'object' && obj !== null) { for (const key in obj) { // 讀取屬性狀態 const des = Object.getOwnPropertyDescriptor(obj, key); // 當key是只讀屬性時,就不能直接賦值了 if(des && des.writable) { obj[key] = this.handleNumber(obj[key]); } } } if (type === 'number') { return rounded(obj, 2); } return obj; } ~~~ &emsp;&emsp;在為 obj 對象賦值時,需要判斷屬性是否可寫,對于只讀屬性會報錯。 ~~~ Cannot assign to read only property 'size' of object '#<Blob>' ~~~ **2)瀏覽器行為** &emsp;&emsp;監控異步通信,重寫 XMLHttpRequest 對象,并通過[Navigator.connection](https://developer.mozilla.org/zh-CN/docs/Web/API/Navigator/connection)讀取當前的網絡環境,例如4G、3G等。 &emsp;&emsp;其實還想獲取當前用戶環境的網速,不過還沒有較準確的獲取方式,因此并沒有添加進來。 ~~~ var _XMLHttpRequest = window.XMLHttpRequest; // 保存原生的XMLHttpRequest // 覆蓋XMLHttpRequest window.XMLHttpRequest = function (flags) { var req = new _XMLHttpRequest(flags); // 調用原生的XMLHttpRequest monitorXHR(req); // 埋入我們的“間諜” return req; }; var monitorXHR = function (req) { req.ajax = {}; // var _change = req.onreadystatechange; req.addEventListener( "readystatechange", function () { if (this.readyState == 4) { // 只上報文本和JSON格式的響應數據 if ( req.responseType && (req.responseType != "text" || req.responseType != "json") ) { return; } var end = shin.now(); // 結束時間 req.ajax.status = req.status; // 狀態碼 if ((req.status >= 200 && req.status < 300) || req.status == 304) { // 請求成功 req.ajax.endBytes = `${_kb(req.responseText.length * 2)}KB`; // KB // console.log('響應數據:'+ req.ajax.endBytes); //響應數據大小 } else { // 請求失敗 req.ajax.endBytes = 0; } // 為監控的響應頭添加 req-id 字段 var reqId = req.getResponseHeader("req-id"); if (reqId) { req.ajax.header ? (req.ajax.header["req-id"] = reqId) : (req.ajax.header = { "req-id": reqId }); } // req.ajax.header req.ajax.interval = `${_rounded(end - start, 2)}ms`; // 單位毫秒 req.ajax.network = shin.network(); // 只記錄6000個字符以內的響應限制,以便讓MySQL表中的message字段能成功存儲 req.responseText.length <= 6000 && (req.ajax.response = req.responseText); // req.ajax.response = req.responseText; if ( req.status < 500 && // 只傳送500以內的通信 req.ajax.url !== "/api/user" && // 不需要監控后臺身份通信 ) { handleAction(ACTION_AJAX, req.ajax); } // console.log('ajax響應時間:'+req.ajax.interval); } }, false ); // “間諜”又對open方法埋入了間諜 var _open = req.open; req.open = function (type, url, async) { req.ajax.type = type; // 埋點 req.ajax.url = url; // 埋點 return _open.apply(req, arguments); }; // 設置請求首部 var _setRequestHeader = req.setRequestHeader; req.setRequestHeader = function (header, value) { if (header === "Authorization") { // 監控身份狀態 req.ajax.header = { [header]: value }; } return _setRequestHeader.apply(req, arguments); }; // 發送請求 var _send = req.send; var start; // 請求開始時間 req.send = function (data) { start = shin.now(); // 埋點 // var bytes = 0; //發送數據大小 if (data) { req.ajax.startBytes = `${_kb(JSON.stringify(data).length * 2)}KB`; req.ajax.data = data; // 傳遞的參數 } return _send.apply(req, arguments); }; }; ~~~ &emsp;&emsp;2022-05-10 在正式調用 handleAction() 方法發送監控信息到后臺之前,可以過濾一些不需要或不影響業務的接口。 &emsp;&emsp;例如上述代碼中的 api/user,第三方的一些日志接口等,這些過濾條件可以暴露給外部作為鉤子函數。 &emsp;&emsp;2022-07-29 在響應頭添加 req-id 字段,為了能與服務端中的日志關聯,詳見后面的第 4 小節。 &emsp;&emsp;在所有的日志中,通信占的比例是最高的,大概在 90% 以上。 &emsp;&emsp;2022-06-18 剛開始會將所有搜集到的內容上報到后臺服務,但是有些數據會比較大,超出 1M 后,后臺就會報 500 的錯誤,計算下來居然占到了總錯誤的 94%。 &emsp;&emsp;于是就控制了ajax響應的大小,并且在上報前也會驗證內容尺寸,因為有的打印內容也會有幾兆,例如打印 base64 格式的圖片。 &emsp;&emsp;瀏覽器的行為還包括跳轉,當前非常流行 SPA,所以在記錄跳轉地址時,只需監聽[onpopstate](https://developer.mozilla.org/zh-CN/docs/Web/API/WindowEventHandlers/onpopstate)事件即可,其中上一頁地址也會被記錄。 ~~~ /** * 在路由中注入自定義邏輯 */ function injectRouter() { var href = location.href; handleAction(ACTION_REDIRECT, { refer: shin.refer, current: href }); shin.refer = href; } /** * 全局監聽跳轉 * 點擊后退、前進按鈕或者調用 history.back()、history.forward()、history.go() 方法才會觸發 popstate 事件 * 點擊 <a href=/xx/yy#anchor>hash</a> 按鈕也會觸發 popstate 事件 */ var _onPopState = window.onpopstate; window.onpopstate = function (args) { injectRouter(); _onPopState && _onPopState.apply(this, args); }; ~~~ &emsp;&emsp;2023-01-01 注意,popstate 事件只會在瀏覽器某些行為下觸發, 比如點擊后退、前進按鈕或者調用 history.back()、history.forward()、history.go() 方法。 &emsp;&emsp;現在流行的路由庫:[react-router](https://reactrouter.com/en/main)和[vue-router](https://router.vuejs.org/zh/),底層都是通過 history.pushState() 和 history.replaceState() 來實現路由的變化。 &emsp;&emsp;因此,如果要監聽這兩個方法,就得采用注入的方式,在事件中添加自定義邏輯。 ~~~ /** * 監聽 pushState() 和 replaceState() 兩個方法 */ var bindEventListener = function (type) { var historyEvent = history[type]; return function () { // 觸發 history 的原始事件 var newEvent = historyEvent.apply(this, arguments); injectRouter(); return newEvent; }; }; history.pushState = bindEventListener("pushState"); history.replaceState = bindEventListener("replaceState"); ~~~ **3)控制臺打印行為** &emsp;&emsp;其實就是重寫 console 中的方法,目前只對 log() 做了處理。在實際使用中發現了兩個問題。 &emsp;&emsp;第一個是在項目調試階段,將數據打印在控制臺時,顯示的文件和行數都是 SDK 的名稱和位置,無法得知真正的位置,很是別扭。 &emsp;&emsp;并且在 SDK 的某些位置調用 console.log() 會形成死循環。后面就加了個 isDebug 開關,在調試時就關閉監控,省心。 ~~~ function injectConsole(isDebug) { !isDebug && ["log"].forEach(function (level) { var _oldConsole = console[level]; console[level] = function () { var params = [].slice.call(arguments);   // 參數轉換成數組 _oldConsole.apply(this, params);       // 執行原先的 console 方法 var seen = []; handleAction(ACTION_PRINT, { level: level, // 避免循環引用 desc: JSON.stringify(params, function (key, value) { if (typeof value === "object" && value !== null) { if (seen.indexOf(value) >= 0) { return; } seen.push(value); } return value; }) }); }; }); } ~~~ &emsp;&emsp;第二個就是某些要打印的變量包含循環引用,這樣在調用[JSON.stringify()](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify)時就會報錯。 &emsp;&emsp;2022-11-22 發現有些打印其實是可以忽略的,例如公司最近引入了一個第三方的 SDK 庫。 &emsp;&emsp;而在該庫中包含大量的打印代碼,這些都應該被忽略掉,忽略后的打印數量從最高的 77W 降低至 3W。 &emsp;&emsp;過濾打印的那段邏輯可以封裝成一個鉤子函數,暴露給外部。 ~~~ function injectConsole(isDebug) { !isDebug && ["log"].forEach(function (level) { console[level] = function () { // ... var desc = JSON.stringify(params, function (key, value) { //... }); // 過濾SDK的打印信息 if (desc && desc.indexOf("SDK") >= 0) { return; } handleAction(ACTION_PRINT, { type: level, desc: desc }); }; }); } ~~~ &emsp;&emsp;2022-11-08 發現當短時間內(例如 1 秒內)有大量的打印(例如幾百次),并且將請求發送給后臺時,會讓電腦 CPU 暴漲,在項目上線后需要注意打印時機和數量。 &emsp;&emsp;2023-06-30 增加對自定義異常 console.error 的監控,在之前的 ["log", "error"] 數組中增加一項。 &emsp;&emsp;2023-07-03 發現如果打印 new Error(),那么字符串序列化的結果是一個空對象({}),看不到真實的錯誤信息。 &emsp;&emsp;需要對 Error 實例做一次適配(如下所示),[getOwnPropertyNames()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/getOwnPropertyNames)靜態方法會返回一個由自有屬性組成的數組。 ~~~ function errorToJSON(error) { const errorObj = {}; Object.getOwnPropertyNames(error).forEach(key => { errorObj[key] = error[key]; }); return errorObj; } ~~~ &emsp;&emsp;注意,這么做后有可能會丟失一些錯誤對象的特定行為和方法,但可以保證得到序列化后的錯誤信息。 **4)全鏈路日志查詢** &emsp;&emsp;2022-07-29 新增全鏈路日志查詢的 ID。在前端監控中,會記錄通信的請求、響應等信息。 &emsp;&emsp;而這些接口基本都是 Node 服務提供的,它們也會有日志,包括 MySQL語句、埋點、內部接口調用等,為了能將通信日志和服務日志關聯,就需要一個標識符。 &emsp;&emsp;我們所有的接口都會由一個統一的 Nginx 網關做轉發,在轉發時,Nginx 會自動生成一個用于識別通信的字符串標識:X-Request-Id。 &emsp;&emsp;這個標識會作為通信上下文的一個 reqId 屬性存在,只要在響應時,給響應頭加上這個屬性就能實現關聯。如下所示,一段 Node.js 的代碼,使用的框架是 KOA。 ~~~ ctx.set('Access-Control-Expose-Headers', 'req-id'); ctx.set('req-id', ctx.reqId); ~~~ &emsp;&emsp;在 SDK 中,得到響應頭后,就能讀取 req-id,并記錄到監控日志表中,由此就能實現兩端日志的關聯。 ~~~ var reqId = req.getResponseHeader('req-id'); if(reqId) { req.ajax.header ? (req.ajax.header['req-id'] = reqId) : (req.ajax.header = { 'req-id':reqId }); } ~~~ &emsp;&emsp;2023-11-09 若響應中不存在 req-id,那么在 Chrome 控制臺會報錯,如下所示。 ~~~ Refused to get unsafe header "req-id" ~~~ &emsp;&emsp;這不是 JavaScript 錯誤,所以代碼執行也不會停止,僅僅是一個提示。 &emsp;&emsp;若要消除此錯誤,需要在調用 getResponseHeader() 方法之前先判斷指定的響應頭是否存在,如下所示。 ~~~ if(req.getAllResponseHeaders().indexOf('req-id') >= 0) reqId = req.getResponseHeader('req-id'); ~~~ ## 三、其他 **1)環境信息** &emsp;&emsp;通過解析請求中的 UA 信息,可以得到操作系統、瀏覽器名稱版本、CPU等信息。 ~~~ { "ua": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.82 Safari/537.36", "browser": { "name": "Chrome", "version": "89.0.4389.82", "major": "89" }, "engine": { "name": "Blink", "version": "89.0.4389.82" }, "os": { "name": "Mac OS", "version": "10.14.6" }, "device": {}, "cpu": {} } ~~~ &emsp;&emsp;圖省事,就用了一個開源庫,叫做[UAParser.js](https://github.com/faisalman/ua-parser-js),在 Node.js 中引用了此庫。 **2)上報** &emsp;&emsp;上報選擇了 Gif 的方式,即把參數拼接到一張 Gif 地址后,傳送到后臺。 ~~~ /** * 組裝監控變量 */ function _paramify(obj) { obj.token = shin.param.token; obj.subdir = shin.param.subdir; obj.identity = getIdentity(); return encodeURIComponent(JSON.stringify(obj)); } /** * 推送監控信息 */ shin.send = function (data) { var ts = new Date().getTime().toString(); var img = new Image(0, 0); img.src = shin.param.src + "?m=" + _paramify(data) + "&ts=" + ts; }; ~~~ &emsp;&emsp;用這種方式有幾個優勢: * 兼容性高,所有的瀏覽器都支持。 * 不存在跨域問題。 * 不會攜帶當前域名中的 cookie。 * 不會阻塞頁面加載。 * 相比于其他類型的圖片格式(BMP、PNG等),能節約更多的網絡資源。 &emsp;&emsp;不過這種方式也有一個問題,那就是采用 GET 的請求后,瀏覽器會限制 URL 的長度,也就是不能攜帶太多的數據,否則會報 431 錯誤。 &emsp;&emsp;在之前記錄 Ajax 響應數據時就有一個判斷,只記錄300個字符以內的響應數據,其實就是為了規避此限制而加了這段代碼。 &emsp;&emsp;不過在正式使用中發現,由于做了字符判斷,因此有時候會缺失查詢列表的信息,而這些信息都很關鍵,對排查起到決定性作用,因為后面就改成了普通的POST提交,這樣就不會有數據量的限制了。 &emsp;&emsp;但存儲量一下子就暴增,從原先每個月100G增加到半個月250G,保存6個月的話就要存儲3T的數據,經濟成本上也增加了不少。 &emsp;&emsp;讓數據組的同事將比較占內存的通信記錄列出,由于不太會用到,因此我單獨做了過濾。 **3)錯誤回放** &emsp;&emsp;2022-12-21 在搜集到報錯時,能夠回放錯誤發生之前的頁面動作。之前曾寫過一篇《[純JavaScript實現頁面行為的錄制](https://www.cnblogs.com/strick/p/12206766.html)》分析了回放原理。 &emsp;&emsp;簡單的說,就是將 DOM 映射成指定的 JSON 結構,每次 DOM 發生變化時,記錄 DOM 信息(即記錄用戶行為),回放就是將 JSON 結構反解析成 DOM 元素。 &emsp;&emsp;其中要監控 DOM 的變化,就需要使用[MutationObserver](https://developer.mozilla.org/zh-CN/docs/Web/API/MutationObserver),它會在 DOM 操作結束后才觸發,是一種異步接口,比起同步的[MutationEvent](https://developer.mozilla.org/en-US/docs/Web/API/MutationEvent),性能要更高。 &emsp;&emsp;雖然知道了原理,但是要實現一個比較完善的回放功能,是一件比較復雜的事情。所以本次將會使用一個開源庫:[rrweb](https://github.com/rrweb-io/rrweb)。 &emsp;&emsp;原先計劃是在發生錯誤時,存儲前面 2 分鐘內的行為記錄,但是發現容量非常大,可能要好多 M。為了節省空間,暫時先存儲 10~20 秒之間的行為,控制在 500KB 以內。 &emsp;&emsp;有兩種方式引入該庫,一種是在頁面源碼中加入,另一種是通過 JavaScript 動態添加。如下所示,為了減少內存占用,在頁面中只保留 3 段行為記錄。 ~~~ var recordEventsMatrix = [[]]; function recordPage(isRecord) { if (!isRecord) { return; } var script = document.createElement("script"); script.src = "//cdn.jsdelivr.net/npm/rrweb@latest/dist/rrweb.min.js"; // 開始監控頁面行為 script.onload = function () { rrweb.record({ emit(event, isCheckout) { // isCheckout 是一個標識,告訴你重新制作了快照 if (isCheckout) { // 最多保留 3 段行為記錄 var deleteCount = recordEventsMatrix.length - 2; deleteCount > 0 && recordEventsMatrix.splice(0, deleteCount); recordEventsMatrix.push([]); } var lastEvents = recordEventsMatrix[recordEventsMatrix.length - 1]; lastEvents.push(event); }, checkoutEveryNms: 10 * 1000 // 每 10 秒重新制作快照 }); }; document.head.append(script); } ~~~ &emsp;&emsp;為了防止數據庫容量增加過快,目前就只有當頁面出現白屏時,才上報到后臺中。 ~~~ /** * 讀取最近 20 秒的行為記錄 */ function getRecentRecord() { var len = recordEventsMatrix.length; if (len === 0) return ""; var events; if (len.length >= 2) { events = recordEventsMatrix[len - 2].concat(recordEventsMatrix[len - 1]); } else { events = recordEventsMatrix[len - 1]; } return JSON.stringify(events); } /** * 推送監控信息 * 改成POST請求 */ shin.send = function (data) { // var ts = new Date().getTime().toString(); // var img = new Image(0, 0); // img.src = shin.param.src + "?m=" + _paramify(data) + "&ts=" + ts; var m = _paramify(data); // 大于8000的長度,就不在上報,廢棄掉 if (m.length >= 8000) { return; } var body = { m: m }; var record; // 當前是一條錯誤日志,并且描述的是奔潰 if (data.category === ACTION_ERROR && data.data.type === ERROR_CRASH) { // 讀取行為記錄 record = getRecentRecord(); // 只有當有內容時,才發送行為記錄 record.length > 0 && (body.r = record); } // 如果修改headers,就會多一次OPTIONS預檢請求 fetch(shin.param.src, { method: "POST", // headers: { // 'Content-Type': 'application/json', // }, body: JSON.stringify(body) }); }; ~~~ &emsp;&emsp;在將此功能上線后,馬上就發現了之前一個困擾我的白屏問題(在《[監控頁面奔潰](https://www.cnblogs.com/strick/p/14986378.html)》一文中,有具體的監控原理)。就是 body 里明明有內容,但是卻上報為白屏。 &emsp;&emsp;查看回放,的確是白屏,于是在控制臺中查看頁面元素,果然發現了端倪,如下圖所示。 :-: ![](https://img.kancloud.cn/1a/c0/1ac05fcc523077018d83aa841b7061cc_2938x1346.png =800x) &emsp;&emsp;html 元素的字體大小是 0,從而導致頁面中涉及到 rem 的計算都為 0,元素的尺寸也就是 0 了。 &emsp;&emsp;經過排查,應該是[flexible.js](https://github.com/beipiaoyu2011/flexible/blob/master/public/js/frame/flexible.debug.js)中的計算問題,因為 getBoundingClientRect() 得到的寬度是 0,從而讓 fontSize 也成為了 0。 ~~~ function refreshRem() { var width = document.documentElement.getBoundingClientRect().width; if (width / dpr > 540) { width = 540 * dpr; } var rem = width / 10; docEl.style.fontSize = rem + "px"; flexible.rem = win.rem = rem; } ~~~ &emsp;&emsp;至于為什么是 0,還沒有準確答案,可能是在讀取時,DOM 元素還不存在。 &emsp;&emsp;解決辦法就是當 width 是 0 時,就給個默認值,例如可讀取視口寬度的 window.innerWidth。 &emsp;&emsp;2022-12-26 雖然加了 window.innerWidth,但還是會出現白屏的情況。 &emsp;&emsp;那是因為 flexible.js 被放在了 shin.js 的后面請求,這就有可能在調用 document.body.clientHeight 取到的值是 0,馬上替換兩者的位置。 **4)身份標識** &emsp;&emsp;每次進入頁面都會生成一個唯一的標識,存儲在[sessionStorage](https://developer.mozilla.org/zh-CN/docs/Web/API/Window/sessionStorage)中。 &emsp;&emsp;在查詢日志時,可通過該標識過濾出此用戶的上下文日志,消除與他不相干的日志,也就是展示他的行為軌跡。 ~~~ function getIdentity() { var key = "shin-monitor-identity"; //頁面級的緩存而非全站緩存 var identity = sessionStorage.getItem(key); if (!identity) { //生成標識 identity = Number( Math.random().toString().substr(3, 3) + Date.now() ).toString(36); sessionStorage.setItem(key, identity); } return identity; } ~~~ &emsp;&emsp;2022-11-21 新增可指定的身份信息標識,在實際使用監控的過程中發現,客服在收到用戶的問題反饋后,會提供給我們 userId 和遇到問題的時間范圍。 &emsp;&emsp;目前我們只能根據時間范圍來排查記錄,然后通過上面的?identity 來縮小范圍,經過上下文的記錄分析后,才能大致知道這條記錄是否是問題用戶的。 &emsp;&emsp;為了能更準確的知道相關記錄是否就是問題用戶的,又增加了一個身份參數,從外面傳進來,或者自動通過 JSBridge 等方式獲取。 ~~~ var defaults = { param: { ... identity: "" //可自定義的身份信息字段 } }; /** * 在客戶端中埋入可識別的身份信息,例如userId */ function injectIdentity(identity) { // 若不是APP或已經指定身份信息,則返回 if (!isApp() || identity) return; // 在JSBridge成功調用后的回調函數 window.getMonitorUserSuccess = function (result) { try { var json = JSON.parse(result); shin.param.identity = json.data.userId; } catch (e) { // console.error(e.message); } }; // 通過JSBridge讀取用戶信息 var iframe = document.createElement("iframe"); iframe.style.display = "none"; iframe.src = "xxx://xxx.yyy/getInfo?callback=getMonitorUserSuccess"; /** * 需要加個定時器,因為調用document.body時,DOM還未存在 * Uncaught TypeError: Cannot read property 'appendChild' of null */ setTimeout(() => { document.body && document.body.appendChild(iframe); }, 500); } ~~~ &emsp;&emsp;在 getIdentity() 函數中,增加一句合并自定義身份字段的代碼。 &emsp;&emsp;2022-12-19 將自定義身份放在默認身份之前,因為這樣便于使用 ES 的前綴查詢。 ~~~ function getIdentity() { //... if (!identity) { // 生成標識 identity = Number( Math.random().toString().substring(3, 6) + Date.now() ).toString(36); // 與自定義的身份字段合并,自定義字段在前,便于使用 ES 的前綴查詢 shin.param.identity && (identity = shin.param.identity + '-' + identity); //... } return identity; } ~~~ &emsp;&emsp;上述針對的是客戶端中的頁面,而在管理后臺中,對身份的處理又略有不同,以我當前公司為例。 &emsp;&emsp;由于所有的接口都會在后臺校驗身份,因此會自帶 JWT 加密過的身份信息(Authorization字段),那么只要將此字段也一并保存,需要時將其[解密](https://jwt.io/),就能知道操作人是誰了。 ~~~ { "type": "GET", "url": "/api/xxx/yyy", "header": { "Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9", "req-id": "52d768494e664885353" }, "status": 200, "endBytes": "13.88KB", "interval": "382.2ms", "network": { "bandwidth": 0, "type": "4G" } } ~~~ **5)Canvas 指紋** &emsp;&emsp;2023-07-10 增加瀏覽器指紋,用于計算 UV 數據,在上報時會附帶此信息。 &emsp;&emsp;首先利用 Canvas 在不同終端中繪制會有細微差別的特點,對生成的圖像進行運算,得到一個指紋,如下所示。 ~~~ /** * ASCII字符串轉換成十六進制 */ export function bin2hex(s: string): string { let o = ''; s += ''; for (let i = 0, l = s.length; i < l; i++) { const n = s.charCodeAt(i).toString(16); o += n.length < 2 ? '0' + n : n; } return o; } /** * Canvas 指紋 * 注意,同型號的手機,其 Canvas 指紋是相同的 */ private getFingerprint(): string { const key = "shin-monitor-fingerprint"; const fingerprint = localStorage.getItem(key); if (fingerprint) return fingerprint; // 繪制 Canvas const canvas = document.createElement("canvas"); const ctx = canvas.getContext("2d"); const txt = "fingerprint"; ctx.textBaseline = "top"; ctx.font = "16px Arial"; ctx.fillStyle = "#F60"; ctx.fillRect(125, 1, 62, 20); ctx.fillStyle = "#069"; ctx.fillText(txt, 2, 15); ctx.fillStyle = "rgba(102, 204, 0, 0.7)"; ctx.fillText(txt, 4, 17); var b64 = canvas.toDataURL().replace("data:image/png;base64,", ""); // window.atob 用于解碼使用 base64 編碼的字符串 const bin = window.atob(b64); // 必須調用 slice() 否則無法轉換 const result = bin2hex(bin.slice(-16, -12)); // 緩存到本地 localStorage.setItem(key, result); return result; } ~~~ &emsp;&emsp;為了提升性能,會將生成的字符串存儲在 localStorage 中,只進行一次指紋計算。 &emsp;&emsp;經過測試發現,即使將客戶端殺掉,再次進入頁面,存儲的值仍然存在,除非進到應用管理,清空數據。 &emsp;&emsp;注意,同型號的手機,其 Canvas 指紋是相同的,因此在服務端接收到參數后,需要再和 UA 和 IP 進行合并。 &emsp;&emsp;最終 MD5 加密得到一個指紋,當然,這個指紋也會出現重復的概率。 **6)未來展望** &emsp;&emsp;目前,SDK 的所有邏輯都是寫在一個文件中的,未來體積極有可能膨脹,那么到時候會影響加載時間。 &emsp;&emsp;可以將各個監控部分以插件的形式分離,例如 打印一個模塊、通信一個模塊,想要什么功能就單獨組合。 &emsp;&emsp;還可以添加生命周期,在各個階段增加回調,引入特殊場景的特殊邏輯,保持高擴展性。 ***** > 原文出處: [博客園-從零開始搞系列](https://www.cnblogs.com/strick/category/1928903.html) 已建立一個微信前端交流群,如要進群,請先加微信號freedom20180706或掃描下面的二維碼,請求中需注明“看云加群”,在通過請求后就會把你拉進來。還搜集整理了一套[面試資料](https://github.com/pwstrick/daily),歡迎閱讀。 ![](https://box.kancloud.cn/2e1f8ecf9512ecdd2fcaae8250e7d48a_430x430.jpg =200x200) 推薦一款前端監控腳本:[shin-monitor](https://github.com/pwstrick/shin-monitor),不僅能監控前端的錯誤、通信、打印等行為,還能計算各類性能參數,包括 FMP、LCP、FP 等。
                  <ruby id="bdb3f"></ruby>

                  <p id="bdb3f"><cite id="bdb3f"></cite></p>

                    <p id="bdb3f"><cite id="bdb3f"><th id="bdb3f"></th></cite></p><p id="bdb3f"></p>
                      <p id="bdb3f"><cite id="bdb3f"></cite></p>

                        <pre id="bdb3f"></pre>
                        <pre id="bdb3f"><del id="bdb3f"><thead id="bdb3f"></thead></del></pre>

                        <ruby id="bdb3f"><mark id="bdb3f"></mark></ruby><ruby id="bdb3f"></ruby>
                        <pre id="bdb3f"><pre id="bdb3f"><mark id="bdb3f"></mark></pre></pre><output id="bdb3f"></output><p id="bdb3f"></p><p id="bdb3f"></p>

                        <pre id="bdb3f"><del id="bdb3f"><progress id="bdb3f"></progress></del></pre>

                              <ruby id="bdb3f"></ruby>

                              哎呀哎呀视频在线观看