1. 程式人生 > 實用技巧 >從0到1搭建前端異常監控系統(Vue + Webpack + Node.js + Egg.js + Jest)

從0到1搭建前端異常監控系統(Vue + Webpack + Node.js + Egg.js + Jest)

轉載:https://github.com/su37josephxia/frontend-basic/tree/master/monitor

您將Get的技能

  • 收集前端錯誤(原生、React、Vue)
  • 編寫錯誤上報邏輯
  • 利用Egg.js編寫一個錯誤日誌採集服務
  • 編寫webpack外掛自動上傳sourcemap
  • 利用sourcemap還原壓縮程式碼原始碼位置
  • 利用Jest進行單元測試

工作流程

  1. 收集錯誤
  2. 上報錯誤
  3. 程式碼上線打包將sourcemap檔案上傳至錯誤監控伺服器
  4. 發生錯誤時監控伺服器接收錯誤並記錄到日誌中
  5. 根據sourcemap和錯誤日誌內容進行錯誤分析

異常收集

首先先看看如何捕獲異常。

JS異常

js異常的特點是,出現不會導致JS引擎崩潰 最多隻會終止當前執行的任務。比如一個頁面有兩個按鈕,如果點選按鈕發生異常頁面,這個時候頁面不會崩潰,只是這個按鈕的功能失效,其他按鈕還會有效。

setTimeout(() => {
  console.log('1->begin')
  error
  console.log('1->end')
})
setTimeout(() => {
  console.log('2->begin')
  console.log('2->end')
})

上面的例子我們用setTimeout分別啟動了兩個任務,雖然第一個任務執行了一個錯誤的方法。程式執行停止了。但是另外一個任務並沒有收到影響。

其實如果你不開啟控制檯都看不到發生了錯誤。好像是錯誤是在靜默中發生的。

下面我們來看看這樣的錯誤該如何收集。

try-catch

JS作為一門高階語言我們首先想到的使用try-catch來收集。

setTimeout(() => {
  try {
    console.log('1->begin')
    error
    console.log('1->end')
  } catch (e) {
    console.log('catch',e)
  }
})

如果在函式中錯誤沒有被捕獲,錯誤會上拋。

function fun1() {
  console.log('1->begin')
  error
  console.log('1->end')
}
setTimeout(() => {
  try {
    fun1()
  } catch (e) {
    console.log('catch',e)
  }
})

控制檯中打印出的分別是錯誤資訊和錯誤堆疊。

讀到這裡大家可能會想那就在最底層做一個錯誤try-catch不就好了嗎。確實作為一個從java轉過來的程式設計師也是這麼想的。但是理想很豐滿,現實很骨感。我們看看下一個例子。

function fun1() {
  console.log('1->begin')
  error
  console.log('1->end')
}

try {
  setTimeout(() => {
    fun1()

  })
} catch (e) {
  console.log('catch', e)
}

大家注意執行結果,異常並沒有被捕獲。

這是因為JS的try-catch功能非常有限一遇到非同步就不好用了。那總不能為了收集錯誤給所有的非同步都加一個try-catch吧,太坑爹了。其實你想想非同步任務其實也不是由程式碼形式上的上層呼叫的就比如本例中的settimeout。大家想想eventloop就明白啦,其實這些一步函式都是就好比一群沒孃的孩子出了錯誤找不到家大人。當然我也想過一些黑魔法來處理這個問題比如代理執行或者用過的非同步方法。算了還是還是再看看吧。

window.onerror

window.onerror 最大的好處就是可以同步任務還是非同步任務都可捕獲。

function fun1() {
  console.log('1->begin')
  error
  console.log('1->end')
}
window.onerror = (...args) => {
  console.log('onerror:',args)
}

setTimeout(() => {
  fun1()
})

  • onerror返回值

    onerror還有一個問題大家要注意 如果返回返回true 就不會被上拋了。不然控制檯中還會看到錯誤日誌。

監聽error事件

window.addEventListener('error',() => {})

其實onerror固然好但是還是有一類異常無法捕獲。這就是網路異常的錯誤。比如下面的例子。

<img src="./xxxxx.png">

試想一下我們如果頁面上要顯示的圖片突然不顯示了,而我們渾然不知那就是麻煩了。

addEventListener就是

window.addEventListener('error', args => {
    console.log(
      'error event:', args
    );
    return true;
  }, 
  true // 利用捕獲方式
);

執行結果如下:

Promise異常捕獲

Promise的出現主要是為了讓我們解決回撥地域問題。基本是我們程式開發的標配了。雖然我們提倡使用es7 async/await語法來寫,但是不排除很多祖傳程式碼還是存在Promise寫法。

new Promise((resolve, reject) => {
  abcxxx()
});

這種情況無論是onerror還是監聽錯誤事件都是無法捕獲的

new Promise((resolve, reject) => {
  error()
})
// 增加異常捕獲
  .catch((err) => {
  console.log('promise catch:',err)
});

除非每個Promise都新增一個catch方法。但是顯然是不能這樣做。

window.addEventListener("unhandledrejection", e => {
  console.log('unhandledrejection',e)
});

我們可以考慮將unhandledrejection事件捕獲錯誤丟擲交由錯誤事件統一處理就可以了

window.addEventListener("unhandledrejection", e => {
  throw e.reason
});

async/await異常捕獲

const asyncFunc = () => new Promise(resolve => {
  error
})
setTimeout(async() => {
  try {
    await asyncFun()
  } catch (e) {
    console.log('catch:',e)
  }
})

實際上async/await語法本質還是Promise語法。區別就是async方法可以被上層的try/catch捕獲。

如果不去捕獲的話就會和Promise一樣,需要用unhandledrejection事件捕獲。這樣的話我們只需要在全域性增加unhandlerejection就好了。

小結

異常型別同步方法非同步方法資源載入Promiseasync/await
try/catch ✔️ ✔️
onerror ✔️ ✔️
error事件監聽 ✔️ ✔️ ✔️
unhandledrejection事件監聽 ✔️ ✔️

實際上我們可以將unhandledrejection事件丟擲的異常再次丟擲就可以統一通過error事件進行處理了。

最終用程式碼表示如下:

window.addEventListener("unhandledrejection", e => {
  throw e.reason
});
window.addEventListener('error', args => {
  console.log(
    'error event:', args
  );
  return true;
}, true);

Webpack工程化

現在是前端工程化的時代,工程化匯出的程式碼一般都是被壓縮混淆後的。

比如:

setTimeout(() => {
    xxx(1223)
}, 1000)

出錯的程式碼指向被壓縮後的JS檔案,而JS檔案長下圖這個樣子。

如果想將錯誤和原有的程式碼關聯起來就需要sourcemap檔案的幫忙了。

sourceMap是什麼

簡單說,sourceMap就是一個檔案,裡面儲存著位置資訊。

仔細點說,這個檔案裡儲存的,是轉換後代碼的位置,和對應的轉換前的位置。

那麼如何利用sourceMap對還原異常程式碼發生的位置這個問題我們到異常分析這個章節再講。

Vue

建立工程

利用vue-cli工具直接建立一個專案。

# 安裝vue-cli
npm install -g @vue/cli

# 建立一個專案
vue create vue-sample

cd vue-sample
npm i
// 啟動應用
npm run serve

為了測試的需要我們暫時關閉eslint 這裡面還是建議大家全程開啟eslint

在vue.config.js進行配置

module.exports = {   
  // 關閉eslint規則
  devServer: {
    overlay: {
      warnings: true,
      errors: true
    }
  },
  lintOnSave:false
}

我們故意在src/components/HelloWorld.vue

<script>
export default {
  name: "HelloWorld",
  props: {
    msg: String
  },
  mounted() {
    // 製造一個錯誤
    abc()
  }
};
</script>
​```html

然後在src/main.js中新增錯誤事件監聽

​```js
window.addEventListener('error', args => {
  console.log('error', error)
})

這個時候 錯誤會在控制檯中被打印出來,但是錯誤事件並沒有監聽到。

handleError

為了對Vue發生的異常進行統一的上報,需要利用vue提供的handleError控制代碼。一旦Vue發生異常都會呼叫這個方法。

我們在src/main.js

Vue.config.errorHandler = function (err, vm, info) {
  console.log('errorHandle:', err)
}

執行結果結果:

React

npx create-react-app react-sample
cd react-sample
yarn start

我們l用useEffect hooks 製造一個錯誤

import React ,{useEffect} from 'react';
import logo from './logo.svg';
import './App.css';

function App() {
  useEffect(() => {	
    // 發生異常
    error()
  });	

  return (
    <div className="App">
      // ...略...
    </div>
  );
}

export default App;

並且在src/index.js中增加錯誤事件監聽邏輯

window.addEventListener('error', args => {
    console.log('error', error)
})

但是從執行結果看雖然輸出了錯誤日誌但是還是服務捕獲。

ErrorBoundary標籤

錯誤邊界僅可以捕獲其子元件的錯誤。錯誤邊界無法捕獲其自身的錯誤。如果一個錯誤邊界無法渲染錯誤資訊,則錯誤會向上冒泡至最接近的錯誤邊界。這也類似於 JavaScript 中 catch {} 的工作機制。

建立ErrorBoundary元件

import React from 'react'; 
export default class ErrorBoundary extends React.Component {
    constructor(props) {
      super(props);
    }
  
    componentDidCatch(error, info) {
      // 發生異常時列印錯誤
      console.log('componentDidCatch',error)
    }
  
    render() {
      return this.props.children;
    }
  }

在src/index.js中包裹App標籤

import ErrorBoundary from './ErrorBoundary'

ReactDOM.render(
    <ErrorBoundary>
        <App />
    </ErrorBoundary>
    , document.getElementById('root'));

最終執行的結果

跨域程式碼異常

(待...)

IFrame異常

(待...)

上一篇我們主要談到的JS錯誤如何收集。這篇我們說說異常如何上報和分析。

異常上報

選擇通訊方式

動態建立img標籤

其實上報就是要將捕獲的異常資訊傳送到後端。最常用的方式首推動態建立標籤方式。因為這種方式無需載入任何通訊庫,而且頁面是無需重新整理的。基本上目前包括百度統計 Google統計都是基於這個原理做的埋點。

new Image().src = 'http://localhost:7001/monitor/error'+ '?info=xxxxxx'

通過動態建立一個img,瀏覽器就會向伺服器傳送get請求。可以把你需要上報的錯誤資料放在querystring字串中,利用這種方式就可以將錯誤上報到伺服器了。

Ajax上報

實際上我們也可以用ajax的方式上報錯誤,這和我們再業務程式中並沒有什麼區別。在這裡就不贅述。

上報哪些資料

我們先看一下error事件引數:

屬性名稱含義型別
message 錯誤資訊 string
filename 異常的資源url string
lineno 異常行號 int
colno 異常列號 int
error 錯誤物件 object
error.message 錯誤資訊 string
error.stack 錯誤資訊 string

其中核心的應該是錯誤棧,其實我們定位錯誤最主要的就是錯誤棧。

錯誤堆疊中包含了絕大多數除錯有關的資訊。其中包括了異常位置(行號,列號),異常資訊

有興趣的同學可以看看這篇文章

https://github.com/dwqs/blog/issues/49

上報資料序列化

由於通訊的時候只能以字串方式傳輸,我們需要將物件進行序列化處理。

大概分成以下三步:

  • 將異常資料從屬性中解構出來存入一個JSON物件

  • 將JSON物件轉換為字串

  • 將字串轉換為Base64

當然在後端也要做對應的反向操作 這個我們後面再說。

window.addEventListener('error', args => {
  console.log(
    'error event:', args
  );
  uploadError(args)
  return true;
}, true);
function uploadError({
    lineno,
    colno,
    error: {
      stack
    },
    timeStamp,
    message,
    filename
  }) {
    // 過濾
    const info = {
      lineno,
      colno,
      stack,
      timeStamp,
      message,
      filename
    }
    // const str = new Buffer(JSON.stringify(info)).toString("base64");
  	const str = window.btoa(JSON.stringify(info))
    const host = 'http://localhost:7001/monitor/error'
    new Image().src = `${host}?info=${str}`
}

異常收集

異常上報的資料一定是要有一個後端服務接收才可以。

我們就以比較流行的開源框架eggjs為例來演示

搭建eggjs工程

# 全域性安裝egg-cli
npm i egg-init -g 
# 建立後端專案
egg-init backend --type=simple
cd backend
npm i
# 啟動專案
npm run dev

編寫error上傳介面

首先在app/router.js新增一個新的路由

module.exports = app => {
  const { router, controller } = app;
  router.get('/', controller.home.index);
  // 建立一個新的路由
  router.get('/monitor/error', controller.monitor.index);
};

建立一個新的controller (app/controller/monitor)

'use strict';

const Controller = require('egg').Controller;
const { getOriginSource } = require('../utils/sourcemap')
const fs = require('fs')
const path = require('path')

class MonitorController extends Controller {
  async index() {
    const { ctx } = this;
    const { info } = ctx.query
    const json = JSON.parse(Buffer.from(info, 'base64').toString('utf-8'))
    console.log('fronterror:', json)
    ctx.body = '';
  }
}

module.exports = MonitorController;

看一下接收後的結果

記入日誌檔案

下一步就是講錯誤記入日誌。實現的方法可以自己用fs寫,也可以藉助log4js這樣成熟的日誌庫。

當然在eggjs中是支援我們定製日誌那麼我麼你就用這個功能定製一個前端錯誤日誌好了。

在/config/config.default.js中增加一個定製日誌配置

// 定義前端錯誤日誌
config.customLogger = {
  frontendLogger : {
    file: path.join(appInfo.root, 'logs/frontend.log')
  }
}

在/app/controller/monitor.js中新增日誌記錄

async index() {
    const { ctx } = this;
    const { info } = ctx.query
    const json = JSON.parse(Buffer.from(info, 'base64').toString('utf-8'))
    console.log('fronterror:', json)
    // 記入錯誤日誌
    this.ctx.getLogger('frontendLogger').error(json)
    ctx.body = '';
  }

最後實現的效果

異常分析

談到異常分析最重要的工作其實是將webpack混淆壓縮的程式碼還原。

Webpack外掛實現SourceMap上傳

在webpack的打包時會產生sourcemap檔案,這個檔案需要上傳到異常監控伺服器。這個功能我們試用webpack外掛完成。

建立webpack外掛

/source-map/plugin

const fs = require('fs')
var http = require('http');

class UploadSourceMapWebpackPlugin {
  constructor(options) {
    this.options = options
  }

  apply(compiler) {
    // 打包結束後執行
    compiler.hooks.done.tap("upload-sourcemap-plugin", status => {
      console.log('webpack runing')
    });
  }
}

module.exports = UploadSourceMapWebpackPlugin;

載入webpack外掛

webpack.config.js

// 自動上傳Map
UploadSourceMapWebpackPlugin = require('./plugin/uploadSourceMapWebPackPlugin')

plugins: [
    // 新增自動上傳外掛
    new UploadSourceMapWebpackPlugin({
      uploadUrl:'http://localhost:7001/monitor/sourcemap',
      apiKey: 'kaikeba'
    })
  ],

新增讀取sourcemap讀取邏輯

在apply函式中增加讀取sourcemap檔案的邏輯

/plugin/uploadSourceMapWebPlugin.js

const glob = require('glob')
const path = require('path')
apply(compiler) {
  console.log('UploadSourceMapWebPackPlugin apply')
  // 定義在打包後執行
  compiler.hooks.done.tap('upload-sourecemap-plugin', async status => {
    // 讀取sourcemap檔案
    const list = glob.sync(path.join(status.compilation.outputOptions.path, `./**/*.{js.map,}`))
    for (let filename of list) {
      await this.upload(this.options.uploadUrl, filename)
    }
  })
}

實現http上傳功能

upload(url, file) {
  return new Promise(resolve => {
    console.log('uploadMap:', file)

    const req = http.request(
      `${url}?name=${path.basename(file)}`,
      {
        method: 'POST',
        headers: {
          'Content-Type': 'application/octet-stream',
          Connection: "keep-alive",
          "Transfer-Encoding": "chunked"
        }
      }
    )
    fs.createReadStream(file)
      .on("data", chunk => {
      req.write(chunk);
    })
      .on("end", () => {
      req.end();
      resolve()
    });
  })
}

伺服器端新增上傳介面

/backend/app/router.js

module.exports = app => {
  const { router, controller } = app;
  router.get('/', controller.home.index);
  router.get('/monitor/error', controller.monitor.index);
  // 新增上傳路由
 router.post('/monitor/sourcemap',controller.monitor.upload)
};
    

新增sourcemap上傳介面

/backend/app/controller/monitor.js

async upload() {
    const { ctx } = this
    const stream = ctx.req
    const filename = ctx.query.name
    const dir = path.join(this.config.baseDir, 'uploads')
    // 判斷upload目錄是否存在
    if (!fs.existsSync(dir)) {
      fs.mkdirSync(dir)
    }

    const target = path.join(dir, filename)
    const writeStream = fs.createWriteStream(target)
    stream.pipe(writeStream)
}

最終效果:

執行webpack打包時呼叫外掛sourcemap被上傳至伺服器。

解析ErrorStack

考慮到這個功能需要較多邏輯,我們準備把他開發成一個獨立的函式並且用Jest來做單元測試

先看一下我們的需求

輸入stack(錯誤棧)ReferenceError: xxx is not defined\n' + ' athttp://localhost:7001/public/bundle.e7877aa7bc4f04f5c33b.js:1:1392'
SourceMap
輸出 原始碼錯誤棧 { source: 'webpack:///src/index.js', line: 24, column: 4, name: 'xxx' }

搭建Jest框架

首先建立一個/utils/stackparser.js檔案

module.exports = class StackPaser {
    constructor(sourceMapDir) {
        this.consumers = {}
        this.sourceMapDir = sourceMapDir
    }
}

在同級目錄下建立測試檔案stackparser.spec.js

以上需求我們用Jest表示就是

const StackParser = require('../stackparser')
const { resolve } = require('path')
const error = {
    stack: 'ReferenceError: xxx is not defined\n' +
        '    at http://localhost:7001/public/bundle.e7877aa7bc4f04f5c33b.js:1:1392',
    message: 'Uncaught ReferenceError: xxx is not defined',
    filename: 'http://localhost:7001/public/bundle.e7877aa7bc4f04f5c33b.js'
}

it('stackparser on-the-fly', async () => {

    const stackParser = new StackParser(__dirname)

    // 斷言 
    expect(originStack[0]).toMatchObject(
        {
            source: 'webpack:///src/index.js',
            line: 24,
            column: 4,
            name: 'xxx'
        }
    )
})

整理如下:

下面我們執行Jest

npx jest stackparser --watch

顯示執行失敗,原因很簡單因為我們還沒有實現對吧。下面我們就實現一下這個方法。

反序列Error物件

首先建立一個新的Error物件 將錯誤棧設定到Error中,然後利用error-stack-parser這個npm庫來轉化為stackFrame

const ErrorStackParser = require('error-stack-parser')
/**
 * 錯誤堆疊反序列化
 * @param {*} stack 錯誤堆疊
 */
parseStackTrack(stack, message) {
  const error = new Error(message)
  error.stack = stack
  const stackFrame = ErrorStackParser.parse(error)
  return stackFrame
}

執行效果

解析ErrorStack

下一步我們將錯誤棧中的程式碼位置轉換為原始碼位置

const { SourceMapConsumer } = require("source-map");
async getOriginalErrorStack(stackFrame) {
        const origin = []
        for (let v of stackFrame) {
            origin.push(await this.getOriginPosition(v))
        }

        // 銷燬所有consumers
        Object.keys(this.consumers).forEach(key => {
            console.log('key:',key)
            this.consumers[key].destroy()
        })
        return origin
    }

    async getOriginPosition(stackFrame) {
        let { columnNumber, lineNumber, fileName } = stackFrame
        fileName = path.basename(fileName)
        console.log('filebasename',fileName)
        // 判斷是否存在
        let consumer = this.consumers[fileName]

        if (consumer === undefined) {
            // 讀取sourcemap
            const sourceMapPath = path.resolve(this.sourceMapDir, fileName + '.map')
            // 判斷目錄是否存在
            if(!fs.existsSync(sourceMapPath)){
                return stackFrame
            }
            const content = fs.readFileSync(sourceMapPath, 'utf8')
            consumer = await new SourceMapConsumer(content, null);
            this.consumers[fileName] = consumer
        }
        const parseData = consumer.originalPositionFor({ line:lineNumber, column:columnNumber })
        return parseData
    }

我們用Jest測試一下

it('stackparser on-the-fly', async () => {

    const stackParser = new StackParser(__dirname)
    console.log('Stack:',error.stack)
    const stackFrame = stackParser.parseStackTrack(error.stack, error.message)
    stackFrame.map(v => {
        console.log('stackFrame', v)
    })
    const originStack = await stackParser.getOriginalErrorStack(stackFrame)

    // 斷言 
    expect(originStack[0]).toMatchObject(
        {
            source: 'webpack:///src/index.js',
            line: 24,
            column: 4,
            name: 'xxx'
        }
    )
})

看一下結果測試通過。

將原始碼位置記入日誌

async index() {
    console.log
    const { ctx } = this;
    const { info } = ctx.query
    const json = JSON.parse(Buffer.from(info, 'base64').toString('utf-8'))
    console.log('fronterror:', json)
    
    // 轉換為原始碼位置
    const stackParser = new StackParser(path.join(this.config.baseDir, 'uploads'))
    const stackFrame = stackParser.parseStackTrack(json.stack, json.message)
    const originStack = await stackParser.getOriginalErrorStack(stackFrame)
    this.ctx.getLogger('frontendLogger').error(json,originStack)
    
    ctx.body = '';
  }

執行效果:

開源框架

Fundebug

Fundebug專注於JavaScript、微信小程式、微信小遊戲、支付寶小程式、React Native、Node.js和Java線上應用實時BUG監控。 自從2016年雙十一正式上線,Fundebug累計處理了10億+錯誤事件,付費客戶有陽光保險、荔枝FM、掌門1對1、核桃程式設計、微脈等眾多品牌企業。歡迎免費試用!

Sentry

Sentry 是一個開源的實時錯誤追蹤系統,可以幫助開發者實時監控並修復異常問題。它主要專注於持續整合、提高效率並且提升使用者體驗。Sentry 分為服務端和客戶端 SDK,前者可以直接使用它家提供的線上服務,也可以本地自行搭建;後者提供了對多種主流語言和框架的支援,包括 React、Angular、Node、Django、RoR、PHP、Laravel、Android、.NET、JAVA 等。同時它可提供了和其他流行服務整合的方案,例如 GitHub、GitLab、bitbuck、heroku、slack、Trello 等。目前公司的專案也都在逐步應用上 Sentry 進行錯誤日誌管理。

總結

截止到目前為止,我們把前端異常監控的基本功能算是形成了一個MVP(最小化可行產品)。後面需要升級的還有很多,對錯誤日誌的分析和視覺化方面可以使用ELK。釋出和部署可以採用Docker。對eggjs的上傳和上報最好要增加許可權控制功能。

參考程式碼位置:https://github.com/su37josephxia/frontend-basic/tree/master/monitor

歡迎指正,歡迎Star。