崩溃告警系统技术方案文档
一、项目背景
当前移动端已集成 Bugly 服务用于线上崩溃统计,开发人员需定时登录后台查看崩溃记录并处理。但在近期一次服务端配置异常事件中,接口下发错误域名导致 APP 启动即闪退,因缺乏实时告警机制,问题在崩溃发生 30 分钟后才被推广人员发现。为提升问题响应效率,降低线上故障影响,需在 APP 中集成崩溃告警服务,实现崩溃事件的实时捕捉与主动通知,确保开发团队及时介入处理。
二、方案设计
本方案旨在构建每日统计与实时告警双维度的崩溃监测体系,具体设计如下:
| 维度 | 每日统计告警 | 实时告警 |
|---|---|---|
| 触发机制 | 每日固定时间(如 9:00)由 Bugly 后台推送数据 | 崩溃事件发生时立即触发客户端采集上报 |
| 数据来源 | Bugly 每日崩溃趋势数据 | 客户端独立采集的崩溃日志数据 |
| 通知内容 | 各版本崩溃率统计、趋势分析 | 实时崩溃堆栈、设备信息、用户标识等 |
| 通知渠道 | 钉钉群组(Markdown 格式日报) | 钉钉群组(含符号化后堆栈详情) |
三、每日统计告警实现
(一)Bugly 配置流程
- Webhook 配置
登录 Bugly 后台,进入【我的产品】→【具体产品】→【更多】→【Webhook】,启用自定义 Webhook功能,配置告警接收 URL 并勾选【每日 Crash 统计】选项。Bugly 将在每日固定时间向该 URL 推送 JSON 格式的崩溃趋势数据。
- 数据格式示例
{
"eventType": "bugly_crash_trend",
"eventContent": {
"appName": "VGifts-iOS",
"date": "20250520",
"datas": [
{
"version": "1.03.13",
"accessUser": 119,
"crashUser": 1,
"url": "http://bugly.qq.com/realtime?..."
}
]
}
}
(二)后端服务开发(Python3 + Flask)
-
接口定义
-
URL:
/webhook/bugly -
请求方法:POST
-
处理逻辑:
- 解析请求数据,提取app_name、date、各版本的access_user,crash_count,crash_user, crash_rate(crash_user/access_user)。
- 过滤crash_rate为 0 的版本,计算有效版本的崩溃率(
crashUser/accessUser×100%)。 - 生成 Markdown 格式的钉钉告警消息,包含版本详情、崩溃指标及 Bugly 后台链接。
-
-
核心代码片段
def parse_crash_data(data):
event_content = data["eventContent"]
return {
"app_name": event_content["appName"],
"date": event_content["date"],
"versions": [
{
"version": v["version"],
"crash_rate": round(v["crashUser"]/v["accessUser"]*100, 2),
"url": v["url"]
}
for v in event_content["datas"]
if v["accessUser"] > 0
]
}
def generate_dingding_message(app_name, date, versions):
message = f"**Bugly崩溃趋势日报 - {date}**\n📱 应用: {app_name}\n"
for version in versions:
message += f"📦 版本: {version['version']}\n"
message += f"📊 崩溃率: {version['crash_rate']}% | 受影响用户: {version['crashUser']}\n"
message += f"[查看详情]({version['url']})\n\n"
return message
四、实时告警实现
(一)客户端崩溃采集
-
方案选型
-
放弃方案:
- 配置Bugly SDK的
crashServerUrl
- 配置Bugly SDK的
-
Bugly 的crashServerUrl配置会导致数据不上报至 Bugly 后台,需自建完整监控体系,成本较高。
- Bugly 的
attachmentForException代理回调实测未触发,无法获取崩溃堆栈。 - 采用方案:集成PLCrashReporter独立采集崩溃日志,将appVersion,appBuild,userId,deviceName,osVersion,appType,deviceId,clientSource,及崩溃堆栈上传自建服务,然后服务端根据版本及build号,获取dsym文件,调用
symbolicatecrash进行符号化,然后裁剪Thread 及 binary images 信息,组装成dingding的markdown 格式,进行通知。 - 实现步骤
- 初始化:
let config = PLCrashReporterConfig(
signalHandlerType: .crash,
logFileMaxSize: 10 * 1024 * 1024
)
let crashReporter = PLCrashReporter(configuration: config)
crashReporter.start()
-
日志裁剪:
- 为了减少上传的数据量,启动日志裁剪方法
- 保留前 n 个关键线程日志,排除冗余线程信息
- 截取前 n 条 Binary Images 记录,快速定位崩溃关联模块。
func simplifyCrashLog(keepThreadsCount: Int = 5, keepBinaryImagesCount: Int = 10) -> String {
let lines = components(separatedBy: .newlines)
var resultLines: [String] = []
var threadCount = 0
var binaryImagesCount = 0
var inBinaryImages = false
for line in lines {
// 保留头部信息
if ["Incident Identifier:", "Hardware Model:", "Process:", "Path:", "Identifier:", "Version:", "Code Type:", "Parent Process:", "Date/Time:", "OS Version:", "Report Version:", "Exception Type:", "Exception Codes:", "Crashed Thread:"].contains(where: line.hasPrefix) {
resultLines.append(line)
continue
}
// 处理 Thread
if line.hasPrefix("Thread ") && threadCount < keepThreadsCount {
threadCount += 1
var currentThreadLines = [line]
var nextLine = lines[lines.firstIndex(of: line)! + 1]
while !nextLine.hasPrefix("Thread ") && nextLine != "" && lines.firstIndex(of: nextLine)! < lines.count - 1 {
currentThreadLines.append(nextLine)
nextLine = lines[lines.firstIndex(of: nextLine)! + 1]
}
resultLines.append(contentsOf: currentThreadLines)
continue
}
// 处理 Binary Images
if line.contains("Binary Images:") {
resultLines.append(line)
inBinaryImages = true
binaryImagesCount = 0
continue
}
if inBinaryImages && binaryImagesCount < keepBinaryImagesCount {
if !line.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
resultLines.append(line)
binaryImagesCount += 1
} else {
inBinaryImages = false // 遇到空行结束
}
continue
}
}
return resultLines.joined(separator: "\n")
}
- 启动时处理历史崩溃:
func buglyDidCatchCrashPostRequest(crashStr: String, stack: String) {
// 发送 POST 请求到自建服务器
let dict = ["errorStack": stack]
guard let jsonData = try? JSONSerialization.data(withJSONObject: dict) else {
return
}
let configURL = IMCrashManagerConfigModel.shared.uploadServer
if let url = URL(string: "(configURL)?(crashStr)") {
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.httpBody = jsonData
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
do {
let task = URLSession.shared.dataTask(with: request) { data, response, error in
if let error = error {
VGLog("Crash upload error: (error)")
} else {
VGLog("Crash report uploaded successfully")
self.crashReporter?.purgePendingCrashReport()
}
}
task.resume()
}
}
}
(二)后端符号化与告警
-
dSYM 文件定位
- 根据 APP 版本号(
appVersion)和构建号(appBuild)拼接路径(如/symbols/1.03.13_42/uuid.txt),读取文件内容匹配崩溃日志中的 UUID,定位对应的.dSYM文件路径。
- 根据 APP 版本号(
def find_dsym_by_uuid(uuid, app_version, app_build):
def is_placeholder(value):
return value.strip() == "-"
use_filter = not (is_placeholder(app_version) or is_placeholder(app_build))
logger.info(f"{uuid} {app_version}_{app_build}")
if use_filter:
logger.info("🔍 仅查找指定版本")
version_dirname = f"{app_version}_{app_build}"
version_path = os.path.join(SYMBOLS_BASE_DIR, version_dirname)
base_dirs = [version_path] if os.path.isdir(version_path) else []
else:
logger.info("🔍 全量查找")
base_dirs = [
os.path.join(SYMBOLS_BASE_DIR, d)
for d in os.listdir(SYMBOLS_BASE_DIR)
if os.path.isdir(os.path.join(SYMBOLS_BASE_DIR, d))
]
for dir_path in base_dirs:
uuid_file_path = os.path.join(dir_path, "uuid.txt")
if not os.path.isfile(uuid_file_path):
continue
try:
with open(uuid_file_path, "r") as f:
for line in f:
match = re.match(r'UUID:\s*[0-9a-fA-F-]+\s+(\w+)\s+(.*.dSYM)', line)
if match:
dsym_path = match.group(1).strip()
logger.info(f"✅ 找到 dSYM 路径:{dsym_path}")
return dsym_path
except Exception as e:
logger.warning(f"⚠️ Failed to read {uuid_file_path}: {e}")
return None
-
符号化处理
- 调用 Xcode 自带的
symbolicatecrash工具,传入崩溃日志路径和 dSYM 路径,生成可读的符号化堆栈信息。 - 符号化耗时较久,加入符号化频次限制,1m内只符号化一次,如果此时有其他堆栈需要符号化,就直接返回原始堆栈信息
- 调用 Xcode 自带的
def symbolicate_crash(crash_log, dsym_path):
with tempfile.NamedTemporaryFile(suffix=".crash") as f:
f.write(crash_log.encode())
command = [
"/Applications/Xcode.app/Contents/Developer/usr/bin/symbolicatecrash",
f.name,
dsym_path
]
env = {"DEVELOPER_DIR": "/Applications/Xcode.app/Contents/Developer"}
result = subprocess.run(command, capture_output=True, text=True)
return result.stdout
- 钉钉告警格式
**【iOS实时崩溃告警】**
🛠️ 平台: iOS
📱 应用: VGifts
📝 应用版本: 1.03.14
🙍 用户ID: 2902
📱 设备型号: iPhone 15
🔧 系统版本: 18.4.1
🆔 deviceId: DD55C0BB-414F-48FB-B67A-FB841B0F271B
💥 崩溃堆栈:
Thread 0 Crashed:
0 vgift 0x10468fd24 PointMallVC.setupUI() + 64804 (PointMallVC.swift:26)
1 VGBaseWidget 0x10af860f8 BaseViewController.viewDidLoad() + 270584
五、系统流程架构
六、技术实现要点
-
性能优化:
- 客户端日志裁剪减少上传数据量,降低网络传输耗时。
- 后端一分钟内只符号化一个堆栈信息,避免消耗更多cpu资源
-
异常处理:
- 符号化失败时保留原始日志并告警,避免信息丢失。
-
CICD符号化
- archive成功后,在symbols中新建{appVersion}_{appBuild} 文件夹,文件夹中有dsyms 和 uuid.txt文件,为下面的符号化做准备
七、源码与依赖管理
-
后端服务源码:gitlab.imiracle.tech/developer/i…
- Python3 + Flask
-
核心依赖:
- Bugly Webhook API(数据来源)
- PLCrashReporter(iOS 崩溃采集)
- Python Flask 框架(后端服务)
- Xcode Symbolication 工具链(符号化处理)
八、总结
本方案通过整合 Bugly 现有数据与独立崩溃采集组件,构建了覆盖 “数据采集 - 处理 - 告警” 全链路的崩溃监测体系。每日统计提供趋势分析,实时告警确保突发问题快速响应,两者结合可显著提升线上故障的发现与处理效率,降低用户影响时长,为 APP 稳定性提供有力保障。