Web 前端崩溃监控优化历程
某天产品经理,在群里吐槽,自己打开自家的网站,浏览器崩溃了。然后又有人运营跟着评论,说某天也遇见了这种情况。这个时候 HR 也跳出来说面试的候选人也说遇到过这种情况。似乎这个时候,无论我们自己的电脑怎样,但是这个问题就必须有结论。
确定问题
其实大家日常在开发的时候,也偶尔会遇到 Crash 的问题,但是大多数原因是我们代码逻辑的问题,因此复现率非常高,我们也容易定位问题。
其实Chrome 崩溃无疑从两个维度去判断,比如是不是存在特别消耗 CPU 计算和内存占用的代码执行,Chrome是采用垃圾回收机制来消除不在引用到的对象,如果我们代码如果没有写好,容易造成无限增加无法销毁的对象,容易引起内存率占用非常高。又或者我们在对页面的 DOM 操作产生死循环,不停的进行 DOM 的操作。这回导致整个 render process 卡死。
第二中便是和用户的系统或者电脑本身的软件冲突或者环境相关,这种更多可能需要客服去用户进行沟通。
当然前面只是说了可能得原因,如果我们可以确认多数人都有复现的案例,并且分布在不同的操作系统以及Chrome 版本中,我们就暂且将它为一个在某种条件下一定复现的问题。
Crash 埋点
由于这种问题更多的是分布在用户浏览器端,我们需要关键的信息来确保,我们可以监听用户那边发生了 Crash。
目前比较常规的手段是 基于 Service Worker 心跳的方案。
大致原理,就是 JS Main Thread 会对 Service Worker 发送心跳消息,告诉它,"我还在正常工作";然后突然一会,Service Worker 发现,没有人跟他发消息了,而且页面也没通知它我是被关掉了,那么这里大概率是页面已经崩溃了。
至于为什么 Service Worker 可以实现这些监听,有兴趣可以阅读 《
Is Service Worker in Sandbox》 里对 Service Worker 的架构介绍,你就可以明白为什么 Service Worker 能够处理崩溃的埋点实现。
代码设计
由于我们埋点库是单独抽离出来的,因此我们会设计成插件的形式,方便引入和配置。
我们新建 sw-start.js
,这是需要你注入 Service Worker 时候引入的代码。
const CHECK_CRASH_INTERVAL = 10000;
const CRASH_THRESHOLD = 15000;
// your log send service
function _sendlog(data) {
const url = 'xxx';
fetch(url, data);
}
function _equalObjectValue(a, b) {
return JSON.stringify(a) === JSON.stringify(b);
}
function initLogSWCrashWatch() {
let pages = {};
let timer;
let sendData = {};
function _checkCrash() {
const now = Date.now();
Object.keys(pages).forEach((id) => {
const page = pages[id];
// 进行时间比较
if ((now - page.t) > CRASH_THRESHOLD) {
if (!_equalObjectValue(sendData, pages[id].data)) {
_sendlog(pages[id].data);
sendData = pages[id].data;
}
delete pages[id];
}
});
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(),
data: data.data,
};
if (!timer) {
timer = setInterval(() => {
_checkCrash();
}, CHECK_CRASH_INTERVAL);
}
} else if (data.type === 'unload') {
delete pages[data.id];
} else if (data.type === 'start') {
pages = {};
}
});
}
initLogSWCrashWatch();
完成之后,需要你将代码引入到的 PWA 注册时候的 sw.js 文件里面:
importScripts('https://your_cdn.com/_对应版本号_/sw-start.js');
...
我们在前端 JS 引入库的初始化和消息发送:
const HEART_INTERVAL = 10000;
const DEFAULT_OPTIONS = {
collectData() {
return {};
},
};
class SWService {
constructor(options = {}) {
// 搜集全局标记关键 action 信息
if (!window.logCrashActions) {
window.logCrashActions = [];
}
this.options = Object.assign(DEFAULT_OPTIONS, options);
if (this._checkServiceWorker()) {
this.init();
this.recordAction();
}
}
_checkServiceWorker() {
return navigator.serviceWorker && navigator.serviceWorker.controller;
}
init() {
const sessionId = util.getUUID();
const self = this;
const heartbeat = function () {
// collectData 为传入自定义函数用于包装埋点数据,比如 url, UA 等
const data = self.options.collectData({
lastAction: window.libCollectorCrashActions,
});
navigator.serviceWorker.controller.postMessage({
type: 'heartbeat',
id: sessionId,
data,
});
};
document.addEventListener('DOMContentLoaded', () => {
navigator.serviceWorker.controller.postMessage({
type: 'start',
id: sessionId,
});
});
window.addEventListener('beforeunload', () => {
navigator.serviceWorker.controller.postMessage({
type: 'unload',
id: sessionId,
});
clearInterval(heartbeat);
});
setInterval(heartbeat, HEART_INTERVAL);
heartbeat();
}
recordAction() {
// delegate.bind(document, '[data-crash-action]', 'click', (e) => {
// lastAction = e.dataset.crashAction;
// });
}
}
export default SWService;
这样你引入后,通过你JS 来控制本身的实例化即可。
单独抽离出来,方便别的开发引入,毕竟 Crash 监听不一定会在大多数应用中使用到,比如很多内部平台对性能要求不高的花,其实不必写到常规功能中去。
定位原因
在搜集到埋点曲线后,我们通过 url 做页面级别分类,发现其实大多数的崩溃来自播放页面,而且其中 iOS Safari局多,也有一定比例来自 Chrome。
终于我们似乎用数据回答了产品经理群里的报告。在范围大幅度定位到页面级别的时候,其实我们在排查难度相对降低了很多。这个时候我们联系到内部经常反馈的发热的问题,说打开页面不一会便会发热,虽然没有出现崩溃的情况,但是肯定内在一定是联系的。由于我们的场景主要是借助 WebGL 做 360 视频流的渲染,我们做了简单的竞品分析。
我们对同一个 Source 类型的视频,在 Youtube 和 自家的平台进行播放的页面性能监听,其中重点关注 CPU 和 内存曲线。我们可以在 Chrome 菜单按钮 more tool 打开 task manager(任务管理)看到我们需要的信息。
通过对比,我们可以看到我们本身在内存占用上超出 Chrome 很多,而且随着时间和页面的刷新内存会不停的增长。
WebGL 销毁
由于我们依赖 React-360 框架,因此我们定位播放的问题,不得不对 360 框架本身进行一些代码排查。
多次刷新,发现内存并没销毁,而且不停的增长,这和我们常规遇到的问题。因此我们第一思路,是我们是否正常释放了内存,WebGL 占用内存很大一部分是我们的 Texture 的 buffer 对象,我们发现了逻辑漏洞是我们并非调用底层 dispose 的方法,而 Chrome 在自己的设计中处于 WebGL 的内存的管理,当页面关掉后,并不会立即释放掉GPU Shared Memory 。
因此我们不得不在监听页面 beforeunloaded
手动触发 dispose
进行当前渲染 context 的对象的删除。
根据分辨率降低栅格数
尽管我们解决多了页面多次刷新内存异常的问题,我们还是没有解决本身占用内存就比竞品高出一头的问题。WebGL 渲染我们比较关心是我们渲染 Texture 所带来的的内存影响,我们在用球面体的时候,会做纹理映射,阅读源码我们发现 我们在球面初始化的时候写死了对于宽高的细分数目,
// ...
this._panoGeomHemisphere = new THREE.SphereGeometry(1000, 1000, 1000, 0, Math.PI);
实际上在测试数据中表现,我们并不需要这么精细的划分,尤其我们在移动端界面的时候,于是我们在原有基础上实现对该变量的配置,实际上 < 50 的分度,完全就可以避免渲染造成的球面折线。
// ...
this._panoGeomHemisphere = new THREE.SphereGeometry(
this._options.radius,
this._options.widthSegs,
this._options.heightSegs,
...
);
在这些对于 WebGL 的一些处理之后,我们再次做了竞品数据的对比,发现已经相差无几。
上线后的观察
我们在对优化的版本灰度上线后,发现数据确实有明显的降低,也是在这样埋点的发现了一些 Safari 的异常,以及本身监控代码实现的优化。从而确保上报的准确性。当然最重要的是,再也没有听到产品经理的小报告和 HR 遇见候选人的尴尬问题。