1. 程式人生 > >#研發解決方案#易車前端監控系統

#研發解決方案#易車前端監控系統

# 背景 自研工具是為了解決內部問題而生,希望通過這些問題引起大家的共鳴: 1. 是否知道重要的業務,該頁面是可以正常服務於使用者的? 2. 能否在問題還沒有大規模爆發之前,快速的感知到業務的異常? 3. 怎麼不去使用者的電腦上就能直觀的看到問題所在,從而俯瞰專案全域性;能否從巨集觀到微觀一路下鑽快速的定位線上告警資訊? 4. 在跨部門溝通時拿出合理的證據,來告訴他這個時間段該介面就是無法訪問的,並告知我們的引數傳的很正確,幫助服務端**反查**問題。 5. 產品和設計同學想要提升使用者體驗,研發不斷迭代功能版本。那這些我們以為的優化點,效果究竟如何?怎麼去衡量? 6. 哪個廣告位,哪個資源位更有價值?怎麼能更為精準的觸達使用者痛點,為提升業務賦能? 我們看到這些疑問,都需要**資料指標**的支撐。從解決這些問題的角度出發,把反覆出現或無法跟其他部門交代的問題,打造成可以幫助我們解決問題的產品。 所以在這種場景下,易車·前端監控應運而生。 它主要是多場景多維度實時的監控大盤,實現瀏覽器客戶端的全鏈路監控,方便團隊事後追查和整改,轉變為事前預警和快速判定根因。 經過詳細的規劃以後,我們把前端監控分為四期,分別為:異常監控(一期)、效能監控(二期)、資料埋點(三期)、行為採集(四期),於 2020 年 6 月 23 號正式啟動研發,目前處於二期階段。 # 關鍵結構 為實現上述需求,監控系統主要分為四個階段來實現;分別是:指標採集、指標儲存、統計與分析、視覺化展示。 ![](https://img2020.cnblogs.com/blog/328599/202102/328599-20210222161132813-571147359.png) **指標採集階段:**通過前端整合的 SDK 收集請求、效能、異常等指標資訊;在客戶端簡單的處理一次,然後上報到伺服器。 **指標儲存階段:**用於接收前端上報的採集資訊,主要目的是資料落地。 **統計與分析階段:**自動分析,通過資料的統計,讓程式發現問題從而觸發報警。人工分析,是通過視覺化的資料面板,讓使用者看到具體的日誌資料,從而發現異常問題根源。 **視覺化展示階段:**通過視覺化的平臺;在這些指標(API 監控、異常監控、資源監控、效能監控)中,追查使用者行為來定位各項問題。 # 整體架構圖 隨著統計需求的增加以及前端應用的上線,資料量由早期的每天 100 多萬條資料;到現在的每天約 7000 萬條資料。架構上也經歷了三次版本的迭代。這是最新版的架構圖,主要經過 6 層處理。 ![](https://img2020.cnblogs.com/blog/328599/202102/328599-20210222161202658-493211893.png) **採集層:**PC 和 H5 使用了一套 SDK 監聽事件採集指標,然後將監聽到的指標通過 REST 介面往 Logback 推送資料。Logback 以長連線的方式,會把這些不同型別的指標資料推送到 Flume 叢集當中。Flume 叢集會將這些資料,分發到 Kafka Topic 進行儲存。 **處理層:**由 Flink 去實時消費;Flink 會消費三種類型,分別是:離線資料落地、實時 ETL+圖譜、明細日誌。 **儲存層:**離線資料會儲存到 HDFS 中;實時 ETL+圖譜資料會儲存到 MySQL 中;明細資料會落入到 ES 中。 **統計層:**離線(DW、DM)、實時(分鐘級->十分鐘級->小時級)的方式,對指標進行彙總和統計。 **應用層:**最後由介面去彙總表和明細 ES 裡查詢資料。 **展示層:**然後前端輸出圖表、報表、明細、鏈路等資訊。 # 技術方案 ## 資料採集 採集最初的願景是希望對業務**無侵入性**,業務系統無需改造,只需要嵌入一段程式碼即可。所以這些採集,都是 SDK 自動化的處理。 SDK 會全域性監聽幾個事件,分別為:錯誤監聽、資源異常的監聽、頁面效能的監聽、API 呼叫的監聽。 ![](https://img2020.cnblogs.com/blog/328599/202102/328599-20210222161216386-1081843.png) 通過這幾項監聽,最終彙總為 3 項指標的採集。 **異常採集:**呼叫 error/unhandledrejection 事件,用於捕獲 JS、圖片、CSS 等資源異常資訊。** **效能採集:**呼叫瀏覽器原生的 performance.timing API 捕獲頁面的效能指標。 **介面採集:**通過 Object.definePropety 代理全域性的 XHR 用於捕獲瀏覽器的 XHR/FETCH 的請求。 ## 採集端 SDK 架構 ![](https://img2020.cnblogs.com/blog/328599/202102/328599-20210222161239970-1393936010.png) SDK 主要分為兩部分: **第一部分:**SDK 主要是 SDK 的驅動,包含:入口、核心工具以及通用型別的推斷。 **第二部分:**也叫做外掛部分(藍色區域),主要實現上面的三項資料指標的採集。 接下來主要會詳細的介紹第二部分,各項指標的採集方案。 ## 異常採集方案 通過監聽 error 錯誤,即可捕獲到所有(JS 錯誤、圖片載入、CSS 載入、JS 載入、Promise 等)異常;它也支援 InternalError、ReferenceError [等 7 種錯誤捕獲](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Error)。 以下是關鍵性程式碼。 ### 監聽事件 ```javascript /** * 監聽 error、unhandledrejection 方法處理異常資訊 * * @param {YicheMonitorInstance} instance SDK 例項 */ export default function setupErrorPlugin(instance: YicheMonitorInstance) { // JS 錯誤或靜態資源載入錯誤 on('error', (e: Event, url: any, lineno: any) => { handleError(instance, e, url, lineno); }); // Promise 錯誤,IE 不支援 on('unhandledrejection', (e: any) => { handleError(instance, e); }); } ``` ### 判斷異常型別 ```javascript /** * W3C 模式支援 ErrorEvent,所有的異常從 ErrorEvent 這裡取 * * @param {MutationEvent} error 資源錯誤、程式碼錯誤 */ function handleW3C(event: any) { switch (event.type) { // 判斷指令碼錯誤,還是資源錯誤 case 'error': event instanceof ErrorEvent ? reportJSError(instance, event) : reportResourceError(instance, event); break; // Promise 是否存在未捕獲 reject 的錯誤 case 'unhandledrejection': reportPromiseError(instance, event); break; } } ``` ### 捕獲異常資料 ```javascript /** * 上報 JS 異常 * * @param {YicheMonitorInstance} instance SDK 例項 * @param {ErrorEvent} event */ export default function reportJSError( instance: YicheMonitorInstance, event: ErrorEvent, ): void { // 設定上報資料 const report = new ReportDataStruct('error', 'js'); const errorInfo = event.error ? event.error.message : `未知錯誤:${event.message}`; // 設定錯誤資訊,相容遠端指令碼不設定 Script error 導致的異常 report.setData({ det: errorInfo.substring(0, 2000), des: event.error ? event.error.stack : '', defn: event.filename, deln: event.lineno, delc: event.colno, rre: 1, }); } ``` ### 處理 IE 相容問題 捕獲異常時處理下 IE 的相容性問題即可,IE 的方案如下: ```javascript /** * IE 8 的錯誤項,所以針對於 IE 8 瀏覽器,我們只需要獲取到它出錯了即可。 * * 1. 錯誤訊息 * 2. 錯誤頁面 * 3. 錯誤行號(因為檔案通常是壓縮的,所以統計 IE8 的行號是沒有任何意義的) * * @param {string} error 錯誤訊息 * @param {string | undefined} url 異常的 URL * @param {number | undefined} lineno 異常行數,IE 沒有列數 */ export function handleIE8Error( error: string, url?: string | undefined, lineno?: number | undefined, ) { return { colno: 0, lineno: lineno, filename: url, message: error, error: { message: error, stack: `IE8 Error:${error}`, }, } as ErrorEvent; } /** * IE 9 的錯誤,需要在 target 裡面獲取到 * * @param { Element | any } error IE9 異常的元素 */ export function handleIE9Error(error: any) { // 獲取 Event const event = error.currentTarget.event; return { colno: event.errorCharacter, lineno: event.errorLine, filename: event.errorUrl, message: event.errorMessage, error: { message: event.errorMessage, stack: `IE9 Error:${event.errorMessage}`, }, } as ErrorEvent; } ``` ## 效能採集方案 ### 瀏覽器頁面載入過程 ![](https://img2020.cnblogs.com/blog/328599/202102/328599-20210222161302193-1167348100.png) ### 效能指標獲取方式 我們藉助於瀏覽器原生的 [Navigation Timing API](https://w3c.github.io/navigation-timing/) 能夠獲取到上述**頁面載入過程**中的各項效能指標資料,用於效能分析,它的時間單位是納秒級。 ![](https://img2020.cnblogs.com/blog/328599/202102/328599-20210222161343315-1496046922.png) 當然也藉助於 [PerformanceObserver API](https://developer.mozilla.org/en-US/docs/Web/API/PerformanceObserver) 等用於測量 [FCP](https://web.dev/fcp/)、[LCP](https://web.dev/lcp/)、[FID](https://web.dev/fid/)、[TTI](https://web.dev/tti/)、[TBT](https://web.dev/tbt/)、[CLS](https://web.dev/cls/) 等關鍵性指標。 ### 詳細的計算公式 | 指標 | 含義 | 計算公式 | | --- | --- | --- | | ttfb | 首位元組時間 | timing.responseStart - timing.requestStart | | domReady | Dom Ready時間 | timing.domContentLoadedEventEnd - timing.fetchStart | | pageLoad | 頁面完全載入時間 | timing.loadEventStart - timing.fetchStart | | dns | DNS 查詢時間 | timing.domainLookupEnd - timing.domainLookupStart | | tcp | TCP 連線時間 | timing.connectEnd - timing.connectStart | | ssl | SSL 連線時間 | timing.secureConnectionStart > 0 ? timing.connectEnd - timing.secureConnectionStart) : 0 | | contentDownload | 內容傳輸時間 | timing.responseEnd - timing.responseStart | | domParse | DOM 解析時間 | timing.domInteractive - timing.responseEnd | | resourceDownload | 資源載入耗時 | timing.loadEventStart - timing.domContentLoadedEventEnd | | waiting | 請求響應 | timing.responseStart - timing.requestStart | | fpt | 白屏時間,老 | timing.responseEnd - timing.fetchStart | | tti | 首次可互動 | timing.domInteractive - timing.fetchStart | | firstByte | 首包時間 | timing.responseStart - timing.domainLookupStart | | domComplete | DOM 完成時間 | timing.domComplete - timing.domLoading | | fp | 白屏時間,新指標 | performance.getEntriesByType('paint')[0] | | fcp | 首次有效內容繪製 | performance.getEntriesByType('paint')[1] | | lcp | 首屏大內容繪製時間 | PerformanceObserver('largest-contentful-paint')" | | 快開比 | | 頁面完全載入時長 ≤ 某時長(如2s)的 取樣PV / 總取樣PV * 100% | | 慢開比 | | 頁面完全載入時長 ≥ 某時長(如5s)的 取樣PV / 總取樣PV * 100% | ## 網路請求採集方案 網路請求,通過 Object.definePropety 的方式對 XHR 做的代理。關鍵性程式碼如下。 ### 重寫 XMLHttpRequest 這部分可以直接參考 [ajax-hook](https://github.com/wendux/Ajax-hook) 的實現原理。 ```javascript export function hook(proxy) { window[realXhr] = window[realXhr] || XMLHttpRequest XMLHttpRequest = function () { const xhr = new window[realXhr]; for (let attr in xhr) { let type = ""; try { type = typeof xhr[attr] } catch (e) { } if (type === "function") { this[attr] = hookFunction(attr); } else { Object.defineProperty(this, attr, { get: getterFactory(attr), set: setterFactory(attr), enumerable: true }) } } const that = this; xhr.getProxy = function () { return that } this.xhr = xhr; } return window[realXhr]; } ``` ### 攔截所有請求 正常的情況下一個頁面會請求多個介面,假如有 20 個請求; 我們期望在階段性的所有請求都結束已後,彙總成一條記錄合併上報,這樣能有效減少請求的併發量。 關鍵性程式碼如下: ```javascript /** * Ajax 請求外掛 * * @author wubaiqing */ // 所有的資料請求,以及總量 let allRequestRecordArray: any = []; let allRequestRecordCount: any = []; // 成功的資料,200,304 的資料 let allRequestData: any = []; // 異常的資料,超時,405 等介面不存在的資料 let errorData: any = []; /** * 監聽 Ajax 請求資訊 * * @param {YicheMonitorInstance} instance SDK 例項 */ export default function setupAjaxPlugin(instance: YicheMonitorInstance) { let id = 0; proxy({ onRequest: (config, handler) => { // 過濾掉聽雲、福爾摩斯、APM if (filterDomain(config)) { // 新增請求記錄的佇列 allRequestRecordArray.push({ id, timeStamp: new Date().getTime(), // 記錄請求時長 config, // 包含:請求地址、body 等內容 handler, // XHR 實體 }); // 記錄請求總數 allRequestRecordCount.push(1); id++; } handler.next(config); }, // 失敗時會觸發一次 onError: (err, handler) => { if (allRequestRecordArray.length === 0) { handler.next(err); return; } for (let i = 0; i < allRequestRecordArray.length; i++) { // 當前的資料 const currentData = allRequestRecordArray[i]; if ( currentData.handler.xhr.status === 0 && // 未傳送 currentData.handler.xhr.readyState === 4 ) { errorData.push( JSON.stringify(handleReportDataStruct(instance, currentData)), ); allRequestRecordArray.splice(i, 1); } } sendAllRequestData(instance); handler.next(err); }, onResponse: (response, handler) =>
{ // 沒有請求就返回 Null if (allRequestRecordArray.length === 0) { handler.next(response); return; } for (let i = 0; i < allRequestRecordArray.length; i++) { // 當前的資料 const currentData = allRequestRecordArray[i]; // 只要請求載入完成,不管是成功還是失敗,都記錄是一次請求 if (currentData.handler.xhr.readyState === 4) { // 正常的請求 if ( (currentData.handler.xhr.status >= 200 && currentData.handler.xhr.status < 300) || currentData.handler.xhr.status === 304 ) { allRequestData.push( JSON.stringify(handleReportDataStruct(instance, currentData)), ); } else { if (currentData.handler.xhr.status > 0) { // 具備狀態碼 // 錯誤的請求 errorData.push( JSON.stringify(handleReportDataStruct(instance, currentData)), ); } } // 刪除當前陣列的值 allRequestRecordArray.splice(i, 1); } } // 傳送資料 sendAllRequestData(instance); handler.next(response); }, }); } function sendAllRequestData(instance) { if ( allRequestData.length + errorData.length === allRequestRecordCount.length ) { // 處理正常請求 if (allRequestData.length > 0 || errorData.length > 0) { handleAllRequestData(instance); } // 處理異常請求 if (errorData.length > 0) { handleErrorData(instance); } // 所有的資料請求,以及總量 allRequestRecordArray = []; allRequestRecordCount = []; // 成功的資料,200,304 的資料 allRequestData = []; // 異常的資料,超時,405 等介面不存在的資料 errorData = []; } } ``` ## 探針載入方案 探針載入有兩種方式,他們分別有一些優缺點: **同步載入:**採集 SDK 放到所有 JS 請求頭的前面;因為載入順序的問題,如果放在其他 JS 請求之後,之前的 JS 出現了異常,就捕獲不到了。因為要提前載入 JS 資源,會對效能有一定影響。 **非同步載入:**採集 SDK 通過執行 JS 後注入到頁面中;如果能保障首次的 JS 無異常,也可以使用非同步的方式載入 SDK,對首屏優化有好處。 目前我們採用的是第一種**同步載入**的方式。 # 產品部分截圖 ## 首頁 首頁會展示所有應用的情報,在首頁可以直觀的發現各應用的異常資料。 ![](https://img2020.cnblogs.com/blog/328599/202102/328599-20210222161416646-1203440516.png) ## 大盤頁面 如果想對某個應用細項的排查,會進入到應用的大盤頁面; 主要會展示該應用,前端的重要性指標,近一個小時內的資料狀況。 目前主要有頁面效能、資源異常、JS 異常、API 介面成功率等重要指標作為衡量。 ![](https://img2020.cnblogs.com/blog/328599/202102/328599-20210222161425488-1297879458.png) ## 詳情頁 詳情頁,就可以看到該應用某項指標的資料細項。方便團隊進行事後的追查、整改,提前預警和快速判定根因所用。 ![](https://img2020.cnblogs.com/blog/328599/202102/328599-20210222161450754-1508935139.png) # # 遇到的問題 SDK 採集到指標以後對資料進行上報時,會做一些過濾性的**前置**操作,如: - 遮蔽掉一些黑名單。 - 指標的削峰填谷。 - 應用資訊的轉換。 - 客戶端 IP 獲取。 - Token 的驗證。 前置處理有一個弊端,因為伺服器會經過解析轉換環節;當資料量達到每日 7000 萬左右,上報的伺服器就扛不住了。 所以我們把資料**前置**處理,變為資料落地**後置**處理;後置處理就是在資料清洗的過程中,在過濾掉黑名單以及異常指標。這樣就減輕了上報伺服器的壓力。 並且倉庫也會保留所有的原始資料,如果出現異常的時,也方便我們溯源,對資料進行恢復。 # 整體規劃 我們分為了四期,目前還處於二期效能監控階段。 | 計劃 | 目標 | 優先順序 | 支援平臺 | 主要解決的問題點 | | :---: | :---: | :---: | :---: | :---: | | 一期 | 異常監控 | 高 | PC、Mobile、小程式 | 異常影響的影響使用者,資源載入異常感知,網路請求異常感知,程式碼報錯異常感知,程式碼報錯的細項(SourceMap)分析 | | 二期 | 效能監控 | 高 | | 效能值(首位元組、DOMReady、頁面完全載入、重定向、DNS、TCP、請求響應等耗時),API 監控(成功率、成功耗時、失敗次數等),頁面引用資源統計,和資源佔比(JS、CSS、圖片、字型、iFrame、Ajax 等),位數對比,95% 的使用者、99% 的使用者、平均使用者 | | 三期 | 資料埋點 | 中 | | 作業系統、解析度、瀏覽器,事件分類(點選事件、滾動事件),具體的指定的事件型別(點選 Banner 圖),事件發生時間,觸發事件的位置(滑鼠 X、Y,可生成熱力圖),訪客標識,使用者標識,鏈路採集 | | 四期 | 行為採集 | 低 | | 進入頁面,離開頁面,點選元素,滾動頁面,操作鏈路,自定義(如,點選廣告位的圖),Chrome 外掛直觀看到埋點 | # 其它 自研 APM 系統方便與內部進行的打通和整合;比如應用釋出後就可以直接推送 SourceMap 檔案;並且能實現線上釋出以後自動進行頁面效能的分析等工作。 如果目前發展階段還不需要自建一個這樣的系統,但業務需要這樣的能力,也可以考慮第三方的一些產品。 ## 商業產品分析 | | 易車 | [聽雲](https://www.tingyun.com/) | [阿里雲 ARMS](https://www.aliyun.com/product/arms) | [Fundebug](https://www.yuque.com/ab93na/project/fcqxvc?inner=2HKDM) | [嶽鷹](https://yueying.effirst.com) | [FrontJS](https://www.frontjs.com/) | | --- | --- | --- | --- | --- | --- | --- | | 頁面效能監控 | 功能齊全 | 基礎功能 | 功能齊全 | 弱 | 功能齊全 | 功能齊全 | | 異常監控 | 基礎功能 | 基礎功能 | 功能齊全 | 功能齊全 | 功能齊全 | 功能齊全 | | API 監控 | 功能齊全 | 基礎功能 | 功能齊全 | 基礎功能 | 基礎功能 | 基礎功能 | | 頁面載入瀑布圖 | 無 | 功能齊全 | 基礎功能 | 無 | 無 | 功能齊全 | | 互動性 | 好 | 一般 | 好 | 不清晰 | 好 | 好 | ## 重要性指標對和阿里 ARMS 對比 易車·前端監控和阿里雲 ARMS 做了一些重要性的指標對比,**均值**的浮動在上下在 5%-8% 左右; ![](https://img2020.cnblogs.com/blog/328599/202102/328599-20210222161632849-1036373400.jpg) ![](https://img2020.cnblogs.com/blog/328599/202102/328599-20210222161640796-828289541.jpg) ![](https://img2020.cnblogs.com/blog/328599/202102/328599-20210222161645032-1511614805.jpg) ## 參考連結 - [User Timing API](https://w3c.github.io/user-timing/) - [Long Tasks API](https://w3c.github.io/longtasks/) - [Element Timing API](https://wicg.github.io/element-timing/) - [Navigation Timing API](https://w3c.github.io/navigation-timing/) - [Resource Timing API](https://w3c.github.io/resource-timing/) - [Server timing](https://w3c.github.io/server-timing/) - [Custom Metrics](https://web.dev/custom-metrics/) - [Lighthouse performance scoring](https://web.dev/performance-scoring/) - [FCP](https://web.dev/fcp/) - [LCP](https://web.dev/lcp/) - [FID](https://web.dev/fid/) - [TTI](https://web.dev/tti/) - [TBT](https://web.dev/tbt/) - [CLS](https://web.dev/cls/)