阅读 487

前端异常捕获上报

通过阅读本文,你将学会:

  • 常见的前端错误类型和捕获方式
  • 前端错误如何上报服务端
  • 服务端如何接收前端上报错误和日志记录
  • 如何编写一个可以在项目打包时 上传 sourcemap 文件的 webpack plugin
  • 如何在服务端 通过 错误日志结合 sourcemap 文件还原 错误代码所在位置
  • 简单的 Jest 单元测试编写

正文开始

使用 try...catch

const func = () => {
  console.log('fun start')
  err
  console.log('fun end')
}

try {
  func()
} catch (err) {
 console.log('err', err)
}

复制代码

预览代码:trycatch捕获异常

  • 缺点:无法捕获 异步 错误
const func = () => {
  console.log('fun start')
  err
  console.log('fun end')
}

try {
  setTimeout(() => {    
    func()
  })
} catch (err) {
 console.log('err', err)
}
复制代码

示例

那应该怎么捕获异步的错误呢?

window.onerror 捕获异步错误

const func = () => {
  console.log('fun start')
  err
  console.log('fun end')
}

setTimeout(() => {    
    func()
  })

window.onerror = (...args) => {
  console.log('args:', args)
}
复制代码

示例

这里我们可以发先,使用 window.onerror 捕获到了我们的异步错误。

但是,它可以捕获到所有类型的错误吗?

比如:资源加载地址错误?

<img src="//xxsdfsdx.jpg" alt="">
    
window.onerror = (...args) => {
  console.log('args:', args)
}
复制代码

示例:资源加载错误

此时,我们看到该资源地址错误没有被 打印出来,那么我们该怎么捕获这种类型错误呢?

window.addEventListener('error)

资源地址错误怎么捕获?


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

 window.addEventListener('error', (event) => {
   console.log('event err:', event)
 }, true) // 第三个参数为 true ,选择捕获的方式监听
复制代码

示例:资源加载错误捕获

promise 怎么捕获?

window.addEventListener('unhandledrejection', (err) =>{}) 捕获

  • 使用 try...catch 无法捕获
const asyncFunc = () => {
  return new Promise((res) => {
    err
  })
}

try {
    asyncFunc()
  } catch(e) {
    console.log('err:', e)
  }
复制代码

示例:try-catch无法捕获promise

  • 使用 addEventListener('unhandledrejection')
const asyncFunc = () => {
  return new Promise((res) => {
    err
  })
}

asyncFunc()


 window.addEventListener('unhandledrejection', (event) => {
   console.log('event err:', event)
 })
复制代码

问题:能否使用一个捕获方式捕获所有的错误?

const asyncFunc = () => {
  return new Promise((res) => {
    err
  })
}

asyncFunc()

// 主动抛出捕获到的 promise 类型的错误
 window.addEventListener('unhandledrejection', (event) => {
   throw event.reason
 })
 
 window.addEventListener('error', (err) => {
   console.log('err:', err)
 }, true)
复制代码

示例

小结

异常类型同步方法异步方法资源加载Promiseasync / await
try/catchyy
onerroryy
addEventListener('error')yyy
addEventListener('unhandledrejection')yy

异常上报服务器

异常上报服务器主要有2 种方式,一是 动态创建 img 标签,二是直接使用 ajax 发送请求上报。这里主要讲述第一种方式

动态创建 img 标签

  • 错误监听和上报代码
// 上报错误
function uploadError({lineno, colno, error: { stack }, message, filename }) {
    console.log('uploadError---', event)
    // 整理我们要的错误信息
    const errorInfo = {
        lineno,
        colno,
        stack,
        message,
        filename
    }
    // 错误信息序列化后使用 base64 编码,避免出现特殊字符导致的错误
    const str = window.btoa(JSON.stringify(errorInfo))
    
    // 创建图片,使用图片给错误收集的后端服务器发送一个 get 请求,
    // 上传的信息:错误资源,错误时间
    new Image().src = `http://localhost:7001/monitor/error?info=${str}`
}

window.addEventListener('unhandledrejection', (event) => {
  // 再次主动抛出
   throw event.reason
 })

window.addEventListener('error', (err) => {
  	console.log('error:', err)
    // 上报错误
    uploadError(err)
})
复制代码
  • 后端收集错误

  • 搭建 eggjs 工程,具体参考 Egg.js官网

    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)
    };
    复制代码
    • 编写 error 上传接口——编写接口,这里使用到了 Buffer-Nodejs
    // app/controller/monitor.js
    
    'use strict';
    
    const Controller = require('egg').Controller;
    
    class MonitorController extends Controller {
      async index() {
        const { ctx } = this;
        const { info } = ctx.query
        // Buffer 接受一个 base64 编码的数据
        const json = JSON.parse(Buffer.from(info, 'base64').toString('utf-8'))
        console.log('error-info', json)
        ctx.body = 'hi, json';
      }
    }
    
    module.exports = MonitorController;
    
    复制代码
    • 编写 error 上传接口——测试
    const info = window.btoa(JSON.stringify({test: 'err'})) // "eyJ0ZXN0IjoiZXJyIn0="
    
    // rest-client 测试接口测试
    GET http://localhost:7001/monitor/error?info=eyJ0ZXN0IjoiZXJyIn0=
    
    // 得到 log 结果:error-info { test: 'err' }
    复制代码

eggjs 记入错误日志

方式:

  • 可以使用 fs 写入文件进行记录
  • 也可以使用 log4j 这种成熟的日志库

当然,在 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
    // Buffer 接受一个 base64 编码的数据
    const json = JSON.parse(Buffer.from(info, 'base64').toString('utf-8'))
    console.log('error-info', json)
    // 写入日志
    this.ctx.getLogger('frontendLogger').error(json)
    ctx.body = 'hi, json';
  }
复制代码
  • 测试
// rest-client 测试
GET http://localhost:7001/monitor/error?info=eyJ0ZXN0IjoiZXJyIn0=
复制代码
  • 结果:查看 /logs/frontend.log 文件中,有具体的日志信息
2021-04-03 11:58:48,543 ERROR 2180 [-/127.0.0.1/-/4ms GET /monitor/error?info=eyJ0ZXN0IjoiZXJyIn0=] { test: 'err' }
复制代码

Vue项目中异常如何采集

Vue3.x 官网

npm i @vue/cli -g

vue create vue-app

cd vue-app

yarn install

yarn serve
复制代码
  • 编写代码,制造 error
// src/components/HelloWorld.vue

// ... 省略部分代码
export default {
    name: 'HelloWorld',
    props: {
        msg: String
    },
    mounted() {
        // methods 中没有定义方法 abc,报错 error
        abc()
    }
}
复制代码
  • 关闭 eslint ,减少影响,让前端服务能跑起来,新建/编辑 vue.config.js
// /vue.config.js

module.exports = {
    // close eslint setting
    devServer: {
        overlay: {
            warning: true,
            errors: true
        }
    },
    lintOnSave: false
}
复制代码
  • 捕获错误
// src/main.js

// 在 vue 里面统一使用这个 方式捕获错误
Vue.config.errorHandler = (err, vm, info) => {
    console.log('errHandler:', err)
    uploadError(err)
}

function uploadError({ message, stack }) {
  console.log('uploadError---')
  // 整理我们要的错误信息
  const errorInfo = {
      stack,
      message,
  }
  // 错误信息序列化后使用 base64 编码,避免出现特殊字符导致的错误
  const str = window.btoa(JSON.stringify(errorInfo))
  
  // 创建图片,使用图片给错误收集的后端服务器发送一个 get 请求,
  // 上传的信息:错误资源,错误时间
  new Image().src = `http://localhost:7001/monitor/error?info=${str}`
}

new Vue({
    render: h => h(App)
}).$mounted('#app')
复制代码
  • 打包 vue 项目,运行测试判断是否捕获错误
yarn build

cd dist

hs
复制代码
  • 删除 dist 目录中的 sourcemap 映射文件,此时报错定位代码就不是源代码了,而是压缩后的代码,不美观

因为打包后的代码 js 文件主要有 2 种

app.xxx.js
app.xxx.js.map
复制代码

我们可以看看 .map 文件的内容结构:

{
  "version": 3,
  "sources": [
    "webpack:///webpack/bootstrap",
    "webpack:///./src/App.vue",
    "webpack:///./src/components/HelloWorld.vue",
    "webpack:///./src/components/HelloWorld.vue?354f",
    "webpack:///./src/App.vue?eabf",
    "webpack:///./src/main.js",
    "webpack:///./src/assets/logo.png",
    "webpack:///./src/App.vue?7d22"
  ],
 "names": [
    "webpackJsonpCallback",
    "data",
    //...
  ],
 "mappings": "aACE,SAASA,EAAqBC...",
 "file": "js/app.9a4488cf.js",
 "sourcesContent": [" \t// install a JSONP callback..."],
 "sourceRoot": ""
}
复制代码

主要包含了这些东西:

  • version Source map的版本,目前为3
  • soruces 转换后的文件名
  • names 转换前的所有变量名和属性名
  • mappings 记录位置信息的字符串
  • file 转换后的文件名
  • sourcesContent 源内容列表(可选,和源文件列表顺序一致)
  • sourceRoot 源文件根目录(可选)

关于 source map 可以参考这 2 篇文章 source-map-阮一峰Source Map 原理及源码探索 - Jooger的文章 - 知乎

后面,我们将从 app.xxx.js.map 中进行解析,还原错误代码

sourcemap 上传插件

编写一个 UploadSourceMapWebpackPlugin 插件,用于每次打包代码的时候自动上传到服务器指定目录

  • 编写 webpack plugin
// frontend/plugin/uploadSourceMapWebpackPlugin.js

class UploadSourceMapWebpackPlugin {
  constructor(options) {
    this.options = options
  }
  apply(compiler) {
    console.log('UploadSourceMapWebpackPlugin apply')
  }
}

module.exports = UploadSourceMapWebpackPlugin
复制代码
  • 配置 插件
// /vue.config.js 
// refer:https://cli.vuejs.org/zh/config/#configurewebpack
const UploadSourceMapWebpackPlugin = require('./plugin/uploadSourceMapWebpackPlugin')

module.exports = {
  configureWebpack: {
    plugins:[
      new UploadSourceMapWebpackPlugin({
        uploadUrl: 'http://localhost:7001/monitor/sourcemap'
      })
    ]
  },
  // close eslint setting
  devServer: {
    overlay: {
      warning: true,
      errors: true
    }
  },
  lintOnSave: false
}
复制代码
  • 打包测试
yarn build

# 此时,我们可以看到命令行中的 log
Building for production...UploadSourceMapWebpackPlugin apply
复制代码

接下来,完成 UploadSourceMapWebpackPlugin 插件的详细功能

const path = require('path')
const glob = require('glob')
const fs = require('fs')
const http = require('http')


class UploadSourceMapWebpackPlugin {
  constructor(options) {
    this.options = options
  }
  apply(compiler) {
    console.log('UploadSourceMapWebpackPlugin apply')
    // 定义在打包后执行
    compiler.hooks.done.tap('UploadSourceMapWebpackPlugin', async status => {
      // 读取 sourceMap 文件
      const list = glob.sync(path.join(status.compilation.outputOptions.path, `./**/*.{js.map,}`))
      console.log('list', list)
      // list [
      //   '/mnt/d/Desktop/err-catch-demo/vue-app/dist/js/app.d15f69c0.js.map',
      //   '/mnt/d/Desktop/err-catch-demo/vue-app/dist/js/chunk-vendors.f3b66fea.js.map'
      // ]
      for (let filename of list) {
        await this.upload(this.options.uploadUrl, filename)
      }
    })

  }
  upload(url, file) {
    return new Promise(resolve => {
      console.log('upload Map: ', 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()
      })
    })
  }
}

module.exports = UploadSourceMapWebpackPlugin
复制代码

作用:

在每一次 build done 的时候:

  • 读取 sourceMap 文件
  • 将读取的 sourceMap 文件上传到指定服务器中

Eggjs 服务器 sourceMap 上传接口

  • 新增后端路由
'use strict';

// /app/router.js

/**
 * @param {Egg.Application} app - egg application
 */
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)
};

复制代码
  • 新增接口,文件信息写入
'use strict';

/app/controller/monitor.js

const Controller = require('egg').Controller;
const path = require('path')
const fs = require('fs')
class MonitorController extends Controller {
  
  // ...
  async upload() {
    const { ctx } = this
    // 拿到的是一个 流
    const stream = ctx.req
    const filename = ctx.query.name
    const dir = path.join(this.config.baseDir, 'upload')
    // 判断 upload 是否存在
    if (!fs.existsSync(dir)) {
      fs.mkdirSync(dir)
    }
    const target = path.join(dir, filename)
    // 创建写入流写入信息
    console.log('writeFile====', target);
    const writeStream = fs.createWriteStream(target)
    stream.pipe(writeStream)
  }
}

module.exports = MonitorController;

复制代码
  • 关闭 csrf
// /config/config.default.js

config.security = {
    // 可能存在 scrf 风险,这里设置关闭
    csrf: {
      enable: false
    }
  }
复制代码
  • 测试
yarn build

# egg-server log info
writeFile==== D:\Desktop\err-catch-demo\backend\upload\app.d15f69c0.js.map
writeFile==== D:\Desktop\err-catch-demo\backend\upload\chunk-vendors.f3b66fea.js.map
复制代码

Stack 解析函数

  • 安装 error-stack-parser
yarn add error-stack-parser
复制代码

编写测试用例:

  • 解析 error.stack 信息
// /app/utils/stackparser.js

'use strict';

const ErrorStackParser = require('error-stack-parser');
const { SourceMapConsumer } = require('source-map');
const path = require('path');
const fs = require('fs');

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

  parseStackTrack(stack, message) {
    const error = new Error(message);
    error.stack = stack;
    const stackFrame = ErrorStackParser.parse(error);
    return stackFrame;
  }

  async getOriginalErrorStack(stackFrame) {
    const origin = [];
    for (const v of stackFrame) {
      origin.push(await this.getOriginPosition(v));
    }
    return origin;
  }

  // 从 sourceMap 文件读取错误信息
  async getOriginPosition(stackFrame) {
    let { columnNumber, lineNumber, fileName } = stackFrame;
    fileName = path.basename(fileName);
    // 判断 consumers 是否存在
    let consumer = this.consumers[fileName];
    if (!consumer) {
      // 读取 sourceMap
      const sourceMapPath = path.resolve(this.sourceMapDir, fileName + '.map');
      // 判断文件是否存在
      if (!fs.existsSync(sourceMapPath)) {
        // 不存在则返回源文件
        return stackFrame;
      }
      const content = fs.readFileSync(sourceMapPath, 'utf-8');
      consumer = await new SourceMapConsumer(content, null);
      this.consumers[fileName] = consumer;
    }

    const parseData = consumer.originalPositionFor({ line: lineNumber, columnNumber });
    return parseData;
  }
};
复制代码
  • 测试准备:先将 /upload 内的 .map 文件拷贝到 app/utils/__test__ 目录中
  • 测试用例:
// 如何通过sourcemap手工还原错误具体信息? https://www.zhihu.com/question/285449738
// /app/utils/stackparser.spec.js
'use strict';

const StackParser = require('../stackparser');

// const { resolve } = require('path');
// const { hasUncaughtExceptionCaptureCallback } = require('process');

const error = {
  stack: 'ReferenceError: abc is not defined\n' +
  '    at Proxy.mounted (http://127.0.0.1:8080/js/app.c82461cf.js:1:606)\n' +
  '    at i (http://127.0.0.1:8080/js/chunk-vendors.b64c81c0.js:1:8614)\n' +
  '    at c (http://127.0.0.1:8080/js/chunk-vendors.b64c81c0.js:1:8697)\n' +
  '    at Array.e.__weh.e.__weh (http://127.0.0.1:8080/js/chunk-vendors.b64c81c0.js:1:15852)\n' +
  '    at I (http://127.0.0.1:8080/js/chunk-vendors.b64c81c0.js:1:10078)\n' +
  '    at Q (http://127.0.0.1:8080/js/chunk-vendors.b64c81c0.js:1:31862)\n' +
  '    at mount (http://127.0.0.1:8080/js/chunk-vendors.b64c81c0.js:1:22532)\n' +
  '    at Object.e.mount (http://127.0.0.1:8080/js/chunk-vendors.b64c81c0.js:1:50901)\n' +
  '    at Object.8287 (http://127.0.0.1:8080/js/app.c82461cf.js:1:1066)\n' +
  '    at o (http://127.0.0.1:8080/js/app.c82461cf.js:1:1178)',
  message: 'abc is not defined',
  filename: 'http://127.0.0.1:8080/js/app.c82461cf.js:1:606',
};


it('test==========>', async () => {
  const stackParser = new StackParser(__dirname);
  // console.log('path', path.basename(__dirname));
  // console.log('Stack:', error.stack);
  const stackFrame = stackParser.parseStackTrack(error.stack, error);
  stackFrame.map(v => {
    // console.log('stackFrame: ', v);
    return v;
  });

  const originStack = await stackParser.getOriginalErrorStack(stackFrame);

  console.log('originStack=======>0', originStack[0]);

  // 断言,需要手动修改下面的断言信息,只测试第 0 个例子
  // eslint-disable-next-line no-undef
  expect(originStack[0]).toMatchObject({
    line: 15,
    column: 8,
    name: 'abc',
    source: 'webpack://front/src/components/HelloWorld.vue',
  });
});


复制代码

这里,我们可以看到,我们需要通过 压缩后的代码报错信息还原成的 sourceMap 对应的文件路径和错误代码所在行数等详细信息:

{
    line: 15,
    column: 8,
    name: 'abc',
    source: 'webpack://front/src/components/HelloWorld.vue',
}
复制代码
  • 测试
cd backend/app/utils

npx jest stackparser --watch
复制代码

显示测试用例通过,测试,我们就完成了:

  • 前端常见异常上报服务端
  • 服务端通过 sourceMap 文件进行错误场景还原:错误代码所在文件和行数

此时,我们就可以精准定位错误代码了。

以上~~~

参考资料:

文章分类
前端
文章标签