shin-monitor源碼分析

  在經過兩年多的線上沉澱後,將監控代碼重新用 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 目錄

  在 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 需要自行計算,才能得到,我採用了一套比較簡單的規則。

  • 首先,通過 MutationObserver 監聽每一次頁面整體的 DOM 變化,觸發 MutationObserver 的回調。
  • 然後在回調中,爲每個 HTML 元素(不包括忽略的元素)打上標記,記錄元素是在哪一次回調中增加的,並且用數組記錄每一次的回調時間。
  • 接着在觸發 load 事件時,先過濾掉首屏外和沒有高度的元素,以及元素列表之間有包括關係的祖先元素,再計算各次變化時剩餘元素的總分。
  • 最後在得到分數最大值後,從這些元素中挑選出最長的耗時,作爲 FMP。

  爲了能更好的描述出首屏的時間,將 LCP 和 FMP 兩個時間做比較,取最長的那個時間。

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章