造轮子是一辈子的事
背景
在上文中 「桌面端」|Electron 崩溃自行上报实现 我们在之前建设了桌面端崩溃上报的能力,只是为了看一下线上的崩溃率,但对于后续如何归因问题、分析问题、解决问题,并没有在前期设计方案,导致崩溃虽然产生了,但很难去排查:
- 抓不到大头崩溃问题:没有问题归因,甚至没有 Dump 文件解析,导致最起码的问题原因也无法直观显示,需要团队成员自行使用符号表解析工具自己解析。
- 解决不了崩溃问题:崩溃问题排查都是极为耗时的,如果不能锁定大概模块范围,那也没办法进行人员排期进行工作分工。
从上得知,我们需要从零开始构建桌面端崩溃分析监控链路,目标上是能力对齐友盟等三方分析平台,提供基本的崩溃归因分析能力。
分析
在上文方案中,崩溃记录在 ELK 日志平台,崩溃信息上传在 OSS 文件存储。
那后续需要做:
- 读取 ELK 的崩溃记录。
- 从 OSS 把崩溃具体信息 Dump 文件获取下来。
- 解析 Dump 文件,生成可以阅读的文本格式。
- 聚合归因可观测,直观显示每个版本的不同崩溃的数量级,并持续监控。
选型
基于公司现有基建设施,做如下选择:
- 选择 Python 脚本做自动化流程。
- 选择 Teamcity 作为脚本触发平台。
- 选择公司自建的质量分析平台作为数据展示。
具体方案

读取崩溃记录
从 ELK 中获取崩溃记录,用的是一个比较取巧的办法,从网站 URL 中分析出,获取数据列表接口(接口略)。
通过去请求这个接口,传入合适的 params 和 header 即可获取到一段时间内的崩溃记录列表。
这里可以看到我有一个重试 10 次的注解,原因是这个接口不太稳定,不仅慢,而且经常字段上会会缺斤少两(一脸懵逼)。
下载崩溃信息
在 ELK 日志平台上存储的是 OSS 上的文件地址,具体的崩溃信息是存储在这个 Dump 文件中。
想要分析它,首先需要把它下载到本地。
这只需要提供一个批量下载的方法即可:
这里简单实现即可,毕竟脚本每隔一段时间只执行一次,不会有什么性能瓶颈。
分析 Dump 文件
解析方式
Dump 文件是一个二进制格式的文件,需要使用 breakpad 进行解析。
当然,解析成文本的文件只有简单的错误说明和内存地址,想看具体堆栈还需要符号表进行转换。
这里有两种方式:
一种直接使用 mimdump 库,从 (github.com/electron/el...)找到当前 Electron 发布的符号表地址:

当然是要用符合我们项目的 Electron 版本。
附代码示意:

第二种是直接使用封装好的第三方库(electron-minidump),它是基于 mimdump 的二次封装。
好处是它自身已经包括通用的符号表库引用:

问题是它使用的是最新的 Electron 版本进行符号表转换,但我们项目版本比较低,解析不出来,需要找到 electron-minidump 的安装地址,把我们项目版本的符号表放进去即可:
bash
npm root -g # 找到 npm - g 的安装目录
找到 electron-minidump 安装地址下的 /cache 目录:

从中找到 electron 的缓存 pdb 包:

把我们下载适应版本的放进去即可。
批量解析
同样,我们需要对多个 Dump 文件进行解析,所以需要构造一个批量方法:
把二进制 dump 文件批量转换成 txt 文本文件:

执行后,就可以看到具体的崩溃原因及崩溃堆栈了。
当然整个过程是耗时的,每个 dmp 文件的解析都大概需要 10+ 秒,不过对于崩溃归因这个场景来说也不影响,我们每天执行一次即可。
归因聚合
我们有了这些可分析的崩溃信息后,还需要做崩溃类型汇总,对崩溃问题进行聚合,把高优问题筛选出来优先处理。
与 App 有些不同的是,App 上不同的崩溃地址,可能也还是同一个问题,所以友盟上还有分析堆栈,模糊聚合的能力。
而桌面端除了内存溢出外(内存比较特别,崩溃的点只是最后一棵稻草,而不是发生崩溃的位置,所以没有什么意义,这个需要额外的建设内存监控机制)。
其他崩溃地址是清晰的,所以这阶段根据地址做聚合即可。
这一段附一下代码:
Python
class Ascribe:
@staticmethod
def ascribe_to(list: list[ElkModel]):
"""
归因分析
"""
if list.__len__() == 0:
return []
folder_name = Define.analysis_dir
sort_list = sorted(list, key=lambda x: x.create_time)
data_list = []
for model in sort_list:
file_name = folder_name + "/" + os.path.basename(
model.id) + ".dmp.txt"
reason = Ascribe.analysis(file_name)
data = Ascribe.format_data(
model,
reason['os'],
reason['crash_reason'],
reason['crash_keyword'],
reason['content'],
)
data_list.append(data)
result = Ascribe.statistics(data_list)
return result, data_list
@staticmethod
def analysis(file_name: str) -> dict:
"""
分析文件
"""
content = ""
with open(file_name, 'r') as f:
content = f.read()
# 根据正则表达式获取操作系统信息
os_pattern = r"Operating system:\s*(.*?)\n"
os_result = re.findall(os_pattern, content)
os = "unknown"
if os_result.__len__() > 0:
os = os_result[0].strip()
# 根据正则表达式提取崩溃原因信息
crash_reason_pattern = r"Crash reason:\s*(.*?)\n"
crash_reason_result = re.findall(crash_reason_pattern, content)
crash_reason = "unknown"
if crash_reason_result.__len__() > 0:
crash_reason = crash_reason_result[0].strip()
# 根据正则表达式提取崩溃地址信息
crash_address_pattern = r"Crash address:\s*(.*?)\n"
crash_address_result = re.findall(crash_address_pattern, content)
crash_address = "unknown"
if crash_address_result.__len__() > 0:
crash_address = crash_address_result[0].strip()
crash_keyword_pattern = r"\(crashed\)\n*(.*?)\n"
crash_keyword_result = re.findall(crash_keyword_pattern, content)
crash_keyword = "unknown"
if crash_keyword_result.__len__() > 0:
crash_keyword = crash_keyword_result[0].strip()
return {
"crash_reason": crash_reason,
"crash_address": crash_address,
"crash_keyword": crash_keyword,
"content": content,
"os": os,
}
def statistics(data_list: list) -> list:
"""
统计
"""
dict = {}
for data in data_list:
key = data["keyStack"]
if data["exceptionName"] == "Out of Memory":
key = "Out of Memory"
item = dict.get(key, None)
if item == None:
dict[key] = {
"total": 1,
"devices": set([data["deviceId"]]),
"data": data,
"list": [data],
}
else:
devices: set = item["devices"]
devices.add(data["deviceId"])
list: list = item["list"]
list.append(data)
dict[key] = {
"total": item["total"] + 1,
"devices": devices,
"data": data,
"list": list
}
result = []
# 遍历每一个分组
for _, value in dict.items():
# 统计每个崩溃的数量及崩溃的设备数量
crash_count = value["total"]
device_ids: set = value["devices"]
device_count = device_ids.__len__()
last_crash_info = value["data"]
last_crash_info['crashNum'] = crash_count
last_crash_info['imeiCount'] = device_count
for data in value["list"]:
data["aggregateIssueKey"] = last_crash_info["issueKey"]
result.append(last_crash_info)
return result
这样我们就在脚本上构建了一个归因聚合能力,可以统计出每类崩溃的崩溃总数和崩溃设备数。
平台观测
这里采用质量管理平台(qa.gaoding.com/h5/qacenter...)来进行查看及任务汇总。
采集策略
由于 ELK 为了降本,只保留了 7 天数据,且质量管理平台存储数据的话也需要额外开发工作(当前 App 处理上,质量平台也只是调用友盟的接口)。
所以策略上:
- 脚本一次执行采集 7 天数据。
- 每半小时执行一次脚本。
- 质量管理平台只存储最新数据。
上报 API
- 清理数据
- post 统计数据。
- post 明细数据。
具体效果
归因查询

明细查询

监控预警

总结
崩溃归因分析到此就结束了,希望能给大家一些启发,在 Electron 这个丐版客户端上,造轮子造的更舒服一点 ...
但如何解决问题,也是很头疼的一件事 ...
后续,笔者还会写几篇关于 Electron 的文章,需要的同学可以关注下。
感谢阅读,如果对你有用请点个赞 ❤️