背景
自研工具是為了解決內部問題而生,希望通過這些問題引起大家的共鳴:
- 是否知道重要的業務,該頁面是可以正常服務於用戶的?
- 能否在問題還沒有大規模爆發之前,快速的感知到業務的異常?
- 怎么不去用戶的電腦上就能直觀的看到問題所在,從而俯瞰項目全局;能否從宏觀到微觀一路下鑽快速的定位線上告警信息?
- 在跨部門溝通時拿出合理的證據,來告訴他這個時間段該接口就是無法訪問的,並告知我們的參數傳的很正確,幫助服務端反查問題。
- 產品和設計同學想要提升用戶體驗,研發不斷迭代功能版本。那這些我們以為的優化點,效果究竟如何?怎么去衡量?
- 哪個廣告位,哪個資源位更有價值?怎么能更為精准的觸達用戶痛點,為提升業務賦能?
我們看到這些疑問,都需要數據指標的支撐。從解決這些問題的角度出發,把反復出現或無法跟其他部門交代的問題,打造成可以幫助我們解決問題的產品。
所以在這種場景下,易車·前端監控應運而生。
它主要是多場景多維度實時的監控大盤,實現瀏覽器客戶端的全鏈路監控,方便團隊事后追查和整改,轉變為事前預警和快速判定根因。
經過詳細的規划以后,我們把前端監控分為四期,分別為:異常監控(一期)、性能監控(二期)、數據埋點(三期)、行為采集(四期),於 2020 年 6 月 23 號正式啟動研發,目前處於二期階段。
關鍵結構
為實現上述需求,監控系統主要分為四個階段來實現;分別是:指標采集、指標存儲、統計與分析、可視化展示。
指標采集階段:通過前端集成的 SDK 收集請求、性能、異常等指標信息;在客戶端簡單的處理一次,然后上報到服務器。
指標存儲階段:用於接收前端上報的采集信息,主要目的是數據落地。
統計與分析階段:自動分析,通過數據的統計,讓程序發現問題從而觸發報警。人工分析,是通過可視化的數據面板,讓使用者看到具體的日志數據,從而發現異常問題根源。
可視化展示階段:通過可視化的平台;在這些指標(API 監控、異常監控、資源監控、性能監控)中,追查用戶行為來定位各項問題。
整體架構圖
隨着統計需求的增加以及前端應用的上線,數據量由早期的每天 100 多萬條數據;到現在的每天約 7000 萬條數據。架構上也經歷了三次版本的迭代。這是最新版的架構圖,主要經過 6 層處理。
采集層:PC 和 H5 使用了一套 SDK 監聽事件采集指標,然后將監聽到的指標通過 REST 接口往 Logback 推送數據。Logback 以長連接的方式,會把這些不同類型的指標數據推送到 Flume 集群當中。Flume 集群會將這些數據,分發到 Kafka Topic 進行存儲。
處理層:由 Flink 去實時消費;Flink 會消費三種類型,分別是:離線數據落地、實時 ETL+圖譜、明細日志。
存儲層:離線數據會存儲到 HDFS 中;實時 ETL+圖譜數據會存儲到 MySQL 中;明細數據會落入到 ES 中。
統計層:離線(DW、DM)、實時(分鍾級->十分鍾級->小時級)的方式,對指標進行匯總和統計。
應用層:最后由接口去匯總表和明細 ES 里查詢數據。
展示層:然后前端輸出圖表、報表、明細、鏈路等信息。
技術方案
數據采集
采集最初的願景是希望對業務無侵入性,業務系統無需改造,只需要嵌入一段代碼即可。所以這些采集,都是 SDK 自動化的處理。
SDK 會全局監聽幾個事件,分別為:錯誤監聽、資源異常的監聽、頁面性能的監聽、API 調用的監聽。
通過這幾項監聽,最終匯總為 3 項指標的采集。
異常采集:調用 error/unhandledrejection 事件,用於捕獲 JS、圖片、CSS 等資源異常信息。**
性能采集:調用瀏覽器原生的 performance.timing API 捕獲頁面的性能指標。
接口采集:通過 Object.definePropety 代理全局的 XHR 用於捕獲瀏覽器的 XHR/FETCH 的請求。
采集端 SDK 架構
SDK 主要分為兩部分:
第一部分:SDK 主要是 SDK 的驅動,包含:入口、核心工具以及通用類型的推斷。
第二部分:也叫做插件部分(藍色區域),主要實現上面的三項數據指標的采集。
接下來主要會詳細的介紹第二部分,各項指標的采集方案。
異常采集方案
通過監聽 error 錯誤,即可捕獲到所有(JS 錯誤、圖片加載、CSS 加載、JS 加載、Promise 等)異常;它也支持 InternalError、ReferenceError 等 7 種錯誤捕獲。
以下是關鍵性代碼。
監聽事件
/**
* 監聽 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);
});
}
判斷異常類型
/**
* 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;
}
}
捕獲異常數據
/**
* 上報 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 的方案如下:
/**
* 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;
}
性能采集方案
瀏覽器頁面加載過程
性能指標獲取方式
我們借助於瀏覽器原生的 Navigation Timing API 能夠獲取到上述頁面加載過程中的各項性能指標數據,用於性能分析,它的時間單位是納秒級。
當然也借助於 PerformanceObserver API 等用於測量 FCP、LCP、FID、TTI、TBT、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 的實現原理。
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 個請求;
我們期望在階段性的所有請求都結束已后,匯總成一條記錄合並上報,這樣能有效減少請求的並發量。
關鍵性代碼如下:
/**
* Ajax 請求插件
*
* @author wubaiqing <wubaiqing@vip.qq.com>
*/
// 所有的數據請求,以及總量
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,對首屏優化有好處。
目前我們采用的是第一種同步加載的方式。
產品部分截圖
首頁
首頁會展示所有應用的情報,在首頁可以直觀的發現各應用的異常數據。
大盤頁面
如果想對某個應用細項的排查,會進入到應用的大盤頁面;
主要會展示該應用,前端的重要性指標,近一個小時內的數據狀況。
目前主要有頁面性能、資源異常、JS 異常、API 接口成功率等重要指標作為衡量。
詳情頁
詳情頁,就可以看到該應用某項指標的數據細項。方便團隊進行事后的追查、整改,提前預警和快速判定根因所用。
遇到的問題
SDK 采集到指標以后對數據進行上報時,會做一些過濾性的前置操作,如:
- 屏蔽掉一些黑名單。
- 指標的削峰填谷。
- 應用信息的轉換。
- 客戶端 IP 獲取。
- Token 的驗證。
前置處理有一個弊端,因為服務器會經過解析轉換環節;當數據量達到每日 7000 萬左右,上報的服務器就扛不住了。
所以我們把數據前置處理,變為數據落地后置處理;后置處理就是在數據清洗的過程中,在過濾掉黑名單以及異常指標。這樣就減輕了上報服務器的壓力。
並且倉庫也會保留所有的原始數據,如果出現異常的時,也方便我們溯源,對數據進行恢復。
整體規划
我們分為了四期,目前還處於二期性能監控階段。
計划 | 目標 | 優先級 | 支持平台 | 主要解決的問題點 |
---|---|---|---|---|
一期 | 異常監控 | 高 | PC、Mobile、小程序 | 異常影響的影響用戶,資源加載異常感知,網絡請求異常感知,代碼報錯異常感知,代碼報錯的細項(SourceMap)分析 |
二期 | 性能監控 | 高 | 性能值(首字節、DOMReady、頁面完全加載、重定向、DNS、TCP、請求響應等耗時),API 監控(成功率、成功耗時、失敗次數等),頁面引用資源統計,和資源占比(JS、CSS、圖片、字體、iFrame、Ajax 等),位數對比,95% 的用戶、99% 的用戶、平均用戶 | |
三期 | 數據埋點 | 中 | 操作系統、分辨率、瀏覽器,事件分類(點擊事件、滾動事件),具體的指定的事件類型(點擊 Banner 圖),事件發生時間,觸發事件的位置(鼠標 X、Y,可生成熱力圖),訪客標識,用戶標識,鏈路采集 | |
四期 | 行為采集 | 低 | 進入頁面,離開頁面,點擊元素,滾動頁面,操作鏈路,自定義(如,點擊廣告位的圖),Chrome 插件直觀看到埋點 |
其它
自研 APM 系統方便與內部進行的打通和整合;比如應用發布后就可以直接推送 SourceMap 文件;並且能實現線上發布以后自動進行頁面性能的分析等工作。
如果目前發展階段還不需要自建一個這樣的系統,但業務需要這樣的能力,也可以考慮第三方的一些產品。
商業產品分析
易車 | 聽雲 | 阿里雲 ARMS | Fundebug | 岳鷹 | FrontJS | |
---|---|---|---|---|---|---|
頁面性能監控 | 功能齊全 | 基礎功能 | 功能齊全 | 弱 | 功能齊全 | 功能齊全 |
異常監控 | 基礎功能 | 基礎功能 | 功能齊全 | 功能齊全 | 功能齊全 | 功能齊全 |
API 監控 | 功能齊全 | 基礎功能 | 功能齊全 | 基礎功能 | 基礎功能 | 基礎功能 |
頁面加載瀑布圖 | 無 | 功能齊全 | 基礎功能 | 無 | 無 | 功能齊全 |
交互性 | 好 | 一般 | 好 | 不清晰 | 好 | 好 |
重要性指標對和阿里 ARMS 對比
易車·前端監控和阿里雲 ARMS 做了一些重要性的指標對比,均值的浮動在上下在 5%-8% 左右;