异常监控系统

异常类型

js 异常

  • try-catch

    缺点:无法捕获异步错误

  • window.error

    可捕获异步,信息全面

    返回 true 就不会被上抛了。不然控制台中还会看到错误日志。

    缺点:无法捕获网络请求错误

  • 监听 error 事件

    window.addEventListener('error',() => {})
  • Promise 错误

    window.addEventListener("unhandledrejection", (e) => {
      throw e.reason;
    });
  • Async/await 错误

    本质就是 Promise 监听 unhandledrejection 向上抛出错误即可

  • 总结

    我们可以将 unhandledrejection 事件抛出的异常再次抛出就可以统一通过 error 事件进行处理了。

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

这是 AOP(面向切面编程)设计模式,当错误发生的时候,我们会在 catch 中重新 throw 一个错误出来,最后在将原生 addEventListener 抛出执行。

Vue

Vue.config.errorHandler = (err, vm, info) => {
  let { message, name, script = "", line = 0, column = 0, stack } = err;
  console.log("errorHandler:", err);
};

React

错误边界仅可以捕获其子组件的错误。错误边界无法捕获其自身的错误。如果一个错误边界无法渲染错误信息,则错误会向上冒泡至最接近的错误边界。

使用 componentDidCatch 进行错误捕获

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;
  }
}

环境信息收集

贝贝方案

行为分类

用户行为

使用 addEventListener 全局监听点击事件,将用户行为(click、input)和 dom 元素名字收集。当错误发生将错误行为一起上报。

浏览器行为

监听 XMLHttpRequest 对象的 onreadystatechange 回调函数,在回调函数执行时收集数据。

监听 window.onpopstate,页面跳转的时会触发此方法,将信息收集。

错误上报

img 上报

推荐使用 1*1 的 gif

原因是:
1、没有跨域问题
2、发 GET 请求之后不需要获取和处理数据、服务器也不需要发送数据
3、不会携带当前域名 cookie!
4、不会阻塞页面加载,影响用户的体验,只需 new Image 对象
5、相比于 BMP/PNG 体积最小,可以节约 41% / 35% 的网络资源小

new Image().src = "http://localhost:7001/monitor/error";

ajax 上报

axios.post("http://localhost:7001/monitor/error");

上报数据

image-20201119171440046

// 分别是错误信息,错误地址,lineno,colno,error.message,error.stack
// 将info信息序列化后上传
const str = window.btoa(JSON.stringify(info));
const host = "http://localhost:7001/monitor/error";
new Image().src = `${host}?info=${str}`;

数据清洗去重

      except: [
        /^Script error\.?/,
        /^Javascript error: Script error\.? on line 0/,
      ], // 忽略某个错误
     repeat:5
// 重复出现的错误,只上报config.repeat次
    repeat(error) {
      const rowNum = error.rowNum || '';
      const colNum = error.colNum || '';
      const repeatName = error.msg + rowNum + colNum;
      this.repeatList[repeatName] = this.repeatList[repeatName]
        ? this.repeatList[repeatName] + 1
        : 1;
      return this.repeatList[repeatName] > this.config.repeat;
    }
 
    // 忽略错误
    except(error) {
      const oExcept = this.config.except;
      let result = false;
      let v = null;
      if (utils.typeDecide(oExcept, 'Array')) {
        for (let i = 0, len = oExcept.length; i < len; i++) {
          v = oExcept[i];
          if ((utils.typeDecide(v, 'RegExp') && v.test(error.msg))) {
            result = true;
            break;
          }
        }
      }
      return result;
    }

sourceMap 上传

let mapKeys = Object.keys(compilation.assets).filter(item => /.map$/.test(item.toLowerCase()))
let promiseList = []
for (let item in mapKeys) {
    promiseList.push(
        getClient(
            appKeys,
            appVersion,
            mapKeys[item].substr(mapKeys[item].lastIndexOf('/') + 1),
            compilation.assets[mapKeys[item]].source().toString()
        )
    )
}
Promise.all(promiseList)

插入 html

const HtmlWebpackPlugin = require('html-webpack-plugin')
const { type } = require('os')
const { resolve } = require('path')
const { compilation } = require('webpack')
class HtmlAddAttrPlugins {
    constructor(options = {}) {
        this.options = options
    }
 
    addAttr (tag, key, val) {
        if (!tag || !tag.length) return
        tag.forEach((tag, index) => {
            let value = val
            if (typeof val === 'function') {
                value = val(tag, compilation, index)
            }
            !tag.attributes && (tag.attributes = {})
            tag.attributes[key] = value
        })
    }
    apply (compiler) {
        let _self = this
        compiler.hooks.compilation.tap('htmlPlugin', compilation => {
            HtmlWebpackPlugin.getHooks(compilation).alterAssetTagGroup.tapAysnc('htmlPlugin',
                (data, cb) => {
                    let options = Object.assign({}, null, _self.options.attributes)
                    Object.keys(options).forEach((key) => {
                        let val = options[key]
                        if (typeof value != "string" && typeof value != 'function') return
                        _self.addAttr(data.headTags, key, value)
                        _self.addAttr(data.bodyTags, key, value)
                    })
                    if (typeof cb === 'function') {
                        cb(null, data)
                    } else {
                        return new Promise(resolve => resolve(data))
                    }
                })
        })
    }
}

异常收集

这里使用 egg 进行异常收集

将错误接收并转码写入到日志中

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 = '';
  }

整理与上报方案

除了异常报错信息本身,我们还需要记录用户操作日志,以实现场景复原。这就涉及到上报的量和频率问题。如果任何日志都立即上报,这无异于自造的 DDOS 攻击。因此,我们需要合理的上报方案。

前端存储日志

存储方式cookielocalStoragesessionStorageIndexedDBwebSQL
类型key-valuekey-valueNoSQLSQL
数据格式stringstringstringobject
容量4k5M5M500M60M
进程同步同步同步异步异步
检索keykeykey, indexfield
性能读快写慢读慢写快

综合之后,IndexedDB 是最好的选择,它具有容量大、异步的优势,异步的特性保证它不会对界面的渲染产生阻塞。缺点,就是 api 非常复杂,不像 localStorage 那么简单直接。针对这一点,我们可以使用hello-indexeddb这个工具

当一个事件、变动、异常被捕获之后,形成一条初始日志,被立即放入暂存区(indexedDB 的一个 store),之后主程序就结束了收集过程,后续的事只在 webworker 中发生。在一个 webworker 中,一个循环任务不断从暂存区中取出日志,对日志进行分类,将分类结果存储到索引区中,并对日志记录的信息进行丰富,将最终将会上报到服务端的日志记录转存到归档区

日志分析

日志分析的关键在于 webpack 打包时将打包完成的 sourceMap 进行上传

webpack Plugins

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,}`))
        // console.log('list:', list)
        for (let filename of list) {
            await this.upload(this.options.uploadUrl, filename)
        }
    })
}

服务端接收并保存

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)
}

反序列化 Error

使用 error-stack-parser 将上传的

信息上报

用户体验层

关键性能指标

业务层

  • PV(Page View):页面浏览量或点击量
  • UV():指访问某个站点的不同 ip 地址的人数
  • 页面停留时间:用户在每一个页面的停留时间

小程序错误上报

差异化劫持,格式化上报

劫持 APP 方法

  // 劫持原小程序App方法
  rewriteApp() {
    const originApp = App;
 
    const self = this;
    App = function (app) {
      // 合并方法,插入记录脚本
      ['onLaunch', 'onShow', 'onHide', 'onError'].forEach((methodName) => {
        const userDefinedMethod = app[methodName]; // 暂存用户定义的方法
        if (methodName === 'onLaunch') {
          self.getNetworkType();
          self.config.setLocation && self.getLocation();
          self.config.setSystemInfo && self.getSystemInfo();
        }
        app[methodName] = function (options) {
          methodName === 'onError' && self.error({ msg: options }); // 错误上报
          return userDefinedMethod && userDefinedMethod.call(this, options);
        };
      });
      return originApp(app);
    };
  }

劫持 Page 方法

  // 劫持原小程序Page方法
 function rewritePage() {
    const originPage = Page;
    Page = (page) => {
      Object.keys(page).forEach((methodName) => {
        typeof page[methodName] === 'function'
          && this.recordPageFn(page, methodName);
      });
      // 强制记录两生命周期函数
      page.onReady || this.recordPageFn(page, 'onReady'); // 这个函数记录错误的时间,所在页面,方法名等信息
      page.onLoad || this.recordPageFn(page, 'onLoad');
      // 执行原Page对象
      return originPage(page);
    };
  }

环境信息获取

//app.js
globalData:{
    referrer:{}
}
onShow(options){
    this.globalData.referrer=options
}
 
// SDK.js
let App= getApp()
let refererObj= App.globalData.referrer

参考文章