從零開始搞監控系統(1)——SDK
目前市面上有許多成熟的前端監控系統,但我們沒有選擇成品,而是自己動手研發。這裡面包括多個原因:
- 填補H5日誌的空白
- 節約公司費用支出
- 可靈活地根據業務自定義監控
- 回溯時間能更長久
- 反哺運營和產品,從而優化產品品質
- 一次難得的練兵機會
前端監控地基本目的:了解當前項目實際使用的情況,有哪些異常,在追蹤到後,對其進行分析,並提供合適的解決方案。
前端監控地終極目標: 1 分鐘感知、5 分鐘定位、10 分鐘恢復。目前是初版,離該目標還比較遙遠。
SDK(採用ES5語法)取名為 shin.js,其作用就是將數據通過 JavaScript 採集起來,統一發送到後台,採集的方式包括監聽或劫持原始方法,獲取需要上報的數據,並通過 gif 傳遞數據。
整個系統大致的運行流程如下:
一、異常捕獲
異常包括運行時錯誤、Promise錯誤、框架錯誤等。
1)error事件
為 window 註冊 error 事件,捕獲全局錯誤,過濾掉與業務無關的錯誤,例如「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." || !event.filename) { return; } if ( errorTarget !== window && errorTarget.nodeName && LOAD_ERROR_TYPE[errorTarget.nodeName.toUpperCase()] ) { handleError(formatLoadError(errorTarget)); } else { handleError( formatRuntimerError( event.message, event.filename, event.lineno, event.colno, event.error ) ); } }, true //捕獲 ); /** * 生成 laod 錯誤日誌 * 需要載入資源的元素 */ function formatLoadError(errorTarget) { return { type: LOAD_ERROR_TYPE[errorTarget.nodeName.toUpperCase()], desc: errorTarget.baseURI + "@" + (errorTarget.src || errorTarget.href), stack: "no stack" }; }
2)unhandledrejection事件
為 window 註冊 unhandledrejection 事件,捕獲未處理的 Promise 錯誤,當 Promise 被 reject 且沒有 reject 處理器時觸發。
window.addEventListener( "unhandledrejection", function (event) { //處理響應數據,只抽取重要資訊 var response = event.reason.response; //若無響應,則不監控 if (!response) { return; } var desc = response.request.ajax; desc.status = event.reason.status; handleError({ type: ERROR_PROMISE, desc: desc }); }, true );
Promise 常用於非同步通訊,例如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 (//localhost:8000/umi.js:18813:15)" }
這樣就能分析出 500、502、504 等響應碼所佔通訊的比例,當高於日常數量時,就得引起注意,查看是否在哪塊邏輯出現了問題。
有一點需要注意,上面的結構中包含響應資訊,這是需要對 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 }; }); }
公司中有一套項目依賴的是 jQuery 庫,因此要監控此處的異常通訊,需要做點改造。
好在所有的通訊都會請求一個通用函數,那麼只要修改此函數的邏輯,就能覆蓋到項目中的所有頁面。
搜索了API資料,以及研讀了 jQuery 中通訊的源碼後,得出需要聲明一個 xhr() 函數,在函數中初始化 XMLHttpRequest 對象,從而才能監控它的實例。
並且在 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; }); } });
3)框架錯誤
框架是指目前流行的React、Vue等,我只對公司目前使用的這兩個框架做了監控。
React 需要在項目中創建一個 ErrorBoundary 類,捕獲錯誤。
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; } }
其中 reactError() 方法在組裝錯誤資訊。
/** * 處理 React 錯誤(對外) */ shin.reactError = function (err, info) { handleError({ type: ERROR_REACT, desc: err.toString(), stack: info.componentStack }); };
如果要對 Vue 進行錯誤捕獲,那麼就得重寫 Vue.config.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: err.toString(), //描述 stack: err.stack //堆棧 }); // 控制台列印錯誤 if ( typeof console !== "undefined" && typeof console.error !== "undefined" ) { console.error(err); } // 執行原始的錯誤處理程式 if (typeof _vueConfigErrorHandler === "function") { _vueConfigErrorHandler.call(err, vm, info); } }; };
如果 Vue 是被模組化引入的,那麼就得在模組的某個位置調用該方法,因為此時 Vue 不會綁定到 window 中,即不是全局變數。
4)難點
雖然把錯誤都搜集起來了,但是現代化的前端開發,都會做一次程式碼合併壓縮混淆,也就是說,無法定位錯誤的真正位置。
為了能轉換成源碼,就需要引入自動堆棧映射(SourceMap),webpack 默認就帶了此功能,只要聲明相應地關鍵字開啟即可。
我選擇了 devtool: “hidden-source-map”,生成完成的原始程式碼,並在腳本中隱藏Source Map路徑。
//# sourceMappingURL=index.bundle.js.map
在生成映射文件後,就需要讓運維配合,編寫一個腳本(在發完程式碼後觸發),將這些文件按年月日小時分鐘的格式命名(例如 202103041826.js.map),並遷移到指定目錄中,用於後期的映射。
之所以沒有到秒是因為沒必要,在執行發程式碼的操作時,發布按鈕會被鎖定,其他人無法再發。
映射的邏輯是用 Node.js 實現的,會在後文中詳細講解。注意,必須要有列號,才能完成程式碼還原。
二、行為搜集
將行為分成:用戶行為、瀏覽器行為、控制台列印行為。監控這些主要是為了在排查錯誤時,能還原用戶當時的各個動作,從而能更好的找出問題出錯的原因。
1)用戶行為
目前試驗階段,就監聽了點擊事件,並且只會對 button 和 a 元素上註冊的點擊事件做監控。
/** * 全局監聽事件 */ var eventHandle = function (eventType, detect) { return function (e) { if (!detect(e)) { return; } handleAction(ACTION_EVENT, { type: eventType, desc: e.target.outerHTML }); }; }; // 監聽點擊事件 window.addEventListener( "click", eventHandle("click", function (e) { var nodeName = e.target.nodeName.toLowerCase(); // 白名單 if (nodeName !== "a" && nodeName !== "button") { return false; } // 過濾 a 元素 if (nodeName === "a") { var href = e.target.getAttribute("href"); if ( !href || href !== "#" || href.toLowerCase() !== "javascript:void(0)" ) { return false; } } return true; }), false );
2)瀏覽器行為
監控非同步通訊,重寫 XMLHttpRequest 對象,並通過 Navigator.connection 讀取當前的網路環境,例如4G、3G等。
其實還想獲取當前用戶環境的網速,不過還沒有較準確的獲取方式,因此並沒有添加進來。
var _XMLHttpRequest = window.XMLHttpRequest; //保存原生的XMLHttpRequest //覆蓋XMLHttpRequest window.XMLHttpRequest = function (flags) { var req; req = new _XMLHttpRequest(flags); //調用原生的XMLHttpRequest monitorXHR(req); //埋入我們的「間諜」 return req; }; var monitorXHR = function (req) { req.ajax = {}; req.addEventListener( "readystatechange", function () { if (this.readyState == 4) { 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 } else { //請求失敗 req.ajax.endBytes = 0; } req.ajax.interval = _rounded(end - start, 2) + "ms"; //單位毫秒 req.ajax.network = shin.network(); //只記錄300個字元以內的響應 req.responseText.length <= 300 && (req.ajax.response = req.responseText); handleAction(ACTION_AJAX, req.ajax); } }, 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 _send = req.send; var start; //請求開始時間 req.send = function (data) { start = shin.now(); //埋點 if (data) { req.ajax.startBytes = _kb(JSON.stringify(data).length * 2) + "KB"; req.ajax.data = data; //傳遞的參數 } return _send.apply(req, arguments); }; }; /** * 計算KB值 */ function _kb(bytes) { return _rounded(bytes / 1024, 2); //四捨五入2位小數 } /** * 四捨五入 */ function _rounded(number, decimal) { return parseFloat(number.toFixed(decimal)); } /** * 網路狀態 */ shin.network = function () { var connection = window.navigator.connection || window.navigator.mozConnection || window.navigator.webkitConnection; var effectiveType = connection && connection.effectiveType; if (effectiveType) { return { bandwidth: 0, type: effectiveType.toUpperCase() }; } var types = "Unknown Ethernet WIFI 2G 3G 4G".split(" "); var info = { bandwidth: 0, type: "" }; if (connection && connection.type) { info.type = types[connection.type]; } return info; };
在所有的日誌中,通訊占的比例是最高的,大概在 90% 以上。
瀏覽器的行為還包括跳轉,當前非常流行 SPA,所以在記錄跳轉地址時,只需監聽 onpopstate 事件即可,其中上一頁地址也會被記錄。
/** * 全局監聽跳轉 */ var _onPopState = window.onpopstate; window.onpopstate = function (args) { var href = location.href; handleAction(ACTION_REDIRECT, { refer: shin.refer, current: href }); shin.refer = href; _onPopState && _onPopState.apply(this, args); };
3)控制台列印行為
其實就是重寫 console 中的方法,目前只對 log() 做了處理。在實際使用中發現了兩個問題。
第一個是在項目調試階段,將數據列印在控制台時,顯示的文件和行數都是 SDK 的名稱和位置,無法得知真正的位置,很是彆扭。
並且在 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; }) }); }; }); }
第二個就是某些要列印的變數包含循環引用,這樣在調用 JSON.stringify() 時就會報錯。
三、其他
1)環境資訊
通過解析請求中的 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": {} }
圖省事,就用了一個開源庫,叫做 UAParser.js,在 Node.js 中引用了此庫。
2)上報
上報選擇了 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; };
用這種方式有幾個優勢:
- 兼容性高,所有的瀏覽器都支援。
- 不存在跨域問題。
- 不會攜帶當前域名中的 cookie。
- 不會阻塞頁面載入。
- 相比於其他類型的圖片格式(BMP、PNG等),能節約更多的網路資源。
不過這種方式也有一個問題,那就是採用 GET 的請求後,瀏覽器會限制 URL 的長度,也就是不能攜帶太多的數據。
在之前記錄 Ajax 響應數據時就有一個判斷,只記錄300個字元以內的響應數據,其實就是為了規避此限制而加了這段程式碼。
3)身份標識
每次進入頁面都會生成一個唯一的標識,存儲在 sessionStorage 中。在查詢日誌時,可通過該標識過濾出此用戶的上下文日誌,消除與他不相干的日誌。
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; }