前端异常监控
前端异常包含很多种情况:1. js 编译时异常(开发阶段就能排)2. js 运行时异常;3. 加载静态资源异常(路径写错、资源服务器异常、CDN 异常、跨域)4. 接口请求异常等
异常监控具体指标
- JS 语法错误、代码运行异常 ✅
- Http 请求异常 ✅
- 静态资源加载异常 ✅
- Promise 异常 ✅
- Iframe 异常
- 跨域 Script error ✅
- 崩溃和卡顿
各种概率计算
- JS 错误率 = 错误样本量 / 总样本量
- API 成功率 = 接口调用成功的样本量 / 总样本量
try catch
try catch 只能捕获到同步的运行时错误,不能捕获语法和异步错误
- 同步运行时错误 ✅
try {
console.log(a);
} catch (e) {
console.log('错误捕获', e);
}
错误捕获 ReferenceError: a is not defined 2. 语法错误 ❌
try {
let a = '3.15;
} catch(e) {
console.log('错误捕获', e);
}
Uncaught SyntaxError: Invalid or unexpected token。
注意这并不是 try catch 捕获到的错误,而是浏览器控制台默认打印出来的
- 异步错误 ❌
try {
setTimeout(() => {
a.map((v) => v);
}, 1000);
} catch (e) {
console.log('错误捕获', e);
}
Uncaught ReferenceError: a is not defined
window.onerror
JS 运行时错误发生时,window 会触发一个 ErrorEvent 接口的 error 事件,并执行 window.onerror()
/**
* @param {String} message 错误信息
* @param {String} source 出错文件
* @param {Number} lineno 行号
* @param {Number} colno 列号
* @param {Object} error Error对象(对象)
*/
window.onerror = (message, source, lineno, colno, error) => {
console.log('error message:', message);
console.log('position', lineno, colno);
console.error('错误捕获:', error);
return true; // 异常不继续冒泡,浏览器默认打印机制就会取消
};
- 同步运行时错误 ✅
console.log(a);
输出如下信息
error message: Uncaught ReferenceError: a is not defined
1.html:20 position 25 17
1.html:21 错误捕获: ReferenceError: a is not defined
at 1.html:25
- 异步运行时错误 ✅
setTimeout(() => {
arr.map((v) => v);
}, 1000);
输出如下信息
error message: Uncaught ReferenceError: arr is not defined
1.html:20 position 26 9
1.html:21 错误捕获: ReferenceError: arr is not defined
at 1.html:26
- 网络请求异常 ❌
<img src="http://a.com/a.png" />
GET http://a.com/a.png net::ERR_NAME_NOT_RESOLVED
不论是静态资源异常,或者接口异常,错误都无法捕获到。
tips: window.onerror 函数只有在返回 true 的时候,异常才不会向上抛出,否则即使是知道异常的发生控制台还是会显示 Uncaught Error: xxxxx
补充 DOM0 级事件和 DOM2 级事件的区别
- DOM0 级事件如下
- 在标签内写 onclick 事件
<input type="button" onclick="alert(0);" />
- 在 JS 写 onlicke=function(){}函数
var btn = document.getElementsByClassName('button');
btn.onclick = function () {
alert(0);
};
存在的问题:
- 重复添加会被覆盖掉,所以如果使用 window.onerror 的话,如果有其他的这个时间,就会被覆盖掉
- DOM2 级事件,addEventListener()和 removeEventListener() addEvenetListener()、removeEventListener() 有三个参数: 第一个参数是事件名(如 click, IE 是 onclick); 第二个参数是事件处理程序函数; 第三个参数如果是 true 则表示在捕获阶段调用,为 false 表示在冒泡阶段调用。
可以为元素添加多个事件处理程序,触发时会按照添加顺序依次调用。
为什么没有 1 级:因为 1 级 DOM 标准中并没有定义事件相关的内容,所以没有所谓的 1 级 DOM 事件模型。
script error
跨域的脚本会给出 "Script Error." 提示,拿不到具体的错误信息和堆栈信息。 复现过程
<input type="button" value="script error" onclick="a()" />
<script src="http://127.0.0.1:3200/a.js"></script>
a.js 的内容为
function a() {
throw new Error('Fail a.js');
}
使用 koa-static
const Koa = require('koa');
const app = new Koa();
const KoaStatic = require('koa-static');
// 静态资源目录对于相对入口文件index.js的路径
// 使用 koa-static 使得前后端都在同一个服务下
app.use(KoaStatic(__dirname));
app.listen(3200, () => {
console.log('启动成功');
});
此时用 window.addEventListener('error') 获取到的信息为 script error。
一般情况,如果出现 Script error 这样的错误,基本上可以确定是出现了跨域问题。这时候,是不会有其他太多辅助信息的,但是解决思路无非如下: 跨源资源共享机制( CORS ):我们为 script 标签添加 crossOrigin 属性。
// <script src="http://127.0.0.1:3200/a.js" crossorigin></script>
// 或者动态去添加 `js` 脚本:
const script = document.createElement('script');
script.crossOrigin = 'anonymous';
script.src = url;
document.body.appendChild(script);
这个时候捕获到错误:Uncaught ReferenceError: a is not defined,通过 Network 可以看到 请求 http://127.0.0.1:3200/a.js 出错,CORS error
所以,还需要在服务器端设置:Access-Control-Allow-Origin
const Koa = require('koa');
const app = new Koa();
const KoaStatic = require('koa-static');
const cors = require('@koa/cors');
app.use(cors());
// 静态资源目录对于相对入口文件index.js的路径
// 使用 koa-static 使得前后端都在同一个服务下
app.use(KoaStatic(__dirname));
app.listen(3200, () => {
console.log('启动成功');
});
此时再次执行捕获到异常:Uncaught Error: Fail a.js
或者加载下面一段 JS,可以让我们在没有跨域头的情况下,拿到 4000 按钮事件处理器的执行异常信息。
const originAddEventListener = EventTarget.prototype.addEventListener;
EventTarget.prototype.addEventListener = function (type, listener, options) {
const wrappedListener = function (...args) {
try {
return listener.apply(this, args);
} catch (err) {
throw err;
}
};
return originAddEventListener.call(this, type, wrappedListener, options);
};
- 改写 EventTarget 的 addEventListener 方法
- 对传入的 listener 进行包装,返回包装过的 listener,对其执行进行 try-catch
- 浏览器不会对 try-catch 起来的异常进行跨域拦截,所以 catch 到的时候,是有堆栈信息的;
- 重新 throw 出来异常的时候,执行的是同域代码,所以 window.onerror 捕获的时候不会丢失堆栈信息;
我们不仅知道异常堆栈,而且还知道导致该异常的事件处理器,是在何处添加进去的。实现这个效果,也很简单:
(() => {
const originAddEventListener = EventTarget.prototype.addEventListener;
EventTarget.prototype.addEventListener = function (type, listener, options) {
+ // 捕获添加事件时的堆栈
+ const addStack = new Error(`Event (${type})`).stack;
const wrappedListener = function (...args) {
try {
return listener.apply(this, args);
}
catch (err) {
+ // 异常发生时,扩展堆栈
+ err.stack += '\n' + addStack;
throw err;
}
}
return originAddEventListener.call(this, type, wrappedListener, options);
}
})();
同样的道理,我们也可以对 setTimeout、setInterval、requestAnimationFrame 甚至 XMLHttpRequest 做这样的拦截,得到一些我们本来得不到的信息。
上面这种写法只对 DOM2 级的事件起作用,因为我们拦截的是 addEventListener 事件
参考
静态资源加载
定义:正常情况下,html 页面中主要包含的静态资源有:js 文件、css 文件、图片文件,这些文件加载失败将直接对页面造成影响甚至瘫痪,所有我们需要把他们统计出来。不太确定是否需要把所有静态资源文件的加载信息都统计下来,既然加载成功了,页面正常了,应该就没有统计的必要了,所以我们只统计加载出错的情况。 收集方法:
- 回调方法 onerror(Object.onerror):该方法在静态资源跨域加载时是无法获取报错信息的。
var img = document.getElementById('#img');
img.onerror = function (e) {
// 捕获错误
console.log(e);
};
- 利用 performance.getEntries()方法:获取到加载成功的资源列表。onload 事件中遍历出所有页面资源集合,利用排除法,到所有集合中过滤掉成功的资源列表,即为加载失败的资源。 此方法看似合理,也确实能够排查出加载失败的静态资源,但是检查的时机很难掌握,另外,如果遇到异步加载的 js 也就歇菜了。 performance.getEntries() 用来统计静态资源相关的时间信息,返回一个数组,数组的每个元素代表对应的静态资源的信息。 属性介绍
- initiatorType 资源属性,有 img、css 等
- duration 请求花费的时间
- 其他的与上面的 window.performance.timing 的属性一样
// 浏览器获取网页时,会对网页中每一个对象(脚本文件、样式表、图片文件等等)发出一个HTTP请求。而通过window.performance.getEntries方法,则可以以数组形式,返回这些请求的时间统计信息,每个数组成员均是一个PerformanceResourceTiming对象!
function performanceGetEntries() {
// 判断浏览器是否支持
if (!window.performance && !window.performance.getEntries) {
return false;
}
var result = [];
// 获取当前页面所有请求对应的PerformanceResourceTiming对象进行分析
window.performance.getEntries().forEach((item) => {
result.push({
url: item.name,
entryType: item.entryType,
type: item.initiatorType,
'duration(ms)': item.duration,
});
});
// 控制台输出统计结果
console.table(result); // 表示已经加载的资源
// 然后把整个资源的数量减去已经加载好的资源,剩下的就是没有加载出来的资源的数量。
}
- 添加一个 Listener(error)来捕获前端的异常,也是我正在使用的方法,比较靠谱。但是这个方法会监控到很多的 error, 所以我们要从中筛选出静态资源加载报错的 error,判断 e.target.localName 是否有值,有值就是资源错误, 代码如下:
/**
* 监控页面静态资源加载报错
*/
function loadResourceError() {
window.addEventListener(
'error',
function (e) {
console.log(e, '错误捕获===');
if (e) {
let target = e.target || e.srcElement;
let isElementTarget = target instanceof HTMLElement;
if (!isElementTarget) {
// js错误
console.log('js错误===');
// js error处理
let { filename, message, lineno, colno, error } = e;
let { message: ErrorMsg, stack } = error;
} else {
// 页面静态资源加载错误处理
console.log('资源加载错误===');
let { type, timeStamp, target } = e;
let { localName, outerHTML, tagName, src } = target;
let typeName = target.localName;
let sourceUrl = '';
if (typeName === 'link') {
sourceUrl = target.href;
} else if (typeName === 'script') {
sourceUrl = target.src;
}
alert('资源加载失败,请刷新页面或切换网络重试。(' + sourceUrl + ')');
}
}
// 设为true表示捕获阶段调用,会在元素的onerror前调用,在window.addEventListener('error')后调用
},
true,
);
}
// 我们根据e.target的属性来判断它是link标签,还是script标签。目前只关注只监控了css,js文件加载错误的情况。
/**
* 监控页面静态资源加载报错
*/
function recordResourceError() {
// 当浏览器不支持 window.performance.getEntries 的时候,用下边这种方式
window.addEventListener(
'error',
function (e) {
var typeName = e.target.localName;
var sourceUrl = '';
if (typeName === 'link') {
sourceUrl = e.target.href;
} else if (typeName === 'script') {
sourceUrl = e.target.src;
}
var resourceLoadInfo = new ResourceLoadInfo(RESOURCE_LOAD, sourceUrl, typeName, '0');
resourceLoadInfo.handleLogInfo(RESOURCE_LOAD, resourceLoadInfo);
},
true,
);
}
我们根据报错是的 e.target 的属性来判断它是 link 标签,还是 script 标签。由于目前我关注对前端造成崩溃的错误,所以目前只监控了 css,js 文件加载错误的情况。
要做实时监控和预警,还需要知道更多详细的信息 解决方案
- 统计出每天的量,列出每天加载报错的变化,点击图表的 bar, 可以看到每天的数据变化,以作对比。
- 分析出静态资源加载出错主要发生在哪些页面上,缩小排查的范围。
- 分析出影响用户的人数,也许很多错误就发生在一个人身上,减少盲目排查。
window.addEventListener(‘error’)与 window.onerror 的异同点在于:
- 前者能够捕获到资源加载错误,后者不能。
- 都能捕获 js 运行时错误,捕获到的错误参数不同。前者参数为一个 event 对象;后者为 msg, url, lineNo, columnNo, error 一系列参数。event 对象中都含有后者参数的信息。 注意:
- window 上 error 事件代理,过滤 window 本身的 error;根据标签类型判断资源类型,src 或 href 为资源地址;为了捕获跨域 js 的错误,需要在相应资源标签上添加 crossorigin 属性。
- addEventListener 的第三个参数 一定要是 true,表示在捕获阶段触发,如果改成 false 就是冒泡阶段,那是获取不到错误事件的。
JS 错误监控
如:一段时间内,应用 JS 报错的走势(chart 图表)、JS 错误发生率、JS 错误在 PC 端发生的概率、JS 错误在 IOS 端发生的概率、JS 错误在 Android 端发生的概率,以及 JS 错误的归类。然后,我们再去其中的 Js 错误进行详细的分析,辅助我们排查出错的位置和发生错误的原因。
如:JS 错误类型、 JS 错误信息、JS 错误堆栈、JS 错误发生的位置以及相关位置的代码;JS 错误发生的几率、浏览器的类型,版本号,设备机型等等辅助信息 为了得到这些数据,我们需要在上传的时候将其分析出来。在众多日志分析中,很多字段及功能是重复通用的,所以应该将其封装起来。
// 设置日志对象类的通用属性
function setCommonProperty() {
this.happenTime = new Date().getTime(); // 日志发生时间
this.webMonitorId = WEB_MONITOR_ID; // 用于区分应用的唯一标识(一个项目对应一个)
this.simpleUrl = window.location.href.split('?')[0].replace('#', ''); // 页面的url
this.customerKey = utils.getCustomerKey(); // 用于区分用户,所对应唯一的标识,清理本地数据后失效
this.pageKey = utils.getPageKey(); // 用于区分页面,所对应唯一的标识,每个新页面对应一个值
this.deviceName = DEVICE_INFO.deviceName;
this.os = DEVICE_INFO.os + (DEVICE_INFO.osVersion ? ' ' + DEVICE_INFO.osVersion : '');
this.browserName = DEVICE_INFO.browserName;
this.browserVersion = DEVICE_INFO.browserVersion;
// TODO 位置信息, 待处理
this.monitorIp = ''; // 用户的IP地址
this.country = 'china'; // 用户所在国家
this.province = ''; // 用户所在省份
this.city = ''; // 用户所在城市
// 用户自定义信息, 由开发者主动传入, 便于对线上进行准确定位
this.userId = USER_INFO.userId;
this.firstUserParam = USER_INFO.firstUserParam;
this.secondUserParam = USER_INFO.secondUserParam;
}
// JS错误日志,继承于日志基类MonitorBaseInfo
function JavaScriptErrorInfo(uploadType, errorMsg, errorStack) {
setCommonProperty.apply(this);
this.uploadType = uploadType;
this.errorMessage = encodeURIComponent(errorMsg);
this.errorStack = errorStack;
this.browserInfo = BROWSER_INFO;
}
JavaScriptErrorInfo.prototype = new MonitorBaseInfo();
JS 错误发生率 = JS 错误个数(一次访问页面中,所有的 js 错误都算一次)/PV (PC,IOS,Android 平台同理)
前端接口监控
ajax
如果拦截 ajax 请求
fetch
如何拦截 fetch 请求,如何处理 http code 为 200 时,接口返回的结构体中的错误
如何监控前端接口请求? 万变不离其宗,他们都是对浏览器的这个对象 window.XMLHttpRequest 或者 fetch 进行了封装,所以我们只要能够监听到这个对象的一些事件,就能够把请求的信息分离出来。
/**
* 页面接口请求监控
*/
function recordHttpLog() {
// 监听ajax的状态
function ajaxEventTrigger(event) {
var ajaxEvent = new CustomEvent(event, {
detail: this,
});
window.dispatchEvent(ajaxEvent);
}
var oldXHR = window.XMLHttpRequest;
function newXHR() {
var realXHR = new oldXHR();
realXHR.addEventListener(
'loadstart',
function () {
ajaxEventTrigger.call(this, 'ajaxLoadStart');
},
false,
);
realXHR.addEventListener(
'loadend',
function () {
ajaxEventTrigger.call(this, 'ajaxLoadEnd');
},
false,
);
// 此处的捕获的异常会连日志接口也一起捕获,如果日志上报接口异常了,就会导致死循环了。
// realXHR.onerror = function () {
// siftAndMakeUpMessage("Uncaught FetchError: Failed to ajax", WEB_LOCATION, 0, 0, {});
// }
return realXHR;
}
var timeRecordArray = [];
window.XMLHttpRequest = newXHR;
window.addEventListener('ajaxLoadStart', function (e) {
var tempObj = {
timeStamp: new Date().getTime(),
event: e,
};
timeRecordArray.push(tempObj);
});
window.addEventListener('ajaxLoadEnd', function () {
for (var i = 0; i < timeRecordArray.length; i++) {
if (timeRecordArray[i].event.detail.status > 0) {
var currentTime = new Date().getTime();
var url = timeRecordArray[i].event.detail.responseURL;
var status = timeRecordArray[i].event.detail.status;
var statusText = timeRecordArray[i].event.detail.statusText;
var loadTime = currentTime - timeRecordArray[i].timeStamp;
if (!url || url.indexOf(HTTP_UPLOAD_LOG_API) != -1) return;
var httpLogInfoStart = new HttpLogInfo(
HTTP_LOG,
url,
status,
statusText,
'发起请求',
timeRecordArray[i].timeStamp,
0,
);
httpLogInfoStart.handleLogInfo(HTTP_LOG, httpLogInfoStart);
var httpLogInfoEnd = new HttpLogInfo(
HTTP_LOG,
url,
status,
statusText,
'请求返回',
currentTime,
loadTime,
);
httpLogInfoEnd.handleLogInfo(HTTP_LOG, httpLogInfoEnd);
// 当前请求成功后就在数组中移除掉
timeRecordArray.splice(i, 1);
}
}
});
}
// 设置日志对象类的通用属性
function setCommonProperty() {
this.happenTime = new Date().getTime(); // 日志发生时间
this.webMonitorId = WEB_MONITOR_ID; // 用于区分应用的唯一标识(一个项目对应一个)
this.simpleUrl = window.location.href.split('?')[0].replace('#', ''); // 页面的url
this.completeUrl = utils.b64EncodeUnicode(encodeURIComponent(window.location.href)); // 页面的完整url
this.customerKey = utils.getCustomerKey(); // 用于区分用户,所对应唯一的标识,清理本地数据后失效,
// 用户自定义信息, 由开发者主动传入, 便于对线上问题进行准确定位
var wmUserInfo = localStorage.wmUserInfo ? JSON.parse(localStorage.wmUserInfo) : '';
this.userId = utils.b64EncodeUnicode(wmUserInfo.userId || '');
this.firstUserParam = utils.b64EncodeUnicode(wmUserInfo.firstUserParam || '');
this.secondUserParam = utils.b64EncodeUnicode(wmUserInfo.secondUserParam || '');
}
// 接口请求日志,继承于日志基类MonitorBaseInfo
function HttpLogInfo(uploadType, url, status, statusText, statusResult, currentTime, loadTime) {
setCommonProperty.apply(this);
this.uploadType = uploadType; // 上传类型
this.httpUrl = utils.b64EncodeUnicode(encodeURIComponent(url)); // 请求地址
this.status = status; // 接口状态
this.statusText = statusText; // 状态描述
this.statusResult = statusResult; // 区分发起和返回状态
this.happenTime = currentTime; // 客户端发送时间
this.loadTime = loadTime; // 接口请求耗时
}