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


背景

自研工具是為了解決內部問題而生,希望通過這些問題引起大家的共鳴:

  1. 是否知道重要的業務,該頁面是可以正常服務於用戶的?
  2. 能否在問題還沒有大規模爆發之前,快速的感知到業務的異常?
  3. 怎么不去用戶的電腦上就能直觀的看到問題所在,從而俯瞰項目全局;能否從宏觀到微觀一路下鑽快速的定位線上告警信息?
  4. 在跨部門溝通時拿出合理的證據,來告訴他這個時間段該接口就是無法訪問的,並告知我們的參數傳的很正確,幫助服務端反查問題。
  5. 產品和設計同學想要提升用戶體驗,研發不斷迭代功能版本。那這些我們以為的優化點,效果究竟如何?怎么去衡量?
  6. 哪個廣告位,哪個資源位更有價值?怎么能更為精准的觸達用戶痛點,為提升業務賦能?

我們看到這些疑問,都需要數據指標的支撐。從解決這些問題的角度出發,把反復出現或無法跟其他部門交代的問題,打造成可以幫助我們解決問題的產品。

所以在這種場景下,易車·前端監控應運而生。
它主要是多場景多維度實時的監控大盤,實現瀏覽器客戶端的全鏈路監控,方便團隊事后追查和整改,轉變為事前預警和快速判定根因。

經過詳細的規划以后,我們把前端監控分為四期,分別為:異常監控(一期)、性能監控(二期)、數據埋點(三期)、行為采集(四期),於 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 等用於測量 FCPLCPFIDTTITBTCLS 等關鍵性指標。

詳細的計算公式

指標 含義 計算公式
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% 左右;


參考鏈接


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM