前端异常监控

为什么要做前端异常监控

  • 有些问题只存在于线上特定的环境
  • 后端错误有监控,前端错误没有监控

基本实现

参考我们nodejs服务层和app的错误上报处理,基本流程为:
触发错误->捕获错误->错误上报->记录日志文件->存入ELK方便查询

  • 错误捕获:

    1. 接口请求error
    2. 全局监听异常
    3. 主动捕获
  • 错误上报:
    这里是需要我们参照app的地方,我们有一个日志服务器专门接收记录日志。

    这里需要我们的上报数据格式和日志服务器一致。

  • 记录日志文件和存入ELK
    我们根据上报的日志数据中两个key生成不同的日志文件,然后将ELK和这个日志文件绑定能够方便我们在kibana上查询。

根据基本流程,可以确定我们每个部分的职责:

  • 前端

    • 报错事件监听
    • 报错处理上报
  • 后端

    • 提供接口收集报错
    • 存储入ELK方便查询

预期问题

  • 前端错误分类
  • 如何查明出错位置
  • 错误如何上报
  • 错误消息数据结构
  • 如何平滑的应用在业务项目中

错误分类

  1. javascript异常
    • 语法错误
    • 运行时错误
    • script文件内错误(跨域和未跨域)
  2. JS文件、CSS文件、img图片等(资源)的404错误(其实是有onerror事件的dom)
  3. promise的异常捕获
  4. ajax请求错误

错误捕获

  1. 主动捕获(try catch / promise catch)
  2. 全局捕获(onerror / addEventListener)

主动捕获

我们在一些运算之后,得到一个期望的结果,然而结果不是我们想要的,这时可以上报一下错误。
基本上主动捕获也就是要求我们调用Logger.error(error, tag, message)(这个是前端监控js文件提供的一个方法)方法主动上报

try catch 捕获

try {} catch(err) {}无法捕捉到异步错误和语法错误

自动添加try catch仅能对js文件生效,无法对html文件进行操作。(可以在catch中上报关于代码位置)

全局捕获

onerror事件
/**
  * @param {String}  msg    错误信息
  * @param {String}  url    出错文件
  * @param {Number}  row    行号
  * @param {Number}  col    列号
  * @param {Object}  error  错误详细信息
  */
window.onerror = function (msg, url, row, col, error) {
  console.log('我知道错误了');
  console.log({
    msg,  url,  row, col, error
  })
  return true; // 注意,在返回 true 的时候,异常才不会继续向上抛出error;
};

打印如下:

我知道错误了
{
    msg: "Uncaught ReferenceError: error is not defined", 
    url: "file:///Users/beifeng/Desktop/test.html", 
    row: 25, 
    col: 5, 
    error: ReferenceError: error is not defined at 
}

通过为页面上的 script 标签添加 crossOrigin 属性完成跨域上报,别忘了服务器也设置 Access-Control-Allow-Origin 的响应头。(解决跨域的js脚本错误上报)

通常我们使用window.onerror来捕获js脚本的错误信息。但是对于跨域调用的js脚本,onerror事件只会给出很少的报错信息:error: Script error.这个简单的信息很明显不足以看出脚本的具体错误,所以我们可以使用crossorigin属性,使得加载的跨域脚本可以得出跟同域脚本同样的报错信息:
<script crossorigin src="http://www.lmj.com/demo/crossoriginAttribute/error.js"></script>
如果是这样,www.lmj.com的服务器必须给出一个Access-Control-Allow-Origin的header,否则无法访问此脚本。

// 举个例子
// http://localhost:8080/index.html
<script>
  window.onerror = function (msg, url, row, col, error) {
    console.log('我知道错误了,也知道错误信息');
    console.log({
      msg,  url,  row, col, error
    })
    return true;
  };
</script>
<script src="http://localhost:8081/test.js" crossorigin></script>

// http://localhost:8081/test.js
setTimeout(() => {
  console.log(error);
});

onerror事件 是无法捕获到网络异常的错误(资源加载失败,裸奔,图片显示异常等)。当我们遇到 <img src="./404.png"> 报 404 网络请求异常的时候,onerror 是无法帮助我们捕获到异常的。

window.addEventListener监听error事件

由于网络请求异常不会事件冒泡,因此必须在捕获阶段将其捕捉到才行,但是这种方式虽然可以捕捉到网络请求的异常,但是无法判断 HTTP 的状态是 404 还是其他比如 500 等等,所以还需要配合服务端日志才进行排查分析才可以。

<script>
/**
  * @param {String}    event         监听事件
  * @param {function}  function      出错文件
  * @param {Boolean}   useCapture    指定事件是否在捕获或冒泡阶段执行。
  *        true - 事件句柄在捕获阶段执行
  *        false- false- 默认。事件句柄在冒泡阶段执行
  */
window.addEventListener('error', (error) => {
  console.log('我知道 404 错误了');
  console.log(
    error
  );
  return true;
}, true);
</script>
<img src="./404.png" alt="">


打印如下:

我知道 404 错误了
<!--资源加载错误-->
bubbles:false
cancelBubble:false
cancelable:false
composed:false
currentTarget:null
defaultPrevented:false
falseeventPhase:0
isTrusted:true
path:Array(5) // dom树
  0:img
  1:div
  2:body
  3:html
  4:document
  5:Window
returnValue:true
srcElement:img // 发生错误的dom
target:img
timeStamp:5.545000000000001 // 错误发生的时间(按页面加载时间为0)
type:"error"

<!--运行时错误-->
bubbles:false
cancelBubble:false
cancelable:true
colno:13 // 列号
composed:false
currentTarget:Window
defaultPrevented:true
error:TypeError: hahahah at file:///home/jhjr/Desktop/%E5%89%8D%E7%AB%AF%E5%BC%82%E5%B8%B8%E7%9B%91%E6%8E%A7/test.html:22:13
  message:"Unexpected identifier" // 错误信息
  stack:"SyntaxError: Unexpected identifier" // 错误栈
eventPhase:0
filename:"file:///home/jhjr/Desktop/%E5%89%8D%E7%AB%AF%E5%BC%82%E5%B8%B8%E7%9B%91%E6%8E%A7/test.html" // 错误文件
isTrusted:true
lineno:22 // 行号
message:"Uncaught TypeError: hahahah"
path:Array(1) // DOM树
returnValue:false
srcElement:Window // 发生错误的dom
target:Window
timeStamp:1005.4350000000001 // 错误发生的时间(按页面加载时间为0)
type:"error"

网上说addEventListener的浏览器兼容性不太好,去Can I use查了一下其实还好.具体看这里addEventListener.

还有一个问题是它的error对象在不同浏览器会有不同的一个体现,这里需要注意下.

promise异常捕获

现代的浏览器其实已经能够支持promise语法了,所以在promise异常捕获这一块我们也还是要注意一下.

  • 人工手动catch捕获(这个是基本的,和try...catch...是一样的).
  • 通过浏览器自带的unhandledrejection事件来监听全局没有catch的promise执行.但是这个的兼容性不是很好,具体可以看下unhandledrejection
<script>
  window.addEventListener('unhandledrejection', function(err) {
    console.log(err);
  });
</script>

new Promise(function(resolve, reject) {
  reject(new Error('haha'))
})

打印如下:

bubbles:false
cancelBubble:false
cancelable:true
composed:false
currentTarget:Window
defaultPrevented:false
eventPhase:0
isTrusted:true
path:Array(1)
promise:Promise // 捕获到的错误promise
reason:Error: haha at http://localhost:3000/promise_error:21:12 at Promise  (<anonymous>) at http://localhost:3000/promise_error:20:3  // 其实就是错误栈
  message: "haha"
  stack: "Error: haha↵    at http://localhost:3000/promise_error:21:12↵    at Promise (<anonymous>)↵    at http://localhost:3000/promise_error:20:3"
returnValue:true
srcElement:Window
target:Window
timeStamp:55.190000000000005
type:"unhandledrejection"

异常如何上报

监控拿到报错信息之后,接下来就需要将捕捉到的错误信息发送到信息收集平台上,常用的发送形式主要有两种:

  • 通过 Ajax 发送数据(xhr和jquery)
  • 动态创建 img 标签的形式
    function report(error) {
      var reportUrl = 'http://xxxx/report';
      new Image().src = reportUrl + 'error=' + error;
    }
    

最终选择的还是通过xhr的方式,考虑不想依赖别的script文件。

数据结构

var deviceInfo = {
  "c": "website", // 客户端类别
  "p": "web-mobile-pay", // 客户端包名
  "l": "error", // 日志级别
  "t": new Date().getTime(), // 事件发生时间
  "v": Logger.version, // 客户端版本号
  "uid": userId,
  "ua": navigator.userAgent
};
var logs = [{
  "tag": data.tag || window.location.pathname, // tag 默认为网页路由
  "message": data.message || error.message || '',
  "url": window.location.href,  // 网址
  "filename": data.filename || "",  // 若全局捕获,文件名
  "lineno": data.lineno || 0, // 若全局捕获,行号
  "colno": data.colno || 0, // 若全局捕获,列号
  "domPath": domPath, // 若全局捕获页面dom问题,dom路径
  "element": element.outerHTML || "", // 若全局捕获页面dom问题,出错html代码
  "error": {
    "name": error.name || "",
    "message": error.message || "",
    "stack": error.stack || ""
  },
  uid: userId,
  userName: userName,
  mobile: mobile,
  branchNo: branchNo,
  idNo: idNo,
  clientId: clientId,
  clientName: clientName
}];

{deviceInfo: deviceInfo, logs: logs}

这里参照了我们已有的日志服务的基本数据格式和移动网站的日志上报相关的字段.
主要获取的信息包括:设备信息, 浏览器信息, 用户信息, 错误信息.

如何平滑的应用在业务项目中

自动化添加监听文件

通过gulp的插件gulp-inject查找ejs/html文件中的标签<head><title>进行插入对应的script脚本。

gulp.task('inject-js', function () {
  return gulp.src('src/views/**/*.ejs')
    .pipe(inject(gulp.src(['./src/public/js/*.js']), {
      starttag: '<head>',
      endtag: '<title>'
    }))
    .pipe(gulp.dest('dist/test'));
});

避免addEventListener重复监听

  1. 通过在window对象上添加一个字段errorListenerStatus来作为error监听的标识。
  2. 尽量避免在非主文件上去注入script脚本。

其他问题

  • 压缩代码无法定位到错误具体位置怎么办
    sourceMap解析

  • 用户行为记录

    • 可以在cookie里记录用户在网站的访问记录
    • 对错误发生页面进行页面截屏

    不是很好的方式,当时技术上是可以实现的。

具体代码

var Logger = {
  maxRouterNum: 5,
  getCookie: function (name) {
    var arr;
    var reg = new RegExp('(^| )' + name + '=([^;]*)(;|$)');
    if ((arr = document.cookie.match(reg))) return decodeURIComponent((arr[2]));
    else return '';
  },
  dataStructChange: function (data) {
    var element = data.srcElement || data.target || {};
    var error = data.reason || data.error || {};
    var domPath = '';
    var router = Logger.getRouter();
    // cookie 解析
    var cookies = document.cookie ? document.cookie.split('; ').reduce(function (total, currentValue, currentIndex, arr) {
      var cookieArr = currentValue.split('=');
      total[cookieArr[0]] = cookieArr[1];
      return total;
    }, {}) : {};

    data.path && (domPath = data.path.map(function (item) {
      return item.nodeName || 'window';
    }).join(', '));

    var p = Logger.getCookie('package');
    var version = Logger.getCookie('version');
    var userId = Logger.getCookie('userId');

    // 基本数据结构
    var deviceInfo = {
      'c': 'website', // 客户端类别
      'p': p, // 客户端包名
      'l': 'error', // 日志级别
      't': new Date().getTime(), // 事件发生时间
      'v': version, // 客户端版本号
      'uid': userId,
      'ua': navigator.userAgent
    };
    var logs = [{
      'tag': data.tag || window.location.pathname, // tag 默认为网页路由
      'message': data.message || error.message || '',
      'url': window.location.href, // 网址
      'filename': data.filename || '', // 若全局捕获,文件名
      'lineno': data.lineno || 0, // 若全局捕获,行号
      'colno': data.colno || 0, // 若全局捕获,列号
      'domPath': domPath, // 若全局捕获页面dom问题,dom路径
      'element': element.outerHTML || '', // 若全局捕获页面dom问题,出错html代码
      'error': {
        'name': error.name || '',
        'message': error.message || '',
        'stack': error.stack || ''
      },
      'router': router, // 用户访问路径
      'cookies': cookies
    }];

    // console.log({deviceInfo: deviceInfo, logs: logs});

    return 'deviceInfo=' + JSON.stringify(deviceInfo) + '&logs=' + JSON.stringify(logs);
  },
  // 请求
  request: function (data, url) {
    try {
      // gulp-replace
      var requestUrl = Logger.getCookie('env') == 'production' ? 'production' : 'staging';
      url = url || requestUrl;
      // gulp-replace end

      // 创建异步对象
      var xhr = new XMLHttpRequest();
      // 设置请求的类型及url
      xhr.open('post', url, true);
      // post请求一定要添加请求头才行不然会报错
      xhr.setRequestHeader('Content-type', 'application/x-www-form-urlencoded');
      // 发送请求,此调用可能失败catch
      xhr.send(data);
      xhr.onreadystatechange = function () {
        // 这步为判断服务器是否正确响应
        if (xhr.readyState == 4 && xhr.status == 200) {
          // console.log(xhr.responseText);
        }
      };
    } catch (e) {
      console.log(e);
    }
  },
  errorReport: function (data) {
    Logger.request(Logger.dataStructChange(data));
  },
  error: function (error, tag, message) {
    Logger.errorReport({tag: tag, error: error, message: message});
  },
  setRouter: function () {
    var maxNum = Logger.maxRouterNum;
    var router = sessionStorage.getItem('router');
    var routerArr = [];

    if (router) {
      // 每个记录之间用|分割
      routerArr = router.split('|');
      // 只记录最近的5个访问
      routerArr.length >= maxNum && (routerArr = routerArr.slice(routerArr.length - maxNum + 1));
    }

    routerArr.push(JSON.stringify({path: window.location.href, date: new Date().getTime()}));
    sessionStorage.setItem('router', routerArr.join('|'));
  },
  getRouter: function () {
    var router = sessionStorage.getItem('router');
    return router || '';
  }
};

if (!window.errorListenerStatus) { // 避免多次监听
  if (window.addEventListener) { // 所有主流浏览器,除了 IE 8 及更早版本
    // 全局error监听
    window.addEventListener('error', Logger.errorReport, true);
  } else if (window.attachEvent) { // IE 8 及更早版本
    window.attachEvent('onerror', Logger.errorReport);
  }

  // 全局promise no catch error监听
  // 支持性不太好,火狐不支持
  window.addEventListener('unhandledrejection', Logger.errorReport, true);
  window.errorListenerStatus = true;
}

// 用户访问记录
Logger.setRouter();

// 设备信息
// 手机型号, 手机系统

// 浏览器信息
// 浏览器类型

// 错误信息
// 页面路径, 文件名称, 错误行列号, 错误类型, 错误信息, 错误栈(DOM树), 时间戳,

// 用户信息
// cookie

传送门

前端监控异常方案
如何做前端异常监控?
前端代码异常监控

前端异常捕获方法
前端魔法堂——异常不仅仅是try/catch
Capture and report JavaScript errors with window.onerror
GlobalEventHandlers.onerror
addEventListener() 方法,事件监听

平滑应用
gulp-inject

JavaScript Source Map
JavaScript Source Map 详解
使用source-map实现对已压缩发布的前端代码的异常捕获与记录
source-map

Raven.js

浏览器截屏
html2canvas
导出HTML5 Canvas图片,并上传服务器

信息获取
js获取ip地址,操作系统,浏览器版本等信息,可兼容

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,214评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,307评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,543评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,221评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,224评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,007评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,313评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,956评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,441评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,925评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,018评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,685评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,234评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,240评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,464评论 1 261
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,467评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,762评论 2 345

推荐阅读更多精彩内容