an ckick pictrue

吃饭不洗...segmentfault 专栏

前端监控体系怎么搭建?

Issue链接更新于:2020-03-18

前端监控体系怎么搭建?

背景

前端一直是距离用户最近的一层,随着产品的日益完善,我们会更加注重用户体验,而前端异常特别是外网客户异常,一直是前端开发的痛点。最近在家办公,对公司的监控系统,又做了一遍复习,特作此记录。

异常是不可控的,会影响最终的呈现结果,所以任何一个成熟的前端团队,都有充分的理由去做这样的事情:

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 :3:14

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');

可以看到,我们捕获到了异常:
image

2.来试试异步运行时错误:

setTimeout(() => {  
  const a = 0x01;
  // a s是number, 不是string;
  const b = a.startWith('0x'); 
  // undefined.map(v => v);
});  

控制台输出了:

image

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()来阻止默认打印,是因为这个错误,我们是捕获阶段获取到的,而不是冒泡;

控制台输出:

image

由于网络请求异常不会事件冒泡,因此必须在捕获阶段将其捕捉到才行,但是这种方式虽然可以捕捉到网络请求的异常,但是无法判断 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 ,这个很简单。

没有写 catchPromise 中抛出的错误无法被 onerrortry-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);
});

可以看到如下输出:

image

提醒一句:与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);
});

可以看到如下输出:

image

提醒一句:基本上成熟的前端团队,都会封装自己的http请求库,所以最好的方式是监控库和http请求库协作的方式来实现;

六、React 框架异常捕获

在日常开发中,web开发基本都是基于React和Vue这种成熟的UI框架来做,因为工作里只用到了React,所以这里不涉及Vue。 16提供了两个钩子 componentDidCatchgetDerivedStateFromError,详情见官方文档, 使用他们可以非常简单的获取到 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); 
}  

简单解释一下:

  • 改写了 EventTargetaddEventListener 方法;
  • 对传入的 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