乐趣区

关于javascript:web-crash监控

背景

web crash 指的是页面的非正常卸载,此时不会触发页面的 unload 事件。

个别监控 web crash 就是利用没有 unload 事件这样一个特点:
在页面 load 后,往 sessionStorage 外面放一个 tag: true, unload 后置为 false

window.addEventListener('load', function () {sessionStorage.setItem('tag', 'true');
});

   window.addEventListener('beforeunload', function () {sessionStorage.setItem('tag', 'false');
   });

   if(sessionStorage.getItem('tag') &&
      sessionStorage.getItem('tag') !== 'true') {/** 页面异样退出了 */}

然而这种计划只实用于页面解体,并且用户在原浏览器 tab 从新关上解体页面的场景。更实用的办法是应用 localStorage

const currentPageId = Math.random() + '';
window.addEventListener('load', function () {const pageObj = JSON.parse(localStorage.getItem('pageObj') || '""');
      pageObj.currentPageId = 'true';
      localStorage.setItem('pageObj', JSON.stringify(pageObj));
});

   window.addEventListener('beforeunload', function () {const pageObj = JSON.parse(localStorage.getItem('pageObj') || '""');
      delete pageObj.currentPageId;
      localStorage.setItem('pageObj', JSON.stringify(pageObj));
   });
    
   if(sessionStorage.getItem('pageObj'))  {
      // parse 取出 pageObj
      for (let page in pageObj) {if (page === 'true') {
               /** 该页面异样退出了 */
               delete pageObj[page];
          }
      }
   }

这种用法的缺点是,如果关上 tabA 页面没有敞开,又关上 tabB,会认为 A 页面是异样退出,触发 crash 上报。

所以不论是基于 sessionStorage 还是 localStorage,都有缺点,而且依赖用户再次关上页面的行为。最好的计划是基于 service-worker 来写

基于 service-worker 的 web crash 上报

什么是 service-worker: https://developer.mozilla.org/zh-CN/docs/Web/API/Service_Worker_API

service-worker 是独立于页面的一个 worker,页面 JS 线程挂掉后,不会影响 service-worker 工作。

<!DOCTYPE html>
<html lang="en">
<head>
  <title></title>
</head>
<body>
  <noscript>You need to enable JavaScript to run this app.</noscript>
  <button id="btn">click</button>
</body>
<script>   document.getElementById('btn').addEventListener("click", () => {console.log('clicked');
      while(true) {}});
  if ('serviceWorker' in navigator) {navigator.serviceWorker.register('/sw.js', { scope: '/'}).then(function (registration) {
      // Registration was successful
      console.log('ServiceWorker registration successful with scope:', '/');
    }).catch(function (err) {
      // registration failed :(console.log('ServiceWorker registration failed:', err);
    });
    if (navigator.serviceWorker.controller !== null) {
      let HEARTBEAT_INTERVAL = 5 * 1000; // 每五秒发一次心跳
      let sessionId = Math.random() + '';
      let heartbeat = function () {console.log('heartbeat');
        navigator.serviceWorker.controller.postMessage({
          type: 'heartbeat',
          id: sessionId,
          data: {key: 'some-data'} // 附加信息,如果页面 crash,上报的附加数据
        });
      }
      window.addEventListener("beforeunload", function () {console.log('heartbeat');
        navigator.serviceWorker.controller.postMessage({
          type: 'unload',
          id: sessionId
        });
      });
      setInterval(heartbeat, HEARTBEAT_INTERVAL);
      heartbeat();}
  }
</script>

</html>
// sw.js
const CHECK_CRASH_INTERVAL = 10 * 1000; // 每 10s 查看一次
const CRASH_THRESHOLD = 15 * 1000; // 15s 超过 15s 没有心跳则认为曾经 crash
const pages = {}
let timer;
function selfConsole(str) {console.log('---sw.js:' + str) ;
}
function send(data) {
  // @IMP: 此处不能应用 XMLHttpRequest
  // https://stackoverflow.com/questions/38393126/service-worker-and-ajax/38393563
  fetch('/save-data', {
    method: 'post',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify(data)
  })
  .then(json)
  .then(function (data) {selfConsole('Request succeeded with JSON response', data);
  })
  .catch(function (error) {selfConsole('Request failed', error);
  });
}
function checkCrash(data) {const now = Date.now()
  for (var id in pages) {let page = pages[id]
    if ((now - page.t) > CRASH_THRESHOLD) {
      // 上报 crash
      delete pages[id]
      send({
        appName: data.key,
        attributes: {
          env: data.env || 'production',
          pageUrl: location.href,
          ua: navigator.userAgent,
          msg: 'crashed',
          content: '22222'
        },
        localDateTime: +new Date()});
    }
  }
  if (Object.keys(pages).length == 0) {clearInterval(timer)
    timer = null
  }
}
self.addEventListener('message', (e) => {
  const data = e.data;
  if (data.type === 'heartbeat') {pages[data.id] = {t: Date.now()
    }
    selfConsole('recieved heartbeat')
    selfConsole(JSON.stringify(pages));
    if (!timer) {timer = setInterval(function () {selfConsole('checkcrash');
        checkCrash(e.data.data)
      }, CHECK_CRASH_INTERVAL)
    }
  } else if (data.type === 'unload') {selfConsole('recieved unloaded')
    delete pages[data.id]
  }
})

> 代码上传到了 [https://github.com/Lie8466/web-crash-report](https://github.com/Lie8466/web-crash-report)


#### 模仿 JS 线程被 block

关上 localhost:5000,能够看到 service 注册胜利,sw 可能收到心跳且失常打印
![image.png](/img/bVbLYuv)

点击 click, 页面 JS 线程进入死循环,不会再往 sw 发送心跳数据。等 15s 左右,sw 定时器监听到该页面间隔上次心跳超过 15s,发送一个 save 申请

![image.png](/img/bVbLYva)


#### 模仿页面挂掉

关上两个 tab 页,别离关上 localhost:5000,都关上 devTools。此时两个页面共用一个 service-worker,console 打印出两个页面的心跳数据

![image.png](/img/bVbLYvq)



关上浏览器的工作管理器

![image.png](/img/bVbLYvN)
![image.png](/img/bVbLYwb)

能够看到 service-worker 有其独自的过程 (是否是独自的过程取决于浏览器的分配情况,service-worker 也可能是附丽在某一个 tab 的过程中,像下图这种)
![image.png](/img/bVbLYw6)

选中不与 service worker 共享过程的页面,终止过程。页面间接被终止,此时不会触发 unload。触发了 crash 监控上报.

被终止的页面解体
![image.png](/img/bVbLYxO)
另一个页面的 network 显示出 save-data 打印
![image.png](/img/bVbLYyg)

### 留神

* Service Worker can only work on Https and localhost sites,非 https 页面 navigator.serviceWorker 是 undefined
* 因为 sw 势力比拟大,能够代理页面外部的所有申请,所以其安全性要求特地高,注册的 sw.js 要求必须是页面域名下的,所以个别的谬误监控 SDK 并不能应用 service-worker 监听业务方页面解体信息,须要业务方本人注册 service-worker 本人上报,且会耗费肯定的浏览器内存

### 参考文档

> [https://zhuanlan.zhihu.com/p/40273861](https://zhuanlan.zhihu.com/p/40273861)
退出移动版