首頁 > 軟體

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

2021-02-22 16:30:07

背景

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

  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% 左右;


參考連結


IT145.com E-mail:sddin#qq.com