http://jdc.jd.com/archives/2175
在 Web 应用异常复杂的今天,一个页面不单单只包含文字、图片和超链接,还可能包含复杂表单、大量动画、海量交互。很多 Web 应用完全单页化,操作体验、复杂程度堪比原生应用。这对开发者们来说,是巨大的挑战,纵然有 unit test、code review、各种黑白盒测试保价护航,也无法保证代码上线后,面对成千上万用户、在各类浏览器下、遇到未知数据时不出问题。所以,对一个拥有大量用户的互联网产品而言,一个可靠的前端异常数据采集、上报、处理、监控、报警平台是非常有必要的。今天,我想来谈谈如何采集异常数据。
注:本文中的“错误”和“异常”,都是脚本执行中的报错信息,只是表述不同,原则上可以划等号。
可采集的异常
页面的异常有很多种,HTML 标签异常,CSS 展现异常,样式、图片、脚本文件的请求异常、脚本执行异常。有些涉及用户自身的网络环境,如网速很慢、被运营商强行注入标签或脚本,我们很难规避;有些仅仅是展示上的问题,如文本和按钮没有对齐、文本折行,用户自己就能觉察和规避,不影响正常使用;但有些异常,如交互逻辑错误、获取填充数据提交导致的脚本错误,会立刻终止用户的下一步操作,这类异常危害最大。用户不是开发者,不知道是什么问题导致脚本异常,甚至不知道已经发生了异常。我们主要抓取的,就是此类异常。那么,哪些具体的数据需要采集呢?
全局错误
打开浏览器自带的开发者工具,当一个错误发生时,我们可以立刻得到提示,并且知道错误发生的位置以及调用的堆栈信息。我们可以通过 window.onerror 来捕获页面上的各种脚本执行异常,它能帮助我们获取有用的信息。这个方法存在兼容性问题,在不同的浏览器上提供的数据不完全一致,部分过时的浏览器只能提供部分数据。它的标准函数签名是这样的:
1
2
|
window.onerror = function (message, url, lineNo, columnNo, error)
|
一共5个参数:
- message {String} 错误信息。直观的错误描述信息,不过有时候你确实无法从这里面看出端倪,特别是压缩后脚本的报错信息,可能让你更加疑惑。
- url {String} 发生错误对应的脚本路径。
- lineNo {Number} 错误发生的行号。
- columnNo {Number} 错误发生的列号。
- error {Object} 具体的 error 对象,继承自 window.Error 的某一类,部分属性和前面几项有重叠,但是包含更加详细的错误调用堆栈信息,这对于定位错误非常有帮助。
这些信息如果可以完整提供的话,相信我们能很快定位错误。但是不同浏览器对同一个错误的 message 是不一样的。IE10- 浏览器只能获取到 message,url 和 lineNo,columnNo 以及具体的 error 是获取不到的。但是 window.event 对象却实时提供了 errorLine 和 errorCharacter,以此来对应相应的行列号信息。调用堆栈虽然获取不到,但在 onerror 中,arguments.callee.caller 可以递归出调用堆栈。在不同 IE 版本下面,获取的调用堆栈也不相同。这里 你可以看到例子。这一类信息是最直接的错误信息信息,是必须要捕获并上报的。下表可以对比同源策略下不同浏览器默认可获取的参数值:
Browser | Message & Url | Line numbers | Column numbers | Stack trace |
---|---|---|---|---|
Chrome | ✓ | ✓ | ✓ | ✓ |
Firefox | ✓ | ✓ | ✓ | ✓ |
Edge | ✓ | ✓ | ✓ | ✓ |
IE11 | ✓ | ✓ | ✓ | ✓ |
IE10 | ✓ | ✓ | ✓ | ✓ |
IE 9 | ✓ | ✓ | ||
IE 8 | ✓ | ✓ | ||
Safari 6+ | ✓ | ✓ | ✓ | ✓ |
iOS Safari 6+ | ✓ | ✓ | ✓ | ✓ |
Opera 15+ | ✓ | ✓ | ✓ | ✓ |
Android Browser 4.4 | ✓ | ✓ | ✓ | ✓ |
Android Browser 4 – 4.3 | ✓ | ✓ |
Ajax 上下文
回想下,有那么几次,程序在测试和开发时一点问题都没有,但上线请求到各种五花八门的数据以后,问题才出现。有时明明定位了异常的位置,但是在已知数据的回归测试下,仍然无法复现。如果这时有发生错误时的数据上下文,就很容易排错了。所以,Ajax 的请求上下文对于排错会有一定帮助。你要是用过 unit test 的一些辅助工具(如 sinon )里面的各种 fake 方法,就一定不陌生如何去fake XMLHttpRequest.prototype 上的各类方法。这样,我们可以 hook 住 XMLHttpRequest 对象 open、send 时的数据,也可以获取返回数据的 statusCode、statusText,甚至是 responseText(不建议获取这种可能会是大容量数据的信息)。
操作上下文
异常的造成,除了直接请求到的,可能产生于于交互中。设想一个存在表单的场景,对表单的每个字段进行判断和处理,或者一个控件的 onchange 触发一系列的逻辑,也有可能造成异常。如果可以提供部分表单控件的信息,对于错误的定位将会更加便利。表单控件大体上分为两类,click型和 input 型,也就是 点击类 和 输入类。
- 点击类:
a
,button
,input[button]
,input[submit]
,input[radio]
,input[checkbox]
- 输入类:
input[text]
,input[password]
,textarea
,select
统一需要记录的如 tagName、标签中的 attribute。不同的对象需要记录不同的值,checkbox 要知道是否被勾选,select 最好带着选择项的 value 和 text,textarea 记录全部 value 不现实,只记录有没有值或者字符串的长度就可以了,这些辅助信息都有可能帮助我们定位错误。
页面依赖
现在的系统,几乎都是构建在一些流行的库之上的。jQuery、angular、react.js、vue.js、backbone、underscore、knockout,这些常用的类库,发布时大都会带版本信息,如
1
2
3
4
5
6
7
8
9
|
`jQuery`, `jQuery.fn.jquery`,
`jQuery ui`, `jQuery.ui.version`,
`lodash(underscore)`,`_.VERSION`,
`Backbone`, `Backbone.VERSION`,
`knockout`, `ko.version`,
`Angular`, `angular.version.full`,
`React`, `React.version`,
`Vue`, `Vue.version`,
|
有些异常,通常是伴随着类库的升级而发生的。如果你的很多页面中用到了 jQuery,但你升级后无法回归测试到所有页面,那些页面中用到了已经修改或者废弃的方法时,就可能会异常。这时候,一个页面依赖库的信息可能就会彻底帮到你。
除了上述的类库以外,大多数类库会直接暴露一个引用到 window 对象中,以供开发者调用。你只要简单循环一下 window 对象,看看哪些属性包含 Version,version,VERSION 就可以了。虽然会有些类库逃过你的检测,不过可以作为一个补充。
自定义数据
除了默认采集的数据,提供自定义数据接口也是一个必要的功能。因为不同产品的业务需求是千差万别的,你永远不知道哪些数据对开发者有用。如果有了自定义数据,开发者就能通过自定义数据来进行异常类型的区分。比如国际化的站点,可能简单通过一个 lang 字段就可以区分不同语言环境的异常。
浏览器数据
你应该有这种经历,代码开发、测试各种主流浏览器都 bingo 了,上线后用户投诉说他的浏览器下有问题。这属于特定浏览器下面的异常,上线之前如果测试覆盖度不够就很难发现。比如,IE8 里面 catch、default 是作为保留关键字的,但是在 Chrome 下就不是。你可以在 JScript Reserved Words 和 Reserved keywords as of ECMAScript 6 对比 JScript 和 ECMAScript 6 中的关键字。如果监控系统显示,这种错误只发生在 IE8 的页面时,你就可以快速定位这个错误。简单的说,浏览器数据只需要 osType、browserType、browserVersion 就可以了,这些通过 userAgent 就能获得。
其他数据
除了上述几种比较重要的数据,屏幕的分辨率、错误发生的客户端时间信息有时也会成为我们定位错误的有力参考。在流量允许的范围内,尽可能多的提供环境数据是必要的。
理想美好,现实残酷,困难重重
我们尽可能多的获取到了错误信息和相关的环境信息,应该能非常迅速的定位错误,但实际情况要复杂的多。浏览器的兼容性、安全设置,静态服务器的配置等有时也是不可控的。很多差异性和不确定性导致我们很难获取到理想的数据。
同源策略 & ‘Script error.’
首当其冲就是跨域问题。现在的站点,静态文件大多都是放在一个独立的域名下面。既可以减少浏览器并发的域名限制,又能通过 CDN 提高资源的访问速度。默认情况下,在本域名下捕获到一个跨域脚本的错误信息时,只能获取到一条信息 Script error.,没有文件信息,没有行列号数据,更没有详细的错误对象,这使得其他额外的信息变得很鸡肋。解决这个问题不但需要在服务器端增加 Access-Control-Allow-Origin 的配置,客户端引用脚本时也需要给脚本增加 crossorigin=”anonymous” 的属性,只有这两个同时设置时,浏览器才会把详细的错误数据都吐出来。
注:crossorigin 存在兼容性问题,使用上也有要注意的地方。如果只设置了 crossorigin,而不在服务器设置 Access-Control-Allow-Origin,部分浏览器连这个脚本文件都不加载。在 IE edge 之前,是不支持 crossorigin 的,即使设置了以上两项,你仍然只能获取到 Script error.。下图以 Chrome 为例,对比不同情况下,错误的返回情况。
设置crossorigin | 服务端CORS开启 | 是否加载脚本文件 | 异常内容 |
---|---|---|---|
✗ | ✗ | ✓ | ‘Script error’ |
✓ | ✗ | ✗(见下图) | — |
✓ | ✓ | ✓ | 包含所有信息 |
✗ | ✓ | ✓ | ‘Script error’ |
(uglifyjs + combo) vs sourcemap
目前大多数站点的静态脚本文件,上线时都要压缩混淆的。所以发生错误时,获取到的行号就是第 1 行,列号会是一个巨大无比的数。这时你只能依赖错误信息和文件路径来定位错误。好在我们有 sourcemap,有了它,我们可以定位到源代码的位置。关于 sourcemap, 阮大这篇 详解 你可以去了解 sourcemap 的原理,mozilla 开源了一个 sourcemap的工具,可以靠它来生成 sourcemap 或者根据 sourcemap 反算出变量名称和行列号。
通过这种方式,把生成 sourcemap 保存在线下,发生错误时,通过 sourcemap,既可以保护代码安全,又能快速定位问题
也有很多站点,采用 combo 的方式一次性请求多个脚本文件,这时报错信息变得更加复杂。如果 combo 服务器把所有文件合并到一行的话,行列号信息就变成了无用的信息,你就只剩下 message 一项可以参考了,可以设置 combo 时的策略,比如每个文件另起 n 行,中间可以间隔几个空行,这样我们在打包时给每个独立的文件加上 banner 信息,即使 combo 后,我们也知道发生错误的文件是哪个了。下图是京东 combo 服务器返回的合并后的文件:
节流
根据上面我们需要提交的数据类型计算,一个错误发生时,上报的数据量还是蛮大的。如果一个异常一直重复触发,连续不断的向服务器轰炸,既是数据冗余,也造成流量浪费。所以,对于异常信息的上报,从上报内容和上报频率上,应该加以限制。
上报内容的限制比较简单,通过可插拔的配置,灵活的上报需要的数据类型。可以根据错误信息关键字过滤不需要上报的错误或者哪个页面的报错不上报。
上报频率的限制上,大致上有以下方案是可行的。
- 随机上报。不是所有的错误都上报,给定一个条件,满足条件的的才上报。
- 合并上报。当出现异常时,给定一个队列和延时。如果延时期内又出现了新的错误,就加入到错误队列中。延时期到或者队列达到最大容量,把队列中的所有异常信息集中上报。合并上报还有一个好处,就是有些公共的信息,比如依赖信息、浏览器相关、用户自定义数据都可以合并到一起。
- 服务端限制。客户端的东西总是不可控的。服务器最好也做一下监测,当客户端在单位时间内上报的错误数超过限制时,直接返回 429。这种情况可能会误伤同一个网段的其他用户。
- 数据压缩。如果要采集的数据量不大,那么明文的数据上报就可以。但是对于合并上报这种情况,一次的数据量可能要十几k,对于日 pv 大的站点来说,产生的流量还是很可观的。所以有必要对数据进行压缩上报。lz-string 是一个非常优秀的字符串压缩类库,兼容性好,代码量少,压缩比高,压缩时间短。下图是单个错误上报信息压缩前后的字符串长度对比,可以看到压缩前是 1860,压缩后是 501,耗时 6 毫秒,压缩了大约 70%。
数据差异化
差异化主要表现在错误信息不一致。相同的异常,在不同浏览器下,message 不一致、columnNo 不一致、error 不一致(有的没有这个对象)。这就需要一个 normalize 函数将差异化的信息尽量抹平。这篇文章 有些一致化的实现方案可以参考。
其他资源
对 img、link、script 资源的动态加载,可以通过给标签添加 onerror 回调函数,获取到这类资源是否加载成功。但没有方案可以全局控制,因为各种类库可能都有自己加载外部资源的实现方式,如果不侵入,我们是无法给每一个动态资源添加上 onerror 回调的。
运营商注入
国内大多数站点依然是 http 的,很容易被运营商注入脚本或者页面。此类脚本也有可能导致错误,但并不属于我们可维护范围,在浏览器端能做的工作也十分有限。但是在上报的服务端可以建立黑名单或者白名单的方式,对于注入产生异常的脚本域名进行封堵。当然,最好的方案还是直接支持 https,可大大杜绝这类错误的发生。
跨域上报
拿到异常信息后,下一步就是如何上报。由于我们获取的数据类型比较多,数据量不算少,单纯的get方式无法满足需求。可以通过 ajax 的 post 方法提交数据,这需要在上报服务端设置 Access-Control-Allow-Origin 允许跨域的提交。对于低端的浏览器(ie6-7),由于不支持跨域 ajax 提交,ifamre+post 是一个比较完美的解决方案。
由于同源策略的限制,从不同 protocol 发送数据都认为是不安全的,所以上报服务器还要提供 http/https 的双向入口以适应不同的协议类型。
设计原则
尽可能多的提供错误信息和上下文对于定位错误确实有很大的帮助,但是受限于实际情况,我们无法保证远程静态服务器可控,用户浏览器可控,所以在设计采集模块时,有几方面需要重点关注。
配置可插拔
可插拔体现在
- 上报内容可配置。可以做到页面级的数据配置,按需发送数据。前面讲到了很多类型的数据:异常数据、 ajax 上下文、交互上下文、依赖库信息、自定义数据、浏览器信息,并不一定是所有的页面的都需要上报这些信息。一个门户页面,pv 很高,只对异常数据感兴趣,不希望造成额外的流量,其他的数据都不需要发送;一个企业内部的 erp,需要保证数据的完整性,每个错误都不放过,不太在乎流量,所有异常数据都需要上报。
- 上报频率可配置。是 one by one 的连续上报还是组合起来随机上报或者延时上报,完全根据页面的需求来定制。
兼容多平台、多浏览器
正因为很多异常具有特定性、平台单一性,才不容易在测试时被发现。所以采集异常时要尽量多的兼容各种平台浏览器,保证在不同浏览器上都能上报错误信息。桌面端特别要符合国情,至少保证 ie 浏览器上面数据的完整性。目前,浏览器住战场已经从桌面端转移到了移动端,移动端带来的流量在突飞猛进,而移动端浏览器又在重现几年前桌面端兼容性的乱象。所以,采集系统也需要考虑移动端的性能
、兼容性
和数据种类
。
支持自定义数据
即使内置了很多类型的数据上报,但总无法面面俱到。这时提供一个自定义数据接口很有必要。使用者可以根据不同页面上报不同的自定义数据来进行异常分析。比如一个正在运行的系统,由于历史原因,有两个线上版本的脚本在部署,我可以给不同的系统添加对应的版本号数据,通过监测,对比错误发生在不同版本系统中的比重。
最后
「Talk is cheap. Show you the code」 flextracker 是我基于上面所讲内容的一个简单实现,你可以根据自己的需求来重新封装。
参考
- how-to-catch-javascript-errors-with-window-onerror-even-on-chrome-and-firefox
- Cross-domain Script Errors
- JS stacktraces. The good, the bad, and the ugly
- ‘Script Error’ and get the most data possible from cross-domain JS errors
- blink start window.onerror extra new params support after stable 28
- lz-string: JavaScript compression, fast!
- 如何做前端异常监控?
- 前端代码异常日志收集与监控