如何搭建异常捕获平台|场景重现

3,195 阅读24分钟

一、前言

代码会过时,但思想永存

1.1背景

    当前互联网产品竞争激烈的环境下,前端研发在一个产品的生产链上承担了越来越重要的角色。作为直达用户的一层应用,跟安卓IOS的原生App一样,可以说是直接衡量一个项目产品好坏的第一道关口。

    和原生应用不同的是,前端应用在运行环境方面存在太多不可控的复杂因素,并没有一个相对稳定运行环境来保证我们的项目一定不出问题。有时候哪怕我们自测再充分,在不同用户复杂的运行环境和操作之下,也难免会出现开发者意想不到的问题。

    出现问题不可怕,可怕的是我们解决线上问题的手段贫乏,效率低下,导致项目的体验和质量低下。这也是一个产品初期,前端这一块发生比较普遍的现象。

    业务场景中的痛点

  • 无法第一时间获知用户访问您的站点时遇到的错误。
  • 反馈链过长,信息无法及时同步。
  • 发现问题是无法第一时间定位到根源。
  • 每个应用内有大量的异步数据调用,而它们的性能、成功率都是未知的。
  • 错误发生时,需要客户或者反馈人员花费大量时间配合协作获取相关信息
  • 发生错误时,用户操作回放

1.2现状

    在没有一个好的解决方案之前,通常一个解决线上问题的发生和解决的过程是这样:


  • 用户端某个页面发生了异常错误,很多用户尝试几次后,选择失望地退出页面,对这个产品印象大减分
  • 有比较用心的用户有渠道反馈给了项目的工作人员,工作人员经过简单沟通了解情况后,反馈给项目的负责人
  • 项目这边通知对应开发人员,尝试解决问题;但可能了解的信息较少,需要经过几层询问,还可能需要用户提供尽可能多的错误信息
  • 开发人员尝试解决后,上线项目,询问用户是否解决了问题


    这已经是一个比较理想且和谐的线上问题解决案例了。事实上很多时候,我们很难那么顺利地解决用户端的问题,测试现有的资源无法复现,用户端和我们研发之间相隔太多层级,都使我们无法及时准确地了解错误本身。想要让产品的质量和服务更上一层楼,不能等总是用户发现错误,我们需要及时快速地响应解决错误,这时候一个好的监控平台十分必要。

1.3业界的情况

    对于一些大厂,自然是接入了一些监控系统。市面上的监控系统有很多,比如阿里云的ARMSUC的岳鹰国外的sentry

  • 定制化较差(如:没有录屏,没有用户行为的堆栈信息,没有主动监测)。
  • 无法贴合产品线业务。
  • 基本上都是收费的。

    更重要的一点是,这些功能是很通用,可未必能够满足我们自己的需求。所以我们打算搭建一套属于映客前端自己的监控平台。

1.4方案

    总的来说,前端监控平台可以帮我们解决了如下问题:


  • 及时响应

        快速发现线上问题,并在用户反馈之前解决,减小线上问题带来影响

  • 快速定位

         详细地了解用户端的页面信息,方便协助排查解决问题

  • 数据统计

         统计项目数据,评估项目质量,进一步可以生成多维度的数据分析报表

  • 效率提升

         减少研发阶段异地沟通的成本,测试中的项目也能很快发现并解决问题,加快研发效率

  • 定制化

         消除购买第三方产品的成本,定制化我们需要的功能

  • 技术沉淀

          减少解决不同机型等运行环境的兼容性问题的时间,进一步整理出一份前端研发规范文档,提升团队技术沉淀

二、基本架构

2.1架构流程

    为了打造一个轻量级、易接入的监控平台,我们设计了异常上报过程的整个流程架构,如下:



    如图展示,在项目中只需要引入一段js,即可开始监听错误事件,记录用户行为,检查运行环境信息,录屏(选择接入)等四个模块的任务。当一旦检测到错误发生,或者用户主动调用我们暴露给开发者的一个全局方法,即可实现上报。上报信息除了错误本身的信息以外,还包括此时记录下来的所有页面信息。

2.2接入方式

2.2.1jssdk方式引入

    其中,webcdn.inke.cn/edith.cn/ed…是录屏模块,可选择引入


2.2.2npm方式引入


三、基本原理

虽然流程图的结构展示了抓取错误主要途径是监听错误事件,我们还需要了解一下,前端的脚本语言javascript是如何监听错误事件的

3.1JS错误

    JS错误包含两种:

  • JS编译时错误:主要是语法错误,代码内容无法解析。这类错误开发阶段就能排查,打包工具也会检测出这一类错误。


  • JS运行时错误。


    JS引擎在检测语法没有错误的情况下,开始运行代码内容。由于JS是一门解释型语言,JS引擎执行前并不知道要执行下一行代码的逻辑是否能被识别,所以可能会出现运行时的错误,如: 引用错误,类型错误等。

     当运行发生错误时,会抛出一个错误对象Error,此时需要用户需要用try...catch语句对错误进行捕获处理。如果在该行代码的上层所有调用栈中,都没有catch住这个错误,那就会在全局window 下会触发一个 ErrorEvent 接口的 error 事件,并执行 window.onerror()

3.2错误类型

     JS的内置对象Error,就是用来生成描述所有错误对象的构造函数,包含内建的标准错误类型有:

  • SyntaxError (语法错误)
  • ReferenceError(引用错误)
  • RangeError (范围错误)
  • TypeError (类型错误)
  • URLError (URL错误)
  • EvalError (eval错误)

3.3其他异常错误 

    虽然全局的error事件能够帮助我们监听到Js运行时的异常,可是一个页面里的错误可不仅仅就这些,因此需要其他方式检测到这些错误。

3.3.1Promise的错误处理

    Promise处理JS异步已经是非常普遍的一个方式,但如果开发者没有捕获Promise的reject处理方法,全局会触发unhandledrejection事件,并执行window.onunhandledrejection()。如下图:



    值得注意的是,如果一个Promise错误最初未被处理,但是稍后又得到了处理,则会触发rejectionhandled事件。因此最好在监听到unhandledrejection事件时,不要立刻触发上报,可以选择等待一定时间,监听是否被处理了,到时再进行上报处理。尤其在混合开发的时候容易遇到。

3.3.2资源加载错误

    资源加载错误,小则影响页面展示,大则影响页面所有功能,因此也需要检测。img、script里的src和link标签里的href属性存在时,会请求对应的资源。如果错误资源报错,该标签会触发error事件,执行DOM的onerror方法,但并不会冒泡到全局。因此需要在事件捕获阶段就监听到,由于此类事件的target是元素本身,而不是全局window,因此可以和其他js报错区别开来。

3.3.3网络请求错误

    前端页面的网络请求的方式有两种:XMLHttpRequest(简称XHR)和fetch。都可以通过劫持这两个请求,来自定义事件,来实现监听(下文详细说明)

3.4项目的目标和原则

    在开始搭建之前,我们内部确定了这个项目的基本功能和目标,总结如下:

  • 轻量级

    我们希望插入其他项目的代码量在10k以内(不包含录屏模块)。轻量的资源才能更快的加载,减少性能的影响

  • 独立性

    即项目内部报错,绝不影响接入的项目的任何功能,内部功能防干扰的同时对全局影响做到最小。所以项目内部需要一个完善的自我catch错误的机制,保证项目的稳定性。

  • 兼容性

    虽然现在我们的项目很多都是面向新型浏览器的业务,但是我们也没放弃老版本浏览器的兼容,包括IE。因此要求我们的项目里使用的核心API都是兼容性很强的。

四、搭建过程

    确定了基本原则,就要开始搭建项目了。虽然项目要有很强兼容性,可不代表我们要放弃工程化开发。本项目选择使用rollup构建打包项目,对比webpackrollup的优势就是轻量、简洁。如果我们只是需要写一个 JavaScript 工具或者库,使用 rollup 就非常适合,rollup 专注于打包 JavaScript,简单明了,易于上手,事实上很多前端流行的库都是使用 rollup 打包的,比如 React、Vue、Moment 等。项目的配置文件很简单,可通过官方提供cli进行快速搭建,rollup-starter-lib。rollup.config.js的内容如下:


接下来,我将针对流程图里初始化后的四个模块逐个讲解实现。

4.1错误监听模块

    错误监听是本项目的核心,我们所有功能都是围绕这个展开。

4.1.1onerror还是addEventListener?

    遇到的第一个问题就是这个,前面说到全局错误事件可以有这两种方式捕获到,确实都可以,功能上没什么差别。有不少人倾向onerror,因为兼容性更好,毕竟addEventListener在IE8以前不支持。是的,根据我们兼容性的原则来看,当然是选用onerror更好。but,onerror有很大的缺点,就是可以被覆盖。我们总不希望,用户可以这样轻易地把我们的功能破坏掉吧。

    因此采用addEventListener,开发者可以继续添加事件回调,那IE8以前我们就不支持了?并不是,IE8以前有另一个方法attachEvent,只不过是监听的事件名前面要加on。所以我们可以封装出一个事件监听的方法,来在项目内部使用。而且我们可以把监听的回调函数加上try...catch处理,防止内部报错对外部造成影响,因此也封装了一个函数包装方法。如下:


4.1.2Promise异常监听

    如3.3.1提到的原理,Promise的,Promise监听的实现代码如下:



4.1.3资源加载异常监听

     如3.3.2提到的原理,在捕获期间监听全局error事件,代码如下:


4.1.4网络请求错误监听

    从前端逻辑代码来说,这并不算是前端的错误。但是作为一个完整的webview应用,网络请求是页面功能实现非常重要的部分,上报网络请求的错误,可以协助我们排查服务端的问题。网络请求的监听不仅仅在错误的时候需要,用户行为记录里也需要,因此现在这里统一讲述吧。

    一般前端请求都是ajax请求,也有fetch请求的,以及前端框架自己封装的请求等等。总之他们封装的方法各不相同,但是万变不离其宗,他们都是对浏览器的这个对象 window.XMLHttpRequest 进行了封装,所以我们只要能够监听到这个对象的一些事件,就能够把请求的信息分离出来,代码如图(这里还包含了上传事件):


    看的出来,经过这一段js的注入,全局的XHR请求都会在各个阶段触发对应的事件,这样我们就可以监听我们需要的内容,包括跨域的错误。

    通过这种方法,已经能够监听到大部分的ajax请求了,然而却无法监听到fetch的请求事件,这是怎么回事呢?明明fetch也是基于XMLHttpRequest实现的一层封装啊。原来事实上,fetch的代码实现是内置在浏览器中的,它必然先用监控代码执行,所以,我们在添加监听事件的时候,是无法监听fetch里边的XMLHttpRequest对象的。怎么办呢?其实也简单,重写一个fecth即可,因此我们只需要类似于上面的方式重写一个fetch方法即可,代码如图:


    经过上面两个方式的封装,我们即可监听到网络请求的各个事件了,包括错误事件。

    在请求拦截方面还有很多的业务场景可以使用,例如我们团队整理的大数据项目时序控制解决方案(未公布),针对了业务中请求资源浪费的痛点进行请求优化。

4.2录屏模块

    没有看错,确实是录屏,能够还原用户所有操作的“视频”,我们能够获取到用户在报错过之前的所有行为。这是我们这个项目的黑科技,目前市面上极少有项目有这个功能,可以非常直观地看到发生在用户端的错误现象,从此告别让用户截屏录屏等繁琐操作了!问题反馈链路大大缩短。

4.2.1效果和基本原理

    话不多说,先看看我们实际的效果吧。




    有录屏并不是最惊讶的,惊讶的是,上传这样一段录屏数据,花费了多少数据量呢,答案是20kb~50kb。也就是说,我们并不会占用多少宽带。那对页面的性能会有影响吗,到底怎么实现的呢?

    谜底揭晓,其实之所以这样的录屏并不算是真正的视频,是因为我们并没有去一帧一帧地绘图,来拼凑成所谓的视频。而是使用了使用 MutationObserver API(IE11以下的浏览器不支持,其他不支持的浏览器版本还包括这些,监听页面dom的变化和用户交互事件,生成类似胶卷一样的Dom快照数据,所以只是记录了变化的Dom, 并不会对页面性能有太多影响。gif中的页面,并没有用到视频组件,里面实际上是一个Dom区域,用一个iframe包裹,按照时间戳还原用户端的Dom变化,因此我们得到数据量并没有很大。

4.2.2接入方式

    由于这个功能是选择性接入的,因此并不影响主要流程。只要引入我们提供的cdn链接或者npm模块,录屏记录就开始操作,同时全局就会注入一个获取录屏数据方法。同时我们还不允许这个方法被重写和配置来保护我们的全局属性。

    另外,考虑到页面中可能存在一些隐私相关的内容不希望被录制,我们也会有敏感信息过滤机制:如果某个DOM节点配置了_edith-privateclassName,那么该元素在进行录制前就会被预先隐藏掉来保障隐私安全。

4.3主动检测

    我们解决报错问题,除了专注报错本身,运行环境对代码的影响也非常大,因此我们需要了解尽量多的运行环境信息。一般运行环境的信息在navigator.userAgent已经描述得比较详细了,除此之外,我们还能获得什么信息吗?

    监控平台我们安装了主动检测模块,可以利用requestIdleCallback函数中执行浏览器空闲队列任务,我们在任务中插入一些主动检测模块

  • 网速检测
  • 多媒体模块检测
  • 手机硬件检测
  • ....

    主动检测模块可以让我们收集到更多的环境信息,更加精准的定位到问题的根源。

4.3.1 网络速度和延迟


    计算延迟可以通过js加载一张1x1的极小图片,测试出图片加载的所用的时长。但是,第一次加载图像时,一般会比后续加载花费更长的时间,即使我们确保图像没有被缓存。因为第一次在两个主机之间打开TCP连接时,它们需要“握手”。一旦建立连接,它就会保持打开状态,直到两端都通过类似的握手决定关闭它。所以我们尽量以后面几次数据的平均值为准,代码如图:


     计算网络速度也是类似的想法,只是图片要大一些,不过我们可以不用选择img标签的形式,而是用ajax请求的方式。原因主要是两点:

  1. 图片文件大小js可以自己读取;
  2. 可以异步。

    同样,考虑到http请求需要建立连接,以及等待响应,这些过程也会消耗一些时间,所以以上的方法可能不会准确的检测出网络带宽。(此功能会增加用户的流量,因此慎用)参考代码如下:


    此外,chrome65+也提供了一个API,navigator.connection.downlink 来得到网络宽带的信息;

    不过对于执行这些检测的时机也是需要点技巧的,在不干扰用户行为的情况下,或者说,在浏览器空闲的时候执行这些检测才是合理的。因此我们可以利用一个API在合适的时机来调用这些数据。requestIdleCallback就是一个在浏览器的空闲时段内调用的函数排队的API,只不过这是一个比较新的API,因此需要做好处理,代码如下图:


4.3.2性能数据

     Performance 接口可以获取到当前页面中与性能相关的信息,performance.timing提供了在加载和使用当前页面期间发生的各种事件的性能计时信息,performance.getEntries()提供了当前页面所有资源的性能数据,包括文件资源,渲染数据等,不过我们并不全部需要,可以通过performance.getEntriesByType()获取指定的资源数据。这些数据都可以协助我们在多个维度,分析页面的性能。

4.4.用户行为记录模块

    虽然我们有了录屏的功能,但是我们对用户的行为了解并不彻底,毕竟在操作频繁的情况下,录屏最多只提供几秒内的数据,因此更加详细的行为记录十分必要。因此我们选择从用户交互、网络请求、路由跳转三个维度来记录用户的行为,他们按照打开页面开始后的时间戳,依次记录,方便还原。

4.4.1为存储记录的对象封装通用方法

    在模块化的开发方式下,数据对象作用域隔离不是问题,但为了方便添加记录,做到既可以添加数据,又可以根据唯一标识覆盖记录来保证数据是可以实时刷新,同时内部实现溢出自动出栈的效果,我们需要给目标对象添加一个add方法,项目中各个维度的记录都调用此方法, 代码如下:

    有了这样的对象存贮,接下来就可以着手如何添加记录了。

4.4.2用户交互事件捕获

    一个前端页面里比较隐藏的错误往往发生在用户在进行了某些交互操作后出现,因此为了清晰还原用户端的行为,记录用户的交互是第一要素。我们这里暂时只记录了点击事件,其他行为如滚动页面行为, 元素进入用户视野等等,或者有些操作是在TouchStartTouchMoveTouchEnd等事件中进行,这些大多跟业务场景比较贴近,后续会用可配置的方式来记录上传这类行为,首先我们来看看如何实现记录点击事件。


    由于开发者可能会对一些点击事件进行阻止冒泡,因此在监听点击事件时必须得在捕获阶段,同时,我们需要记录一些点击事件目标Target的详情,如:

  • class,id

  • outerHTML:不超过200个字符,省略中间部分

  • XPath:模拟计算,方便得知Dom所在的位置

4.4.3网络请求记录

    在说明如何监听网络请求错误的事件时,我们讲解了如何对网络请求的各个阶段进行了事件监听。与点击这类事件不同,网络请求是一个异步的过程,不能只监听一次事件。设想一下,我们如果只监听开始请求事件,则将无法得知请求是否完成, 耗时多少;同样地如果只监听请求结束事件,万一在请求过程中页面发生了错误,开始触发上报,那我们此时就没有记录到此次请求。这都是不合理的,因此需要不断跟踪这一个请求从发起到结束的全部状态。 在这之前我们需要了解XHR的请求事件的触发顺序,如图:



    由图可见,在没有发生网络错误的情况下,我们需要监听不同的事件来记录网络请求的所有过程,其中关键的节点包括:loadstart, progress(upload) ,progress,loadend。不过我们发现监听这些事件时,发现以下几点问题:

  1. 事件detail里的xhr对象,包含的信息很少,像method,post请求的body,自定义请求头都拿不到
  2. 在loadstart事件里,拿不到任何xhr的信息,如果上报出错,就拿不到任何XHR的相关信息,因为此时XHR还没被初始化,url,method还都不知道
    针对这些问题,我们不能仅仅只是监听XHR的事件,还需要代理,open、send和setRequestHeader方法,在调用的参数里抓取到相关参数,并触发自定义事件,在上述封装XMLHTTPRequest代码里加上一段,代码如图:


    因此为了每个事件有价值,拿到此时真实的网络请求数据,监听如下事件:ajaxOpen(替代loadstart),progress,loadend。同时给XHR示例添加时间戳等方法,每次更新记录最新的耗时。

有一点需要注意,我们的上报和网络监测等内部功能,不应该添加到记录里,因此需要有白名单控制

    fetch请求的监听跟XHR类似,只需监听fetchStartfetchEnd事件,只是抓取到的参数没有那么丰富。其他跟XHR一样,记录类型不同而已。添加记录的代码如下图:


4.4.4页面路由跳转记录

    为什么要记录页面跳转呢?有时候我们的页面错误,并不是因为当前页面的逻辑出错了,而可能受其他页面的逻辑影响。在单页应用中就很常见,状态管理使多个页面的数据得以共享,当我能够清晰地了解到我的路由跳转是什么样的顺序,我们就能评估报错会是受到了哪里的影响。

    除此之外,还有一个很重要的原因,页面之间的通信经常是以url的形式完成,如果发生原子参数丢失,或者拼接错误,如果我们不知道页面之前的跳转,自然很难找到原因。这曾经在我们一个项目中就困扰了非常之久,不了解报错的页面参数如何丢失,从哪里跳转过来,url如何发生了变化,加上这一个检测,我们就能解决这一痛点。

于是我们可以通过什么样的方式监听,页面url的变化呢?

  1. hashchange事件
  2. 劫持pushState和replaceState方法,触发自定义事件
  3. 不支持上述事件的用定时器轮询检查

    单页应用的前端路由里的hash模式就是基于hashchange事件来更改视图,用这个事来检测路由变化自然没有任何问题。但是history里的pushState和replaceState两个更改url的方法并不会任何事件,因此可以重写这两个方法,触发自定义事件。重写这两个方法的代码如下:


    但是如果在IE浏览器环境下,是IE8以前不支持hashchange的。这种情况下就用定时器时刻监听url是否有变化。那么代码如下:


     当然,除此之外,location.href和location.replace也会跳转页面,更改url,但并不会触发popstate。而且可惜的是,这两个location的属性是不可配置(configurable: false)的,所以无法重写。不过到了这里我们并不用为这里不能监听而沮丧,毕竟这两个方法会重置页面,因此我们只需要知道报错的url和documen.referrer也能够了解到足够的信息,甚至可以上报一些本地存储的数据,这样可以不用非得知道这里是如何跳转,也能排查问题了。

为什么没有用popstate事件?

最开始的想法里,popstate肯定是需要的,也确实能够监听URL变化,但是有问题的是:

  1. 单页应用的hashchange事件触发时,popstate也会触发,监听多余了
  2. popstate的事件回调里,拿不到上一个页面的url,监听没有意义

五、END

5.1功能盘点

  • js运行时错误异常捕获上报
  • Promise未处理异常捕获上报
  • 文件资源加载报错捕获上报
  • 网络请求报错捕获上报
  • 用户行为记录(交互事件,网络请求,页面跳转)
  • 录屏
  • 运行环境信息检测
  • 主动监测环境信息
  • 自动埋点上报
  • 主动埋点上报

5.2现阶段

    目前该平台已经初步在一些内部项目中试水使用了,通过团队中台的推送能力,可以实现钉钉、邮箱的实时通知,而且能制定通知规则。


5.3下阶段

    我们分两期来做异常捕获平台项目,本阶段主打客户端数据上报监控。下阶段主打数据统计平台。初步计划:

  • 数据清洗
  • 数据削峰
  • 数据统计
  • 数据可视化(大屏)
  • 资源加载性能
  • LCP
  • FID
  • CLS

    预计数据统计平台初版完成时间将在8月20左右,下一篇文章将围绕数据统计来撰写,把我们开发中遇到的问题,我们是如何解决的,最终成果等通过文章等形式分享出来。

感谢观看!

六、参考文献