为什么要做前端异常监控
- 有些问题只存在于线上特定的环境
- 后端错误有监控,前端错误没有监控
基本实现
参考我们nodejs服务层和app的错误上报处理,基本流程为:
触发错误->捕获错误->错误上报->记录日志文件->存入ELK方便查询
-
错误捕获:
- 接口请求error
- 全局监听异常
- 主动捕获
-
错误上报:
这里是需要我们参照app的地方,我们有一个日志服务器专门接收记录日志。这里需要我们的上报数据格式和日志服务器一致。
记录日志文件和存入ELK
我们根据上报的日志数据中两个key生成不同的日志文件,然后将ELK和这个日志文件绑定能够方便我们在kibana上查询。
根据基本流程,可以确定我们每个部分的职责:
-
前端
- 报错事件监听
- 报错处理上报
-
后端
- 提供接口收集报错
- 存储入ELK方便查询
预期问题
- 前端错误分类
- 如何查明出错位置
- 错误如何上报
- 错误消息数据结构
- 如何平滑的应用在业务项目中
错误分类
- javascript异常
- 语法错误
- 运行时错误
- script文件内错误(跨域和未跨域)
- JS文件、CSS文件、img图片等(资源)的404错误(其实是有onerror事件的dom)
- promise的异常捕获
- ajax请求错误
错误捕获
- 主动捕获(try catch / promise catch)
- 全局捕获(onerror / addEventListener)
主动捕获
我们在一些运算之后,得到一个期望的结果,然而结果不是我们想要的,这时可以上报一下错误。
基本上主动捕获也就是要求我们调用Logger.error(error, tag, message)
(这个是前端监控js文件提供的一个方法)方法主动上报
try catch 捕获
- 要求程序员在编写代码时,注意基本的异常捕获
- 自动对JavaScript函数块添加异常捕获利用UglifyJS,使用AST为所有函数加上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重复监听
- 通过在window对象上添加一个字段
errorListenerStatus
来作为error监听的标识。 - 尽量避免在非主文件上去注入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