Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

前端监控体系怎么搭建? #46

Open
closertb opened this issue Feb 18, 2020 · 0 comments
Open

前端监控体系怎么搭建? #46

closertb opened this issue Feb 18, 2020 · 0 comments
Labels
JS 原生JS相关

Comments

@closertb
Copy link
Owner

closertb commented Feb 18, 2020

前端监控体系怎么搭建?

背景

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

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

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
JS 原生JS相关
Projects
None yet
Development

No branches or pull requests

1 participant