从0到1实现自己的前端异常监控SDK

4,069 阅读6分钟

上一篇文章简单的认识了前端常见的一些异常,及其各自出现场景,这是前端监控的第二篇文章,主要讲述大致大致需要使用那些技术,下一篇讲完成一个实际的sdk

为什么要做前端监控

快速定位线上问题,优化线上产品体验,捕获一些由于特殊情况导致的无法重现的客户问题

我们要实现的功能

  • 1)后端接口异常监控
    比如:某个接口报错500或者503
  • 2)前端页面显示错误(资源文件异常监控)
    页面图片或者某个js加载失败或者找不到资源404
    比如:某个图片不支持跨域503
  • 3)前端代码错误监控
    某个函数内部代码逻辑错误导致的报错
  • 4)支持浏览器关闭依然可以收集到错误信息
  • 5)支持source-map定位源码错误位置
  • 6)支持自定义接口错误信息描述

自己画的简单原型

modao.cc/app/791e27d…

以上这些需求足以满足对前端监控的基本需求,当人如果要做的复杂的话,还需要最uv这类的指标,我们这里就不做了,其实这些也是很简单,只是添加一些数据维度的指标而已

1.) 处理业务代码错误部分

开始之前先必须知道它的区别

/*
 * @param msg{String}:  错误消息
 * @param url{String}:  发生错误页面的url
 * @param line{Number}: 发生错误的代码行
 */
window.onerror = function(msg, url, line){
  return true;
}

特性:

  1. 能捕获到js执行错误,不能捕获带有src的标签元素的加载错误。
  2. 参数对应5个值(错误信息,所在文件,行,列,错误信息)
  3. 函数体内用return true可以不让异常信息输出到控制台
//event{}  错误对象
 window.addEventListener("error", function(event){
    event.preventDefault();
 },true);
  

特性:

  1. 为捕获状态时(第三个参数为true)能捕获到js执行错误,也能捕获带有src的标签元素的加载错误。
  2. 为冒泡状态时(第三个参数为false)能捕获到js执行错误,不能捕获带有src的标签元素的加载错误。
  3. 参数对应1个值,异常事件,错误信息都在里面
  4. 函数体内用preventDefault可以不让异常信息输出到控制台

捕获到的异常

//网络资源类型异常

alt

//业务代码类型异常

alt

所以:

因为window.addEventListener("error")不仅仅能够监听代码层面的异常,而已还能监控静态资源的异常,为了满足前面的需求,所以我们肯定需要选用window.addEventListener("error")来做处理

window.addEventListener捕获到的异常是这样的,

如何处理前端接口层面的监控

既然我们是做sdk那当然就需要考虑sdk技术兼容性问题,但是前端无论使用vue,react还是angular最终统一的数据请求部分都是ajax,无论用的什么第三方库,最底层的还是ajax,所以需要监控接口层面的异常,我们只需要拦截ajax请求即可

一个拦截ajax请求的库ajax-hook

Ajax-hook是一个精巧的用于拦截浏览器XMLHttpRequest的库,它可以在XMLHttpRequest对象发起请求之前和收到响应内容之后获得处理权。通过它你可以在底层对请求和响应进行一些预处理。

安装

yarn add ajax-hook

大致的使用情况是这样的:

import {proxy} from "ajax-hook"
function onAddError() {
  // ...
  // 存储异常数据
  // 记得一定要先存在本地缓存,然后根据自己的上报规则按规则上报
  //先只写伪代码,下篇文章具体实现
  saveError();
}

function saveError(){

}

proxy({
  //XMLHttpRequest请求发起前进入
  onRequest: (config, handler) => {
      handler.next(config);
  },
  //XMLHttpRequest请求发生错误时进入,比如超时;注意,不包括http状态码错误,
  //如404仍然会认为请求成功
  onError: (err, handler) => {
      onAddError(err); //存储异常
      handler.next(err)
  },
  //XMLHttpRequest请求成功后进入,response中包含了请求参数和响应结果
  onResponse: (response, handler) => {
      onAddError(err); //存储异常
      handler.next(response)
  }
})

捕获到的异常是这样的

现在,我们便拦截了浏览器中通过XMLHttpRequest发起的所有网络请求!在请求发起前,会先进入onRequest钩子,调用handler.next(config) 请求继续,如果请求成功,则会进入onResponse钩子,如果请求发生错误,则会进入onError

至此异常捕获部分基本完成了,如果你需要捕获WebSocket的异常,就需要在WebSocketonerror抛出异常了或者重写webSocket类了,其他类似的WebWorker,postMessage的异常也是需要做异常监听的,或者当他们发生异常的时候,在实际的异常函数中抛出一个自定义异常事件,然后自己sdk去捕获这个事件

还有个就是,前端现在基本都是使用了Promise,所以必要对Promise的异常做独立监听

window.addEventListener("unhandledrejection", event => {
  console.log(`error: ${event}`);
});

2.) 异常数据传递部分

至此异常监控部分的功能就完成了,现在要做的是把捕获到的异常上报到数据库,那怎么保证浏览器关闭后,存储起来的异常数据也能够上报成功呢?

navigator.sendBeacon

navigator.sendBeacon() 方法可用于通过HTTP将少量数据异步传输到Web服务器。

  • 数据可靠,浏览器关闭请求也照样能发
  • 异步执行,不会影响下一页面的加载
  • 同时不会延迟页面的卸载或影响下一导航的载入性能
  • API使用简单

浏览器兼容性很乐观

使用

// 浏览器卸载事件
window.addEventListener('unload',logData,false);
function logData(){
   //上报错误信息
   navigator.sendBeacon("http://npmhook/api/sendLog",{
     //参数
   });
}

如果浏览器不支持使用navigator.sendBeacon,就使用创建Image标签的形式发送数据

const img = new Image();
img.onload =()=>{}
img.src = `http://npmhook/api/sendLog?data='数据'`;

3.) 需要收集的数据部分

{
    userId:"账号标识ID",
    url:"当前页面URL",
    browser:"所属浏览器",
    version:"浏览器版本",
    system:"所属系统",
    referer:"入口页面",
    jsPath:"异常js文件路径",
    errorObj:"异常错误信息json字符串",
    connection:"连接网络情况",
    ...//额外传入的参数
}

4.) sdk编写(简洁版)

import {proxy} from "ajax-hook";
/***异常类 */
function HookErrorSdk(config){
  this.api="http://127.0.0.1:4002";
  this.formObj=config.formObj||{};
  this.userId=config.userId;
  this.appkey=config.appkey;
  this.httpCodeMap=config.httpCodeMap||{},
  this.AppErrorList=[];
  this.connection=navigator.connection 
  || navigator.mozConnection 
  || navigator.webkitConnection;
/*发送上报数据
 * @param param  object    错误对象
 */
 this.ToString=function(param){
  return JSON.stringify(param);
 }
 /*组装ajax异常
 * @param event object  错误对象
 */
 this.getAjaxError=function(event){
   // ... 处理自定义错误httpCodeMap
    return {
     jsPath:event.filename,
     errorObj:JSON.stringify({
     message:event.error.message,
     stack:event.error.stack
     })
    }
  }
 /*组装业务代码异常
 * @param event object  错误对象
 * @param type  string  错误类型
 */
  this.getErrorObj=function(event,type){
     const errObj=type==="ajax"?
     this.getAjaxError(event)
    :this.getCodeError(event);
    return Object.assign({
      userId:this.userId,
      url:document.location.href,
      browser:navigator.userAgent,
      version:navigator.appVersion,
      system:navigator.platform,
      referer:document.referrer,
      connection:this.connection.type
    },errObj)
   }
  /*收集异常
  * @param event object  错误对象
  */
  this.getCodeError=function(event){
    return {
      jsPath:event.filename,
      errorObj:this.ToString({
      message:event.error.message,
      stack:event.error.stack
      })
     }
  }
  /*注册监听
 * @param sdkLisConfig  object 监听配置
 */
  this.addListeners=function(sdkLisConfig){
     //根据返回的监听配置注册对应的监听事件,我这里全部写上了
     window.addEventListener("error", function(event){
       this.addError(event,"js");
       event.preventDefault();
     },true);
     window.addEventListener('unload',()=>{
       //卸载的时候需要把数据上报上去,具体看自己怎么传数据
       this.ajax();
     });
     window.addEventListener("unhandledrejection", event => {
       this.addError(err,"js");
    });
  }
  /*注册ajax 代理拦截
  */
  this.initAjaxProxy=function(){
    proxy({
      //请求发起前进入
      onRequest: (config, handler) => {
          handler.next(config);
      },
      //请求发生错误时进入,比如超时;注意,不包括http状态码错误,如404仍然会认为请求成功
      onError: (err, handler) => {
        this.addError(err,"ajax");
        handler.next(err)
      },
      //请求成功后进入
      onResponse: (response, handler) => {
        this.addError(response,"ajax");
        handler.next(response)
      }
    })
  }
 /*注册sdk
 * @param config  object    错误对象
 * @param success function  成功函数
 * @param fail    function  失败函数
 */
  this.initSdk=function(config,success,fail){
    let xmlHttp = window.XMLHttpRequest ? 
    new XMLHttpRequest() 
    : new ActiveXObject('Microsoft.XMLHTTP');
    xmlHttp.open("post", this.api+"/api/login", true);
    xmlHttp.setRequestHeader('Content-Type','application/json;charset=UTF-8');
    xmlHttp.onreadystatechange = function () {
      if (xmlHttp.readyState == 4 && xmlHttp.status == 200) {
        let sdkLisConfig = JSON.parse(xmlHttp.responseText);
        success(sdkLisConfig);
      } else {
        fail(xmlHttp.responseText);
      }
    };
    xmlHttp.send(this.ToString({appkey:config.appkey}));
  }

 /*注册sdk,new 对象的时候就调用了
 * @param        config object    错误对象
 * @param ()     function         成功函数
 * @param ()     function         失败函数
 */
  this.initSdk(config,(sdkLisConfig)=>{
    //注册成功
    this.addListeners();
    this.AppErrorList=localStorage.getItem("AppErrorList")?
    JSON.parse(localStorage.getItem("AppErrorList")):[];
    let timeObj=setInterval(() => {
       if(this.AppErrorList.length>0){
         this.postError(this.AppErrorList[0]);
       }
    }, config.timeNum||5000);
    window.addEventListener('unload',()=>{
     clearTimeout(timeObj);
    },false);
  },(error)=>{
    //出错失败
    return new Error(`注册异常${error}`)
  })
}
/*发送上报数据
 * @param param  object    错误对象
 * @param () function  错误对象
 * @param ()    function  错误对象
 */
HookErrorSdk.prototype.ajax=function(param,success, fail){
  if(navigator.sendBeacon&&typeof(navigator.sendBeacon)==="function"){
    navigator.sendBeacon(this.api+"/api/sendLog",this.ToString(param)).then((res)=>{
      success(res)
    }).catch((res)=>{
      fail(res)
    })
  }else{
    let img = new Image();
    img.onload =function (res){
      success(res);
    }
    img.onerror=function(res){
      fail(res);
    }
    img.src = this.api+`?data=`+this.ToString(param);
  }
}
/*代码异常
 * @param param  object    错误对象
 * @param type   string    错误类型
 */
HookErrorSdk.prototype.addError=function(event,type){
  let error=this.getAxaxError(event,type);
  this.AppErrorList.push(Object.assign(error,this.formObj));
  localStorage.setItem("AppErrorList",this.ToString(this.AppErrorList));
}
/*最终的上报错误
 * @param obj  object    错误信息对象
 */
HookErrorSdk.prototype.postError=function(obj){
  this.ajax(obj,()=>{
    this.AppErrorList.shift();
    localStorage.setItem("AppErrorList",this.ToString(this.AppErrorList));
  },()=>{
    return new Error("错误收集服务异常")
  })
}

HookErrorSdk.js 内部应该包含这些功能

1.根据appKey注册SDK,获取使用权限,获取需要上报的错误规则

2.使用window.addEventListener('error')开启全局资源文件和业务代码异常监听(WebSocketWebWorker,postMessage)根据自己需要记得也要考虑

3.使用window.addEventListener('unhandledrejection')开启Promise异常监控

4.使用ajax-hook拦截ajax捕获接口异常监控

5.使用localStorage存储异常数据避免数据丢失

6.使用setInterval定时检测异常

7.使用navigator.sendBeacon或者创建image形式上报异常数据

//自己最终写成的sdk,`Hook`是我自己的前缀
import HookErrorSdk from 'HookErrorSdk';
const hookSdk=new HookErrorSdk({
  userId:"88888888",
 //appkey
  appKey: 'hook-aa-g8g4yc2d', 
  //自定义http本项目错误code 未配置则取接口返回的错误信息
  httpCodeMap:{
    801:"token失效"
  },
  //额外传入的参数
  formObj:{}
})

5.) 后台解析异常部分

至此前端异常捕获部分的技术点都好了,那我们收集到异常后,如何定位异常源码位置呢?资源异常我们直接能够根据资源的名称就可以明显看出异常点,所以这个不需要考虑定位源码位置,而接口异常也是如此,根据接口url和参数就可以定位了

最为主要的是要还原业务代码的异常,前面我们通过window.addEventListener捕获了业务代码的异常截图如下

其中图中异常部分有个,stack对象,利用stack我们可以根据stacktracey这个库来获取具体代码异常位置,然后定位源码位置

source-map.js

用于解析sourcemap文件

stacktracey.js

用于解析错误信息的stack

结束

至此整个流程已经确定,大致的编码结构已经完成,下一篇将是如何具体优化成一个可以使用的sdk,最后关注我,下篇文章见!

最后

欢迎大家关注我的个人公众号【前端人】,定期分享使用干货,原创不易有用,请点赞谢谢!