客户端日志与可观测性复盘:分级落盘、远程采集、崩溃后自动回传与服务端拉日志
项目 social-app
1. 背景与问题
这是一款面向日本市场的语音社交 App,集成了第三方 IM SDK、第三方音视频 SDK,还有一个游戏引擎模块。线上问题往往跨越好几层:一次「进房后没声音」,可能是业务接口、IM 信令、音视频 SDK 中的任何一环出了问题。
客户端日志要回答的问题因此不止一个:
- 开发和测试时:能在设备上直接看到详细日志,方便复现;
- 崩溃时:崩溃报告里要有崩溃前发生了什么;
- 非崩溃的异常:接口失败、上传失败、内购异常,要能按类别统计;
- 用户反馈时:问题发生在别人的手机上,要能把那台设备上的日志拿回来。
这套日志方案在项目早期就建立了,之后陆续加上了远程采集和日志回传。这篇按「写到哪里、怎么回传、哪里可以更好」来复盘它。
2. 整体设计
2.1 一个门面,四个去向
业务代码只调用一个门面,不需要知道日志最终去了哪里。门面根据级别和包类型决定分发:
| 去向 | 调试包 / 测试包 | 正式包 |
|---|---|---|
| 本地文件 | debug 及以上 |
info 及以上 |
| 崩溃平台面包屑 | debug、info、error |
info、error |
| 非致命异常 | 按类别记录 | 按类别记录 |
| 远程采集 | warning 及以上 |
warning 及以上 |
2.2 三十多种异常类别
非致命异常按类别记录到崩溃平台:网络请求、IM、上传、语音房、内购、线程等三十多类。分类的意义在于:「上传失败」和「内购失败」在崩溃平台上是两条独立的曲线,某一类突然变多时一眼就能看出来,而不是淹没在一堆杂乱的错误字符串里。
3. 关键实现
3.1 本地落盘
- 基于开源日志库,内部异步写文件,不阻塞调用线程;
- 每次启动生成一个按时间命名的文件,格式里包含时间、文件、函数、行号和级别符号;
- 启动后在后台清理修改时间超过 30 天 的文件。
3.2 远程采集
接入一个远程日志采集 SDK,参数是这样配置的:
| 参数 | 值 | 作用 |
|---|---|---|
| 条数阈值 | 3 条 | 攒够就发一批 |
| 时间间隔 | 10 秒 | 不够条数也按时发 |
| 失败重试 | 3 次,间隔 30 秒 | 弱网下不丢、也不狂发 |
| 最低级别 | warning |
只收值得关注的 |
3.3 崩溃后自动回传音视频 SDK 日志
音视频问题光看业务日志往往不够,需要 SDK 自己的日志。但 SDK 日志很大,不可能每次都传。于是规则是:只有上次运行崩溃了,才在网络恢复时把当天的 SDK 日志传上去一次。
「其它失败也标记已传」是一个刻意的取舍:宁可丢一次日志,也不要在坏网络下反复重传,把用户流量和电量耗在日志上。
另外,IM 自动登录因为「操作过于频繁」失败时,会单独回传一次 IM SDK 的日志,目录和音视频日志分开。
3.4 服务端点名拉日志
用户反馈问题时,服务端可以通过 IM 的自定义系统通知,让指定用户的 App 把某一段时间的日志传上来。
// 示意,非原始代码
func handleLogTask(_ task: LogTask) {
guard !isUploading else { return } // 同一时间只跑一个任务
isUploading = true
let files = logFiles(kind: task.kind)
.filter { task.range.overlaps($0.timeRange) }
let zip = archive(files)
uploader.uploadZip(zip) { result in
api.reportLogTask(id: task.id, result: result)
isUploading = false
}
}
日志种类包括 App 自身、IM、语音和游戏引擎四类。拉日志复用了通用的上传框架,没有为它单独写一套上传。
3.5 调试工具和业务日志
测试包里有调试页,可以查看、分享、删除 App、音视频和游戏引擎日志。音视频和游戏引擎日志列表的多选删除。
麦位互动表情的下载链路按阶段记日志:下载、校验、解压、解析各自记录结果。
3.6 级别怎么用
门面能分发,前提是调用方把级别用对。比较合理的用法大致如下:
| 级别 | 适合记录什么 | 去向 |
|---|---|---|
debug |
调试细节、频繁的状态变化 | 只在测试包落盘和写面包屑 |
info |
关键业务节点:进房、发消息、开始上传 | 正式包也落盘,写面包屑 |
warning |
可恢复的异常:重试、降级、校验失败 | 额外进入远程采集 |
error |
不可恢复的失败 | 落盘、面包屑、远程采集 |
级别一旦用乱,比如把高频的状态变化记成 info,正式包的日志文件就会迅速膨胀,崩溃平台的面包屑也会被无关内容挤满,真正有用的前因反而看不到。所以门面统一之后,级别规范和异常类别是需要持续维护的两张表。
4. 边界与失败处理
4.1 日志里的敏感信息
网络层在模型映射失败的分支里,会把完整的请求头拼进日志字符串,而请求头里带有会话凭证。这意味着本地日志文件、以及被回传的日志里,都可能出现可用的凭证。
这类问题可以这样系统地查:
- 怎么发现:在测试包里正常使用一遍,导出日志文件,用关键字(凭证字段名、
Cookie、Authorization等)全文搜索。 - 怎么定位:找到所有把请求或响应整体转成字符串写入日志的位置,尤其是错误分支,因为错误分支最常「为了好排查」把东西全打出来。
- 怎么修:在日志门面这一层统一做脱敏,按字段名白名单输出请求头,而不是指望每个调用点自己小心。
4.2 配置放在哪里
远程采集 SDK 的鉴权配置直接写在源码里,仓库里还留着被注释掉的旧配置。任何能看到源码或反编译安装包的人都能拿到。改进方向是:鉴权放到构建时注入或服务端下发,仓库里不保留明文,旧配置直接删除并轮换。
4.3 其它边界
- 清理逻辑的注释写的是 7 天,代码实际是 30 天,以代码为准,注释应同步修正。
- 拉日志同一时间只允许一个任务,避免并行打包、上传时互相争抢文件和带宽。
5. 演进:能力是一层层加上去的
演进的方向很清楚:从「开发者自己看」到「崩溃时能看」,再到「能把别人设备上的日志拿回来」。每一步都没有推翻前一步,只是给门面多加一个去向,或多加一条回传链路。
5.1 一条日志去哪
门面先看级别和包类型,再决定写不写、往哪写。
崩溃之后,下次网络恢复再传音视频日志,一次为限。
服务端点名和崩溃回传是两条路,都复用上传。
6. 复盘与可改进之处
做对了的地方
- 门面统一:业务代码只有一个入口,换底层库、加去向都不影响调用方。
- 级别 × 包类型的分发矩阵:正式包不打
debug,远程只收warning,控制了性能和流量开销。 - 两条回传链路分工明确:崩溃回传是自动的、范围小;点名拉日志是按需的、可指定种类和时间窗。
在另一个项目里复用时的体会
这套方案后来在另一个项目里复用过。从可迁移性看,门面、级别与包类型的分发矩阵、服务端点名拉日志这几块和具体业务无关,适合直接复用;和业务绑定、需要按新项目重新梳理的,是异常类别的划分,以及哪些第三方 SDK 的日志值得回传。
还可以做得更好
- 统一脱敏:在门面层按字段白名单输出请求头和参数,凭证、手机号等一律打码。
- 配置安全:鉴权移出源码,构建时注入或服务端下发,清理仓库里的明文和注释配置。
- 结构化日志:输出 JSON 行,带上会话、房间、请求等上下文 ID,远端可以按 ID 串起一次完整操作。
- 采样:高频日志(比如房间内的信令)按比例采样,避免在热点路径上产生大量 IO。
- 收敛工具:崩溃平台和远程采集各管一部分,可以评估统一到一个可观测平台,减少接入和排查时来回切换。