从无到有<前端异常监控系统>落地

导火索

  有一天一个测试同事的一个移动端页面白屏了,看样子是页面哪里报错了。  我自己打开页面并没有报错,最后发现报错只存在于他的手机,移动端项目又是在微信环境下,调试起来会比较麻烦,最后用他手机调试才发现问题: 是他账户下面有个对话的消息数据有问题导致页面报错了。  一般遇到这种情况只有用他的手机或者账户调试能很快查到问题,如果是外部的用户怎么办,我没法拿他的手机去测试。

 

  其实这个问题很常见,但是这次我觉得这个问题如果不是我们自己同事发现的,那就很恐怖,可能废很大精力才能查出问题,甚至会导致很严重的线上bug,细思极恐,刚好前不久成都FCC的大前端交流会上叶小钗谈到了监控这块,也让我有所启发,这些公共服务才是公司的核心财富,目前公司业务发展处在上升阶段,未来用户肯定会越来越多,对系统的稳定性要求也会越来越高,那既然我们还缺乏这块的服务,现在做正合适。

 

前期准备

   从提出这个想法的一开始就知道,落地才是关键,否则一切空谈。  刚好半个多月以后,我们前端组需要在公司做一次分享,我现在做个题材就挺适合分享的,其他后端和测试同事也容易听进去一点。  最开始我考虑了后端存储和可视化的情况,想找个现成后端集成工具帮我处理后端的工作。  就找后端同事问了一下,同事推荐了 Elasticsearch+Fluentd+Kibana 。  然后稍微研究了一下,总觉得哪里不对,反正研究了之后发现可能还是需要做一些定制开发才能解决需求,后端同事听了我的需求也是这么说的。  一人之力有限,并且公司业务上的事情也多,找一个后端同事配合极好,利用各自的优势可以更快落地,这样我也可以专注前端的工作和把控整个项目落地。  就这样,我和后端同事商量了一下,他也答应抽空和我一起搞了。   抛开后端的事情,我开始思考前端的工作,去调研一下别人的方案和这块的知识。  有一些三方库或者开源项目提供类似的功能的,做了很简单的了解。  最后想着自己开发更容易去适应自身的业务,并且目前第一版的需求功能也并没有那么大的开发量,那就自己做吧。  前期遇见了一些需要解决和实现的功能点: 生成sourcemap,监听js报错和信息上报,压缩的js代码上报后sourcemap解析问题,如何更平滑的应用在业务项目中,数据存储优化等。

 

基本实现

前端

  •   js报错事件监听+处理上报
  •   构建工具生成sourcemap文件
  •   sourcemap文件上传

后端

  •   提供接口收集报错
  •   读取sourcemap文件,解析上传的报错(解析发生时间:接口收集到后马上处理,后期提取的时候处理)
  •   存储数据

 

监听js报错和信息上报

通过onerror我们能监听和拿到js的报错信息, 可以拿到如下代码的五个参数。  columnNo, error这两个参数在一些老版本的IE8-9浏览器和opera低版本等浏览器上可能拿不到,但是没有关系,我们在代码上兼容拿不到参数的情况,如果缺少后两个参数,传空值就行了。  也可以通过其他方式拿到这些老版本浏览器的columnNo和error参数,目前监控主要是针对移动端,也没太大必要去兼容老版本的浏览器。

window.onerror = function (msg, fileUrl, lineNo, columnNo, error) {}

 

onerror方法大致实现如下:

//如果之前其他代码有绑定onerror,需要替换执行一下,如果没有定义window.error===null。
var oldError = window.onerror;
window.onerror = function (msg, fileUrl, lineNo, columnNo, error) {//最后两个参数有些部分浏览器拿不到,依然需要记录

    var args = Array.prototype.slice.call(arguments);
    
    if (oldError) {
      oldError.apply(window, args)
    }
  var stack = null; if(error && error.stack) stack = error.stack; var json = { msg: msg || null, fileUrl: fileUrl || null, lineNo: lineNo || null, columnNo: columnNo || null, error: stack } var userAgent = navigator.userAgent; if(XMLHttpRequest){ var xhr = new XMLHttpRequest(); xhr.open('post', 'urlxxx', true); xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); xhr.send("message="+JSON.stringify(json)+"&userAgent="+encodeURIComponent(userAgent)) } }

可能存在跨域问题,不同域下的js需要配置script属性 crossorigin="anonymous" 和后端配置 Access-Control-Allow-Origin,但是目前我们的项目不存在js跨域问题。

提示一下onerror并不能拿到所有报错信息,比如网络报错等

现在我们能通过onerror拿到报错信息了,可是线上的代码是经过压缩的,报错的时候我们能拿到的的行列数和变量命都不能告诉我们源代码哪里出错了。这里我们需要用到sourcemap,下面来讲讲它。

 

sourcemap

sourcemap就是一个信息文件,里面储存着位置信息。  也就是说,sourcemap文件记录了代码转换前的位置和转换后对应的位置(http://www.ruanyifeng.com/blog/2013/01/javascript_source_map.html 阮一峰详解)。  下面图1是login.js的压缩版本,第二行的注释指定了map文件的相对路径,浏览器根据注释会找到map文件然后自动解析出来,在调试器里就可以看到源码了;  图2是map文件(json格式);  图3图4介绍sourcemap文件。  图2我们生成的map文件sourcesContent字段直接引入了源文件代码(构建工具可以配置是否给map文件引入源文件),这样可以方便后端解析,如果没有源文件对应的话后端是解析不出正确结果的。

(图1)

(图2)

(图3)

 

 

(图4)

 

 

grunt生成sourcemap:

  我们的移动端项目构建工具比较老了,统一用的grunt作为打包工具。  之前没有在压缩代码时使用sourceMap,因为开发和测试环境没有压缩,所以也不需要在浏览器用sourceMap调试。  然后我就再去修改gruntfile文件(之前不是我写的),sourceMap配置感觉和官方文档对不上,老是报错,最后才发现之前的打包工具的依赖版本是13年的了,也暂时没必要去折腾版本问题了,把老版本的文档翻出来再配置了一下sourcemap文件就成功的生成在源文件的同级目录下了,比如源文件叫xx.js,map文件就是xx.js.map。  我们给js文件加上了md5版本号,所以实际的文件是xx.md5.js和xx.md5.js.map(md5是根据内容变化的)。

 

sourcemap解析问题

思考的时候发现最大的难点应该在sourcemap解析。  最开始后端同事以为sourcemap是nodejs生成的文件,他们后端用的go或者php似乎不能解析吧,如果知道了sourcemap原理就应该知道,它只是一种数据格式和开发语言没关系。  我把map文件和报错信息交给后端同事,他们用go语言的一个工具成功解析出了答案,实现了本地文件的解析。  但是我们需要的是自动化解析,不可能每次都去把存储的报错信息手动的拿出来再去找对应的map文件做人工解析。  所以需要我们后端程序自己去找到map文件,并解析报错信息。

如此一来,后端解析存在两个关键问题:

  1. map文件存储在哪里
  2. 什么时候解析

①map文件存储在哪里

这里只说我们的方案,map文件和源js文件打包到同级目录下,一起上传到服务器(比如js的路径是www.xxx.com/dist/index.md5.js,那map文件的地址就是www.xxx.com/dist/index.md5.js.map),服务端就可以根据报错的js路径再加上.map后缀找到map文件。  压缩文件有一段注释描述sourceMappongURL指定了map文件的位置,打开浏览器之后调试器会找到这个map文件,在浏览器里就能看到源代码,为了避免这种情况,需要服务器配置 .js.map 后缀的文件不可访问。  如果这样的话,服务器解析的时候不能直接去下载静态资源.map文件,而是需要去找到服务器本地对应的map文件,这样要单独配置路径和写逻辑很麻烦,而且文件夹结构有变动的话也不灵活。  所以我们的方案是做token权限校验,map文件必须加正确的token参数,服务器才会返回资源(xxx.js.map?token=xxxx),否则nginx会屏蔽没有token或者token错误的请求。

 

②什么时候解析

两种方法,一种是后端接口收到报错信息之后,马上找到map文件,并解析存储到数据库。  一种是先保留上报信息,通过接口查询的时候再去解析。  我们选择了前者,接口收到数据之后,后端根据当前报错文件的url,去查查本地是否已经下载过当前文件,如果已经存在这个文件,就直接用本地的文件解析,如果本地没有,路径加上.map和token参数,下载对应的map文件到本地,然后再去读取当前本地文件并解析,解析的数据和上报的数据就存为一条记录。  如果是后者的方法,存在很多麻烦的问题,这里不多说了。

 一张图详细描述我们的解析流程:

 

有一种情况可能发生: 当前项目已经更新到1.1版本了,1.0版本的一个报错以前没被触发,这个时候有个用户缓存了1.0版本的代码,并且触发了一个新的报错,这个时候服务器本地存储的map文件里没有这个文件,就会带上token去下载map文件,因为当前已经是1.1版本了,原js文件发生过变动,md5的版本已经对应不上了,这个时候就没法找到map文件了,无法解析,所以这种特殊情况只能存储上报的errorInfo信息。

 

如何更平滑的应用在业务项目中

目前js的onerror方法只有代码量不大,后期还会有叠加。现在的想法是尽量不和业务代码做过多接触,只需要直接引入当前js到各个业务项目中去,每个项目不用对它太多任何配置,让它尽量单纯一点。

 

存储优化

后期是会做管理后台来查询和统计这些异常日志的,同一个错误可能上传报错数据到服务端,后端查询出来是一条条独立的记录,我们不能区分这条记录的报错是不是有重复数据,也不应该让后端去做字段对比。  后来想到给 报错的文件路径+行+列 信息拼在一起字段做md5生成,根据这个唯一值生成md5,最后查询的时候只需要查询当前md5字段就能知道这一条报错一个有多少条记录。  不过我想的太天真了,不同的浏览器报错行列信息有点不一样,同一报错就可能生成不同的md5字符串,即便这里有点问题,我还是继续用这个方案保存了md5(因为内核原因,移动端的差异还是比较小,当前字段也能有一定的区分性)。

我们第一版存储的主要数据(还有一些常规的就不说) :

{
    "businessInfo": "{}",//业务项目自定义的数据
    "errorMd5": "80bb86b86da0607c0dc5c3a77e16eab6",//根据报错部分信息生成的md5
    "manualSendError": "{}",//手动上传的报错信息
    "pageUrl": "http://www.xxx.com/xxx.html",//放生报错的页面url
    "parseError": true,//解释是否失败
    "parsed": '{"col":0,"errKey":"list","file":"xxx.js","line":105}',//解析后的行列、文件路径和变量
    "raw": '{"msg":'', "fileUrl":'', "lineNo":'', "columnNo":'', "error":''}',//onerror的五个参数
    "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Redmi 4X Build/MMB29M; wv)..." //navigator.userAgent
}

 

 

发送邮件

邮件提醒是很有必要的一个功能,目前已经实现实时邮件提醒功能。 公司企业邮箱建个单独的邮箱就叫frontendmonitor@吧,当后端接口收到报错后,把解析数据通过这个邮箱发送给前端,达到提醒效果。   如果是用QQ邮箱或者个人邮箱应该需要在账户里开启smtp服务,QQ企业邮箱是默认开启此功能的。  邮件功能要注意性能和优化问题,不能因为前端报错太多导致服务器挂掉。

 

实际使用后的优化

  • 我们发现不同的浏览器报错的变量可能不一样,同一个报错在chrome浏览器和firefox上 columnNo 参数一点偏差。  用两种报错解析了一下,如下图,报错的代码都是18行,是没问题的,Firefox报错是下图第一个:console 18 0 true,chrome是testBase 18 0 true,行数没问题,偏差不影响我们最终查错,我的18行源代码是:console.log(testBase)。  testBase是故意没有申明,testBase是undefined,出问题的应该是testBase这个变量,过从报错情况上看,确实是谷歌浏览器更精准一点。  虽然不在意IE,不过IE11报错列数和firefox一致。

    

  • 页面触发事件报错,用户一直触发按钮,这时就会不停上报错误信息。解决:存储上一个报错信息和时间,进行比对,同一个报错,短时间内避免一直重复发送。
  • 框架模板报错,被框架本身捕获,不会触发window.onerror,需要使用框架本身的全局监听捕获信息后手动上传,这里需要加手动上传错误信息的方法。
  • 引入监控的项目,由于业务原因可能需要上传一些业务信息方便分析,所以预留一个配置字段,上传错误的时候请求会带上业务相关信息。

 

总结

这种非业务服务,来源于个人兴趣和思考,并没有上层压力需要你做或者什么时候做完。  从最开始有个想法、去调研、去找后端同事求助、 开干到最终落地。  这个过程需要自己坚持做下去,因为害怕自己不能最终落地,所以抓紧时间,一步步去实现每个细节的想法,让事情尽快落地和上线,以免自己对这个事情越拖越久。  作为需求方,更好的把握整个项目,加上自己的兴趣,所以这次自己也学习了一点go语言,保证能看懂后端代码和了解后端逻辑,最好能做一点开发,这次在后端同事代码的基础上,实现了发邮件的小功能,我称之为浅入浅出,装完逼就跑路~  现在第一版已经上线,并且在刚上线不到两个小时,就收到了报错邮件,吓得我急忙查找bug,很快查出来了问题来,这个bug应该存在很久了,但是因为没有阻塞性,并且没有影响到业务,也一直没被发现,结论是我们这个前端异常监控功能还是很成功!  后期还有很多功能需要开发,统计、数据可视化、智能报警等等。  第一版落地,就为以后的迭代和进化打下了良好基础。

在做这个事情的过程中,我是想尽快把事情落地,时间也很紧张,也并没有做非常充分的调研,比如现成的一些开源项目是怎么做的。  后来从同事那里了解到 sentry 这些三方开源项目之后,也有一点失落过,虽然我也解决了我的需求,但是三方的开源项目是一个非常完善的系统,提供了很多功能,比我这个强大多了,那我做这个到底有什么意义, 感觉完全和别人比拼不上,未来我这个项目会继续迭代吗,有继续迭代的必要吗?   以后有特殊定制化的需求的时候,也许自己开发的才容易更适应业务,可是有那个机会吗?  这一次落地已经达到我最初的要求了,也能帮我解决目前问题,未来还有很多挑战和迭代等待着,我会带着它一路过关斩将,还是半路死掉?   我想说:

 

最后大力地感谢我司后端同事的大力支持!!~

 

posted @ 2017-12-22 18:06  子慕大诗人  阅读(7755)  评论(14编辑  收藏  举报