在經過兩年多的線上沉澱後,將監控程式碼重新用 TypeScript 編寫,刪除冗餘邏輯,正式開源。
根據 shin-monitor 的目錄結構可知,原始碼集中在 src 目錄中。關於監控系統的迭代過程,可以參考專欄。
入口檔案是 index.ts,旁邊的 utils.ts 是一個工具庫。
在 index.ts 中,將會引入 lib 目錄中的 error、action 和 performance 三個檔案。
1)defaults
宣告 defaults 變數,設定了各個引數的預設屬性,各個引數的使用指南可以檢視註釋、readme 或 demo 目錄中的檔案。
const defaults: TypeShinParams = { src: '//127.0.0.1:3000/ma.gif', // 採集監控資料的後臺接收地址 psrc: '//127.0.0.1:3000/pe.gif', // 採集效能引數的後臺接收地址 pkey: '', // 效能監控的專案key subdir: '', // 一個專案下的子目錄 rate: 5, // 隨機取樣率,用於效能蒐集,範圍是 1~10,10 表示百分百傳送 version: '', // 版本,便於追查出錯源 record: { isOpen: true, // 是否開啟錄影 src: '//cdn.jsdelivr.net/npm/rrweb@latest/dist/rrweb.min.js' // 錄影地址 }, error: { isFilterErrorFunc: null, // 需要過濾的程式碼錯誤 isFilterPromiseFunc: null, // 需要過濾的Promise錯誤 }, console: { isOpen: true, // 預設是開啟,在本地偵錯時,可以將其關閉 isFilterLogFunc: null, // 過濾要列印的內容 }, crash: { isOpen: true, // 是否監控頁面奔潰,預設開啟 validateFunc: null, // 自定義頁面白屏的判斷條件,返回值包括 {success: true, prompt:'提示'} }, event: { isFilterClickFunc: null, // 在點選事件中需要過濾的元素 }, ajax: { isFilterSendFunc: null // 在傳送監控紀錄檔時需要過濾的通訊 }, identity: { value: '', // 自定義的身份資訊欄位 getFunc: null, // 自定義的身份資訊獲取函數 }, };
2)setParams()
在 setParams() 函數中,會初始化引入的 3 個類,然後開始監控頁面錯誤、計算效能引數、監控使用者行為。
function setParams(params: TypeShinParams): TypeShinParams { if (!params) { return null; } const combination = defaults; // 只重置 params 中的引數 for(const key in params) { combination[key] = params[key]; } // 埋入自定義的身份資訊 const { getFunc } = combination.identity; getFunc && getFunc(combination); // 監控頁面錯誤 const error = new ErrorMonitor(combination); error.registerErrorEvent(); // 註冊 error 事件 error.registerUnhandledrejectionEvent(); // 註冊 unhandledrejection 事件 error.registerLoadEvent(); // 註冊 load 事件 error.recordPage(); shin.reactError = error.reactError.bind(error); // 對外提供 React 的錯誤處理 shin.vueError = error.vueError.bind(error); // 對外提供 Vue 的錯誤處理 // 啟動效能監控 const pe = new PerformanceMonitor(combination); pe.observerLCP(); // 監控 LCP pe.observerFID(); // 監控 FID pe.registerLoadAndHideEvent(); // 註冊 load 和頁面隱藏事件 // 為原生物件注入自定義行為 const action = new ActionMonitor(combination); action.injectConsole(); // 監控列印 action.injectRouter(); // 監聽路由 action.injectEvent(); // 監聽事件 action.injectAjax(); // 監聽Ajax return combination; }
函數中做了大量初始化工作,若不需要某些監控行為,可自行刪除。
在 lib 目錄中,存放著整個監控系統的核心邏輯。
1)Http
Http 的主要工作是通訊,也就是將蒐集起來的監控紀錄檔或效能引數,統一傳送到後臺。
並且在 Http 中,還會根據演演算法生成身份標識字串,以及做最後的引陣列裝工作。
監控紀錄檔原先採用的傳送方式是 Image,目的是跨域,但是傳送的資料量有限,像 Ajax 通訊,如果需要記錄響應,那麼長度就會不夠。
因此後期就改成了 fetch() 函數,預設只會上傳 8000 長度的資料。
public send(data: TypeSendParams, callback?: ParamsCallback): void { // var ts = new Date().getTime().toString(); // var img = new Image(0, 0); // img.src = shin.param.src + "?m=" + _paramify(data) + "&ts=" + ts; const m = this.paramify(data); // 大於8000的長度,就不在上報,廢棄掉 if (m.length >= 8000) { return; } const body: TypeSendBody = { m }; callback && callback(data, body); // 自定義的引數處理回撥 // 如果修改headers,就會多一次OPTIONS預檢請求 fetch(this.params.src, { method: "POST", // headers: { // 'Content-Type': 'application/json', // }, body: JSON.stringify(body) }); }
而效能引數的傳送採用了 sendBeacon() 方法,在頁面關閉時也能上報,這是普通的請求所不具備的特性。
它能將少量資料非同步 POST 到後臺,並且支援跨域,而少量是指多少並沒有特別指明,由瀏覽器控制,網上查到的資料說一般在 64KB 左右。
public sendPerformance(data: TypeCaculateTiming): void { // 如果傳了資料就使用該資料,否則讀取效能引數,並格式化為字串 var str = this.paramifyPerformance(data); var rate = randomNum(10, 1); // 選取1~10之間的整數 if (this.params.rate >= rate && this.params.pkey) { navigator.sendBeacon(this.params.psrc, str); } }
2)Error
在 Error 中,會註冊 window 的 error 事件,用於監控指令碼或資源錯誤,在指令碼錯誤中,會提示行號和列號。
不過資源錯誤是看不到具體的錯誤原因的,只會給個結果,出現了錯誤,連錯誤狀態碼也沒有。
window.addEventListener('error', (event: ErrorEvent): void => { const errorTarget = event.target as (Window | TypeEventTarget); // 過濾掉與業務無關或無意義的錯誤 if (isFilterErrorFunc && isFilterErrorFunc(event)) { return; } // 過濾 target 為 window 的異常 if ( errorTarget !== window && (errorTarget as TypeEventTarget).nodeName && CONSTANT.LOAD_ERROR_TYPE[(errorTarget as TypeEventTarget).nodeName.toUpperCase()] ) { this.handleError(this.formatLoadError(errorTarget as TypeEventTarget)); } else { // 過濾無效錯誤 event.message && this.handleError( this.formatRuntimerError( event.message, event.filename, event.lineno, event.colno, // event.error, ), ); } }, true); // 捕獲
還會註冊 window 的 unhandledrejection 事件,用於監控未處理的 Promise 錯誤,當 Promise 被 reject 且沒有 reject 處理器時觸發。
在 unhandledrejection 事件中,對於響應資訊,其實是做了些擴充套件的,參考《SDK中的 unhandledrejection 事件》。
window.addEventListener('unhandledrejection',(event: PromiseRejectionEvent): void => { // 處理響應資料,只抽取重要資訊 const { response } = event.reason; // 若無響應,則不監控 if (!response || !response.request) { return; } const desc: TypeAjaxDesc = response.request.ajax; desc.status = event.reason.status || response.status; // 過濾掉與業務無關或無意義的錯誤 if(isFilterPromiseFunc && isFilterPromiseFunc(desc)) { return; } this.handleError({ type: CONSTANT.ERROR_PROMISE, desc, // stack: event.reason && (event.reason.stack || "no stack") }); }, true);
這 2 個錯誤的使用,都在 demo/error.html 中有所記錄,另一個重要的錯誤是白屏。
在白屏時,還會上報錄影內容,白屏的迭代過程可以參考此處。
對 body 的子元素做深度優先搜尋,若已找到一個有高度的元素、或若元素隱藏、或元素有高度並且不是 body 元素,則結束搜尋。
為了便於定位白屏原因,在白屏時,還會記錄些元素資訊,例如元素型別、樣式、高度等。
private isWhiteScreen(): TypeWhiteScreen { const visibles = []; const nodes = []; //遍歷到的節點的關鍵資訊,用於查明白屏原因 // 深度優先遍歷子元素 const dfs = (node: HTMLElement): void => { const tagName = node.tagName.toLowerCase(); const rect = node.getBoundingClientRect(); // 選取節點的屬性作記錄 const attrs: TypeWhiteHTMLNode = { id: node.id, tag: tagName, className: node.className, display: node.style.display, height: rect.height }; const src = (node as HTMLImageElement).src; if(src) { attrs.src = src; // 記錄影象的地址 } const href =(node as HTMLAnchorElement).href; if(href) { attrs.href = href; // 記錄連結的地址 } nodes.push(attrs); // 若已找到一個有高度的元素,則結束搜尋 if(visibles.length > 0) return; // 若元素隱藏,則結束搜尋 if (node.style.display === 'none') return; // 若元素有高度並且不是 body 元素,則結束搜尋 if(rect.height > 0 && tagName !== 'body') { visibles.push(node); return; } node.children && [].slice.call(node.children).forEach((child: HTMLElement): void => { const tagName = child.tagName.toLowerCase(); // 過濾指令碼和樣式元素 if(tagName === 'script' || tagName === 'link') return; dfs(child); }); }; dfs(document.body); return { visibles: visibles, nodes: nodes }; }
監控白屏的時機,是在 load 事件中,延遲 1 秒觸發。
原先是在 DOMContentLoaded 事件內觸發,經測試發現,當因為指令碼錯誤出現白屏時,兩個事件的觸發時機會很接近。
線上上監控時發現會有一些誤報,HTML是有內容的,那很可能是 DOMContentLoaded 觸發時,頁面內容還沒渲染好。
對於熱門的 React 和 Vue 庫,宣告了兩個方法:reactError() 和 vueError(),將這兩個方法分別應用於專案中,就能監控框架錯誤了。
React 需要在專案中建立一個 ErrorBoundary 類,在類中呼叫 reactError() 方法。
如果 Vue 是被模組化引入的,那麼就得在模組的某個位置呼叫該方法,因為此時 Vue 不會繫結到 window 中,即不是全域性變數。
3)Action
在 Action 中會監控列印、路由、點選事件和 Ajax 通訊。這 4 種行為都會對原生物件進行注入,它們的使用也都可以在 demo 目錄中找到。
以路由為例,不僅要監聽 popstate 事件,還要重寫 pushState 和 replaceState。
public injectRouter(): void { /** * 全域性監聽跳轉 * 點選後退、前進按鈕或者呼叫 history.back()、history.forward()、history.go() 方法才會觸發 popstate 事件 * 點選 <a href=/xx/yy#anchor>hash</a> 按鈕也會觸發 popstate 事件 */ const _onPopState = window.onpopstate; window.onpopstate = (args: PopStateEvent): void => { this.sendRouterInfo(); _onPopState && _onPopState.apply(this, args); }; /** * 監聽 pushState() 和 replaceState() 兩個方法 */ const bindEventListener = (type: string): TypeStateEvent => { const historyEvent: TypeStateEvent = history[type]; return (...args): void => { // 觸發 history 的原始事件,apply 的第一個引數若不是 history,就會報錯 const newEvent = historyEvent.apply(history, args); this.sendRouterInfo(); return newEvent; }; }; history.pushState = bindEventListener('pushState'); history.replaceState = bindEventListener('replaceState'); }
4)Performance
Performance 主要是對效能引數的蒐集,大部分的效能引數是通過 performance.getEntriesByType('navigation')[0] 或 performance.timing 獲取的。
performance.timing 已被廢棄,儘量不要使用,此處只是為了相容。Performance 的迭代過程可以參考此處。
引數的傳送時機有兩者,第一種是 window.load 事件中,第二種是頁面隱藏的事件中。
LCP、FID、FP 等引數可通過瀏覽器提供的物件獲取。
public observerLCP(): void { const lcpType = 'largest-contentful-paint'; const isSupport = this.checkSupportPerformanceObserver(lcpType); // 瀏覽器相容判斷 if(!isSupport) { return; } const po = new PerformanceObserver((entryList): void=> { const entries = entryList.getEntries(); const lastEntry = (entries as any)[entries.length - 1] as TypePerformanceEntry; this.lcp = { time: rounded(lastEntry.renderTime || lastEntry.loadTime), // 時間取整 url: lastEntry.url, // 資源地址 element: lastEntry.element ? removeQuote(lastEntry.element.outerHTML) : '' // 參照的元素 }; }); // buffered 為 true 表示呼叫 observe() 之前的也算進來 po.observe({ type: lcpType, buffered: true } as any); // po.observe({ entryTypes: [lcpType] }); /** * 當有按鍵或點選(包括捲動)時,就停止 LCP 的取樣 * once 引數是指事件被呼叫一次後就會被移除 */ ['keydown', 'click'].forEach((type): void => { window.addEventListener(type, (): void => { // 斷開此觀察者的連線 po.disconnect(); }, { once: true, capture: true }); }); }
FMP 需要自行計算,才能得到,我採用了一套比較簡單的規則。
為了能更好的描述出首屏的時間,將 LCP 和 FMP 兩個時間做比較,取最長的那個時間。