架构-异常监控
前端监控包含了行为监控, 异常监控, 性能监控等, 这里我们主要讨论异常监控.
一般来说, 一个监控系统, 可以分为四个阶段: 日志采集, 日志存储, 统计与分析, 报告与警告.
- 采集: 收集异常日志, 先在本地进行一定的处理, 采用一定的方案上报到服务器.
- 存储: 后端接受前端上报的异常日志, 经过一定处理, 按照一定的存储方案存储.
- 分析: 分为机器自动分析和人工分析
- 机器自动分析: 通过预设的条件和算法, 对存储的日志信息进行统计和筛选, 发现问题, 触发报警
- 人工分析: 通过一个可视化的数据面板, 让用户数据可以看到具体的日志数据, 根据信息, 发现异常问题的根源.
- 报警: 分为告警和预警, 告警按照一定的级别自动报警, 通过设定的渠道, 按照一定的触发规则进行. 预警则在异常发生前,提前预判, 给出警告.
一. 前端异常
前端异常分类
根据严重程度, 可以分为出错,呆滞,损坏,假死,崩溃.
- 出错: 界面错误, 内容与预期不符, 例如点击进入非目标界面, 数据不准确, 错误提示无法理解, 界面错位
- 呆滞: 界面出现操作后没有反应的现象, 例如点击按钮无法提交, 提示成功后无法继续操作. 产品已经存在界面级局部不可用现象
- 损坏: 界面出现无法实现操作目的的喜爱你想, 例如点击无法进行目标界面, 点击无法查看详情内容等. 这类一场出现时, 应用部分功能无法被正常使用.
- 假死: 界面出现卡顿, 无法对任何功能使用的现象. 例如用户无法登陆导致无法使用应用内功能, 由于某个遮罩阻挡且不可关闭导致无法继续后续操作等.
- 崩溃: 应用出现经常性自动退出或无法操作的现象, 例如间歇性的 crash, 网页无法正常加载或加载后无法进行任何操作.
异常错误原因分类
前端产生异常的原因主要分为 5 类:
- 逻辑错误(经常)
- 业务逻辑判断条件错误
- 事件绑定顺序错误
- 调用栈时序错误
- 错误的操作 JS 对象
- 等等
- 数据类型错误(经常)
- 将 null 视为对象读取 property
- 将 undefined 视为数组进行遍历
- 将字符串形式的数据直接用于加运算
- 函数传参错误
- 等等
- 语法句法错误(较少)
- 网络错误:
- 网络慢
- 数据未返回数据但仍 200, 前端按正常进行数据遍历
- 提交数据时网络中断
- 服务端 500 错误前端未作处理
- 等等
- 系统错误:
- 内存不足
- 磁盘不足
- 浏览器不支持的 JS 语法
- 兼容性问题
- 等等
二. 异常采集
采集内容
当异常出现的时候, 我们需要知道异常的具体信息, 根据异常的具体信息来决定采用什么样的解决方案.
在采集异常信息时, 可以遵循 4W 原则:
WHO did WHAT and get WHICK exception in WHICK environment ?
- 用户信息
出现异常是该用户的信息, 包含并不限于用户当前时刻的状态, 权限等, 以及需要区分用户可多终端登录时, 异常对应的终端信息.
- 行为信息
用户进行了什么操作时产生的异常: 所在界面的路径, 执行了什么操作, 操作时使用了那些数据, 当时的 API 吐了什么数据给客户端, 如果是提交操作, 提交了什么数据, 上一个路径, 上一个行为日志记录的 ID 等.
- 异常信息
产生异常的代码信息: 用户操作的 DOM 元素节点, 异常级别, 异常类型, 异常描述, 代码 stack 信息等.
- 环境信息
网络环境: 设备型号, 标识码, 操作系统版本, 客户端版本, API 接口版本等.
下面是一份参考字段:
| 字段 | 类型 | 描述 |
|---|---|---|
| requestId | String | 一个界面产生一个 requestId |
| traceId | String | 一个阶段产生一个 traceId,用于追踪和一个异常相关的所有日志记录 |
| hash | String | 这条 log 的唯一标识码,相当于 logId,但它是根据当前日志记录的具体内容而生成的 |
| time | Number | 当前日志产生的时间(保存时刻) |
| userId | String | 用户唯一标识符 |
| userStatus | Number | 当时,用户状态信息(是否可用/禁用) |
| userRoles | Array | 当时,前用户的角色列表 |
| userGroups | Array | 当时,用户当前所在组,组别权限可能影响结果 |
| userLicenses | Array | 当时,许可证,可能过期 |
| path | String | 所在路径,URL |
| action | String | 进行了什么操作 |
| referer | String | 上一个路径,来源 URL |
| prevAction | String | 上一个操作 |
| data | Object | 当前界面的 state、data |
| dataSources | Array | <Object> 上游 api 给了什么数据 |
| dataSend | Object | 提交了什么数据 |
| targetElement | HTMLElement | 用户操作的 DOM 元素 |
| targetDOMPath | Array | <HTMLElement> 该 DOM 元素的节点路径 |
| targetCSS | Object | 该元素的自定义样式表 |
| targetAttrs | Object | 该元素当前的属性及值 |
| errorType | String | 错误类型 |
| errorLevel | String | 异常级别 |
| errorStack | String | 错误 stack 信息 |
| errorFilename | String | 出错文件 |
| errorLineNo | Number | 出错行 |
| errorColNo | Number | 出错列位置 |
| errorMessage | String | 错误描述(开发者定义) |
| errorTimeStamp | Number | 时间戳 |
| eventType | String | 事件类型 |
| pageX | Number | 事件 x 轴坐标 |
| pageY | Number | 事件 y 轴坐标 |
| screenX | Number | 事件 x 轴坐标 |
| screenY | Number | 事件 y 轴坐标 |
| pageW | Number | 页面宽度 |
| pageH | Number | 页面高度 |
| screenW | Number | 屏幕宽度 |
| screenH | Number | 屏幕高度 |
| eventKey | String | 触发事件的键 |
| network | String | 网络环境描述 |
| userAgent | String | 客户端描述 |
| device | String | 设备描述 |
| system | String | 操作系统描述 |
| appVersion | String | 应用版本 |
| apiVersion | String | 接口版本 |
不同情况下收集的字段可能是不一样的, 因此这种情况下, 使用文档数据库更合适用来存储这些数据.
异常捕获
前端捕获异常分为全局捕获和单点捕获. 全局捕获代码几种, 易于管理; 单点捕获作为补充, 对某些特殊情况进行捕获, 分散, 灵活, 不利于管理.
- 全局捕获
- 通过全局的接口, 将捕获的代码集中写在一个地方:
window.addEventListener('error'); //当资源加载失败或无法使用时,会在Window对象触发error事件。例如:script 执行时报错。
window.addEventListener('unhandledrejection'); //当Promise被reject并且没有得到处理的时候,会触发unhandledrejection事件。
document.addEventListener('click'); //捕获顶层的点击事件
- 框架级别的全局监听, 例如
axios中使用interceptor进行拦截, vue, react 都有自己的错误的采集接口 - 通过对全局函数进行封装包裹, 实现在调用该函数时自动捕获异常
- 对实例方法重写(Patch), 在原有功能基础上包裹一层, 例如对
console.error进行重写, 是的在使用方法不变的情况下也可以进行异常捕获
- 单点捕获
在业务中对的那个但那块进行包裹, 或在逻辑流程中打点, 实现有针对性的异常捕获:
try..catch- 专门写一个函数来捕获异常信息, 在异常发生时, 调用该函数
- 专门写一个函数来包裹其他函数, 得到一个新函数, 该新函数运行结果和原函数一模一样, 只在发生异常时可以捕获异常.
- 跨域脚本异常
由于浏览器安全策略限制,跨域脚本报错时,无法直接获取错误的详细信息,只能得到一个 Script Error。例如,我们会引入第三方依赖,或者将自己的脚本放在 CDN 时。
解决 Script Error 的方法:
方案一:
- 将 js 内联到 HTML 中
- 将 js 文件与 HTML 放在同域下
方案二:
- 为页面上 script 标签添加
crossorigin属性 - 被引入脚本所在服务端响应头中,增加
Access-Control-Allow-Origin来支持跨域资源共享
异常录制
对于一个异常, 其异常发生的位置, 并不一定是异常根源所在的位置. 我们需要对异常现场进行还原才能复原问题全貌, 甚至避免类似的问题在其他界面中发生. 异常录制就是这样一个概念.
所谓的一场录制, 实际上就是通过技术手段, 收集用户的操作过程, 对用户的每一个操作都进行记录, 在发生异常时, 把一定时间区间内的记录重新运行, 形成影响进行播放, 让调试着无需向用户询问, 就能看到用户当时的操作过程.

用户在界面上的操作产生的 events 和 mutation 被产品收集, 上传到服务器, 经过队列处理按顺序放在数据库中. 当需要进行一场重现的时候, 将这些记录从数据库中取出, 采用一定的技术方案, 顺序播放这些记录, 即可实现一场还原.
三. 异常上报
除了采集异常本身, 我们还会采集与异常相关的用户行为日志. 单纯一条异常日志并不能帮助我们快速定位问题根源, 并且要采集与异常相关的用户行为日志. 收集用户的行为日志的时候需要采用一定的技巧, 不能用户每一个操作后, 就立即将该行为上传服务器, 一般我们会将日志在存储在用户客户端本地, 达到一定条件之后, 再上传到服务器.
本地持久化方案可以参照浏览器存储一节.
其中, IndexedDB 是最好的选择, 容量大, 异步, 不会对程序造成阻塞. 并且 indexedDB 是分库的, 每个库又分 store, 能够按照索引进行查询, 具有完整的数据库管理思路. 可以使用hello-indexeddb这个工具, 它使用Promise对复杂 api 进行封装, 简化操作, 使得 indexedDB 的使用更加便捷, 除此之外, indexedDB 是被广泛使用的 HTML5 标准, 兼容大部分浏览器.
下面是具体的使用思路:

当一个事件, 变动, 异常被捕获之后, 形成一条初始日志, 被立即放入暂存区(indexedDB 中的一个 store), 之后主程序结束收集过程, 后续的事务在 webworker 中完成, 在一个 webworker 中, 一个循环任务不断从暂存区中取出日志, 对日志进行分类, 将分类结果存储在索引去中, 并对日志记录的信息进行丰富, 将最终上报到服务器的日志记录转存到归档区, 当一条日志在归档区中的时间超过一定时间之后, 它就没有价值了, 就会被移动到回收区, 在经历一定时间时间后, 就会被从回收区中清除.
前端整理日志
在一个 webworker 中, 会对日志进行整理后存到索引区和归档区.
这里的上报主要是按照索引进行的, 因此, 在前端的日志整理工作, 主要是根据日志特征, 整理出不同的索引. 我们在收集日志的时候, 会给每一条日志打上一个 type, 以此进行分类, 并创建索引, 同时通过object-hashcode计算每个log对象的 hash 值, 作为这个 log 的唯一标识.
- 将所有日志按时序存放在归档区, 并将新入库的日志加入索引.
BatchIndexes: 批量上报索引(包含性能等其他日志), 可一次批量上报 100 条MomentIndexes: 即时上报, 一次全部上报FeedbackIndexes: 用户犯规上报, 一次上报一条BlockIndexes: 区块上报, 按异常/错误(traceId, requestId)分块, 一次上报一块
- 上报完成后, 被上报过的日志对应的索引删除
- 3 天以上日志进入回收区
- 7 天以上的日志从回收区清除
其他:
requestId: 同时追踪前后端日志, 由于后端也会记录自己的日志, 因此, 在前端请求 api 的时候, 默认戴上了 requestID, 后端记录的日志就可以和前端日志对应起来traceId: 追踪一个异常发生前后的相关日志, 当应用启动时, 创建一个traceid, 直到一个异常发生时, 刷新traceid. 把一个traceId相关的requestid收集起来, 把这些requestID相关的日志组合起来, 最终就是这个异常相关的所有日志, 可以用来对异常进行复盘.
下面是一个具体的例子:

解释如下:
hash4是一条异常日志,hash4对应的traceId为traceid2,- 有两条该
traceid2的日志, 其中对应的reqid有2,3, reqid2,3分别对应2,1条请求,将三条日志全部记录在起义, 称为一个block- 利用
hash的集合, 得出这个block的hash, 在索引区中简历索引, 等待上报.
实际上, 一个 block 就是一个异常的 traceid 对应的所有的 requesID 分别对应的所有的日志记录.
上报日志
上报日志在 webworker 中进行, 为了和整理区分, 可以分两个 worker. 上报的流程为:
- 在每一个循环中, 从索引区取出对应条数的索引,
- 通过索引中的 hash, 到归档区取出完整的日志记录,
- 将日志记录上传到服务器
按照上报的频率(重要紧急度), 可将上报分为四种:
- 即时上报
收集到日志以后, 立即触发上报函数, 仅用于 A 类异常. 由于网络不确定因素影响, A 类日志上报需要有一个确认机制, 只有确认服务端已经成功接受该上报信息之后, 才算完成, 否则需要有一个循环机制, 确认上报成功.
- 批量上报
将收集到的日志存储在本地, 当收集到一定数量之后再打包一次性上报, 或者按照一定的频率, 打包上传, 这相当于把多次合并为一次上报, 以降低对服务器的压力
- 区块上报
将一次异常的场景打包为一个区块后进行上报, 和批量上报不同, 区块上报针对异常, 而不保证日志的完整性.
- 用户主动提交
在界面上提供一个按钮, 用户主动反馈 bug, 有利于加强与用户的互动.
或者当异常发生时, 虽然对用户没有任何影响, 但是应用监控到了, 染出一个提示框, 让用户选择是否愿意上传日志, 这种方案合适涉及用户隐私数据时.
| / | 即时上报 | 批量上报 | 区块上报 | 用户反馈 |
|---|---|---|---|---|
| 时效 | 立即 | 定时 | 稍延时 | 延时 |
| 条数 | 一次全部上报 | 一次 100 条 | 单次上报相关条目 | 一次 1 条 |
| 容量 | 小 | 中 | – | – |
| 紧急 | 紧急重要 | 不紧急 | 不紧急但重要 | 不紧急 |
下面是一个上报的大致流程:

在上报是, 先通过 hash 查询, 让客户端知道准备要上报的日志集合中, 是否存在已经被服务端保存好的日志, 如果已经存在, 就将这些日志删除, 避免重复上报, 浪费浏览.
为了确保上报是政工的, 在上报时需要有一个确认机制, 由于服务端接收到上报日志以后并不会立即存入数据库, 而是放到一个队列中, 所以在确认日志已经记录到数据库后需要在进行确认处理.
压缩上报数据
在上报之前进行数据压缩可以减少流量和网络负载.
特别是对于合并上报, 压缩可以提高很多性能. lz-string是一个非常优秀的字符串压缩类库, 但它基于 LZ78 压缩,如果后端不支持解压,可选择 gzip 压缩,一般而言后端会默认预装 gzip,因此,选择 gzip 压缩数据也可以,工具包 pako 中自带了 gzip 压缩,可以尝试使用。
四. 日志接受与存储
接入层与消息队列
一般通过独立的日志服务器接受客户端日志, 接受过程中, 要对客户端日志内容的合法性, 安全性等进行甄别, 防止被人攻击. 而且由于日志提交一般比较频繁, 多客户端同时并发的情况也很常见. 通过消息队列将日志信息注意处理后写入到数据库进行保存也是比较常见的方案

上图为腾讯 BetterJS 的架构图, 其中接入层和推送中心就是这里提到的接入层和消息队列.
BetterJS 将整个前端监控的各个模块进行拆分,推送中心承担了将日志推送到存储中心进行存储和推送给其他系统(例如告警系统)的角色,但我们可以把接收日志阶段的队列独立出来看,在接入层和存储层之间做一个过渡。
日志存储系统
存储日志, 是个不得不做的应用. 对于一个上了规模的应用, 要提供标准高效的日志监控服务, 需要在日志存储架构上下一些功夫. 比较成熟的方案有:Hbase 系,Dremel 系,Lucene 系等.
日志存储系统的特点是, 数据量大, 数据结构不规律, 写入并发高, 查询需求量大. 一个日志存储系统, 需要解决写入缓冲, 存储介质按日志时间选择, 为方便快速读取而设计合理的索引系统等.
搜索
日志体量比较大, 在庞大的数据中找到需要的日志记录需要比较好的搜索引擎, Splunk 是一套成熟的日志存储系统,但它是付费使用的。按照 Splunk 的框架,Elk 是 Splunk 的开源实现,Elk 是 ElasticSearch、Logstash、Kibana 的结合,ES 基于 Lucene 的存储、索引的搜索引擎;logstash 是提供输入输出及转化处理插件的日志标准化管道;Kibana 提供可视化和查询统计的用户界面。
五. 日志统计与分析
一个完善的日志统计分析工具需要提供各方面方便的面板,以可视化的方式给日志管理员和开发者反馈信息。
- 用户维度
同一个用户的不同请求实际上会形成不同的 story 线,因此,针对用户的一系列操作设计唯一的 request id 是有必要的。同一个用户在不同终端进行操作时,也能进行区分。用户在进行某个操作时的状态、权限等信息,也需要在日志系统中予以反应。
- 时间维度
一个异常操作的前后 story 线, 串联起来观察, 不单单涉及一个用户的一次操作, 可能是一连串事件的最终结果
- 性能维度
应用过程的性能情况, 界面加载时间, api 请求时长统计, 单元计算消耗, 呆滞时间等
- 运行环境维度
应用以及服务所运行的环境情况, 网络, 操作系统, 设备信息, 服务器 CPU, 内存状况, 网络, 宽带等.
- 细粒度代码追踪
异常代码 stack 信息, 定位到发生异常的代码位置和异常堆栈
- 场景回溯
通过将异常相关的用户日志连接起来,以动态的效果输出发生异常的过程。
六. 监控与通知
发现异常后, 对异常进行相应, 并且进行推送和告警, 甚至自动处理.
触发告警
当日志信息进入接入层是, 可以触发监控逻辑, 当日志信息中存在较为高级别的异常是, 也可以立即发出告警, 告警消息队列和日志入库队列可以分开来管理, 实现并行.
对入库日志信息进行统计, 对异常信息进行告警, 对监控异常进行响应, 所谓监控异常, 是值: 有规律的异常一般而言让人放心, 比较麻烦的是突然之间的异常. 例如在某一个时段频繁接受某一个异常, 就需要提高警惕.
除了系统开发时配置的默认告警条件, 还应该提供给日志管理员可配置的自定义触发条件
- 日志含有什么内容时
- 日志统计达到什么度/量时
- 向符合什么条件的用户告警
推送可以通过邮件, 短信, 微信, 电话进行推送, 推送的频率最好也是以级别来确定.
对于日志统计信息, 可以做到自动生成日报, 周报, 月报, 年报并邮件发送
当异常发生的时候, 系统可以调用工单系统 API 自动生成 bug 单, 工单关闭后反馈给监控系统, 形成对异常处理的追踪信息的记录.
七. 异常修复
sourcemap
前端代码大部分都是压缩后发布的, 上报的 stack 信息需要还原为源码信息, 才能快速定位源码进行修改.
发布时, 只部署 js 脚本到服务器上, 将 sourcemap 文件 上传到监控系统, 在监控系统中展开 stack 信息时, 利用 sourcemap 文件对 stack 信息进行解码, 得到源码中的具体信息.
但是需要保证 sourcemap 必须和真是环境的版本对应, 还必须和 git 中的某个 commit 节点对应, 这样才能保证在查差异的时候可以正确利用 stack 信息定位 bug 位置.
- 从告警到预警
预警的本质是,预设可能出现异常的条件,当触发该条件时异常并没有真实发生,因此,可以赶在异常发生之前对用户行为进行检查,及时修复,避免异常或异常扩大。
怎么做呢?其实就是一个统计聚类的过程。将历史中发生异常的情况进行统计,从时间、地域、用户等不同维度加以统计,找出规律,并将这些规律通过算法自动加入到预警条件中,当下次触发时,及时预警。
- 智能修复
自动修复错误。例如,前端要求接口返回数值,但接口返回了数值型的字符串,那么可以有一种机制,监控系统发送正确数据类型模型给后端,后端在返回数据时,根据该模型控制每个字段的类型。
八. 异常修复
主动异常测试
撰写异常用例,在自动化测试系统中,加入异常测试用户。在测试或运行过程中,每发现一个异常,就将它加入到原有的异常用例列表中。
随机异常测试
模拟真实环境,在模拟器中模拟真实用户的随机操作,利用自动化脚本产生随机操作动作代码,并执行。
定义异常,例如弹出某个弹出框,包含特定内容时,就是异常。将这些测试结果记录下来,再聚类统计分析,对防御异常也很有帮助。
九. 部署
多客户端
一个用户在不同终端上登录,或者一个用户在登录前和登录后的状态。通过特定算法生成 requestID,通过该 requestId 可以确定某个用户在独立客户端上的一系列操作,根据日志时序,可以梳理出用户产生异常的具体路径。
集成便捷性
前端写成包,全局引用即可完成大部分日志记录、存储和上报。在特殊逻辑里面,可以调用特定方法记录日志。
后端与应用本身的业务代码解耦,可以做成独立的服务,通过接口和第三方应用交互。利用集成部署,可以将系统随时进行扩容、移植等操作。
管理系统的可扩展
整套系统可扩展,不仅服务单应用,可支持多个应用同时运行。同一个团队下的所有应用都可以利用同一个平台进行管理。
日志系统权限
不同的人在访问日志系统时权限不同,一个访问者只能查看自己相关的应用,有些统计数据如果比较敏感,可以单独设置权限,敏感数据可脱敏。
十. 其他
性能监控
异常监控主要针对代码级别的报错, 单页关注性能异常:
- 运行时性能: 文件, 模块, 函数, 算法
- 网络请求速率
- 系统性能
API Monitor
后端 API 对前端的影响也非常大,虽然前端代码也控制逻辑,但是后端返回的数据是基础,因此对 API 的监控可以分为:
- 稳定性监控
- 数据格式和类型
- 报错监控
- 数据准确性监控
数据脱敏
敏感数据不被日志系统采集。由于日志系统的保存是比较开放的,虽然里面的数据很重要,但是在存储上大部分日志系统都不是保密级,因此,如果应用涉及了敏感数据,最好做到:
- 独立部署,不和其他应用共享监控系统
- 不采集具体数据,只采集用户操作数据,在重现时,通过日志信息可以取出数据 api 结果来展示
- 日志加密,做到软硬件层面的加密防护
- 必要时,可采集具体数据的 ID 用于调试,场景重现时,用 mock 数据替代,mock 数据可由后端采用假的数据源生成
- 对敏感数据进行混淆