closertb.github.io
closertb.github.io copied to clipboard
前端监控体系怎么搭建?
前端监控体系怎么搭建?
背景
前端一直是距离用户最近的一层,随着产品的日益完善,我们会更加注重用户体验,而前端异常特别是外网客户异常,一直是前端开发的痛点。最近在家办公,对公司的监控系统,又做了一遍复习,特作此记录。
异常是不可控的,会影响最终的呈现结果,所以任何一个成熟的前端团队,都有充分的理由去做这样的事情:
1.成熟的工程化,前端监控系统必不可少;
2.远程定位问题,对于对外web页面,让客户配合找bug是一件及其不职业且低效的事情;
3.错误预警上报,及早发现并修复问题;
4.问题复现,尤其是移动端,机型,系统都是问题;
对于 JS
而言,我们面对的仅仅只是异常,异常的出现不会直接导致 JS
引擎崩溃,最多只会使当前执行的任务终止。
需要处理哪些异常?
对于前端来说,我们可做的异常捕获还真不少。总结一下,大概如下:
-
JS
语法错误、代码运行异常 -
Http
请求异常 - 静态资源加载异常
-
Promise
异常 -
Iframe
异常 - 跨域
Script error
- 崩溃和卡顿
下面针对每种具体情况来说明如何处理这些异常。
点兵点将
一、Try-Catch 错误捕获
try-catch
只能捕获到同步的运行时错误,对语法和异步错误却无能为力。
1.同步运行时错误:
try {
let version = '3.15';
console.log(ver);
} catch(e) {
console.log('错误捕获:',e);
}
输出:错误捕获: ReferenceError: ver is not defined
at
2.不能捕获到语法错误,我们修改一下代码,删掉一个单引号:
try {
let version = '3.15;
console.log(version);
} catch(e) {
console.log('错误捕获:',e);
}
输出:Uncaught SyntaxError: Invalid or unexpected token;
值得注意的是,这并不是try-catch捕获到的错误,而是浏览器控制台默认打印出来的;
以上两种包括多种语法错误,在我们开发阶段基本Eslint就会捕获到,在线上环境出现的可能性比较小,如果是,那就是前端工程化基础不好。
3.异步错误
try {
setTimeout(() => {
undefined.map(v => v);
}, 1000)
} catch(e) {
console.log('错误捕获:',e);
}
输出:Uncaught TypeError: Cannot read property 'map' of undefined
at setTimeout (:3:11), 和前面一样,这里try-catch未捕获到错误,而是浏览器控制台默认打印出来的;
二、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; // 异常不继续冒泡,浏览器默认打印机制就会取消
}
1.首先试试同步运行时错误
const a = 0x01;
// a s是number, 不是string;
const b = a.startWith('0x');
可以看到,我们捕获到了异常:
2.来试试异步运行时错误:
setTimeout(() => {
const a = 0x01;
// a s是number, 不是string;
const b = a.startWith('0x');
// undefined.map(v => v);
});
控制台输出了:
3.接着,我们试试网络请求异常的情况:
/* 在actionTest 中,加入一个Img标签:asd.png是不存在的 */
<img src="http://closertb.site/asd.png" />
我们发现,不论是静态资源异常,或者接口异常,错误都无法捕获到。
特别提醒:
window.onerror
函数只有在返回true
的时候,异常才不会向上抛出,否则即使是知道异常的发生控制台还是会显示Uncaught Error: xxxxx
在实际的使用过程中,onerror
主要是来捕获预料之外的错误,而 try-catch
则是用来在可预见情况下监控特定的错误,两者结合使用更加高效。
问题又来了,捕获不到静态资源加载异常怎么办?
三、window.addEventListener 静态资源加载错误
当一项资源(如图片或脚本)加载失败,加载资源的元素会触发一个 Event
接口的 error
事件,并执行该元素上的onerror()
处理函数。这些 error
事件不会向上冒泡到 window
,但可以被window.addEventListener error
监听捕获。
// 仅处理资源加载错误
window.addEventListener('error', (event) => {
let target = event.target || event.srcElement;
let isElementTarget = target instanceof HTMLScriptElement || target instanceof HTMLLinkElement || target instanceof HTMLImageElement;
// console.log('isEl', isElementTarget);
if (!isElementTarget) return false;
const url = target.src || target.href;
// 上报资源地址
console.log('资源加载位置', event.path);
console.error('静态资源错误捕获:','resource load exception:', url);
}, true);// 关于这里为什么不可以用e.preventDefault()来阻止默认打印,是因为这个错误,我们是捕获阶段获取到的,而不是冒泡;
控制台输出:
由于网络请求异常不会事件冒泡,因此必须在捕获阶段将其捕捉到才行,但是这种方式虽然可以捕捉到网络请求的异常,但是无法判断 HTTP
的状态是 404
还是其他比如 500
等等,所以还需要配合服务端日志才进行排查分析才可以。
需要注意:
- 不同浏览器下返回的
error
对象可能不同,需要注意兼容处理。 - 需要注意避免
addEventListener
重复监听。
敲黑板:以下代码,如果img没有被插入到html,是不能被addEventListener捕获到的,
个人猜测
:其原因就是,没有被添加到html,错误只存在内存中,并没有和window对象关联上
const img = new Image();
img.onload = () => {
console.log('finish');
};
img.src = 'https://closertb.site/abc.jpg'; // 触发错误
// document.body.appendChild(img);
四、Promise Catch
在 promise
中使用 catch
可以非常方便的捕获到异步 error
,这个很简单。
没有写 catch
的 Promise
中抛出的错误无法被 onerror
或 try-catch
捕获到,所以我们务必要在 Promise
中不要忘记写 catch
处理抛出的异常。
解决方案: 为了防止有漏掉的 Promise
异常,建议在全局增加一个对 unhandledrejection
来全局监听Uncaught Promise Error
:
window.addEventListener("unhandledrejection", function(e){
console.log('错误捕获:', e);
e.preventDefault()
});
测试列子: 见actionTest/index.js
// 一个post请求
mockTest().then((data) => {
console.log('succ', data);
});
可以看到如下输出:
提醒一句:与onError 采用return true来结束控制器的默认错误打印,unhandledrejection如果去掉控制台的异常显示,需要加上:
e.preventDefault();
虽然可以使用增加unhandledrejection
的监听来捕获promise的异常处理,但处理fetch
或者ajax
的异常捕获,还是不太适合,因为他只能捕获到这个错误,而无法获取错误出现的位置和错误详情;
五、Http请求错误
在使用 ajax
或者 fetch
请求数据时, 这里主要说Fetch, 以上说过unhandledrejection能捕获到请求的异常,但没法获取到请求的详情,哪个url 发起,传参是什么,一无所知。所有这里最好的方式就是重写fetch,具体操作:
const originFetch = window.fetch;
window.fetch = (...args) => {
return originFetch.apply(this, args).then(res => {
// 没有res.ok状态,那catch仅能捕获到网络的错误, 请求错误就捕获不到;
if(!res.ok) {
throw new Error('request faild');
}
return res;
}).catch((error) => {
console.log('request错误捕获:', error, { ...args, message: 'request faild' }); // 上报错误
return {
message: 'request faild'
}
});
}
还是上面的测试列子: 见actionTest/index.js
// 一个post请求
mockTest().then((data) => {
console.log('succ', data);
});
可以看到如下输出:
提醒一句:基本上成熟的前端团队,都会封装自己的http请求库,所以最好的方式是监控库和http请求库协作的方式来实现;
六、React 框架异常捕获
在日常开发中,web开发基本都是基于React和Vue这种成熟的UI框架来做,因为工作里只用到了React
,所以这里不涉及Vue
。 16提供了两个钩子 componentDidCatch
与 getDerivedStateFromError
,详情见官方文档, 使用他们可以非常简单的获取到 react
下的错误信息;
详细见代码src/index.js
class Root extends React.PureComponent {
state = { hasError: false }
// static getDerivedStateFromError(error) {
// // 更新 state 使下一次渲染能够显示降级后的 UI
// return { hasError: true };
// }
// 可以捕获并打印发生在其子组件树任何位置的 JavaScript 错误,并且,它会渲染出备用 UI
// 两者都可以做错误边界,但同时存在时只响应其中一个,优先响应getDerivedStateFromError。
componentDidCatch(error) {
this.setState({ hasError: true });
console.log('errorcatch', error); // 上报Error
}
render() {
const { hasError } = this.state;
if (hasError) {
return <div>有错误</div>
}
return (
<Provider store={store}>
<Router>
<Route path="/" component={Layout} />
</Router>
</Provider>
);
}
}
需要注意的是: Error boundaries
并不会捕捉下面这些错误。
1.事件处理器
2.异步代码
3.服务端的渲染代码
4.在 error boundaries
区域内的错误
实际使用中,我们只在根组件去可以定义一个 error boundary
组件,然后整个UI的错误都通过这里上报!像Dva这种框架,也在最外层提供了上报入口。
七、Script error
一般情况,如果出现 Script error
这样的错误,基本上可以确定是出现了跨域问题。这时候,是不会有其他太多辅助信息的,但是解决思路无非如下:
跨源资源共享机制( CORS
):我们为 script
标签添加 crossOrigin
属性。
// <script src="http://closertb.site/index.js" crossorigin></script>
// 或者动态去添加 `js` 脚本:
const script = document.createElement('script');
script.crossOrigin = 'anonymous';
script.src = url;
document.body.appendChild(script);
特别注意,服务器端需要设置:Access-Control-Allow-Origin
此外,我们也可以试试这个-解决 Script Error 的另类思路:
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
捕获的时候不会丢失堆栈信息;
利用包装 addEventListener
,我们还可以达到「扩展堆栈」的效果:
(() => {
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);
}
})();
八、iframe 异常
对于 iframe
的异常捕获,可以通过 window.onerror
来实现,一个简单的例子可能如下:
<iframe src="./iframe.html" frameborder="0"></iframe>
<script>
window.frames[0].onerror = function (message, source, lineno, colno, error) {
console.log('捕获到 iframe 异常:',{message, source, lineno, colno, error});
return true;
};
</script>
现在iframe在前端中应用比较少,这里不再展开
九、崩溃和卡顿
卡顿也就是网页暂时响应比较慢,通常我们说的60fps, 就是描述这个的,卡顿的现象就是造成JS
无法及时执行。但崩溃就不一样了,崩溃直接造成JS
不运行了,JS执行进程卡死,相比网页崩溃上报更难?崩溃和卡顿都是不可忽视的,都会导致用户体验不好,而加剧用户流失。
1、卡顿的实现相对比较简单,我们可以通过requestAnimationFrame采集样本,来判断页面是否长期(几秒内)低于30fps或其他阈值。
看下面具体实现:
const rAF = (() => {
const SIXTY_TIMES = 60;
const requestAnimationFrame = window.requestAnimationFrame;
if (requestAnimationFrame) {
return (cb) => {
const timer = requestAnimationFrame(() => {
cb();
window.cancelAnimationFrame(timer);
});
};
// requestAnimationFrame 兼容实现
})();
function stuck() {
const stucks = [];
const startTime = Date.now();
const loop = (startCountTime = Date.now(), lastFrameCount = 0) => {
const now = Date.now();
// 每一帧进来,计数一次
const nowFrameCount = lastFrameCount + 1;
// 大于等于一秒钟为一个周期;比如如果是正常的fps: 那当第61次时,即1017毫秒,这里就满足
if (now > ONE_SECOND + startCountTime) {
// 计算一秒钟的fps: 当前计数总次数 / 经过的时长;
const timeInterval = (now - startCountTime) / ONE_SECOND;
const fps = Math.round(nowFrameCount / timeInterval);
if (fps > 30) { // fps 小于30 判断为卡顿
stucks.pop();
} else {
stucks.push(fps);
}
// 连续三次小于30 上报卡顿(还有一种特殊情况,前面2次卡顿,第三次不卡,接着再连续两次卡顿,也满足)
if (stucks.length === 3) {
console.log(new Error(`Page Stuck captured: ${location.href} ${stucks.join(',')} ${now - startTime}ms`));
// 清空采集到的卡顿数据
stucks.length = 0;
}
// 即休息一个周期(我这里定义的是一分钟),重新开启采样
const timer = setTimeout(() => {
loop();
clearTimeout(timer);
}, 60 * 1000);
return;
}
rAF(() => loop(startCountTime, nowFrameCount));
};
loop();
};
2、崩溃的监控相对于稍显复杂来说,在这篇文章讲的很清楚:网页崩溃的监控:
A方案: 采用load 和 beforeLoad 监听和sessionStorage来实现, 看代码:
window.addEventListener('load', function () {
// 进页面,首先检测上次是否崩溃
if(sessionStorage.getItem('good_exit') &&
sessionStorage.getItem('good_exit') !== 'true') {
/*
insert crash logging code here
*/
console.log('Hey, welcome back from your crash, looks like you crashed on: ' + sessionStorage.getItem('time_before_crash'));
}
sessionStorage.setItem('good_exit', 'pending');
setInterval(function () {
sessionStorage.setItem('time_before_crash', new Date().toString());
}, 1000);
});
window.addEventListener('beforeunload', function () {
// 离开页面前,重置标志位
sessionStorage.setItem('good_exit', 'true');
});
这个方案有两个问题:
- 采用 sessionStorage 存储状态,但通常网页崩溃/卡死后,用户会强制关闭网页或者索性重新打开浏览器,sessionStorage 存储但状态将不复存在;
- 而如果将状态存储在 localStorage 甚至 Cookie 中,如果用户先后打开多个网页,但不关闭,good_exit 存储的一直都是 pending,完了,每有一次网页打开,就会有一个 crash 上报。
B方案,采用Service Worker
-
Service Worker
有自己独立的工作线程,与网页区分开,网页崩溃了,Service Worker
一般情况下不会崩溃; -
Service Worker
生命周期一般要比网页还要长,可以用来监控网页的状态; - 网页可以通过
navigator.serviceWorker.controller.postMessage API
向掌管自己的SW
发送消息。 - 原理就是,一个页面打开。就将这个页面在
SW中注册
;并每隔1s或几秒向SW汇报
一下,SW收到消息后更新这个页面的最后更新时间;SW自己,每隔几秒(大于前面的时间)扫描
自己注册页面的更新时间,如果某个页面最后更新时间是大于N秒,则可以判断为崩溃;
JS实现:
// 页面 JavaScript 代码
if (navigator.serviceWorker.controller !== null) {
let HEARTBEAT_INTERVAL = 5 * 1000; // 每五秒发一次心跳
let sessionId = `${location.href}-${uuid()}`;
let heartbeat = function () {
navigator.serviceWorker.controller.postMessage({
type: 'heartbeat',
id: sessionId,
data: {} // 附加信息,如果页面 crash,上报的附加数据
});
}
window.addEventListener("beforeunload", function() {
navigator.serviceWorker.controller.postMessage({
type: 'unload',
id: sessionId
});
});
setInterval(heartbeat, HEARTBEAT_INTERVAL);
heartbeat();
}
// serviceWOker.js
const CHECK_CRASH_INTERVAL = 10 * 1000; // 每 10s 检查一次
const CRASH_THRESHOLD = 15 * 1000; // 15s 超过15s没有心跳则认为已经 crash
const pages = {}
let timer
function checkCrash() {
const now = Date.now()
for (var id in pages) {
let page = pages[id]
if ((now - page.t) > CRASH_THRESHOLD) {
// 上报 crash
delete pages[id]
}
}
if (Object.keys(pages).length == 0) {
clearInterval(timer)
timer = null
}
}
worker.addEventListener('message', (e) => {
const data = e.data;
if (data.type === 'heartbeat') {
pages[data.id] = {
t: Date.now()
}
if (!timer) {
timer = setInterval(function () {
checkCrash()
}, CHECK_CRASH_INTERVAL)
}
} else if (data.type === 'unload') {
delete pages[data.id]
}
})
总结
以上基本涵盖了监控系统中90%以上的错误捕获案例,但这只是监控系统的开端,只能算是Demo级别的代码。市面上有很多成熟的监控库可参考,比如FunderBug,Raven-Js等,我们团队的监控库就是是在Raven上做了一层扩展,然后结合IndexDb和压缩库(pako),以及服务端日志收集采用Koa来实现,知识点很多,但前面这些非常重要。
参考
前端代码异常监控实战
如何优雅处理前端异常?
Error Boundaries
前端监控知识点
Capture and report JavaScript errors with window.onerror