從零開始搞監控系統(1)——SDK

  目前市面上有許多成熟的前端監控系統,但我們沒有選擇成品,而是自己動手研發。這裡面包括多個原因:

  • 填補H5日誌的空白
  • 節約公司費用支出
  • 可靈活地根據業務自定義監控
  • 回溯時間能更長久
  • 反哺運營和產品,從而優化產品品質
  • 一次難得的練兵機會

  前端監控地基本目的:了解當前項目實際使用的情況,有哪些異常,在追蹤到後,對其進行分析,並提供合適的解決方案。

  前端監控地終極目標: 1 分鐘感知、5 分鐘定位、10 分鐘恢復。目前是初版,離該目標還比較遙遠。

  SDK(採用ES5語法)取名為 shin.js,其作用就是將數據通過 JavaScript 採集起來,統一發送到後台,採集的方式包括監聽或劫持原始方法,獲取需要上報的數據,並通過 gif 傳遞數據。

  整個系統大致的運行流程如下:

  

一、異常捕獲

  異常包括運行時錯誤、Promise錯誤、框架錯誤等。

1)error事件

  為 window 註冊 error 事件,捕獲全局錯誤,過濾掉與業務無關的錯誤,例如「Script error.」、JSBridge告警等,還需統一資源載入和運行時錯誤的數據格式。

// 定義的錯誤類型碼
var ERROR_RUNTIME = "runtime";
var ERROR_SCRIPT = "script";
var ERROR_STYLE = "style";
var ERROR_IMAGE = "image";
var ERROR_AUDIO = "audio";
var ERROR_VIDEO = "video";
var ERROR_PROMISE = "promise";
var ERROR_VUE = "vue";
var ERROR_REACT = "react";
var LOAD_ERROR_TYPE = {
  SCRIPT: ERROR_SCRIPT,
  LINK: ERROR_STYLE,
  IMG: ERROR_IMAGE,
  AUDIO: ERROR_AUDIO,
  VIDEO: ERROR_VIDEO
};
/**
 * 監控異常
 */
window.addEventListener(
  "error",
  function (event) {
    var errorTarget = event.target;
    // 過濾掉與業務無關的錯誤
    if (event.message === "Script error." || !event.filename) {
      return;
    }
    if (
      errorTarget !== window &&
      errorTarget.nodeName &&
      LOAD_ERROR_TYPE[errorTarget.nodeName.toUpperCase()]
    ) {
      handleError(formatLoadError(errorTarget));
    } else {
      handleError(
        formatRuntimerError(
          event.message,
          event.filename,
          event.lineno,
          event.colno,
          event.error
        )
      );
    }
  },
  true //捕獲
);
/**
 * 生成 laod 錯誤日誌
 * 需要載入資源的元素
 */
function formatLoadError(errorTarget) {
  return {
    type: LOAD_ERROR_TYPE[errorTarget.nodeName.toUpperCase()],
    desc: errorTarget.baseURI + "@" + (errorTarget.src || errorTarget.href),
    stack: "no stack"
  };
}

2)unhandledrejection事件

  為 window 註冊 unhandledrejection 事件,捕獲未處理的 Promise 錯誤,當 Promise 被 reject 且沒有 reject 處理器時觸發。

window.addEventListener(
  "unhandledrejection",
  function (event) {
    //處理響應數據,只抽取重要資訊
    var response = event.reason.response;
    //若無響應,則不監控
    if (!response) {
      return;
    }
    var desc = response.request.ajax;
    desc.status = event.reason.status;
    handleError({
      type: ERROR_PROMISE,
      desc: desc
    });
  },
  true
);

  Promise 常用於非同步通訊,例如axios庫,當響應異常通訊時,就能藉助該事件將其捕獲,得到的結果如下。

{
  "type": "promise",
  "desc": {
    "response": {
      "data": "Error occured while trying to proxy to: localhost:8000/monitor/performance/statistic",
      "status": 504,
      "statusText": "Gateway Timeout",
      "headers": {
        "connection": "keep-alive",
        "date": "Wed, 24 Mar 2021 07:53:25 GMT",
        "transfer-encoding": "chunked",
        "x-powered-by": "Express"
      },
      "config": {
        "transformRequest": {},
        "transformResponse": {},
        "timeout": 0,
        "xsrfCookieName": "XSRF-TOKEN",
        "xsrfHeaderName": "X-XSRF-TOKEN",
        "maxContentLength": -1,
        "headers": {
          "Accept": "application/json, text/plain, */*",
        },
        "method": "get",
        "url": "/api/monitor/performance/statistic"
      },
      "request": {
        "ajax": {
          "type": "GET",
          "url": "/api/monitor/performance/statistic",
          "status": 504,
          "endBytes": 0,
          "interval": "13.15ms",
          "network": {
            "bandwidth": 0,
            "type": "4G"
          },
          "response": "Error occured while trying to proxy to: localhost:8000/monitor/performance/statistic"
        }
      }
    },
    "status": 504
  },
  "stack": "Error: Gateway Timeout
    at handleError (//localhost:8000/umi.js:18813:15)"
}

  這樣就能分析出 500、502、504 等響應碼所佔通訊的比例,當高於日常數量時,就得引起注意,查看是否在哪塊邏輯出現了問題。

  有一點需要注意,上面的結構中包含響應資訊,這是需要對 Error 做些額外擴展的,如下所示。

import fetch from 'axios';
function handleError(errorObj) {
  const { response } = errorObj;
  if (!response) {
    const error = new Error('你的網路有點問題');
    error.response = errorObj;
    error.status = 504;
    throw error;
  }
  const error = new Error(response.statusText);
  error.response = response;
  error.status = response.status;
  throw error;
}
export default function request(url, options) {
  return fetch(url, options)
    .catch(handleError)
    .then((response) => {
      return { data: response.data };
    });
}

  公司中有一套項目依賴的是 jQuery 庫,因此要監控此處的異常通訊,需要做點改造。

  好在所有的通訊都會請求一個通用函數,那麼只要修改此函數的邏輯,就能覆蓋到項目中的所有頁面。

  搜索了API資料,以及研讀了 jQuery 中通訊的源碼後,得出需要聲明一個 xhr() 函數,在函數中初始化 XMLHttpRequest 對象,從而才能監控它的實例。

  並且在 error 方法中需要手動觸發 unhandledrejection 事件。

$.ajax({
  url,
  method,
  data,
  success: (res) => {
    success(res);
  },
  xhr: function () {
    this.current = new XMLHttpRequest();
    return this.current;
  },
  error: function (res) {
    error(res);
    Promise.reject({
      status: res.status,
      response: {
        request: {
          ajax: this.current.ajax
        }
      }
    }).catch((error) => {
      throw error;
    });
  }
});

3)框架錯誤

  框架是指目前流行的React、Vue等,我只對公司目前使用的這兩個框架做了監控。

  React 需要在項目中創建一個 ErrorBoundary 類,捕獲錯誤。

import React from 'react';
export default class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }
  componentDidCatch(error, info) {
    this.setState({ hasError: true });
    // 將component中的報錯發送到後台
    shin && shin.reactError(error, info);
  }
  render() {
    if (this.state.hasError) {
      return null
      // 也可以在出錯的component處展示出錯資訊
      // return <h1>出錯了!</h1>;
    }
    return this.props.children;
  }
}

  其中 reactError() 方法在組裝錯誤資訊。

/**
 * 處理 React 錯誤(對外)
 */
shin.reactError = function (err, info) {
  handleError({
    type: ERROR_REACT,
    desc: err.toString(),
    stack: info.componentStack
  });
};

  如果要對 Vue 進行錯誤捕獲,那麼就得重寫 Vue.config.errorHandler(),其參數就是 Vue 對象。

/**
 * Vue.js 錯誤劫持(對外)
 */
shin.vueError = function (vue) {
  var _vueConfigErrorHandler = vue.config.errorHandler;
  vue.config.errorHandler = function (err, vm, info) {
    handleError({
      type: ERROR_VUE,
      desc: err.toString(),   //描述
      stack: err.stack      //堆棧
    });
    // 控制台列印錯誤
    if (
      typeof console !== "undefined" &&
      typeof console.error !== "undefined"
    ) {
      console.error(err);
    }
    // 執行原始的錯誤處理程式
    if (typeof _vueConfigErrorHandler === "function") {
      _vueConfigErrorHandler.call(err, vm, info);
    }
  };
};

  如果 Vue 是被模組化引入的,那麼就得在模組的某個位置調用該方法,因為此時 Vue 不會綁定到 window 中,即不是全局變數。

4)難點

  雖然把錯誤都搜集起來了,但是現代化的前端開發,都會做一次程式碼合併壓縮混淆,也就是說,無法定位錯誤的真正位置。

  為了能轉換成源碼,就需要引入自動堆棧映射(SourceMap),webpack 默認就帶了此功能,只要聲明相應地關鍵字開啟即可。

  我選擇了 devtool: “hidden-source-map”,生成完成的原始程式碼,並在腳本中隱藏Source Map路徑。

//# sourceMappingURL=index.bundle.js.map

  在生成映射文件後,就需要讓運維配合,編寫一個腳本(在發完程式碼後觸發),將這些文件按年月日小時分鐘的格式命名(例如 202103041826.js.map),並遷移到指定目錄中,用於後期的映射。

  之所以沒有到秒是因為沒必要,在執行發程式碼的操作時,發布按鈕會被鎖定,其他人無法再發。

  映射的邏輯是用 Node.js 實現的,會在後文中詳細講解。注意,必須要有列號,才能完成程式碼還原。

二、行為搜集

  將行為分成:用戶行為、瀏覽器行為、控制台列印行為。監控這些主要是為了在排查錯誤時,能還原用戶當時的各個動作,從而能更好的找出問題出錯的原因。

1)用戶行為

  目前試驗階段,就監聽了點擊事件,並且只會對 button 和 a 元素上註冊的點擊事件做監控。

/**
 * 全局監聽事件
 */
var eventHandle = function (eventType, detect) {
  return function (e) {
    if (!detect(e)) {
      return;
    }
    handleAction(ACTION_EVENT, {
      type: eventType,
      desc: e.target.outerHTML
    });
  };
};
// 監聽點擊事件
window.addEventListener(
  "click",
  eventHandle("click", function (e) {
    var nodeName = e.target.nodeName.toLowerCase();
    // 白名單
    if (nodeName !== "a" && nodeName !== "button") {
      return false;
    }
    // 過濾 a 元素
    if (nodeName === "a") {
      var href = e.target.getAttribute("href");
      if (
        !href ||
        href !== "#" ||
        href.toLowerCase() !== "javascript:void(0)"
      ) {
        return false;
      }
    }
    return true;
  }),
  false
);

2)瀏覽器行為

  監控非同步通訊,重寫 XMLHttpRequest 對象,並通過 Navigator.connection 讀取當前的網路環境,例如4G、3G等。

  其實還想獲取當前用戶環境的網速,不過還沒有較準確的獲取方式,因此並沒有添加進來。

var _XMLHttpRequest = window.XMLHttpRequest;   //保存原生的XMLHttpRequest
//覆蓋XMLHttpRequest
window.XMLHttpRequest = function (flags) {
  var req;
  req = new _XMLHttpRequest(flags);        //調用原生的XMLHttpRequest
  monitorXHR(req);     //埋入我們的「間諜」
  return req;
};
var monitorXHR = function (req) {
  req.ajax = {};
  req.addEventListener(
    "readystatechange",
    function () {
      if (this.readyState == 4) {
        var end = shin.now();          //結束時間
        req.ajax.status = req.status;     //狀態碼
        if ((req.status >= 200 && req.status < 300) || req.status == 304) {
          //請求成功
          req.ajax.endBytes = _kb(req.responseText.length * 2) + "KB";   //KB
        } else {
          //請求失敗
          req.ajax.endBytes = 0;
        }
        req.ajax.interval = _rounded(end - start, 2) + "ms";   //單位毫秒
        req.ajax.network = shin.network();
        //只記錄300個字元以內的響應
        req.responseText.length <= 300 &&
          (req.ajax.response = req.responseText);
        handleAction(ACTION_AJAX, req.ajax);
      }
    },
    false
  );

  // 「間諜」又對open方法埋入了間諜
  var _open = req.open;
  req.open = function (type, url, async) {
    req.ajax.type = type;    //埋點
    req.ajax.url = url;     //埋點
    return _open.apply(req, arguments);
  };

  var _send = req.send;
  var start;     //請求開始時間
  req.send = function (data) {
    start = shin.now();      //埋點
    if (data) {
      req.ajax.startBytes = _kb(JSON.stringify(data).length * 2) + "KB";
      req.ajax.data = data;   //傳遞的參數
    }
    return _send.apply(req, arguments);
  };
};
/**
 * 計算KB值
 */
function _kb(bytes) {
  return _rounded(bytes / 1024, 2);   //四捨五入2位小數
}
/**
 * 四捨五入
 */
function _rounded(number, decimal) {
  return parseFloat(number.toFixed(decimal));
}
/**
 * 網路狀態
 */
shin.network = function () {
  var connection =
    window.navigator.connection ||
    window.navigator.mozConnection ||
    window.navigator.webkitConnection;
  var effectiveType = connection && connection.effectiveType;
  if (effectiveType) {
    return { bandwidth: 0, type: effectiveType.toUpperCase() };
  }
  var types = "Unknown Ethernet WIFI 2G 3G 4G".split(" ");
  var info = { bandwidth: 0, type: "" };
  if (connection && connection.type) {
    info.type = types[connection.type];
  }
  return info;
};

  在所有的日誌中,通訊占的比例是最高的,大概在 90% 以上。

  瀏覽器的行為還包括跳轉,當前非常流行 SPA,所以在記錄跳轉地址時,只需監聽 onpopstate 事件即可,其中上一頁地址也會被記錄。

/**
 * 全局監聽跳轉
 */
var _onPopState = window.onpopstate;
window.onpopstate = function (args) {
  var href = location.href;
  handleAction(ACTION_REDIRECT, {
    refer: shin.refer,
    current: href
  });
  shin.refer = href;
  _onPopState && _onPopState.apply(this, args);
};

3)控制台列印行為

  其實就是重寫 console 中的方法,目前只對 log() 做了處理。在實際使用中發現了兩個問題。

  第一個是在項目調試階段,將數據列印在控制台時,顯示的文件和行數都是 SDK 的名稱和位置,無法得知真正的位置,很是彆扭。

  並且在 SDK 的某些位置調用 console.log() 會形成死循環。後面就加了個 isDebug 開關,在調試時就關閉監控,省心。

function injectConsole(isDebug) {
  !isDebug &&
    ["log"].forEach(function (level) {
      var _oldConsole = console[level];
      console[level] = function () {
        var params = [].slice.call(arguments);   // 參數轉換成數組
        _oldConsole.apply(this, params);       // 執行原先的 console 方法
        var seen = [];
        handleAction(ACTION_PRINT, {
          level: level,
          // 避免循環引用
          desc: JSON.stringify(params, function (key, value) {
            if (typeof value === "object" && value !== null) {
              if (seen.indexOf(value) >= 0) {
                return;
              }
              seen.push(value);
            }
            return value;
          })
        });
      };
    });
}

  第二個就是某些要列印的變數包含循環引用,這樣在調用 JSON.stringify() 時就會報錯。

三、其他

1)環境資訊

  通過解析請求中的 UA 資訊,可以得到作業系統、瀏覽器名稱版本、CPU等資訊。

{
  "ua": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.82 Safari/537.36",
  "browser": {
    "name": "Chrome",
    "version": "89.0.4389.82",
    "major": "89"
  },
  "engine": {
    "name": "Blink",
    "version": "89.0.4389.82"
  },
  "os": {
    "name": "Mac OS",
    "version": "10.14.6"
  },
  "device": {},
  "cpu": {}
}

  圖省事,就用了一個開源庫,叫做 UAParser.js,在 Node.js 中引用了此庫。

2)上報

  上報選擇了 Gif 的方式,即把參數拼接到一張 Gif 地址後,傳送到後台。

/**
 * 組裝監控變數
 */
function _paramify(obj) {
  obj.token = shin.param.token;
  obj.subdir = shin.param.subdir;
  obj.identity = getIdentity();
  return encodeURIComponent(JSON.stringify(obj));
}
/**
 * 推送監控資訊
 */
shin.send = function (data) {
  var ts = new Date().getTime().toString();
  var img = new Image(0, 0);
  img.src = shin.param.src + "?m=" + _paramify(data) + "&ts=" + ts;
};

  用這種方式有幾個優勢:

  • 兼容性高,所有的瀏覽器都支援。
  • 不存在跨域問題。
  • 不會攜帶當前域名中的 cookie。
  • 不會阻塞頁面載入。
  • 相比於其他類型的圖片格式(BMP、PNG等),能節約更多的網路資源。

  不過這種方式也有一個問題,那就是採用 GET 的請求後,瀏覽器會限制 URL 的長度,也就是不能攜帶太多的數據。

  在之前記錄 Ajax 響應數據時就有一個判斷,只記錄300個字元以內的響應數據,其實就是為了規避此限制而加了這段程式碼。

3)身份標識

  每次進入頁面都會生成一個唯一的標識,存儲在 sessionStorage 中。在查詢日誌時,可通過該標識過濾出此用戶的上下文日誌,消除與他不相干的日誌。

function getIdentity() {
  var key = "shin-monitor-identity";
  //頁面級的快取而非全站快取
  var identity = sessionStorage.getItem(key);
  if (!identity) {
    //生成標識
    identity = Number(
      Math.random().toString().substr(3, 3) + Date.now()
    ).toString(36);
    sessionStorage.setItem(key, identity);
  }
  return identity;
}