12

房产经纪人页面错误信息采集方案

 4 years ago
source link: http://mp.weixin.qq.com/s?__biz=MzI1NDc5MzIxMw%3D%3D&%3Bmid=2247488629&%3Bidx=1&%3Bsn=2194313984698cf32773fe38d60b0b18
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

导语

前端异常监控已不是什么特别新鲜的事情了,但其重要性,对于一个站点的质量把控而言却不容忽视。本文希望借一则案例将前端异常监控拉回人们视野,并对网络经纪人web站的错误信息采集方案做一简单介绍。

背景

去年年中一次普通的上线过程中,代码上线后,js error数量异常升高。

代码是经过测试环境、沙箱环境测试过的。很明显,异常的起因是由特定浏览器、特定数据上下文、特定交互操作顺序下的一种没有被我们考虑到的场景所致。由于Error被定位到房源发布页,不敢怠慢,先做了回滚。

但问题还是要查下去。通过上报上来的错误日志拿到了错误类型及调用栈(stack trace)信息,也定位到了报错文件及代码块,报错是从基础UI库里报出来的。但由于调用栈太深,之前也没有考虑到用Error.stackTraceLimit参数来调大error栈帧数, stack trace默认只有10行,能拿到的错误信息又太过于底层,无法有效定位异常根源。因为对于底层方法而言,外层调用入口很多,没有足够的调用链信息就无法确定异常调用来源,原本一向无敌的stack trace,那刻却变得非常鸡肋。至于如何拿到混淆代码stack trace里的原始内容,请参见https://github.com/mozilla/source-map,这里不做过多说明。

要解决当下这个js error并不难,加个边界保护就行。但传入的数据本不该就以这样形式出现在那儿,一定是外层业务代码哪里调用不当导致了异常,而我们却因为没有抓手,无从知晓其根源。

当然,最后原因还是被查到了。其间各种费时费力,此处不再赘述。

痛点及问题梳理

坦率来说,好久没有关注过前端异常监控这块儿了。而这次事件却击中了我们的一个痛点,就是在排查一些线上问题上耗费了我们太多时间和精力,往往会因为要应对一个线上情况,导致项目排期上受到影响。也正是基于这一点,让我们开始重新审视现有的页面异常捕获方案,看看是不是可以做一些调整,来降低排查线上问题的痛苦度。

通过审视和梳理,发现现有页面异常监控方案,确实存在很多做得不到位的地方。于是把整理后的问题罗列如下:

1、异常场景抓取不够全面,只用到window.onerror处理了js运行时异常,而对于promise异常、资源加载异常、xhr异常这些都没处理;

2、对于偶发异常没有现场还原能力。这类异常往往是在特定浏览器下、特定数据条件下,特定交互顺序下导致的。由于难以复现,要修复这类问题的成本也最高;

3、部分跨域错误遗失。存在个别script标签遗漏crossdomain属性,导致Script Error 0 0 null的情况,在跨域情况下丢失了具体报错信息;

4、没有可用性自检能力。有时一些场景没有error日志,但却不能确定到底是一切正常,还是采集脚本在特定浏览器环境或特定框架下出现了问题,导致没有把日志抓回来所致,无法在线验证采集器是否正常。

5、数据收集、分类,分析报表以及异常报警这块还没去深入做。

总结分析与设计

有了问题,就要考虑解决方案了。调研了业内比较出名的监控方案,有Sentry、ARMS、Fundebug等。一是了解一下大致的实现思路,二是评估一下直接接入的可能性。如果要直接接入,那肯定挑一个免费开源的,Sentry当属首选。但深入调研后,发现要接入并部署Sentry,后端侧要做的事情工作量其实更大。当下面临的情况是业务上等着用,资源投入又不可能太多,于是打算先找一个中间过渡方案,依托现有错误采集服务端的资源,在前端侧快速调整一下,来支持线上业务。同时也要为后续接入Sentry、或是中台的监控方案预留可扩展性。
基于这种考虑,这次调整的重点放在数据采集上,主要解决采集错误信息不够充分的问题,来缓解问题定位上的痛点。至于数据采集上来后的可视化平台,数据分析报表等比较重的工作,还是考虑后续通过接入第三方监控系统或中台的服务来解决。
接下来看一下具体的调整措施:
1、对原有监控探针做一些调整,通过在window上添加监听和对事件劫持来补全之前漏抓的场景;
2、强化静态代码扫描,lint规则中加入针对script标签crossdomain的检查。对于被允许CORS域名下的script标签,如果没有crossdomain属性直接lint报错。暂不对document.createElement("script")做劫持来添加crossdomain属性,因为第三方js中可能会用到,且有些域名不允许跨域,可能产生加载报错,所以只在lint环节打印出来,人工确认一下;
3、针对偶发类错误设计一套场景还原方案,通过采集更多维度的信息来辅助还原异常现场,后面会具体讲到具体方案;
4、增加采集器自检能力。在URL中预留一个自检查开关,默认处于关闭状态,需要自检时可手动打开。例如/user/brokerhomeV2?xxx_trace=true。开关打开后会异步执行document.createElement("script")来加载固定地址下的一段脚本,该脚本里是故意设计好的一些报错用例,方便在线验证。这个脚本独立发布,可以任意修改。同时,开关打开后,日志上报方法会在控制台打印上报内容,便于开发人员及时确认。
5、在1的基础上对监听和事件劫持做wrap,预留对接入第三方监控的兼容性。
改造前后方案对比:

bMzyeeB.png!web

至于vue或react里的异常,用window.onerror是捕获不到的。需要借助vue的errorHandler,react的ErrorBoundary来处理。以vue为例,实现思路大致如下:

function wrapErrorHandler(ErrorDetector, Vue) {

Vue = Vue || window.Vue;

if (!Vue || !Vue.config) return;

var _oldOnError = Vue.config.errorHandler;

Vue.config.errorHandler = function VueErrorHandler(error, vm, info) {

var metaData = {};

// vm and lifecycleHook are not always available

if (Object.prototype.toString.call(vm) === '[object Object]') {

metaData.componentName = formatComponentName(vm);

metaData.propsData = vm.$options.propsData;

}

if (typeof info !== 'undefined') {

metaData.lifecycleHook = info;

}

ErrorDetector.captureError(error, {

extra: metaData

});

if (typeof _oldOnError === 'function') {

_oldOnError.call(this, error, vm, info);

}

};

}

以上代码参考了raven.js中vue integration的实现。Vue对errorHandler的wrap要以es module依赖方式安装进来,并打包到bundle js中,不能像普通探针那样直接以script标签加载。但可以和普通error探针共存,调用探针上captureError方法,从而共享异常处理逻辑和异常上报逻辑。

接下来重点说一下 报错现场还原 这一块

做现场还原,目的是要解决在偶发异常(非必现)出现的时候,能够了解到客户端究竟发生了什么,从而为后续问题的排除、复验提供抓手和依据。

先来看看现场信息可能包含那些:

终端信息:操作系统(platform),浏览器相关(userAgent),显示器(screen)等;

访问信息:URL信息,refer,upstream_addr,method,用户会话ID,地理位置、时间等;

错误信息:error基本信息及其错误栈信息;

行为信息:用户鼠标、键盘的操作队列;

VM快照:Vue或react项目顶层store中数据模型的快照;

屏幕快照:出错时页面的截屏。

其中,终端信息、访问信息、错误信息比较好拿,且现有监控已经拿到了。但还有两点需要完善,一、就是stack trace的扩容(10行不够)。二、看看有没有办法拿到异步方法的stack trace(long stack trace)。至于VM快照,由于房产经纪人页面还需要兼容低版本浏览器的缘故,大多数页面还没迁到数据驱动框架下,暂不在采集。而能不能采集到用户行为信息和屏幕快照,就成为能否还原现场的关键。接下来具体看一下如何采集信息。

总结

对于页面错误信息,我们把它大致归为两类。一类是页面加载过程中就会报出来的错误,一类是页面加载完成后,需要用户在特定操作下,才会报出的错误。第一类错误理论上在浏览器(UA)确定的情况下,用户会话ID和URL确定的情况下,该类错误绝大多数可以说是必现的。这类错误通过现有的UA、用户会话ID和URL可以直接还原场景。不需要再采集用户行为信息和截屏。第二类就是由用户交互产生的,这类正是场景比较复杂,文章开头例子中难以复现的那种,需要借助采集用户行为信息和截屏来还原现场。
这两类信息在采集时需要区分对待,第一类信息只采集终端信息、访问信息、错误信息,第二类信息才需要加上行为信息和截屏信息作为补充。对于这块的处理是通过在window.onload配置一个开关来实现,开关实现如下:

$(window).load(function(){

ErrorDetector.pageLoadComplete = true;

})

RF7FN3M.jpg!web

关于用户行为信息的采集,这里有二项需要确认。一是怎么采集到用户行为,二是采集来的数据如何进行合理分割。

行为采集一定是不能侵入到业务代码中的,所以必须通过监听和劫持来达成。这个好办,Sentry不是能采集到嘛,拿来参考一下。以下参考了Sentry中raven.js的采集实现,将下列函数进行了Wrap:

window.setTimeout
window.setInterval
window.requestAnimationFrame
EventTarget.addEventListener
EventTarget.removeEventListener
XMLHTTPRequest.open
XMLHTTPRequest.send
window.fetch
History.pushState
History.replaceState
接下来看一下数据分片
页面加载完成后,开始录制用户行为,行为数据以队列形式存放在监控探针js的全局变量上。这个变量上的内容会在异常发生时,被上报给后台服务。但这个录制什么时候停,什么时候重新开始录制,却需要制定一个策略。我们的方案采取以URL变化和交互error发生两个时刻来做切割。理由如下:
1、URL变化意味着场景切换,场景切换前上下文的交互细节不需要过多关注。URL、用户会话ID、浏览器(UA)能够共同确定交互类错误发生前的最近上下文环境;
2、同一页上发生多次异常,没有必要每个上报内容都包含页面一进来到报错时点的全量交互信息,以报错点切割开就行。每段报错切片会按时间顺序上报给后台,将它们合在一起可以形成上下文链,这个从时间上可以确定。
3、采集数据不是越多越好,对网络传输,数据存储都会是挑战,甚至会淹没有效数据。所以应避免上报过多冗余数据。
基于以上理由,每次上报的用户行为采集切片方案设计如下:

MnIZZnY.png!web

至于SPA类的web,需要监控探针暴露一个方法给到router切换时来调用,以处理清空队列等动作。
聊完了用户行为抓取,再来看屏幕截取。
理论上拿回用户行为日志后,可以在本地浏览器环境中注入上下文后,逐一执行来进行回放,从而还原现场。但是,现实情况下要搭建这样一套交互回放系统,其成本投入却是现阶段不能承受的,所以放弃,还是老老实实截图去。虽说截图对于问题定位不是必须的,但有总比没有好,一图胜万言,查问题效率上是有很大提升的。实践下来,截屏+stack trace绝对是问题定位大杀器。
接下来看看截屏数据采集的具体方式:
截屏是借助html2canvas插件来实现的。通过将当前页转成canvas后再序列化为base64,base64的内容会和行为数据队列序列化后的结果一起上报给服务端。
截屏转base64代码如下:

function captureScreen() {

var targetDom = document.querySelector("body");

html2canvas(targetDom, {

useCORS: true,

allowTaint: false

height: targetDom.scrollHeight,

width: targetDom.scrollWidth,

}).then(function(canvas) {

var context = canvas.getContext('2d');

context.mozImageSmoothingEnabled = false;

context.webkitImageSmoothingEnabled = false;

context.msImageSmoothingEnabled = false;

context.imageSmoothingEnabled = false;

var quality = 0.92;

var base64Image = canvas.toDataURL('image/png', quality).substring(22);

ErrorDetector.accidentScene.img = base64Image;

})

}

代码中imageSmoothEnabled = false是为了提高图片清晰度,抗锯齿用的。由于html2canvas中用到了Promise,对于低版本浏览器需要加入promise垫片,这里用的是promise-polyfill。至于base64是否再要做压缩,默认图片质量下实际生成的大小在220~450K之间,在保证清晰度情况下,这个大小范围是可接受的。

异常上报

现场信息采集到了,上报时如何不给现有错误日志服务带来太大压力,这是我们要考虑的。

先来看要不要做节流。去年上半年异常日志峰值在每分钟6K左右,日均上报的error量在20万上下。目前后台服务完全可以承受这样的上报量级甚至更高,在项目发布时我们往往更关注异常返回的及时性,所以暂不考虑用节流方式来降低总请求量。

但是有一个问题,原来单个上报数据量少(1K以内),现在要对现场还原,用户行为信息(往大了算10K内)+屏幕截屏(450K内),加一起以上限计算有460K。如果按这个大小每个客户,每次发生异常都上报,很可能会对现有后台服务产生影响,而且客户端网络上行也会收到一定影响。所以,必须要减少网络上报数据的传输量。

先来看压缩方案。由于截屏占上报size的大头,用户行为信息几乎可以忽略,所以压缩本质可以理解为对图片的压缩。要在现有基础上压缩好几个数量级,不降低清晰度降是不现实的,但降低清晰度有可能导致图片失去定位问题的作用,单纯的图片压缩方案有点得不偿失。

再看另一条路,在单个上报size不变情况下,把上报次数砍掉,似乎也能达成目标。查了一下单日上报错误日志情况,以总量20万计,实际按错误分类也就40~100之间。刨去页面刚进入不需要采集场景的这类错误类型,那数量就更少了。对于同一个原因导致的问题,后台只要拿到一次可复现的场景就可以了。同样的,对于终端浏览器而言,同一个原因导致的错误现场只上报一次。这样的话,对现有错误搜集服务来说几乎没有新增的压力。这里有一点要补充说明一下,这里同一个原因错误上报去重只针对用户行为信息和屏幕截屏,终端信息、访问信息、错误信息还是会及时上报,不会做防重,这还是为了保证项目发布时异常反馈的及时性。

eyyya2n.png!web

于是上报方案设计如下:
1、交互场景error只搜集js运行时这类错误,在window.onerror中采集。错误唯一性通过message,source,lineno,colno加分隔符后拼接成的字符串作为唯一标识(unique key)。
2、window.onerror中区分对待页面加载过程中的错误和页面加载后由用户行为操作产生的错误。通过在window.onload中设置加载完毕标志,加载中错误走原来通道上报,加载后走场景还原上报通道。这样可以有效降低图片上报数量。
3、同一错误只在客户端上报一次。利用客户端存储将上报过错误的唯一标识存下来,下次上报前比对是否已上报过,已上报过则忽略,否则继续上报。
4、同一错误服务端只采集一次。由于用户基数大,仅靠客户端去重是不能有效降低上报总量的。搭建一台node配redis,用来存储上报过错误的唯一标识。客户端上报前会先拿唯一标识去服务器端作比对,如果没有上传过才上传,上传后唯一标识会存入本地客户端存储中,下次直接在本地比较时就会拦截,不会给node服务带来压力。
为了在客户端做一层去重,需要在本地保留上传过错误的摘要。本地存储采用indexedDB,以队列存储,先进先出,大小配30(数字是按线上交互类报错的单个用户平均数放大5倍后的预估值)。

UjEnYzq.jpg!web

实际去做的时候,发现最初的方案有些问题

1、首先,用message+source+lineno+colno来唯一标识特定错误是不精确的。一些错误是由基础方法里抛出来的,报错位置相同,但产生原因不相同。要唯一标识出特定原因的错误还是得用stack trace。
2、存储用indexeddb的话,有低版本浏览器兼容问题。为了兼容低版本浏览器还得单独实现一套基于localstorage的队列存储,做是可以做,但就当下而言不经济。
针对上面的问题,采取如下调整:
对于第一个问题,把唯一标识换成stack trace。但limit放大后的stack trace太长了,还有格式问题,直接拿来做unique key不太合适。所以对其md5一下后作为key使用。
至于第二个问题,放弃indexeddb直接用localstorage。但这样一来,如果还是走每次上报就更新localstorage的模式的话,要做size超限判断、删除等操作就很麻烦。思来想去后,决定采用批量更新模式。
每次发生场景上报后不直接写localstorage,而是放到js内存对象中更新,以map形式存储。在window.beforeunload时,将map对象序列化后一次性存储下来。在页面载入时,监控探针也能一次性从localstorage里取到map后反序列化到内存变量中,这对于后续的判重来说是比较方便的。至于更新策略,是通过保留引用在队列中,通过维护队列的先进先出从而更新map对象里的内容的。

iMvyInJ.jpg!web

至此,方案调整完毕。

总结

就目前的监控方案而言,主要是解决了页面数据采集问题,在业务上补上了之前在监控上遗留下来的一块短板。在大方向上,后续还是会接入中台或第三方的监控体系中,因为整个监控体系极为复杂庞大,我们没有必要自己去造这个轮子,借力就好了。

但就这次实践本身而言,对经纪人前端来说还是有很多收获。首先,通过这次实践,实实在在能帮我们缓解查线上问题这个痛点。之前偶发性问题往往需要花费我们2个小时甚至1天来定位问题,现在多数情况15分钟内可以搞定。其次,由于补全了异常场景,对于监控本身能发挥的作用更有信心了,上新项目也更有底气了。同时,实践也为我们留下不少基础设施,比如,截图逻辑是我们特有的,stace trace limit控制也很方便,今后接入其他监控系统后,这些资产仍可以保留下来,为我所用。

最后,实现监控本身已不是一个大的问题,但如何把监控做得全面、做得到位,并不断去优化,却是还是值得我们不断去思考的。

参考文献

1、学习 sentry 源码整体架构,打造属于自己的前端异常监控SDK

https://juejin.im/post/5dba5a39e51d452a2378348a

2、前端代码异常监控实战

https://zhuanlan.zhihu.com/p/32709628

3、前端监控概述

https://www.alibabacloud.com/help/zh/doc-detail/58652.htm?spm=a2c63.p38356.b99.91.46f918f3mekWSB

作者简介

孙淘熔:HBG二手房技术部资深开发工程师,18年3月加入公司,目前负责中国网络经纪人、网络门店、家装店铺等前端侧工作,对面向B端的前端开发有深入了解。

阅读推荐


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK