直到这周之前,我知道 我们的应用崩了的唯一途径,是有人来告诉我。大多数人不会说。他们重新打开应用,如果又崩了,就不再打开了。API 那边是有覆盖的(所有 ERROR 日志都会发到一个 Discord 频道),但手机上的一次崩溃,在我能看到的地方不留任何痕迹。
其实应用里已经有一个上报机制。应用每次把错误信息展示给用户时,都会把这条信息 POST 到 /client-errors/,服务端以 ERROR 级别记录,所以同样会进 Discord。加这个是因为有一次删除操作其实 成功了 ,可那一行还留在列表里,再点一下就弹出 “No Item matches the given query”。服务端只记了两条普通的 404,什么也没说。它覆盖的是应用捕获并展示出来的错误。崩溃不一样:本该发送报告的那段 JavaScript,恰恰就是刚刚死掉的那个。
最直接的办法是用 Sentry。我很喜欢 Sentry 的 SDK,React Native 上的崩溃上报之所以算是已解决的问题,靠的就是它。我不想要的是:又多一家供应商持有用户数据,隐私政策里又多一行,又多一张账单,而以我们的规模,这东西一天顶多几条报告。所以我留下 SDK,换掉服务:应用的 DSN 指向我们自己的 API,大约 650 行 Django 负责接收、分组、报警和保存。
这篇文章讲它是怎么工作的,以及我怎么确认一次真实的原生崩溃确实能送到。
DSN 的小把戏#
Sentry 的 DSN 长这样:https://<public-key>@<host>/<project-id>。SDK 会把它变成一个发往 https://<host>/api/<project-id>/envelope/ 的 POST 请求,key 放在 X-Sentry-Auth 头里。这里面没有任何东西是 sentry.io 专属的。所以打包进应用的 DSN 是:
https://<CRASH_REPORT_KEY>@api.example.com/1Django 里加一条路由,正好匹配 SDK 会调用的地址:
path("api/<int:project_id>/envelope/", crash_envelope, name="crash_envelope"),这个 key 存在于每一个安装包里,所以它不是秘密。它的作用是:万一这个接口开始招来垃圾请求,我可以换掉它;另外服务端没配置 key 时会拒绝一切请求,而不是来者不拒:
def crash_envelope(request, project_id):
expected = settings.CRASH_REPORT_KEY
if not expected:
return Response({"detail": "Crash reporting is not configured"}, status=503)
if not hmac.compare_digest(_key(request).encode(), expected.encode()):
return Response({"detail": "Unknown key"}, status=401)
..._key 从 X-Sentry-Auth 头里读出 sentry_key=,读不到就退回到查询字符串,有些 SDK 是放在那里的。
SDK 发的是什么:envelope#
Sentry 的传输格式叫 envelope:先是一行 JSON 头,后面跟任意多个 item,每个 item 是一行 JSON 头加一段 payload。如果 item 头里有 length,payload 就恰好是这么多字节;否则一直读到下一个换行符。
{"event_id":"9ec79c33...","dsn":"https://[email protected]/1"}
{"type":"event","length":4123}
{ ...the event JSON, 4123 bytes... }
{"type":"client_report"}
{"timestamp":1791400000.5,"discarded_events":[]}有两个细节让我栽了跟头:
- 原生 SDK 会 gzip 请求体,JavaScript 的不会。
@sentry/react-native捕获的 JS 错误是明文发来的,而 sentry-cocoa 或 sentry-android 发来的原生崩溃带着Content-Encoding: gzip。解码器能处理 gzip、zlib 包装的 deflate 和裸 deflate,解压到 1MB 就停,因为一个崩溃事件只有几 KB,再大就不是崩溃事件了。 - 带长度前缀的 payload 里可以有换行符。 如果按
\n切分,原生 SDK 发来的每个事件都会被切坏。有length的时候必须按它来读。
解析器大约四十行。它只保留 event 类型的 item;session、transaction、附件和 client report 照单全收,然后丢弃。
用 SDK 听得懂的方式回应#
这一点比我预想的重要。SDK 在磁盘上维护一个队列,凡是不确定送达的都会重试,所以服务端怎么回应,决定了它会不会一直重试下去:
- 存好了,或者是故意丢弃的,都回 200。如果我对 session item 回一个 4xx,SDK 就会留着它,永远重试。
- 被限流时回 429 加
Retry-After。这个接口按 IP 限流(每小时 120 次),因为陷入崩溃循环的手机每次启动都会发一条报告,而 SDK 收到 429 会像你期望的那样退避。 - envelope 格式不对回 400,记成 warning 而不是 error:一份坏掉的报告只说明某个客户端坏了,下一份正常的报告会告诉我崩在哪。
SDK 如果不确定第一次是否送达,还会重发同一个事件,所以 event_id 上有唯一约束,重复的就悄悄跳过。
没有 source map 时怎么分组#
自己写接收端,就得做一个 Sentry 替你做了的选择。Sentry 按堆栈给崩溃分组。但 release 版的 Hermes bundle 是压缩过的,函数名都是单个字母,行列号每次构建都会变。按堆栈帧分组,同一个 bug 每次发版都会开一个新 issue。
没有 source map(文末再说),JavaScript 错误里最稳定的就是它的类型和信息。所以指纹由异常类型加信息组成,并把每次出现都不一样的部分遮掉:
_MASKS = [
(re.compile(r"0x[0-9a-fA-F]+"), "<addr>"),
(re.compile(r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b"), "<uuid>"),
(re.compile(r"\b[0-9a-fA-F]{16,}\b"), "<hex>"),
(re.compile(r"\d+"), "<n>"),
]Cannot read property 'title' of undefined (reading index 3) 和 (reading index 17) 会归为同一个 issue。如果 SDK 自己带了 fingerprint 且其中没有 {{ default }},就以它为准,这样默认分组不对时我可以从客户端强制指定。没有异常的事件退而使用遮罩后的日志信息,两者都没有就用最上面的应用内堆栈帧。原生信号崩溃的信息大概是 Signal 6, Code 0,所以这类崩溃按信号分组,粒度很粗,但在有符号化之前,这样是诚实的。
报警,但不刷屏#
每个指纹对应一行 CrashIssue,每份报告是它下面的一个 CrashEvent。issue 只在以下几种情况下以 ERROR 级别记录(Discord handler 监听的就是这个级别):
- 新出现的 issue
- 已解决的 issue 又出现,此时会重新打开它
- 第 10、100、1,000、10,000 次出现
普通的重复只会让计数器加一。标记为 忽略 的 issue 照样计数,但从不报警。再加上 Discord handler 自己的限流(同一条消息十分钟最多一次,每小时最多 30 条报警),坏版本上的崩溃循环在频道里只是几条消息,而不是一面墙。
存储也一样有上限:每个 issue 保留 最近 50 条 事件,新的进来就删掉旧的。这让后台图表的第一版出了问题,因为 50 条事件说不出上周某件事发生了多少次。所以 issue 行上还存了一个小小的 daily_counts 字典,{"2026-10-09": 3, ...},只保留 90 天,在给计数器加一的同一个行锁里更新。图表从它取数;事件只用来阅读。
有两个小的竞态需要处理。一个全新的崩溃同时打到几台手机上时,两份报告可能都想创建这个 issue。输的那个会因为指纹唯一约束拿到 IntegrityError,然后用 select_for_update 重新读取那一行。90 天的窗口从服务器的"今天"算起,而不是从字典里最新的日期算起,因为时钟设错的手机可能把报告的日期标成任何一天,包括明年。
存储的 payload 上限是 128KB。超出的话,先丢 breadcrumbs 和 debug_meta;还超,就只保留异常、信息、版本和平台,并加一个 _truncated 标记,让我知道它被截过。
脱敏#
SDK 开着 sendDefaultPii: false,所以理论上不会发送 IP 地址、cookie 或请求体。服务端并不依赖这一点:存储之前,user 里只留 id 和 username,request 整个删掉,以防以后某个 SDK 版本或设置开始发送这些东西。
用真机测试又发现了两样需要去掉的东西:
- breadcrumbs 记录的是完整 URL,包括查询字符串。 应用的搜索请求长这样:
/api/v1/items/?search=...,所以搜索之后紧跟着的一次崩溃,会把用户搜了什么存下来。现在服务端会把每个 breadcrumb 的 URL 在?处截断。 - console 类 breadcrumb 带着传给
console.log的原始参数,可能是应用恰好打印出来的任何对象。消息文本保留(它有用),参数丢掉。
这两样都是看了一份真实报告才发现的,这也是下一节存在的主要理由。
客户端#
应用里所有相关代码都走一个文件 services/crashReporting.ts,原因和 OTA SDK 只通过一个文件调用一样:OTA bundle 也会跑在原生模块还不存在时构建的安装包上。在没有 RNSentry 的安装包上,顶层写 import * as Sentry from "@sentry/react-native" 会在导入时就抛异常,结果就是白屏。所以 SDK 是延迟 require 的,而且要先确认原生模块真的存在:
function nativeModulePresent(): boolean {
try {
return Boolean(TurboModuleRegistry.get("RNSentry") ?? NativeModules.RNSentry);
} catch {
return false;
}
}
export function initCrashReporting(): boolean {
if (!DSN || Platform.OS === "web" || !nativeModulePresent()) return false;
const Sentry = require("@sentry/react-native");
Sentry.init({
dsn: DSN,
release: Constants.expoConfig?.version ?? "unknown",
sendDefaultPii: false,
tracesSampleRate: 0,
enableAutoSessionTracking: false,
enableAutoPerformanceTracing: false,
enableAppStartTracking: false,
enableNativeFramesTracking: false,
enableStallTracking: false,
enableUserInteractionTracing: false,
maxBreadcrumbs: 50,
beforeSend(event) { /* tag the OTA bundle */ return event; },
});
return true;
}只上报错误。服务端不存 trace、session、profile 或 replay,所以这些一个都没开。没有 DSN(开发环境、E2E 构建)时 SDK 根本不会启动。EAS 的构建 profile 会设置 DSN 和环境(staging 或 production),两类构建上报到同一个接口,后台能把它们区分开。登录用户用 Firebase UID 标识,绝不用名字或邮箱。
错误边界需要显式调用。Expo Router 的根 ErrorBoundary 会捕获渲染错误并显示自己的界面,所以这个错误根本到不了 SDK 挂钩的全局处理器。现在 layout 导出一个小包装组件,在 useEffect 里调用 captureException,然后照旧渲染路由自带的 boundary。每个路由的 boundary 也是同样做法。放在 effect 里而不是渲染过程中,是为了只上报一次,而不是每次重新渲染都报。根组件还用 Sentry.wrap 包了一层,如果这一步抛异常,就退回到原始组件。
Web 端旁边有一个 crashReporting.web.ts,所有函数都是空操作。Metro 会选平台专属的文件,所以 SDK 根本不会进入 web bundle。Web 应用也没有原生模块可以运行它。
原来的 /client-errors/ 上报保留。在 2.5.9 之前的安装包上,它仍然是唯一的上报渠道,而且它覆盖的是那些不算崩溃的错误。
没有 sentry-cli,没有 build phase,也没有 expo prebuild;这个项目的 ios/ 和 android/ 目录是手工编辑并提交的,autolinking 自己就找到了原生模块。这个功能随 2.5.9 发布。
证明原生崩溃能送到#
单元测试 POST 的是手写的 envelope,只能证明解析器能处理我 以为 SDK 会发的东西。发布两天后,生产环境一份崩溃报告都没有。当时只有三个人用上了 2.5.9,所以零是说得通的,但从服务端看,“没人崩溃"和"崩溃送不到"长得一模一样。我想亲眼看到一份真的。
于是我构建了一个 Release 版的模拟器包,把 DSN 指向本地 8002 端口上的 Django,用一个临时 key:
EXPO_PUBLIC_CRASH_DSN=http://localcrashtestkey0000@localhost:8002/1 \
EXPO_PUBLIC_CRASH_ENVIRONMENT=local-verify \
xcodebuild -workspace ios/MyApp.xcworkspace -scheme MyApp -configuration Release \
-destination 'platform=iOS Simulator,id=<udid>' -derivedDataPath /tmp/iosbuild(用 Release 而不是 Debug:Debug 构建会链接 dev launcher,行为不一样,而用户跑的是 Release。)然后安装、启动,等几秒钟,再像真实故障那样把它杀掉:
xcrun simctl install booted /tmp/iosbuild/Build/Products/Release-iphonesimulator/MyApp.app
xcrun simctl launch booted com.example.myapp # prints the pid
kill -ABRT <pid>模拟器里的应用就是普通的 macOS 进程,所以信号到达 SentryCrash 处理器的方式和真机上完全一样。处理器把报告写到磁盘;进程临死时什么都不发送。下次启动时 SDK 找到它并发出去。我重新启动应用,一秒后模拟器的网络日志里就出现了一条到 :8002 的连接。
然后 Django 日志里什么都没有,这让我愣了一会儿:在我设的日志级别下,开发服务器只记录非 2xx 的响应。数据库里是有的:
SIGABRT: Signal 6, Code 0 | cocoa 2.5.9 iOS 27.1 iPhone19,4 handled=False
received 09:19:53, crashed 09:19:38
contexts: app, device, os, ota_updates, trace · 24 breadcrumbs从崩溃到入库十五秒,大部分时间花在我手动重启上。而看真实的 payload,教会了我测试教不了的东西:
- 用户 ID 不是 Firebase UID。 测试包并没有真正登录,所以 sentry-cocoa 退而使用它自己的按安装生成的 ID。这一点值得知道,也值得写进隐私说明:登录时报告带的是账号 ID,未登录时是一个随机的安装 ID。
- 没有时区。 我原以为 culture context 会有。device context 里有
locale、内存、屏幕尺寸、低电量模式和内核版本,就是没有时区。 - 原生崩溃不经过
beforeSend。 我在 JS 的beforeSend里加了一个ota标签,标明正在运行的 OTA bundle,但原生崩溃是下次启动时由原生 SDK 组装的,根本不经过 JS 钩子。要给原生崩溃打标签,得在 bundle 确定后用setTag设到 scope 上,这样它才会同步到原生层。 - breadcrumbs 暴露了应用在做什么。 console 警告、view controller 生命周期,还有 HTTP 请求,其中包括 Firebase Analytics 自己发往
app-analytics-services.com的请求,这也提醒了我应用还在跟谁通信。而且这些 URL 带着查询字符串,前面说的搜索词问题就是这么发现的。
怎么看这些报告#
Django 后台加了一个仿照 Sentry 的 issue 页面:顶部是各项计数、保留事件中不同用户的数量,以及 Resolve / Ignore / Reopen 按钮;下面是根据 daily_counts 画出的 30 天每日柱状图;保留事件按版本、系统、设备、OTA bundle、环境以及致命/已处理分别统计的占比(每项列出前四个值,其余归为"other”);然后一次展示一个事件,包括异常、堆栈帧(应用帧高亮,库的帧折叠起来)、breadcrumb 时间线和设备信息,最底下是原始 JSON。issue 列表每行有一条 14 天的迷你走势图。整个页面就是一个模板加一个读取已存 payload 的模块,不需要任何新依赖。
做这个页面时顺手修了一个问题:原生崩溃存下来的"culprit"帧原本是 ? (/private/var/containers/Bundle/Application/<uuid>/MyApp.app/MyApp),也就是二进制文件在设备上的路径,里面带着每次安装都不同的 UUID。现在显示为 0x1000a1b2c (MyApp)。单份报告的后台 URL 会重定向到所属 issue 的页面并选中该事件,这样看一次崩溃只有一个地方,而不是两个。
隐私政策,整份重写#
崩溃报告差不多就是个人信息:一个账号 ID、一个手机型号,以及某人在应用崩溃前一分钟做了什么。所以隐私政策需要加一段。要写好,就得准确说明一份报告里有什么,于是我照着刚看过的 payload 列了出来:错误本身、应用版本和 OTA 更新、手机型号、系统和语言、可用内存和屏幕尺寸、最近几十步操作(打开的页面、去掉 ? 之后部分的网址、应用自己的日志),以及账号 ID 或随机的安装 ID。政策里也写明了报告直接发到我们自己的服务器,不会发给 Sentry 或任何其他崩溃上报公司。保存期限在表格里单独占一行:每个问题最近 50 份报告,外加 90 天的每日计数。
一旦开始拿代码核对其中一段,就很难不去核对其余部分。原来的政策经不起核对。删除账号页面写着:关闭账号时,推送通知的 token 和 passkey 会立即删除,服务器日志会在 90 天内清除。这些都不是真的。关闭账号会解除登录方式的绑定,并把资料匿名化,但 token 记录和 passkey 记录会一直留着,直到有人手动删除;日志归档目前也没有过期时间。所以整份政策根据代码实际做了什么的清单重写了一遍,那几行现在写的是"暂未自动删除,请发邮件给我们"。听起来没那么让人安心,但它是准确的。
现在代码仓库里有一条规则:政策描述的是代码做了什么,而不是我们打算做什么;任何在新地方收集、发送或保存个人数据的改动,都要在同一个 commit 里更新它。这包括应用里的摘要页面、删除账号页面,以及只存在于商店后台的 App Store 和 Play 隐私表单。正是崩溃上报这个改动,让我把这条规则写了下来。
还缺什么#
符号化。 这是真正的缺口,也是 sentry.io 能做而我还没替代的那件事。JavaScript 的堆栈是压缩过的,原生代码的堆栈没有符号化:
- JavaScript: 堆栈帧指向 Hermes bundle 内部。要解决,就得为每个商店构建和每个 OTA bundle 保留合成后的 Hermes source map,按版本和 bundle 索引,在页面渲染时套用到 JS 帧上。这是下一步要做的,因为我们的崩溃大部分会是 JavaScript 的。
- iOS 原生: 指令地址需要该次构建的 dSYM。
- Android: 开启压缩时 Java 帧会被 R8 混淆(需要
mapping.txt),C++ 帧需要 NDK 符号。
前面还发现了一个小一点的缺口:原生崩溃目前还不带 OTA 标签,因为它只在 beforeSend 里设置。在运行中的 bundle 确认之后把它设到 scope 上,只要几行代码,我还没写。
目前,一个 issue 能告诉我 什么 崩了、在哪个版本、哪个 OTA bundle、哪台设备上,以及用户崩溃前在做什么。它没法告诉我崩在源码的 哪里 。对一个一周发几次版的小应用来说,到目前为止,异常类型、信息和 breadcrumbs 已经足够找到问题了。
接收端大约 650 行(解析器、分组、报警、模型和视图),后台页面另外 700 行左右,测试大约 400 行,在原本就在运行的 API 之外不额外花一分钱。难的部分由 SDK 完成:在手机上捕获崩溃,并在下次启动时把报告送出来。
