轉載:https://github.com/su37josephxia/frontend-basic/tree/master/monitor
您將Get的技能
- 收集前端錯誤(原生、React、Vue)
- 編寫錯誤上報邏輯
- 利用Egg.js編寫一個錯誤日志采集服務
- 編寫webpack插件自動上傳sourcemap
- 利用sourcemap還原壓縮代碼源碼位置
- 利用Jest進行單元測試
工作流程
- 收集錯誤
- 上報錯誤
- 代碼上線打包將sourcemap文件上傳至錯誤監控服務器
- 發生錯誤時監控服務器接收錯誤並記錄到日志中
- 根據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就好了。
小結
異常類型 | 同步方法 | 異步方法 | 資源加載 | Promise | async/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 |
其中核心的應該是錯誤棧,其實我們定位錯誤最主要的就是錯誤棧。
錯誤堆棧中包含了絕大多數調試有關的信息。其中包括了異常位置(行號,列號),異常信息
有興趣的同學可以看看這篇文章
上報數據序列化
由於通訊的時候只能以字符串方式傳輸,我們需要將對象進行序列化處理。
大概分成以下三步:
-
將異常數據從屬性中解構出來存入一個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' + ' at http://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。