随着业务快速迭代发展,系统对业务的监控、优化不再局限于行为、性能监控。前端异常监控更能反应用户端的真实体验。精细化的监控可以及时主动发现问题减少损失,针对性的分析治理甚至能带来业务增益。本文结合广告托管团队异常监控治理的经验,介绍从异常打点收集、报警监控、排查分析、治理优化的实战总结。
一、前言
行为、性能、异常打点是前端领域的老生常谈,实践层面很多团队对这些打点的应用范围也是:行为 > 性能 > 异常。这不难理解,行为统计从团队收益角度来看,短期内更加直观,甚至有一些打点本身就是业务需求,如功能上线后的 PV、UV 统计等。
一般对于线上服务来说,后端异常监控是必须项,服务异常的主动发现也多从后端来,前端的异常监控能扮演什么样的角色呢?加入这样的投入从管理者角度来看是划算的吗?异常怎么监控能更快的发现并引导止损?面对这些问题,很多业务的前端异常监控工作,还没开始就结束了。
我们团队在实践中总结了一些思考和经验,希望能对读者有一点帮助。
1.1 业务背景介绍
我们是百度广告托管业务,承接着众多行业的站点建设工作。这其中包含移动/桌面端的web站点、小程序、HN(百度App的类ReactNative方案)等多种载体,每天有大量的访问流量。
对于网民来说我们要保障流畅的阅读、交互体验。
对于广告主来说,我们要提供高质量保障。
通过前端异常监控与治理,业务团队收获了提前发现问题、及时止损,优化广告效果等诸多收益。
1.1.1 要解决什么问题
如文章一开始介绍,业务发展的相当一段时间内,团队的重心一直在后端的监控和报警完善上。但当我们将服务的稳定性治理达到一定的标准后,发现一些线上问题仍然难以召回,例如
1、页面整体或某些部分渲染异常,影响体验甚至广告转化和成本。可能造成的原因举例:
相较后端异常监控,资源加载、JS执行异常都是前端异常监控带来的增量场景,端到端的接口稳定性更接近用户的真实感知,更能表明网络对稳定性带来的影响。
2、小流量场景提前发现问题
产品的发布往往需要小流量、AB 测试的验证。或者某些问题仅在一些特定场景下触发,因为流量限制,很难通过服务数据波动发现,随着扩量造成更大负向影响或客户投诉后才被发现。
前端异常监控能很好地帮助我们解决这些场景的问题。下面将从异常收集、 完善监控报警 、 异常排查 、 异常治理几个阶段,介绍我们的主要工作和经验。
说明:本文更多从业务应用视角讨论问题,对于通用的埋点接受服务、数据处理、展示平台不做太多探讨,所幸团队已经有这样的专业人员和平台。结合我们的业务场景需求,和平台共同设计并支持了通用监控之外的业务异常监控,后面会介绍。
二、异常收集
第一步,我们要把异常情况,以打点的形式发送至收集服务。这包含很多文章提到的通过 window 监听捕获到的 error 等通用方案 ,还有一些更加隐蔽,但对业务有很大影响的业务异常。
2.1 通用异常收集
通用异常收集是一种无侵入的异常收集方式,无需业务开发者主动表达,在系统发生异常时,通过事件的冒泡、事件捕获或者一些框架提供的hook 函数来进行错误的收集。
针对页面中异常进行收集时,主要会涉及两类场景:
针对资源加载异常,业务中会有以下两种监控方式:
window.addEventListener('error', fn, true)
进行捕获。这种方式的好处是,无需在每一个资源中进行错误的处理。但需要注意的一点是由于网络请求异常不会事件冒泡,因此必须在捕获阶段将其捕获到才行。
针对运行时产生的异常, 通常我们使用以下方式进行监控:
页面顶层添加如下事件:
window.onerror 或 window.addEventListener('error', fn)
但这种处理方式也有其局限性,针对未 catch 住的 promise 产生的异常无法进行捕获,所以在业务使用时,一般是额外再添加一个事件监听方法来捕获未被处理的 promise 异常。
window.addEventListener('unhandledrejection', fn)
针对运行时产生的异常,一些前端框架也给我们提供了配置方法来简化我们的日常开发。
React 框架:
在 React 16之后,框架支持componentDidCatch 用于对render 时异常进行捕获。但在使用时需要注意,参见error boundary:https://reactjs.org/docs/error-boundaries.html
Error boundaries do not catch errors for:
Vue 框架:
Vue 框架也提供了类似全局的错误配置。下面方法可以指定组件的渲染和观察期间未捕获错误的处理函数。
Vue.config.errorHandler = (err, vm, info) => {}
从 2.2.0 起,这个钩子也会捕获组件生命周期钩子里的错误。同样的,当这个钩子是 undefined 时,被捕获的错误会通过 console.error 输出而避免应用崩溃。
从 2.4.0 起,这个钩子也会捕获 Vue 自定义事件处理函数内部的错误了。
从 2.6.0 起,这个钩子也会捕获 v-on DOM 监听器内部抛出的错误。另外,如果任何被覆盖的钩子或处理函数返回一个 Promise 链 (例如 async 函数),则来自其 Promise 链的错误也会被处理。
注意:
在捕获到的异常中,经常会看到错误信息为 “Script error” 的异常。该类异常产生的场景是网站请求并执行一个跨域脚本,如果该脚本报错,在全局监听异常的方法中,就会捕获到错误信息为 “Script error” 的异常。由于浏览器的安全限制,这里并未展示出具体的报错信息,这对排查问题是十分不友好的。目前的项目打包之后的资源文件大多会单独部署到 CDN 服务上,资源的引用域名为 CDN 域名,与页面运行的域名不一致。
常见的解决方式为使用打包工具,在 script 链接上添加:
crossorigin(https://developer.mozilla.org/zh-CN/docs/Web/HTML/Attributes/crossorigin)属性,同时要在资源的返回头中添加 access-control-allow-origin: yourorigin.com。这样,通过 CDN 地址引入的 JS 在运行报错时,基于全局的错误监听方法就可以获取到完整的错误信息了。
2.2 业务异常收集
2.2.1 如何定义业务异常
在通用的异常收集基础上,系统增加了业务自定义异常的打点。它是一种“有埋点”的开发方式(相对上层开发者不感知的“无埋点”方式来说)。开发者在程序中显式发出数据点,并常伴随一些当时的运行数据。
为什么要增加这种方式?标准方式采集到的异常数据信息量有限,大多是异常堆栈等。但仍有一些场景不能很好满足:
1、无法直接从底层捕获到
虽然控制台中你看不到飘红的报错,但从业务角度来看有些问题仍需关注。
如:APP 下载业务中,客户需要在页面中绑定渠道下载包,再用页面进行广告投放。有时候客户操作失误,将安卓下载包投放到了 iOS 中,这种情况在页面渲染阶段不会有什么异常,但明显对广告转化是不利的,从业务视角需要被发现解决。
2、一些异常堆栈信息不充分
我们需要获取一些运行时的数据,来辅佐后续问题的定位和分析。比如当前访问的账户 id ,当时应用状态中的某些关键业务数据等。
如:某次业务中发现大量的 “onAndroidBack is not defined” 的异常,通过异常信息中携带的产品线 id ,快速定位到产生异常的产品线,和开发同学沟通后,定位到了问题代码进而进行业务兼容。
3、分析成本高,报警时效低
如果你有过实践,会发现异常数据的分析、统计工作并不轻松,尤其是在非常通用的异常堆栈中找到更精准的问题,及时报警、排查、止损。业务异常能让我们在异常发生时,更加直观地定位根因。我们并不是想在分析阶段偷懒,而是让数据服务的计算逻辑更加简单直接,这样大数据处理时效更高,报警后人工分析修复问题也更高效。
2.3 异常收集协议
为了能够支持通用异常和业务自定义异常,需要设计统一的传输、存储数据协议。
传输和存储协议的设计:
传输协议的的设计需要遵循以下原则:顶层 schema 稳定,业务信息可扩展。除此之外,下游数据处理模块能够快速对接也是其设计时需要重点考虑的一个因素,于是我们对整体的数据传输协议格式进行了以下的定义:
一级 key 延续通用的数据处理模块支持的数据结构,比较重要的有以下几个字段。其中 meta 字段为广告托管业务中一些通用的数据字段。并且对 meta 中的 extra 字段进行了二次开放,通过埋点 sdk 对外暴露的 api ,开发者可以将一些其他的业务数据一起上传,以辅助后期异常的排查。
{
exception: // 储存异常堆栈相关信息
request:// 储存当前页面相关的信息
meta: {
xxx: // 业务相关字段
extra:{
// 开发者可自行扩展的字段
}
}
}
上述的设计在满足稳定性,完备性的前提下,又支持业务的灵活扩展。
这里或许大家会有疑问,为什么 extra 的字段不能打平放在 meta 中?
之所以这样设计,和底层的表结构索引的建立以及下游数据处理的复杂度息息相关。放在 meta 中的业务相关字段是托管页的通用字段,为了方便后续的查询,在数据库中是以列的形式存在,需要提前枚举出,为了定义这些一级 key ,对整体业务进行梳理。最终定义在一级 key 中的字段特征为:
存放在 meta 一级 key 中的数据升级成本较高,需要上下游都理解其业务含义后统一升级。为了方便业务方进行信息的灵活扩展,在 meta 中添加 extra 字段,并将该字段以字符串形式存储到数据库中。数据库存储使用了百度自研的 BaikalDB(https://github.com/baidu/BaikalDB) ,对这类结构化信息的实时存储和读取有很好的支持。
为了能实现通用异常和自定义异常的上报,我们对异常捕获进行了分级。
通用异常使用下面的方式进行上报:
window.addEventListener('error', error => {
logSdk.addWindowErrorLog(error)
},true)
// 借助vue 框架的能力,将运行时的js 异常进行上报
Vue.config.errorHandler = (err, vm, info) => {
logSdk.addCustomErrorLog({
errorKey: xx, // 框架收集到的异常,会有一个默认确定的errorKey。
error: err,
userExtra: {
message: info
}
});
};
业务自定义异常使用下面方式进行上报:
try {
xxx 业务逻辑
} catch(e) {
logSdk.addCustomErrorLog({
errorKey: 'xxx', // 具体的业务类型
error: e,
userExtra: {
// 业务自定义扩展字段。对应传输协议中的extra。
}
})
}
在页面打开时,通过传入业务 meta 信息对埋点 sdk 进行实例化。当发生异常时,如果业务方进行了捕获,则由业务方自行构造错误参数,调用 API 进行上报。未被业务捕获到的异常,将通过框架的统一异常处理逻辑进行上报。同时,注册全局的 error 事件处理方法,针对一些资源加载异常,以及一些其他的异常进行捕获。
这样层层嵌套的模式,即实现了业务方自定义异常的诉求,又可以对未被业务方捕获的异常进行统一收集上报。
在异常日志存储入库前需要对数据进行预处理,这里借助了公司内流式计算平台的能力,针对每条日志数据进行实时 ETL 处理,最终将 meta 中的数据以及在 nginx 层获取到的一些数据实时存储到数据库中。
这样从传输协议的通用性到存储查询的高效性综合考虑后,最终得到了一个存储线上异常日志的表,这个存储异常数据的表结构列非常之多,是一个很大的“宽表”,这个“宽表”为后面的数据聚合报警提供数据支持。
三、完善监控报警
大量的数据要想产生准确高效报警,需要经过下图的流程:基于打点的元数据,创建监控项;基于监控项的统计,设定报警策略。
3.1 圈定监控项
如前所述,监控平台将异常收集并形成一个大宽表,基于宽表,多个聚合项的条件分析,可以满足绝大多数的监控项诉求。
监控项:对某列数据的过滤,如 URL 包含某个 query ,业务类型属于某个范围。平台上支持了如下图的多种过滤条件(支持正则)。
监控聚合:多个监控项的交集。如(业务线 === XXX) && (请求状态码 === 500)。
3.2 制定报警策略
报警策略有三大关键因素:聚合周期、报警接收组、触发机制。
聚合周期
监控项是一种统计规则,聚合周期是对规则统计的窗口。根据数据量、重要性等设定合理的聚合周期。如对于最高优的、波动敏感的广告转化相关异常设定30秒的准实时报警;反之可以适当加大窗口。避免波动较大的监控项频繁误报。
触发机制
触发有阈值和波动两种方式。针对比较平稳的异常数可以设定阈值,例如分日看某些业务指标基本持平;针对有波动的异常,可以通过昨日、上周、两周前来对比,例如用户访问量在一天内成一定规律波动,异常量会跟随波动变化。
如下图波动较大,没有明显的时间规律,适合用阈值:
如下图异常数量有时间规律,可以设定波动报警。
在实践过程中,我们常常双管齐下,平衡报警的准招率是一个很不简单的事情。我们也会一边观察、治理,一边调参。更多的一些挑战和方案,会在后面提到。
报警接收组
通过邮件、即时通信消息、短信等方式保证报警触达。最重要的经验是,报警接收人永远不要单点依赖 !
3.3 挑战
前面提到,异常监控报警的准招重要且有很大挑战。一般 Server 服务会有网关,运行环境稳定,而前端代码运行的环境会更加不可控,对完善监控都是很大挑战。
挑战1: 如何建立完备的监控?
异常都上报之后,必须针对每类异常都能够感知,正常来说,按照异常的类型(资源加载异常、API 异常、JS 执行异常)来分类,针对每一类异常建立监控,即可满足完备性的要求。
但是在实践中发现,这种设置不适合托管页。文章开头提到,托管页的业务场景覆盖不同的端。不同端之前的流量差异巨大,不同端之间复用同一个异常监控项,流量小的端产生的错误很容易被淹没在整体异常中。因此,从托管业务出发,将异常划分为两个维度:异常类型和异常所在的端。
两个维度进行组合进行建立的监控项既可以满足完备性的要求,又可以及时的发现不同端之间产生的问题。
挑战2: 如何提高报警的精准度?
托管页的报警是基于各种条件进行实时的聚合,然后与预设的阈值进行对比来判断是否触发了报警。理论上来讲,报警的准确度取决于业务方,只要聚合的条件足够精准,报警就足够的精准。但是这是一个成本与实践的反复试验,在报警触发之前,你不知道什么样的条件能够排除掉这种无效报警。因此,在业务的不断实践探索中,沉淀了一些通用的异常聚合条件以提升报警的精准性:
排除爬虫流量(通过ua)
只看商业流量(通过商业投放参数判断)
逐步完善的异常黑名单(已知的无法解决的异常,比如外部注入导致的 “Script error” 等异常)
举例来说:开始的时候设置来自某个业务线的 JS 异常报警。聚合条件设置如下
业务线 = xxx && 错误类型 = js异常
优化后的报警聚合条件为:
业务线 = xxx
&& 错误类型 = js异常
&& 商业流量标志 != '' // 排除掉非商业流量
&& ua not like '爬虫ua' // 排除掉爬虫流量
&& error_message not like 'Script error' // 排除掉黑名单中的异常
为避免每个报警项都重复的设置相同的聚合条件,把一些通用的数据在顶层进行过滤,在提升报警精准性的同时减少了每个业务方的配置工作。
挑战3:带有明显周期性的异常监控项如何设置监控的同比和环比?
针对有明显周期性的异常监控在初期设置的时候,一般都会比较谨慎。设置的过小,产生的无效报警会很多。设
置的过大,有报警时无法及时触发。
这种情况在实践中发现:
同环比的设置不应该在一开始设置的,应该观察一段时间再设置。比如,同比昨天的数据,这个阈值的设置应该在至少积累2天数据后再设置,以实际每天数据的波动情况来进行合理的阈值百分比设置。
同比环比的设置不应一成不变的,应该隔一段时间更新一次。随着业务的发展,线上的异常请求是不断变化的,如果发现一段时间内的报警变多了,而排查后发现大部分是无效的报警,这个时候,你就需要重新考虑你的报警设置的是否合理了。
挑战4:如何监控报警后的问题跟进情况?
托管页的异常治理,不仅是一个基础能力的建设。还希望形成异常问题发现、异常跟进、异常解决的工程化能力闭环。异常的跟进打通公司内部任务管理平台,针对每一个报警创建一个任务卡片,由具体的异常负责人进行跟进。当问题解决后,可以在任务卡片上进行具体信息的录入,以此来实现每个异常都有专人跟进处理的目标。为提升问题的跟进率,我们还会基于任务卡片的信息进行例行化的统计,针对卡片停留时长、卡片个数等进行分析计算并打通内部即时通讯工具对卡片统计信息进行例行化推送。
四、异常排查
在收到一个异常报警后,快速定位到报警产生的原因是一个非常常见的业务场景。
从实践中,我们总结出几点能够快速提升异常排查效率的方式:
1、善用聚合
很多时候,线上的异常数据是在某个区间内来回波动。当突然出现一个突刺时,通过聚合可以快速的查询到问题所在。
通过这些聚合条件,可以快速的发现这些突发异常的相似点。常用的聚合选项可以有 ip、ua、设备 id、URL等。
例如:一次线上的资源加载失败报警中,发现异常日志中的页面URL、资源失败URL、投放参数均不相同。排除了个别广告页加大投放流量的可能,也排除了机器脚本刷页面的可能。最后通过 ip 聚合后,发现异常都是在某个地区,和CDN 同学反馈后,发现这个地区缺失存在网络故障,及时的推动,避免了更大范围的损失。
2、完善基础能力的支持
目前线上的 JS 都是压缩之后的。一旦产生了异常信息,在异常堆栈中存储的也是压缩之后的信息,不便于问题的排查。因此,我们协同下游错误分析平台,上传托管页相关的 sourcemap 资源。这样在产生的 JS 执行异常中的报错信息就可以通过 sourcemap 文件,直接定位到原始错误文件位置,方便开发者快速的定位到发生问题的代码位置,提升问题排查效率。
五、异常治理
上面介绍的异常收集、异常报警以及异常的排查,偏重于一种被动的场景。触发了线上报警,才会介入问题的排查。但其实除了这些偶尔的突刺带来的报警问题跟进,我们也主动出击,针对线上现存的一些异常,探究一些通
用的方案,以主动优化线上的异常场景,提升托管页线上的稳定性。
首先,为了统一治理目标,协同各方一起处理线上异常,我们从以下几步出发进行线上异常治理目标的设定:
1.明确异常错误类型
在 JS 执行异常,API 异常,资源加载异常的基础上,再次进行细分。最终落地4个异常类型,分别为:
JS 执行异常
API 异常
图片资源加载异常
SCRIPT 资源加载异常
2.清洗数据
由于托管落地页运行的场景不一,为排除一些测试数据或者网络爬虫数据的影响,在数据的筛选时只看来自商业流量的错误。
线上已知的一些由于端上注入导致的一些不影响前端稳定性的异常错误信息,建立错误信息的黑名单,通过具体的错误信息,排除此类错误的干扰。
3.建立合适的数据标准
为抹平不同产品线之间的流量差异,我们提出了单次广告点击产生的异常数的概念。将异常数量的绝对值变成了一个以广告流量为基准的相对值,以此来衡量不同流量产品线下的异常量。这样归一化之后,排除了广告流量对异常数据量的影响。
针对和网络情况相关指标比如:单次广告点击图片/ SCRIPT 加载失败数以及 API 请求失败数
建立数据标准的流程是:
核心思路:此类异常都是由于网络原因导致的,不同产品线之间的值应该趋于一致。基于此,取80分位值作为优化的基准线,没有达到这个基准线的产品线除了网络因素外一定存在其他的问题,可以推动这些产品线向这个统一的标准对齐。(为了避免某一天的数据过于极端,可以考虑取平均值或者去除突刺数据后取最小值来得到最后的目标值)
针对和运行时关系比较密切的指标:单次广告点击 JS 失败数
建立数据标准的流程是:
建立优化目标后,便可以针对性的优化。
针对由于网络原因导致的资源加载异常核心采取以下思路。
- 改用 CDN 链接或减少资源大小可降低第一次加载失败率
我们针对 API 请求异常、SCRIPT 加载异常、图片加载异常分别从底层出发,建立了相应的重试机制。其中,除 SCRIPT 加载异常的重试业务方无感知外,API 请求异常以及图片加载异常,业务方都可以通过传入相关参数来进行业务表达,以支持不同的业务场景。
针对 JS 执行时的异常,我们建立了一个完整的处理流程:
通过以上四步来优化每一类具体的业务异常。
通过以上方式,我们设定了合适的目标,并进行了针对性的优化。最终,每个异常指标的数据在针对性治理后均有不同程度的下降,同时,在异常治理时引入线上实验以衡量降低线上异常数对广告转化的影响,实验结果表明:app 下载,以及线索的转化均有所提升。
六、后记
异常治理是一条难但正确的道路。在业务落地实践中,遇到了很多问题和挑战,我们完成了从0到1的过程,探索了一种可持续的前端异常监控与治理的方式,但是很多事情还需要深耕,这样才能不断的降低托管页前端的异常数量,提升托管页线上的稳定性。