Flutter 异常监控方案与实践
前言
错误监控是维护 App 稳定的重要手段,通过对线上问题的实时监控,来观察 App 是否出现异常状况,以便快速解决问题以及制定修复方案.对于集成了 Flutter 的 App,除了需要提供 crash 崩溃监控,还需要对 Flutter 异常进行监控.
一般来说,监控系统都会包含问题的实时收集上报、问题的归类识别(聚合)以及自动分配、问题定位、实时报警等几个模块.要做一套 Flutter 异常监控也不例外,图中是贝壳在 Flutter 异常监控的整套方案.首先在端上收集异常并进行初步处理,持续集成平台会处理各平台的 app 符号信息并管理 app 相关的基础信息,在监控后台,系统主要处理异常日志数据源,并经过预处理、解析、构建多纬度统计数据、最终展示到前端平台、并会根据一些阈值配置进行异常报警.
本文主要围绕其中移动端 Flutter 异常处理、监控后台异常预处理、监控后台异常的解析处理三部分来介绍贝壳在 Flutter 异常监控的实践与沉淀.
一、移动端 Flutter 异常处理
在介绍 Flutter 异常处理前,我们先了解下 Flutter 异常.
1.1. Flutter 异常
Flutter 异常是指程序中 Dart 代码运行时抛出的错误事件.一般来说,异常种类主要分为 Exception 和 Error,以及它们的子类型.当然开发者也可以自定义非 null 的错误类型.Dart 支持程序抛出非空类型的各种错误,如下代码所示:
对于 Flutter 应用来说,当程序出现异常时,通常情况程序不会崩溃退出,这点不同于 java 或者 Objective-C 这种编程语言.拿 Android Java 应用举例,当异常发生并且没有被捕获,那么默认的uncaughtException
方法就会捕获到异常并且执行System.exit()
杀掉程序,或者异常触发系统底层的异常进程也会直接被杀掉.
但是 Flutter 的处理方式则不一样,异常即使没有被我们主动捕获,系统的默认处理方式也只是 print,或者替换错误 widget,通常在 App 上表现为页面白屏(红屏)、用户操作不响应等,这也是为什么我们在崩溃监控之外需要通过额外的监控平台能力去处理 Flutter 异常.
在 Flutter 运行过程中,采用了事件循环的机制来运行任务(https://dart.cn/articles/archive/event-loop),如下图所示,其中有两个不同优先级的队列,每当有事件任务触发,都会被放到其中一个队列中,其中运行的各个任务是互相独立的.当某个任务出现异常,会导致任务的后续代码不会继续执行,但不会影响其他任务的执行.
1.2. Flutter 异常捕获
和 java 类似,Flutter 也可以通过 try-catch 机制捕获,但是 try-catch 只能捕获同步代码块的异常,对于 future 异步代码块抛出的错误,需要采用 future 提供的 catchError 语句捕获,如下代码:
知道如何捕获错误后,只需再找到合适的地方去捕获 Flutter 错误,下文分为 3 个部分去介绍异常捕获.
1.2.1. Flutter 框架异常捕获
Flutter 框架本身已经捕获了许多 dart 抛出的异常,包括构建期间、布局期间和绘制期间的异常.它通过 FlutterError.reportError
统一处理,如下面代码:
我们可以main
方法中重写onError
方法去实现我们自己的逻辑,如下代码所示:
1.2.2. 其它 dart 异常捕获
对于其它未被 Flutter 框架捕获的 Dart 异常,比如 Future 中的异常等,会被错误发生所在Zone
捕获,Zone
表示一个代码执行的上下文,给异步代码和同步代码提供了一个稳定的运行环境,可以简单理解为一个沙盒,其对于内部发生且未被主动捕获的异常的默认处理方式也是打印输出错误.初始main
函数就在默认区域 ( Zone.root
)的上下文中运行,我们可以通过将runApp()
包裹到自定义的Zone
里,重写捕获异常的方法onError
,如下代码所示:
1.2.3. 白屏(红屏)异常捕获
上文说到,Flutter 框架会捕获到一部分的 dart 异常,除了统一的回调处理,还对一部分导致页面白屏问题的异常,进行了替换错误 widget 的处理,
如上面代码所示,Flutter 框架通过ErrorWidgetBuilder builder
对页面渲染失败异常进行统一替换 widget 的处理,我们通过对其覆盖重写,就能在众多的异常中捕获到页面渲染的异常,方便后面对异常进行分级分类处理.
注意,官方逻辑中,回调替换 widget 的地方也同样上报到了reportError
,我们可以通过 aop 的方式将逻辑替换,否则对上报错误数量有一定影响.
到这里,捕获 Flutter 异常已经完成,最终使用了三个 hook 点去上报异常,为后续的后端服务解析处理做好了源数据准备.当然,光在这些地方收集异常还是不够的,还需要一些异常封装处理,来补充异常运行的状态信息.
1.3. Flutter 异常封装处理
异常信息的封装主要分为两个步骤:异常信息的提取处理、添加附加信息.
1.3.1. Flutter 异常提取处理
首先是异常种类的提取,一般通过runtimeType
就能获取到异常类型;但是要注意的是,之前 hook 上报的地方,有些异常被封装成FlutterErrorDetails
,所以需要对其 exception 进行判断.
再者就是对异常的概述提取,我们通过使用 Flutter 框架中的一个函数exceptionAsString
来获取,如下面代码:
还有就是堆栈的上报,异常上报的地方都会有 stack 信息,对于 Flutter 框架封装的FlutterErrorDetails
,提取stack
即可.处理完异常信息,我们需要给异常信息添加一些额外的运行通用信息,来帮助解决异常.
1.3.2. Flutter 异常附加信息
为了帮助 Flutter 异常的高效解决,我们在异常的上报中添加了一些附加信息,包括异常发生时的设备信息、页面信息、内存信息、路径埋点唯一检索信息.其中,页面信息的获取方式可以在我们的另一篇文章中找到(附地址).这些信息可以帮助我们查看异常的走势和修复状况,如下图:
上报的一些系统现状和运行信息,可以辅助开发同学定位问题:
二、 后台 Flutter 异常预处理
当监控后台收到移动端上报的异常日志,首先要做的就是将收到的异常日志进行预处理,其中最主要的两个模块就是异常的分级分类和异常堆栈的符号化解析.
2.1. Flutter 异常的分级分类
通过上面,我们知道 Flutter 异常并不会导致崩溃,那么 Flutter 异常一定会影响用户么?这里要从 Flutter 异常和 crash 崩溃不同的地方说起.通常,crash 发生时,一定代表我们的用户受到了影响,但 Flutter 异常却不一定.
在所有的 Flutter 异常中,有一部分异常用户并无感知.它可能是初期开发同学的代码不够规范导致无效调用引起,也可能是 build 的多次刷新报错;还有一部分网络异常导致的偶现错误,比如图片错误,这种问题端上同学也不能处理(也有其他的监控服务处理了,比如网络报警服务).在这种情况下,如果我们把所有的错误一股脑放到开发同学面前,不分轻重缓急,他们是没法高效的分优先级去处理.开发同学的精力毕竟有限,我们应该集中精力去处理那些能处理以及对用户真实发生影响的问题.
所以针对 Flutter 异常,我们将其分为 3 大类:
也是在经历了第一阶段开发同学对 Flutter 异常的处理不够积极的情况,我们优化了监控平台的能力,对 Flutter 的异常进行分级分类的处理.
一是区分上报信息,也就是上文提到的上报页面渲染失败异常,包括白屏(红屏)问题,二是后端服务对错误类型进一步分类处理.
首先是渲染失败导致的红屏和白屏是我们的一级问题,对于 CastError、RangeError、PlatformException、NoSuchMethodError、MissingPluginException 等多种错误类型,我们认为其是影响业务的,也将其列为一级问题.其它的,比如图片异常或者网络异常,我们将其放到二级问题.其中比较特殊的,比如 NoSuchMethodError,我们对其中的部分异常进行正则过滤,也放到二级错误中.
其中一级错误和二级错误分别展示到不同的地方以及提供后续不同的报警等处理方式,保证快速聚焦的核心问题上.
2.2. Flutter 的符号化解析
在 Flutter1.17 以上的版本中,官方支持了对 Flutter 产物去除符号表的功能,考虑到集成 Flutter 产物的 app 的安全性和包大小问题,我们在打包系统中集成了这个功能,通过官方支持的打包命令就可以在打包期间分离符号表文件.
—split-debug-info 可以分离出 debug info 符号表信息
但是这也导致 Flutter 异常上报的堆栈是去符号化的,难以阅读理解,如下所示:
在这种情况下,我们需要一个符号化解析系统处理堆栈,将其转化为可理解的堆栈信息.当然剥离符号表信息不仅仅影响了 Flutter 异常的堆栈,对 native crash 中的相关 Flutter 堆栈行也会有影响,所以下文会对这两块分别阐述.
符号表解析首先要做的就是对编译打包过程中符号表文件的处理.
2.2.1. Flutter 符号表文件处理
上文说到,在编译过程中通过命令将符号文件生成并剥离出来并保存到指定目录,文件类似这样app.ios-arm64.symbols
.首先我们会先将文件上传到 artifactory 仓库中,并在打包过程中分析出 app 产物的其它相关信息,比如版本、hash 等等,之后将这些信息发送监控平台进行分析处理,以供之后的符号化解析使用.
完整架构图如下:
有了符号表文件之后,剩下的就是对堆栈进行解析处理,首先是 Flutter 异常的符号化解析.
2.2.2. Flutter 异常符号化解析
首先需要了解app.ios-arm64.symbols
这个从 Flutter 产物中剔除出来的符号文件,它存储了 Dart VM AOT 编译器将源代码映射为信息编码的所有信息,是采用了 DWARF 格式的高度压缩文件.这里拿 ios 举例,Android 同理,通过 file 命令可知,其是一个 ELF 文件:
➜ file app.ios-arm64.symbols
➜ app.ios-arm64.symbols : ELF 64-bit LSB shared object, ARM aarch64, version 1 (SYSV), dynamically linked, BuildID[md5/uuid]=XXXXX, with debug_info, not stripped
ELF (Executable and Linkable Format)是一种为可执行文件,目标文件,共享链接库和内核转储(core dumps)准备的标准文件格式.通过下面命令可以生成两个符号相关文件:
➜ dwarfdump app.ios-arm64.symbols --debug-info > info.txt
➜ dwarfdump app.ios-arm64.symbols --debug-line > line.txt
其中 info 文件中存储的是源码信息,line 文件中存储的是行号相关信息.info 文件中我们拿其中一个函数信息举例:
其中:
TAG_subprogram
是指代函数的意思,
AT_abstract_origin
是其源码信息,
AT_low_pc
和AT_high_pc
是这个函数相对于符号表文件高与低的偏移量,下文简称为pc_offset
在下面这样一行堆栈中,
_kDartIsolateSnapshotInstructions
代表的是这行堆栈的错误信息是在isolate_instructions
指令段中,后面跟的偏移量就是相对isolate_instructions
起始的偏移量,下文简称为isolate_offset
,它在一个符号表中是固定的.
我们只要通过isolate_offset
找到pc_offset
,继而就能找到源码信息.他们的关系也很明显,通过nm
命令找到isolate_instructions
相对符号表文件的偏移量isolate_start
,然后通过相加的方式得到pc_offset
➜ nm app.ios-arm64.symbols | grep_kDartIsolateSnapshotInstructions
➜ _kDartIsolateSnapshotInstructions b 0x6000
其中 0x6000 就是isolate_start
isolate_start + isolate_offset = pc_offset
最终我们只要在 info 文件中找到pc_offset
在哪个源码信息的AT_low_pc
和AT_high_pc
之间,就能找到源码信息.
同样的,拿到这些信息后在 line 文件中我们通过偏移地址的映射关系也能找到对应的行号信息,这里我们就不做阐述.
那么这一套解析逻辑我们如何实现呢,官方既然提供了剔出符号化的逻辑,当然也会有符号化解析的逻辑.通过对 flutter_tools 源码的阅读可知,官方同样提供了一个SymbolizeCommand
的命令用于符号化解析,其通过获取符号文件与堆栈输入,最终通过native_stack_traces
库中的DwarfStackTraceDecoder
解析处理,如下代码所示:
其中对DwarfStackTraceDecoder
逻辑的阅读也能验证上文逻辑.其中计算pc_offset
偏移量的方式,官方还提供了其它几种计算方式:
其中第二种利用堆栈 header 信息中的isolate_instructions: 10b86a000, vm_instructions: 10b866000
这两个运行时偏移地址和abs 000000010bc7d08b
相减也能得出isolate_offset
,之后通过第一种的逻辑最终得到pc_offset
.
除此之外,官方提供的SymbolizeCommand
中的runCommand
仅支持的文件堆栈输入输入,并且后端服务不可能直接依赖整个 dart 的执行环境,所以我们将runCommand
中的逻辑拆分,并扩展可支持堆栈类型,如下代码:
最终通过以下命令打包成一个 linux\macos 可执行脚本flutter symbolize
,提供给后端服务用于解析堆栈信息.
除了 Flutter 异常的符号化解析,去除符号表也会影响到 Flutter 引起的 crash 中的堆栈解析,下面我们介绍解析过程.
2.2.3. Flutter crash 符号化解析
因为涉及到 crash 堆栈,Android 和 iOS 的堆栈与解析方式就有些差别了,下文我们分别描述 iOS 和 Android 中的 Flutter 堆栈的解析处理.
iOS
Flutter 打包后的 iOS 产物是 Framework,其中有 App.Framework 和 Flutter.Framework.其中 App.Framework 里是 Flutter 侧 dart 的相关代码,也是需要利用上文提到的符号化文件进行处理,而 Flutter.Framework 的符号化解析则利用 iOS 的 crash 解析方式处理,这里我们就不做叙述.
对于 iOS crash,其中 App.Framework 产物中引发的崩溃会包含类似下面的堆栈:
我们需要做的就是将其转化为flutter symbolize
脚本能够识别的堆栈,也就是上文提到的这种:
也就是说我们要通过相对 App.Framework 的偏移量得到isolate_offset
,按照上文一样的思路去处理.首先需要计算 App.Framework 中 isolate 和 vm 指令段相对 App.Framework 的偏移地址,通过这两个地址和相对 App.framework 的偏移量相减就能得到相对 isolate 和 vm 的偏移地址,也就是isolate_offset
和vm_offset
.那么如何得到 isolate 和 vm 指令段相对 App.Framework 的偏移地址呢,通过nm
命令也能拿到
➜ nm App.Framework | grep _kDartIsolateSnapshotInstructions
➜ 0000000000008000 T _kDartIsolateSnapshotInstructions
➜ nm App.Framework | grep _kDartVmSnapshotInstructions
➜ 0000000000004000 T _kDartVmSnapshotInstructions
其中 0000000000008000 就是 isolate 指令段相对 App.Framework 的偏移量.
因为这个命令的执行逻辑是对 App.Framework 进行分析,所以它实际上也是在上文提到的持续集成打包时的符号化处理过程中,通过上面的命令分析得到,然后保存到异常监控平台.
具体解析实现步骤如下图:
1、相对 App.framework 偏移量减去持续集成 nm 命令得到的偏移量,得出 isolate_offset 或者 vm_offset;
2、然后利用上一步的结构拼接成
flutter symbolize
能够识别的堆栈.3、对每行堆栈重复执行 1、2 步,然后使用
flutter symbolize
脚本解析出来.
android
Android 和 iOS 的解析同理,我们也只要处理其中包含 dart 代码的 libapp.so 相关的堆栈.对于 Android crash,其中包含的 libapp.so 相关堆栈如下:
它的处理方式就很简单,因为isolate_offset
和vm_offset
直接有了,所以我们只要拼接成转化为flutter symbolize
脚本能够识别的堆栈转化为flutter symbolize
脚本能够识别的堆栈,就可以处理.
到这里,我们已经将 Flutter 堆栈解析成可理解的堆栈信息了,下一步就是利用 Flutter 异常上报的信息,对 Flutter 异常进行解析处理.
三、 后台 Flutter 异常解析
这一部分主要的内容是对异常的聚合和分配,以及统计计算.
3.1.聚合
聚合异常是监控平台一个非常重要的能力,能够帮我们统计某个异常的实时影响情况,根据阈值预警、提前作出反应.
比如说一个异常短时间发生次数超过阈值,那么我们通过报警的方式通知给负责人,然后作出停止灰度,替换线上包或者热修等决策.
聚合采用分行解析堆栈信息的方式,找到错误发生最接近业务(非 Flutter 框架代码)或者最能体现错误的那一行栈帧.
通过以下正则能够分析出每行的类名、函数名、包名、文件名
"^#(\d+) +(.+) \((.+?):?(\d+)?:?(\d+)?\)$"
得出下面这个数据结构对象
之后便可以根据structureName
和 App 构建集成平台的信息进行匹配,如果匹配成功,就把这行栈帧作为聚合的信息.
注意: 聚合信息中不要包含行号信息,因为可能发生异常被修改但并未修复的情况,去掉行号信息可以在这种情况下,让错误还是聚合成一种.
3.1.1.特殊处理
通过业务栈帧来聚合处理异常在一些情况下可能不生效.通过对大量 Flutter 异常堆栈的分析,我们发现,因为 future 异步调用的问题,有许多的堆栈中并没有业务栈帧,并且会把异常聚合到无效栈帧.
比如下面这种 PlatformException 错误信息,如果按照业务栈帧优先的逻辑,对于这种没有业务栈帧的堆栈,就会把第 1 行作为聚合信息,这样这会导致大量的系统相关的MethodChannel
错误都聚合成一种错误,对我们的问题解决以及阈值报警都有很大的干扰.
所以除了业务栈帧优先聚合的逻辑,我们对异步栈帧也做了特殊处理 : 异步栈帧(第 2 行)的调用者高于被调用者.通过这种处理,这类错误会聚合到第三行上,代码中是PlatformViewsService.initUiKitView
这个错误中去.
除此之外,我们也提供了栈帧白名单的策略,也及白名单中的栈帧信息不属于错误信息.也可以使得异常不会聚合到某个无效栈帧中,进一步减少无效堆栈聚合的问题.
3.2.分配
当然,一个高效的监控平台,少不了自动分配异常的能力,这可以帮助负责人快速收到报警和响应错误.上文已经描述了异常发生时聚合的那几帧关键信息,对于分配也是如此.主要采用两个策略:
1、包含业务堆栈的异常,通过构建集成平台的组件维护信息,直接指派到负责人;
2、对于没有业务栈帧的异常,根据异常的种类来分配,比如是白屏问题,就根据上文提到的异常附加信息中的页面信息,来进行指派.
最终效果如下所示:
3.3.统计计算
对于 crash 监控中,崩溃率计算一般会采用两个口径:
会话崩溃率 : 用户每打开一次 app 计做一次会话,用 崩溃次数/会话次数 得到
设备崩溃率 : 每个用户崩溃只计做一次崩溃,用崩溃次数/用户数 得到
但这这两种都不适合 Flutter,因为 Flutter 异常时,app 并没有崩溃,那么按照上面提到的两种计算口径都不能真实的反应 App 稳定性.
比如打开一个页面,可能发生多次异常,但并未崩溃,那么按照会话崩溃率会得到 n/1 这种不合理的异常率,尤其是混合开发中 Flutter 还不是 app 的全部功能的实现,通过会话和设备崩溃率统计还会有更多的偏差,因为用户打开 App 可能没有使用 Flutter 功能.
所以我们采用了一种新的统计口径:
页面异常率, 用户每打开一次页面计做一次 pv, 用 异常数/pv 数得到
通过这种计算方式,我们不仅能够得到 app 中 Flutter 本身整体的页面异常率,还能在后续给单业务页面计算页面的稳定性.
四、 结语
以上,就是贝壳在 Flutter 大规模应用时监控异常稳定性的一些实践和沉淀,希望对你有所帮助.对于 Flutter 异常的处理,未来还有一些需要做的,比如更细致的分类、根据异常种类进行自定义阈值报警、以及异步堆栈回溯、单页面分析等等,有机会的话会给大家带来更多相关的文章.
感兴趣的话记得点个喜欢♥♥♥和关注✩✩✩,后续会第一时间收到其它文章.
版权声明: 本文为 InfoQ 作者【贝壳大前端技术团队】的原创文章。
原文链接:【http://xie.infoq.cn/article/1000b410cd285d040da894ee0】。文章转载请联系作者。
评论