导言

Hades是货拉拉自研的移动端监控渠道,协同日志监控和Devops等渠道,共同支撑集团内部一切移动端工程师的日程研发工作。经过近两年的建造,现在Hades渠道日均处理亿级数据,为货拉拉各事务的继续迭代保驾护航。本文将主要介绍货拉拉在移动端Abort反常监控方向的探究和实践。

本文涉及的若干概念界说如下:

1)Abort反常:特指由HadesCrash SDK(安稳性监控)基于体系MetricKit才能上报的反常;

2)Normal反常:特指由HadesCrash SDK基于Native Crash捕获才能上报的惯例反常;

布景

移动端安稳性监控一直是货拉拉移动端的要点工作之一,而游离在Hades渠道之外的Crash事情依然是一片不知道空白,比方jetsam、watchdog机制引起的反常。这些反常会导致使用闪退,会给用户带来很大的体会损伤。从XNU源码可以看到Jetsam机制是经过发送SIGKILL反常信号的方法来停止进程,而SIGKILL无法在当时进程被疏忽或者被捕获。换句话说,咱们之前监听反常信号的惯例 Crash 捕获计划也就不适用了,那么就会构成一片监控盲区。

货拉拉移动端Abort异常监控实践

以往货拉拉依赖的Firebase、Bugly等友商相同不具备捕获Abort反常才能,业界有一些公司开始探究如何解决这个难题,并有了一些不错的效果,比方阿里、字节等。出于对jetsam、watchdog机制引起的反常事情量级较大的考虑(手淘给出的数据是Abort反常数量是Normal反常数量的三倍左右),货拉拉移动架构团队决定自研对Abort反常的监控,以消除这片监控盲区。

货拉拉移动端Abort异常监控实践

上图是手淘团队共享的一张崩溃类型占比图,可以看到经过信号量捕获的崩溃只占很小部分(引证自《iOS Abort问题体系性解决计划》,如侵权,请联络笔者删去)。

探究

笔者在调研比照多个业界计划后,总结划分为四类:手淘分支法、字节对症下药法、flag标记法以及Apple供给的MetricKit。下面咱们来分别介绍下:

手淘分支法

手淘团队经过记载尽或许多的目标和反常事情,便于后续剖析。结合mmap确保日志写入功能和数据一致性,规划二进制编码协议确保数据的高压缩率,终究上传日志到云端剖析。

日志记载的核心信息如下:

  1. 功能数据(包含CPU、内存数据),用于判断使用当时是不是处于overload状态;
  2. 大内存申请;
  3. Retain Cycle,用于定位Jetsam Event;
  4. 卡顿,用于定位watch dog kill;
  5. 当时存活VC实例数量;

在获取日志后,剖析流程如下:

货拉拉移动端Abort异常监控实践

【小结】该计划需求记载许多Abort事情信息、日志量大、写入功能要求高;云端对应一套聚类剖析的杂乱流程,有必定的本钱,一起也存在误判和无法断定的case。

字节对症下药法

该计划针对不同类型的事情,定制解决计划,比方常见的Crash、Watchdog、OOM、CPU和I/O反常等都有对应的计划,对应如下:

1)Crash: Zombie 监控和 Coredump;

2)Watchdog: 线程状态和死锁线程剖析;

3)OOM: 自研线上MemoryGraph;

4)CPU 和磁盘 I/O 反常: MetricKit;

针对CoreDump和MemoryGraph,本文做一下简略介绍,感兴趣的读者可以进一步研讨。

Coredump 是由 lldb 界说的一种特别的文件格式,Coredump 文件可以还原 APP 在运转到某一时刻的内存运转状态。开发者无需复现问题,就可以经过Coredump完成线上疑难问题的过后调试。

MemoryGraph可以简略理解为线上版Xcode MemoryGraph,但也有不小的差异。它的基本原理:定时检测 APP 的物理内存占用,当它超过阈值的时触发内存 dump动作,此时 SDK 会记载每个内存节点符号化后的信息,以及它们彼此之间的引证联系。

【小结】该计划可以较好地对症下药,但其间的Coredump和在线MemoryGraph均是自研,现在也归于闭源状态,技能复现上具有必定本钱。

Flag标记法

许多业界的长辈经过规划Flag的方法来记载所谓的Abort事情,然后上报数据。可是这种采集的Abort,一般状况下都只能简略的记载次数,而没有详细的仓库,本文不作更多介绍。

Apple官方MetricKit

MetricKit是Apple供给的框架,旨在为开发人员供给各种使用程序度量数据和功能剖析。它包含API和东西,用于搜集和剖析设备上的功能数据,例如CPU使用率、内存使用量、网络连接质量等等,一起,也可以搜集Crash事情。

【小结】MetricKit接入方法十分简略,经过注册代理的方法即可搜集Crash信息,但一起也面临一些问题,比方缺失关键信息(比方单个crash事情的时刻,暂时也未现绑定自界说信息的才能)、漏报数据、回调时刻难琢磨、体系兼容性缺陷等问题。

终究挑选的计划

笔者在比照以上四个计划后,一起综合考虑人力本钱、技能难度、ROI、事务特性等,终究挑选基于体系MetricKit才能来完成Abort反常的监控。

实践

端上流程

货拉拉移动端Abort异常监控实践

订阅&接纳

  • 订阅Metric数据
// 订阅
- (void)subscribeMetricData
{
    if (@available(iOS 14.0, *)) {
        __weak typeof (self) weakSelf = self;
        self.subscribeID = [WPFMetricKitManager addSubscriber:^(NSArray * _Nonnull payload, WPFMetricPayloadType type) {            __strong typeof (weakSelf) strongSelf = weakSelf;            if (type == WPFMetricPayloadTypeDiagnostic) {                [strongSelf handleDiagnosticPayloads:payload];
            }
        }];
    }
}
// 撤销订阅
- (void)unsubscribeMetricData
{
    if (@available(iOS 14.0, *)) {
        [WPFMetricKitManager removeSubscriber:self.subscribeID];
    }
}
  • 接纳Metric数据
- (void)handleDiagnosticPayloads:(NSArray<WPFDiagnosticPayload *> *)payloads API_AVAILABLE(ios(14.0))
{
    dispatch_async(self.taskQueue, ^{
        [self handleMetricPayloads:payloads];// 加工数据
    });
}

笔者在MetricKit之上做了一层封装,也便是WPFMetricKitManager,目的主要有两个:

1)修复iOS 16.0.1、16.0.2的体系缺陷引发的闪退问题。该系列版别上,当APP中有多个事务或模块存在订阅动作时,或许会由于一起读写办理订阅者的集合而引发crash。笔者在第一版功能上线后曾收到crash如下:

Exception Type:  EXC_CRASH (SIGABRT)
Exception Codes: 0x00000000 at 0x0000000000000000
Crashed Thread:  4
Application Specific Information:
*** Terminating app due to uncaught exception 'NSGenericException', reason: '*** Collection <NSConcreteHashTable: 0x282f1cd20> was mutated while being enumerated.
UserInfo:(null)'
// 崩溃线程
Thread 4 Crashed:
0      CoreFoundation                        ___exceptionPreprocess
1      libobjc.A.dylib                       _objc_exception_throw
2      CoreFoundation                        -[__NSSingleObjectEnumerator init]
3      Foundation                            -[NSConcreteHashTable countByEnumeratingWithState:objects:count:]
4      MetricKit                             -[MXMetricManager deliverMetricPayload:]

2)考虑到现在有多个事务有订阅需求,以及未来或许依然有增量需求,因而笔者将其封装成一个库,方便事务快速接入,也不必再担心兼容性问题引发线上Crash;

数据加工

基本数据

char eventID[37];
ksid_generate(eventID); // 复用KSCrash中的identifier生成逻辑
NSString *reason = nil;
if ([diagnostic isKindOfClass:[MXCrashDiagnostic class]]) {
    reason = ((MXCrashDiagnostic *)diagnostic).terminationReason ?: ((MXCrashDiagnostic *)diagnostic).virtualMemoryRegionInfo;
}
NSTimeInterval crashTime = self.payload ? [self.payload.timeStampBegin timeIntervalSince1970] * 1000 : [[NSDate date] timeIntervalSince1970] * 1000;
return @{
    @"appId": "XXX",
    @"appType": @(XX),
    @"clientCrashId": [NSString stringWithUTF8String:eventID],
    @"crashReason": reason ?: @"",
    @"crashTime": @(crashTime),
    @"crashType": @(crashType), // crashType可根据MetricKit中的字段来对应:crash、hang、cpu等
    @"sdkVersion": @"1.0.0",
    @"app": @{
        @"channel": @"appstore",
        @"version": @"3.2.96"
    },
    @"device": @{
        @"deviceId": @"xxxx-xxx-xxx",
        @"systemVersion": @"",
        @"kernelVersion": @"",
        @"manufacturer": @"Apple",
        @"brand": @"iPhone",
        @"model": diagnostic.metaData.deviceType,
        @"cpu": diagnostic.metaData.platformArchitecture,
    },
    @"user": @{
        @"userId": @"", // maybe not the user when event happened
    },
    @"run": @{}
};

经过MetricKit搜集的数据会缺失一些关键信息,比方崩溃时刻(注意不是回传数据中的时刻区间,而是一个具体崩溃的发生时刻)、主进程的UUID(某些状况下没有)、SDK版别、APP版别、设备仅有标识deviceid等等,上面代码块中红色标记的字段即为体现。为此,笔者需求经过一些手法来尽或许的弥补上这些信息;

仓库加工

#if defined(__LP64__)
#define TRACE_FMT               "%-4d%-31s 0x%016lx 0x%lx + %lu\n"
#else
#define TRACE_FMT               "%-4d%-31s 0x%08lx 0x%lx + %lu\n"
#endif
@interface HadesAbortMetricRootFrame : NSObject
@property (nonatomic, copy) NSString *binaryName;
@property (nonatomic, copy) NSString *binaryUUID;
@property (nonatomic, strong) NSNumber *offsetIntoBinaryTextSegment;
@property (nonatomic, strong) NSNumber *address;
@property (nonatomic, strong) NSArray<HadesAbortMetricRootFrame *> *subFrames;
- (instancetype)initWithDictionary:(NSDictionary *)dictionary;
- (NSString *)uploadFormatString;
@end
@implementation HadesAbortMetricRootFrame
- (instancetype)initWithDictionary:(NSDictionary *)dictionary
{
    if (self = [super init]) {
        for (NSString *property in [[self class] wpf_propertyNames]) {
            id value = dictionary[property];
            [self setValue:value forKey:property];
        }
        if (_subFrames) {
            NSArray *array = _subFrames;
            NSMutableArray *subFrames = [NSMutableArray array];
            for (NSDictionary *dic in array) {
                HadesAbortMetricRootFrame *frame = [[HadesAbortMetricRootFrame alloc] initWithDictionary:dic];
                [subFrames addObject:frame];
            }
            _subFrames = subFrames;
        }
    }
    return self;
}
- (NSString *)uploadFormatString
{
    NSMutableString *result = [NSMutableString string];
    [self uploadFormat:result fromFrame:self index:0];
    return result;
}
- (void)uploadFormat:(NSMutableString *)uploadFormat fromFrame:(HadesAbortMetricRootFrame *)frame index:(NSInteger)index
{
    int num;
    const char *image = frame.binaryName.UTF8String;
    uintptr_t address = frame.address.unsignedLongValue;
    uintptr_t loadAddress;
    uintptr_t offset;
    if (@available(iOS 16.0, *)) {
        num = (int)index;
        offset = frame.offsetIntoBinaryTextSegment.unsignedLongValue;
        loadAddress = address - offset;
    } else {
        num = (int)index;
        loadAddress = frame.offsetIntoBinaryTextSegment.unsignedLongValue;
        offset = address - loadAddress;
    }
    [uploadFormat appendFormat:@TRACE_FMT, num, image, address, loadAddress, offset];
    for (HadesAbortMetricRootFrame *subFrame in frame.subFrames) {
        [self uploadFormat:uploadFormat fromFrame:subFrame index:index + 1];
    }
}

值得注意的是,iOS16体系再次出现一个坑点,该体系上,offsetIntoBinaryTextSegment字段含义发生变更,成为了offset,猜测仅仅一个临时bug,后续或许会批改。

其他

从「订阅」到「搜集数据」,再到「加工数据」,核心流程基本完毕了。然而还有一些其他方面值得我们关注,主要是以下几点:

1)假如我们的APP曾经由于包体积管理引入了Mach-O段迁移技能,那么还需求对偏移地址进行批改(可经过 Mach-O文件的LC-MAIN入口来获取主程序main函数的地址,然后算出加载其起始地址)。

2)在开发测试过程中,我们难免会遇到这样的状况:无法及时捕获实在数据,或者捕获后间隔下一次捕获仍有一个很长的窗口期,那么建议在搜集数据后做一个文件级耐久化处理(PS:笔者曾因24小时甚至48小时没有接纳到实在数据而苦恼)。

收益

在Abort反常功能上线后,咱们在货拉拉企业版 iOS上搜集了很多曾经无法采集的crash日志,比方下面这种0x8BADF00D类型的watchdog事情:

货拉拉移动端Abort异常监控实践

下面以货拉拉企业版为例,将从一些目标维度出发,给出一些剖析。

惯例目标剖析

单日重合比照

货拉拉移动端Abort异常监控实践

单日重合是指单个自然日中,Normal反常中一起被Abort反常射中的部分。比方2023/01/12共搜集到 38 个Normal反常事情,其间有 33 个Normal反常事情一起被Abort反常捕获;

为什么Abort反常不是Normal反常的超集?原因有以下几点:

  • 体系掩盖不同:Abort反常只能监控iOS 14及以上的设备,Normal反常掩盖的体系范围则更大;
  • 统计方法约束:为了简化统计方法,现在选用的是比照Normal反常和Abort反常中userid或deviceid是否相同(假如严厉比照仓库,统计难度过高)。假如相一起间段内,Normal反常和Abort反常对应的userid或deviceid相同,则认为是同一个反常;
  • Abort反常存在丢失的状况:在实测包含友商的一些共享中,咱们都注意到MetricKit存在漏报的状况,导致一部分反常日志丢失(比方短时刻多个闪退);

综上,Abort反常并不是Normal反常的超集,也便是说,无法完成肯定的重合,100%的射中。

Abort反常分类散布

货拉拉移动端Abort异常监控实践

Abort反常分为四大类:Abort_crash(crash事情)、Abort_hang(卡顿事情)、Abort_cpu(CPU反常)、Abort_disk(磁盘I/O反常)。现在企业版APP没有收到一例Abort_cpu或Abort_disk反常事情(不扫除与企业版APP事务杂乱性、用操有关,后续将在其他事务侧调查),Abort_crash占比肯定大头。

Abort_crash分类散布(平均值)

Abort_crash作为要点关注目标,其又细分为SIGKILL(watchdog、OOM等)、SIGSEGV、SIGABORT、SIGBUS等,其间SIGKILL占有肯定的大头,这也是Normal反常无法监控的盲区所在。

货拉拉移动端Abort异常监控实践

收益性目标剖析

单日总量比照

货拉拉移动端Abort异常监控实践

从单日总量比照上,咱们可以看出用户视点的安稳性事情远远大于事务视点的。这关于用户体会来说,监控工作仍负重致远

单日增量比照

货拉拉移动端Abort异常监控实践

增量是指单日 Abort反常数量 减去 Normal反常数量的值,必定程度上,该目标可以代表惯例Native反常监控的盲区程度。能注意到2023/01/11有高达287个反常事情发生在用户身上,而事务是无感的。

规划

安稳性目标重界说

咱们需求重新考虑安稳性目标的界说。现在货拉拉各事务均以Normal反常作为衡量安稳性目标的参阅,汇报数据亦是如此。未来Hades渠道更倾向于一起供给Normal反常和Abort反常的目标。原因如下:

  • 不放弃Normal反常目标:为了对标部分竞对(现在来看仍有大部分竞对没有监控Abort反常,为了口径一致,保存Normal反常目标);
  • 添加Abort反常目标:为了更实在的感知用户视点的安稳性体会,有必要添加Abort反常目标;

双剑合璧

从现在的剖析来看,Abort反常能弥补Normal反常很大的监控盲区,而Normal反常也有一些反常是Abort反常无法捕获的,因而双剑合璧是短期最优解(未来随着设备体系迭代和Apple的优化,不扫除完全放弃Normal反常的或许)。

Abort反常中咱们要点关注SIGKILL信号即可,那么终究的产品形态将是Normal反常 + SIGKILL反常。

参阅

developer.aliyun.com/article/770…

mp.weixin.qq.com/s/4-4M9E8Nz…

xie.infoq.cn/article/fc1…

developer.apple.com/documentati…