搭建前端監控,如何採集異常資料?

楊成功發表於2022-06-10

大家好,我是楊成功。

前兩篇,我們介紹了為什麼前端應該有監控系統,以及搭建前端監控的總體步驟,前端監控的 Why 和 What 想必你已經明白了。接下來我們解決 How 如何實現的問題。

如果不瞭解前端監控,建議先看前兩篇:

本篇我們介紹,前端如何採集資料,先從收集異常資料開始。

什麼是異常資料?

異常資料,是指前端在操作頁面的過程中,觸發的執行異常或載入異常,此時瀏覽器會丟擲來報錯資訊。

比如說你的前端程式碼用了個未宣告的變數,此時控制檯會列印出紅色錯誤,告訴你報錯原因。或者是介面請求出錯了,在網路皮膚內也能查到異常情況,是請求傳送的異常,還是介面響應的異常。

在我們實際的開發場景中,前端捕獲的異常主要是分兩個大類,介面異常前端異常,我們分別看下這兩大類異常怎麼捕獲。

介面異常

介面異常一定是在請求的時候觸發。前端目前大部分的請求是用 axios 發起的,所以只要獲取 axios 可能發生的異常即可。

如果你用 Promise 的寫法,則用 .catch 捕獲:

axios
  .post('/test')
  .then((res) => {
    console.log(res);
  })
  .catch((err) => {
    // err 就是捕獲到的錯誤物件
    handleError(err);
  });

如果你用 async/await 的寫法,則用 try..catch.. 捕獲:

async () => {
  try {
    let res = await axios.post('/test');
    console.log(res);
  } catch (err) {
    // err 就是捕獲到的錯誤物件
    handleError(err);
  }
};

當捕獲到異常之後,統一交給 handleError 函式處理,這個函式會將接收到的異常進行處理,並呼叫 上報介面 將異常資料傳到伺服器,從而完成採集。

上面我們寫的異常捕獲,邏輯上是沒問題的,實操起來就會發現第一道坎:頁面這麼多,難道每個請求都要包一層 catch 嗎?

是啊,如果我們是新開發一個專案,在開始的時候就規定每個請求要包一層 catch 也無可厚非,但是如果是在一個已有的規模還不小的專案中接入前端監控,這時候在每個頁面或每個請求 catch 顯然是不現實的。

所以,為了最大程度的降低接入成本,減少侵入性,我們是用第二種方案:在 axios 攔截器中捕獲異常

前端專案,為了統一處理請求,比如 401 的跳轉,或者全域性錯誤提示,都會在全域性寫一個 axios 例項,為這個例項新增攔截器,然後在其他頁面中直接倒入這個例項使用,比如:

// 全域性請求:src/request/axios.js

const instance = axios.create({
  baseURL: 'https://api.test.com'
  timeout: 15000,
  headers: {
    'Content-Type': 'application/json',
  },
})

export default instance

然後在具體的頁面中這樣發起請求:

// a 頁面:src/page/a.jsx
import http from '@/src/request/axios.js';

async () => {
  let res = await http.post('/test');
  console.log(res);
};

這樣的話,我們發現每個頁面的請求都會走全域性 axios 例項,所以我們只需要在全域性請求的位置捕獲異常即可,就不需要在每個頁面捕獲了,這樣接入成本會大大降低。

按照這個方案,結下來我們在 src/request/axios.js 這個檔案中動手實施。

攔截器中捕獲異常

首先我們為 axios 新增響應攔截器:

// 響應攔截器
instance.interceptors.response.use(
  (response) => {
    return response.data;
  },
  (error) => {
    // 發生異常會走到這裡
    if (error.response) {
      let response = error.response;
      if (response.status >= 400) {
        handleError(response);
      }
    } else {
      handleError(null);
    }
    return Promise.reject(error);
  },
);

響應攔截器的第二個引數是在發生錯誤時執行的函式,引數就是異常。我們首先要判斷是否存在 error.response,存在就說明介面有響應,也就是介面通了,但是返回錯誤;不存在則說明介面沒通,請求一直掛起,多數是介面崩潰了。

如果有響應,首先獲取狀態碼,根據狀態碼來判斷什麼時候需要收集異常。上面的判斷方式簡單粗暴,只要狀態碼大於 400 就視為一個異常,拿到響應資料,並執行上報邏輯。

如果沒有響應,可以看作是介面超時異常,呼叫異常處理函式時傳一個 null 即可。

前端異常

上面我們介紹了在 axios 攔截器中如何捕獲介面異常,這部分我們再介紹如何捕獲前端異常。

前端程式碼捕獲異常,最常用的方式就是用 try..catch.. 了,任意同步程式碼塊都可以放到 try 塊中,只要發生異常就會執行 catch:

try {
  // 任意同步程式碼
} catch (err) {
  console.log(err);
}

上面說“任意同步程式碼”而不是“任意程式碼”,主要是普通的 Promise 寫法 try..catch.. 是捕獲不到的,只能用 .catch() 捕獲,如:

try {
  Promise.reject(new Error('出錯了')).catch((err) => console.log('1:', err));
} catch (err) {
  console.log('2:', err);
}

把這段程式碼丟進瀏覽器,列印結果是:

1: Error: 出錯了

很明顯只是 .catch 捕獲到了異常。不過與上面介面異常的邏輯一樣,這種方式處理當前頁面異常沒什麼問題,但從整個應用來看,這樣捕獲異常侵入性強,接入成本高,所以我們的思路依然是全域性捕獲。

全域性捕獲 js 的異常也比較簡單,用 window.addEventLinstener('error') 即可:

// js 錯誤捕獲
window.addEventListener('error', (error) => {
  // error 就是js的異常
});

為啥不用 window.onerror ?

這裡很多小夥伴有疑問,為什麼不用 window.onerror 全域性監聽呢?window.addEventLinstener('error')window.onerror 有什麼區別呢?

首先這兩個函式功能基本一致,都可以全域性捕獲 js 異常。但是有一類異常叫做 資源載入異常,就是在程式碼中引用了不存在的圖片,js,css 等靜態資源導致的異常,比如:

const loadCss = ()=> {
  let link = document.createElement('link')
  link.type = 'text/css'
  link.rel = 'stylesheet'
  link.href = 'https://baidu.com/15.css'
  document.getElementsByTagName('head')[10].append(link)
}
render() {
  return <div>
    <img src='./bbb.png'/>
    <button onClick={loadCss}>載入樣式<button/>
  </div>
}

上述程式碼中的 baidu.com/15.cssbbb.png 是不存在的,JS 執行到這裡肯定會報一個資源找不到的錯誤。但是預設情況下,上面兩種 window 物件上的全域性監聽函式都監聽不到這類異常。

因為資源載入的異常只會在當前元素觸發,異常不會冒泡到 window,因此監聽 window 上的異常是捕捉不到的。那怎麼辦呢?

如果你熟悉 DOM 事件你就會明白,既然冒泡階段監聽不到,那麼在捕獲階段一定能監聽到。

方法就是給 window.addEventListene 函式指定第三個引數,很簡單就是 true,表示該監聽函式會在捕獲階段執行,這樣就能監聽到資源載入異常了。

// 捕獲階段全域性監聽
window.addEventListene(
  'error',
  (error) => {
    if (error.target != window) {
      console.log(error.target.tagName, error.target.src);
    }
    handleError(error);
  },
  true,
);

上述方式可以很輕鬆的監聽到圖片載入異常,這就是為什麼更推薦 window.addEventListene 的原因。不過要記得,第三個引數設為 true,監聽事件捕獲,就可以全域性捕獲到 JS 異常和資源載入異常。

需要特別注意,window.addEventListene 同樣不能捕獲 Promise 異常。不管是 Promise.then() 寫法還是 async/await 寫法,發生異常時都不能捕獲。

因此,我們還需要全域性監聽一個 unhandledrejection 函式來捕獲未處理的 Promise 異常。

// promise 錯誤捕獲
window.addEventListener('unhandledrejection', (error) => {
  // 列印異常原因
  console.log(error.reason);
  handleError(error);
  // 阻止控制檯列印
  error.preventDefault();
});

unhandledrejection 事件會在 Promise 發生異常並且沒有指定 catch 的時候觸發,相當於一個全域性的 Promise 異常兜底方案。這個函式會捕捉到執行時意外發生的 Promise 異常,這對我們排錯非常有用。

預設情況下,Promise 發生異常且未被 catch 時,會在控制檯列印異常。如果我們想阻止異常列印,可以用上面的 error.preventDefault() 方法。

異常處理函式

前面我們在捕獲到異常時呼叫了一個異常處理函式 handleError,所有的異常和上報邏輯統一在這個函式內處理,接下來我們實現這個函式。

const handleError = (error: any, type: 1 | 2) {
  if(type == 1) {
    // 處理介面異常
  }
  if(type == 2) {
    // 處理前端異常
  }
}

為了區分異常型別,函式新加了第二個引數 type 表示當前異常屬於前端還是介面。在不同的場景中使用如下:

  • 處理前端異常:handleError(error, 1)
  • 處理介面異常:handleError(error, 2)

處理介面異常

處理介面異常,我們需要將拿到的 error 引數解析,然後取到需要的資料。介面異常一般需要的資料欄位如下:

  • code:http 狀態碼
  • url:介面請求地址
  • method:介面請求方法
  • params:介面請求引數
  • error:介面報錯資訊

這些欄位都可以在 error 引數中獲取,方法如下:

const handleError = (error: any, type: 1 | 2) {
  if(type == 1) {
    // 此時的 error 響應,它的 config 欄位中包含請求資訊
    let { url, method, params, data } = error.config
    let err_data = {
       url, method,
       params: { query: params, body: data },
       error: error.data?.message || JSON.stringify(error.data),
    })
  }
}

config 物件中的 params 表示 GET 請求的 query 引數,data 表示 POST 請求的 body 引數,所以我在處理引數的時候,將這兩個引數合併為一個,用一個屬性 params 來表示。

params: { query: params, body: data }

還有一個 error 屬性表示錯誤資訊,這個獲取方式要根據你的介面返回格式來拿。要避免獲取到介面可能返回的超長錯誤資訊,多半是介面沒處理,這樣可能會導致寫入資料失敗,要提前與後臺規定好。

處理前端異常

前端異常異常大多數就是 js 異常,異常對應到 js 的 Error 物件,在處理之前,我們先看 Error 有哪幾種型別:

  • ReferenceError:引用錯誤
  • RangeError:超出有效範圍
  • TypeError:型別錯誤
  • URIError:URI 解析錯誤

這幾類異常的引用物件都是 Error,因此可以這樣獲取:

const handleError = (error: any, type: 1 | 2) {
  if(type == 2) {
    let err_data = null
    // 監測 error 是否是標準型別
    if(error instanceof Error) {
      let { name, message } = error
      err_data = {
        type: name,
        error: message
      }
    } else {
      err_data = {
        type: 'other',
        error: JSON.strigify(error)
      }
    }
  }
}

上述判斷中,首先判斷異常是否是 Error 的例項。事實上絕大部分的程式碼異常都是標準的 JS Error,但我們這裡還是判斷一下,如果是的話直接獲取異常型別和異常資訊,不是的話將異常型別設定為 other 即可。

我們隨便寫一個異常程式碼,看一下捕獲的結果:

function test() {
  console.aaa('ccc');
}
test();

然後捕獲到的異常是這樣的:

const handleError = (error: any) => {
  if (error instanceof Error) {
    let { name, message } = error;
    console.log(name, message);
    // 列印結果:TypeError console.aaa is not a function
  }
};

獲取環境資料

獲取環境資料的意思是,不管是介面異常還是前端異常,除了異常本身的資料之外,我們還需要一些其他資訊來幫助我們更快更準的定位到哪裡出錯了。

這類資料我們稱之為 “環境資料”,就是觸發異常時所在的環境。比如是誰在哪個頁面的哪個地方觸發的錯誤,有了這些,我們就能馬上找到錯誤來源,再根據異常資訊解決錯誤。

環境資料至少包括下面這些:

  • app:應用的名稱/標識
  • env:應用環境,一般是開發,測試,生產
  • version:應用的版本號
  • user_id:觸發異常的使用者 ID
  • user_name:觸發異常的使用者名稱
  • page_route:異常的頁面路由
  • page_title:異常的頁面名稱

appversion 都是應用配置,可以判斷異常出現在哪個應用的哪個版本。這兩個欄位我建議直接獲取 package.json 下的 nameversion 屬性,在應用升級的時候,及時修改 version 版本號即可。

其餘的欄位,需要根據框架的配置獲取,下面我分別介紹在 Vue 和 React 中如何獲取。

在 Vue 中

在 Vue 中獲取使用者資訊一般都是直接從 Vuex 裡面拿,如果你的使用者資訊沒有存到 Vuex 裡,從 localStorage 裡獲取也是一樣的。

如果在 Vuex 裡,可以這樣實現:

import store from '@/store'; // vuex 匯出目錄
let user_info = store.state;
let user_id = user_info.id;
let user_name = user_info.name;

使用者資訊存在狀態管理中,頁面路由資訊一般是在 vue-router 中定義。前端的路由地址可以直接從 vue-router 中獲取,頁面名稱可以配置在 meta 中,如:

{
  path: '/test',
  name: 'test',
  meta: {
    title: '測試頁面'
  },
  component: () => import('@/views/test/Index.vue')
},

這樣配置之後,獲取當前頁面路由和頁面名稱就簡單了:

window.vm = new Vue({...})

let route = vm.$route
let page_route = route.path
let page_title = route.meta.title

最後一步,我們再獲取當前環境。當前環境用一個環境變數 VUE_APP_ENV 表示,有三個值:

  • dev:開發環境
  • test:測試環境
  • pro:生產環境

然後在根目錄下新建三個環境檔案,寫入環境變數:

  • .env.development:VUE_APP_ENV=dev
  • .env.staging:VUE_APP_ENV=test
  • .env.production:VUE_APP_ENV=pro

現在獲取 env 環境時就可以這麼獲取:

{
  env: process.env.VUE_APP_ENV;
}

最後一步,執行打包時,傳入模式以匹配對應的環境檔案:

# 測試環境打包
$ num run build --mode staging
# 生產環境打包
$ num run build --mode production

獲取到環境資料,再拼上異常資料,我們就準備好了資料等待上報了。

在 React 中

和 Vue 一樣,使用者資訊可以直接從狀態管理裡拿。因為 React 中沒有全域性獲取當前旅遊的快捷方式,所以頁面資訊我也會放在狀態管理裡面。我用的狀態管理是 Mobx,獲取方式如下:

import { TestStore } from '@/stores'; // mobx 匯出目錄
let { user_info, cur_path, cur_page_title } = TestStore;
// 使用者資訊:user_info
// 頁面資訊:cur_path,cur_page_title

這樣的話,就需要在每次切換頁面時,更新 mobx 裡的路由資訊,怎麼做呢?

其實在根路由頁(一般是首頁)的 useEffect 中監聽即可:

import { useLocation } from 'react-router';
import { observer, useLocalObservable } from 'mobx-react';
import { TestStore } from '@/stores';

export default observer(() => {
  const { pathname, search } = useLocation();
  const test_inst = useLocalObservable(() => TestStore);
  useEffect(() => {
    test_inst.setCurPath(pathname, search);
  }, [pathname]);
});

獲取到使用者資訊和頁面資訊,接下來就是當前環境了。和 Vue 一樣通過 --mode 來指定模式,並載入相應的環境變數,只不過設定方法略有不同。大多數的 React 專案可能都是用 create-react-app 建立的,我們以此為例介紹怎麼修改。

首先,開啟 scripts/start.js 檔案,這是執行 npm run start 時執行的檔案,我們在開頭部分第 6 行加程式碼:

process.env.REACT_APP_ENV = 'dev';

沒錯,我們指定的環境變數就是 REACT_APP_ENV,因為只有 REACT_ 開頭的環境變數可被讀取。

然後再修改 scripts/build.js 檔案的第 48 行,修改後如下:

if (argv.length >= 2 && argv[0] == '--mode') {
  switch (argv[1]) {
    case 'staging':
      process.env.REACT_APP_ENV = 'test';
      break;
    case 'production':
      process.env.REACT_APP_ENV = 'pro';
      break;
    default:
  }
}

此時獲取 env 環境時就可以這麼獲取:

{
  env: process.env.REACT_APP_ENV;
}

總結

經過前面一系列操作,我們已經比較全面的獲取到了異常資料,以及發生異常時到環境資料,接下來就是呼叫上報介面,將這些資料傳給後臺存起來,我們以後查詢和追蹤就很方便了。

如果你也需要前端監控,不妨花上半個小時,按照文中介紹的方法收集一下異常資料,相信對你很有幫助。

文章首發公眾號 程式設計師成功。這個公眾號只做原創,專注於前端工程與架構的分享,關注我檢視更多硬核知識。我還有一個前端工程與架構群,如果有興趣,可加我 微信 入群。

相關文章