技术5/29/2026·8 min

用自建 crash 链路替换 Sentry

我们去掉 Sentry,把 iOS crash 链路搬回自家:发版第三个 stage 上报 dSYM 到 MinIO,getsentry/symbolicator 做符号化,agent 通过 mainline-cli 分析 issue。

起点

iOS 的 crash 上报一直走 Sentry:SDK 在端上,发版管道把 dSYM 上传到 Sentry 的 upload API,符号化在它们的服务器上完成。当我们为了统一到自家 telemetry 栈、从各 App 删掉 PostHog 和 Sentry SDK 之后,crash 链路是最后一处还拴在第三方上的东西。

candidate 构建的发版流程有三个 stage。第三个叫 sentry,只做一件事:把 dSYM 上传到 Sentry。去掉这个依赖,意味着要重建这个 stage,以及它背后整套符号化与分析的后端。

dSYM 上传链路已经是双写

有一个细节让切换比看上去小。切换前两天,sentry stage 就已经在双写:除了上传 Sentry,它还把 dSYM PUT 进自家 MinIO 的 dsym bucket——因为 symbolicator 早已把这个 bucket 配成了 source。双写是在自建链路搭建期间作为兜底加的。

所以这次重构(commit 157b03018)并没有造新路径,而是把已有的 MinIO 写入升为唯一主路径,删掉 Sentry 那一侧:

  • infra/sentry —— 263 行的 HTTP client 加它的测试
  • domain/service/sentry —— 113 行的编排 service
  • config.SentryConfig 以及 SENTRY_API_BASE / ORG_SLUG / TEAM_SLUG / AUTH_TOKEN 等 env

stage 做了全栈重命名:sentryreport_dsym,包括 entity 字段、workflow(WorkflowReportDsym)、task 载荷、DB 列。migration 038 原地重命名 candidate 的列:

ALTER TABLE release_candidates RENAME COLUMN sentry_status TO report_dsym_status;
ALTER TABLE release_candidates RENAME COLUMN sentry_release_id TO report_dsym_debug_id;
-- ...started_at / completed_at / error_msg
UPDATE release_stages SET process_id = 'report_dsym' WHERE process_id IN ('sentry', 'sentry_upload');

净 diff 是 +1221 / -2859。用于服务端错误监控的那套 Sentry(SENTRY_DSN + tracing)是另一回事,保留。

自己提取 debug id

去掉 Sentry 的 upload API,也就去掉了那个返回 dSYM debug id 的东西。symbolicator 按 mach-O 的 LC_UUID 检索调试文件,所以新 stage 在写 bucket 前自己把这个 id 提取出来。

dsym.ExtractDebugIDServer/libs/go/analytics/dsym/extract_uuid.go)直接读 LC_UUID load command(0x1b)。Go 的 debug/macho 不暴露一个有类型的 UUID command,所以它从 load bytes 里按裸 command id 匹配:

const lcUUID = 0x1b

func uuidFromLoads(mf *macho.File) string {
    for _, l := range mf.Loads {
        lb, ok := l.(macho.LoadBytes)
        if !ok {
            continue
        }
        raw := lb.Raw()
        if len(raw) < 24 {
            continue
        }
        // layout: cmd(4) + cmdsize(4) + uuid(16)
        if mf.ByteOrder.Uint32(raw[0:4]) == lcUUID {
            return hex.EncodeToString(raw[8:24])
        }
    }
    return ""
}

它同时处理裸的 thin/fat mach-O DWARF 二进制和 .dSYM.zip(先解出 Contents/Resources/DWARF/<binary>),把内存处理上限卡在 600 MB 防 zip bomb,返回的 id 是小写、32 位 hex、不带连字符——正是 object key 用的形式。report_dsym handler 下载 dSYM、提取 id、写 bucket、标记 stage 完成;writer 未接线时优雅跳过,不阻塞发版链路。

MinIO 的 layout 是最贵的一课

写 bucket 很简单。写成让 symbolicator 找得到文件,花了好几轮,而且每次失败都是静默的——symbolicator 返回 missing,不是 error。

第一版用 layout: native。这名字有误导性:sentry/symbolicator 的 native layout 是微软 symstore 风格、按 4 字符切分的 5 级目录,再加 .app 后缀。我们上传到 20/2680550c.../WidgetCraft,symbolicator 去找的是 2026/8055/0C25/3246/899E/8EC69155D60D.app,永远找不到。

修复(commit d7f5ea2ce)切到 layout: unified,也就是我们原本以为 native 是的样子:

/sources/20/2680550c253246899e8ec69155d60d/debuginfo   (dSYM)
/sources/20/2680550c253246899e8ec69155d60d/executable  (stripped binary)

unified 要求固定文件名——调试文件是 debuginfo——于是 writer 把它写死,caller 不能再传 binary name。这让 layout 的决策在一处成为 single-source-of-truth。同一块区域的其它静默坑,在相邻几个 commit 里一并修掉:symbolicate 请求的字段是 stacktraces 不是 threads;自定义 S3 endpoint 必须用 [name, endpoint] 的 region tuple 传,不能用独立字段,否则被悄悄丢弃;symbolicator 26.x 默认拒绝 RFC1918 地址,要 connect_to_reserved_ips: true;config 模板里的 ${VAR} 要在子进程启动前先 os.ExpandEnv

因为这些都不以 error 的形式暴露,后续一轮(commit 2ddfa74f2)在 CI 里加了静态 configcheck 校验器——sentry/symbolicator 26.5.1 没有 --check-config flag——以及一个 testcontainers e2e:起 MinIO 加 symbolicator,上传一个最小的、只含 LC_UUID 的 mach-O fixture,断言 debug_status: found。原则是:在静默坑上烧掉一天的调试,应该留下会大声失败的检查。

运行时链路

worker 侧(commit 5882bad25)是一个 crash-worker binary,从 Redpanda 消费 events.crash.raw。symbolicator 唯一的调用方就是它,所以没有独立部署,而是作为同进程子进程跑:worker 启动时在 127.0.0.1:3021 fork symbolicator binary,子进程退出则主进程 panic 触发重启——fail fast,不试图自愈。

Pipeline.Process 每条 event 走八步,按「幂等优先」排序:unmarshal、schema 校验、把 raw envelope 归档到 S3(best-effort)、符号化、算 fingerprint、写 ClickHouse crash_events、把投影 upsert 进 mainline Postgres 的 crash_issues 表。符号化失败不是致命的——退回用 raw frames 算 fingerprint,issue 仍能聚合。iOS reporter 发来的 KSCrash JSON 会转成 symbolicator 的 wire 格式:binary image 的 UUID 转小写、浮点地址渲成 0x hex、只保留 crashed 线程。

两路写入刻意独立:ClickHouse 给分析,Postgres 给 issue 业务系统。worker 写 Postgres 投影是用受限 role 跨 VLAN 直连 PG,而不是回调 mainline 的 HTTP RPC。

端上,CrashReporterService 包装 KSCrash 2.5.1,经 Connect-RPC + HMAC 鉴权上报,复用 analytics-reporter 的签名机制(共享签名器被抽到 Foundation/Services/AnalyticsConnectAuth)。待上报的 crash 从一个状态文件 reconcile,并在后台延迟里 flush,冷启动不受影响。

agent 怎么读它

把 issue 放在 mainline Postgres 的意义在于:agent 能用它处理其它一切的同一个 CLI 来查。mainline 的 /api/v1/crash/* REST API(在 commit f5282f9ea 接线)支撑四个 mainline-cli 子命令:

mainline-cli crash triage --product WidgetCraft --release 1.4.0 --since 24h
mainline-cli crash issues --product WidgetCraft --status open
mainline-cli crash show <fingerprint>
mainline-cli crash patch <fingerprint> --status resolved --resolved-in-release 1.4.1

CLI 默认 JSON 输出便于 pipe,--format=table 给人看。issue 记录带着 worker 直接写的投影字段——latest_frames / latest_dist / latest_os_version / latest_device_model——所以 show 直接返回一份有代表性的符号化堆栈,不必再二次 query ClickHouse events。状态修改(patch)只动业务列,从不碰 worker 维护的投影,两个写入方因此不冲突。列表读取刻意不取大字段 frames,只有 GetEvent 才拉完整 stacktrace JSON 给深入分析。

闭环的端到端验证:WidgetCraft AboutPage.swift:60 的数组越界 crash,对着上传的 dSYM 符号化,精确落到那一行。

留下来的约束

  • 切换前留一段双写窗口,能把有风险的迁移变成一次删除。难的那部分(新路径)在旧路径仍承载流量时就已在生产验证。
  • 删一个托管服务时,先盘点它静默提供了什么。Sentry 的 upload API 返回 debug id,这份义务转到我们身上,变成 139 行 mach-O 解析。
  • 一个返回 missing 而非 error 的 symbolicator 会吃掉好几天。我们踩过的每个静默坑现在都是一条 CI lint 或一个 e2e 断言——「在我的复现上能跑」不等于「错的时候会失败」。
  • 把 crash issue 放在平台其它部分都在查的同一个库里,triage 就是 agent 早会用的一个 CLI 调用,而不是一套要另行集成的 console。