↓ 跳过正文

React Native Expo Sentry

用 Sentry 的 SDK,接自己的服务器:React Native 应用的自托管崩溃上报

我们的应用之前没有任何崩溃上报。我没有去注册 sentry.io,而是保留 Sentry 的开源 SDK,把 DSN 指向我们自己 Django API 里的一个接口。本文讲 envelope 格式、没有 source map 时如何给压缩过的崩溃分组、怎样报警又不刷屏团队聊天群、数据脱敏、怎么证明 iOS 上一次原生 SIGABRT 真的能送到,以及为什么最后连隐私政策都重写了。

用 Sentry 的 SDK,接自己的服务器:React Native 应用的自托管崩溃上报

直到这周之前,我知道 我们的应用崩了的唯一途径,是有人来告诉我。大多数人不会说。他们重新打开应用,如果又崩了,就不再打开了。API 那边是有覆盖的(所有 ERROR 日志都会发到团队聊天的一个频道),但手机上的一次崩溃,在我能看到的地方不留任何痕迹。

其实应用里已经有一个上报机制。应用每次把错误信息展示给用户时,都会把这条信息 POST 到 API 上的一个小接口,服务端以 ERROR 级别记录,所以同样会进聊天频道。加这个是因为有一次删除操作其实 成功了 ,可那一行还留在列表里,再点一下就弹出 “No Item matches the given query”。服务端只记了两条普通的 404,什么也没说。它覆盖的是应用捕获并展示出来的错误。崩溃不一样:本该发送报告的那段 JavaScript,恰恰就是刚刚死掉的那个。

最直接的办法是用 Sentry。我很喜欢 Sentry 的 SDK,React Native 上的崩溃上报之所以算是已解决的问题,靠的就是它。我不想要的是:又多一家供应商持有用户数据,隐私政策里又多一行,又多一张账单,而以我们的规模,这东西一天顶多几条报告。所以我留下 SDK,换掉服务:应用的 DSN 指向我们自己的 API,由那边 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/1

Django 里加一条路由,正好匹配 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,所以这类崩溃按信号分组,粒度很粗,但在有符号化之前,这样是诚实的。

报警,但不刷屏
#

每个指纹对应一个 issue,每份报告作为一个事件存在它下面。issue 只在以下几种情况下以 ERROR 级别记录(聊天报警监听的就是这个级别):

  • 新出现的 issue
  • 已解决的 issue 又出现,此时会重新打开它
  • 第 10、100、1,000、10,000 次出现

普通的重复只会让计数器加一。标记为 忽略 的 issue 照样计数,但从不报警。再加上聊天 handler 自己的限流,坏版本上的崩溃循环在频道里只是几条消息,而不是一面墙。

存储也一样有上限:每个 issue 保留 最近 50 条 事件,新的进来就删掉旧的。这让第一版图表出了问题,因为 50 条事件说不出上周某件事发生了多少次。所以每个 issue 还另外记着 90 天的每日计数,和计数器加一放在同一个事务里更新。图表从这些计数取数;事件只用来阅读。

如果你也自己做一个,有两个小的竞态值得处理。一个全新的崩溃同时打到几台手机上时,两份报告可能都想创建这个 issue,所以给指纹加唯一约束,让输的那个重新读取已有的那一行。另外,90 天的窗口要从服务器的"今天"算起,而不是从最新一份报告算起,因为时钟设错的手机可能把报告的日期标成任何一天,包括明年。

payload 上限是 128KB。超出的事件先丢 breadcrumbs 和调试元数据;还超,就只保留异常、信息、版本和平台,并做个标记,让我知道它被截过。

脱敏
#

SDK 开着 sendDefaultPii: false,所以理论上不会发送 IP 地址、cookie 或请求体。服务端并不依赖这一点:存储之前,user 里只留 id 和 username,request 整个删掉,以防以后某个 SDK 版本或设置开始发送这些东西。

用真机测试又发现了两样需要去掉的东西:

  • breadcrumbs 记录的是完整 URL,包括查询字符串。 应用的搜索请求会把搜索词放在查询字符串里,所以搜索之后紧跟着的一次崩溃,会把用户搜了什么存下来。现在服务端会把每个 breadcrumb 的 URL 在 ? 处截断。
  • console 类 breadcrumb 带着传给 console.log 的原始参数,可能是应用恰好打印出来的任何对象。消息文本保留(它有用),参数丢掉。

这两样都是看了一份真实报告才发现的,这也是下一节存在的主要理由。

客户端
#

应用里所有相关代码都走一个小模块,原因和我把 OTA 更新 SDK 包一层一样:OTA bundle 也会跑在原生模块还不存在时构建的安装包上。在没有 RNSentry 的安装包上,顶层写 import * as Sentry from "@sentry/react-native" 会在导入时就抛异常,结果就是白屏。所以 SDK 是延迟 require 的,而且要先确认原生模块真的存在:

import { NativeModules, Platform, TurboModuleRegistry } from "react-native";
import Constants from "expo-constants";

const DSN = process.env.EXPO_PUBLIC_CRASH_DSN;

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,
    // ...turn off the other tracing options the same way
    maxBreadcrumbs: 50,
    beforeSend(event) { /* tag the OTA bundle */ return event; },
  });
  return true;
}

只上报错误。服务端不存 trace、session、profile 或 replay,所以这些一个都没开。没有 DSN(开发环境、E2E 构建)时 SDK 根本不会启动。构建 profile 会设置 DSN 和环境(staging 或 production),两类构建上报到同一个接口,又能区分开。登录用户用账号 ID 标识,绝不用名字或邮箱。

错误边界需要显式调用。Expo Router 的根 ErrorBoundary 会捕获渲染错误并显示自己的界面,所以这个错误根本到不了 SDK 挂钩的全局处理器。解决办法是一个小包装组件:在 useEffect 里调用 captureException,然后照旧渲染路由自带的 boundary,根 boundary 和每个路由的 boundary 都用它。放在 effect 里而不是渲染过程中,是为了只上报一次,而不是每次重新渲染都报。根组件还用 Sentry.wrap 包了一层,如果这一步抛异常,就退回到原始组件。

Web 端有一个 .web.ts 版本的模块,所有函数都是空操作。Metro 会选平台专属的文件,所以 SDK 根本不会进入 web bundle。Web 应用也没有原生模块可以运行它。

证明原生崩溃能送到
#

单元测试 POST 的是手写的 envelope,只能证明解析器能处理我 以为 SDK 会发的东西。发布两天后,生产环境一份崩溃报告都没有。当时只有三个人更新了,所以零是说得通的,但从服务端看,“没人崩溃"和"崩溃送不到"长得一模一样。我想亲眼看到一份真的。

于是我构建了一个 Release 版的模拟器包,把 DSN 指向本地 8000 端口上的 Django,用一个临时 key:

EXPO_PUBLIC_CRASH_DSN=http://localcrashtestkey0000@localhost:8000/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 找到它并发出去。我重新启动应用,一秒后模拟器的网络日志里就出现了一条到 :8000 的连接。

然后 Django 日志里什么都没有,这让我愣了一会儿:在我设的日志级别下,开发服务器只记录非 2xx 的响应。数据库里是有的:

SIGABRT: Signal 6, Code 0 | cocoa 1.4.0 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 不是账号 ID。 测试包并没有真正登录,所以 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 请求,其中包括分析 SDK 自己发往它的数据收集接口的请求,这也提醒了我应用还在跟谁通信。而且这些 URL 带着查询字符串,前面说的搜索词问题就是这么发现的。

怎么看这些报告
#

报告在 Django 后台里看,页面大致仿照 Sentry 的 issue 页:顶部是计数和 Resolve / Ignore / Reopen 按钮,然后是每日柱状图、按版本、系统、设备和 OTA bundle 的分布,再往下一次展示一个事件,包括异常、堆栈帧(应用帧高亮,库的帧折叠起来)、breadcrumbs 和设备信息。不需要任何新依赖。

做这个页面时顺手修了一个问题:原生崩溃的"culprit"帧原本是二进制文件在设备上的路径,里面带着每次安装都不同的 UUID,结果同一个崩溃在每台手机上看起来都不一样。现在只显示地址和二进制名,比如 0x1000a1b2c (MyApp)。

隐私政策,整份重写
#

崩溃报告差不多就是个人信息:一个账号 ID、一个手机型号,以及某人在应用崩溃前一分钟做了什么。所以隐私政策需要加一段。要写好,就得准确说明一份报告里有什么,于是我照着刚看过的 payload 列了出来:错误本身、应用版本和 OTA 更新、手机型号、系统和语言、可用内存和屏幕尺寸、最近几十步操作(打开的页面、去掉 ? 之后部分的网址、应用自己的日志),以及账号 ID 或随机的安装 ID。政策里也写明了报告直接发到我们自己的服务器,不会发给 Sentry 或任何其他崩溃上报公司。保存期限在表格里单独占一行:每个问题最近 50 份报告,外加 90 天的每日计数。

一旦开始拿代码核对其中一段,就很难不去核对其余部分。原来的政策经不起核对。删除账号页面写着:关闭账号时,推送通知的 token 和 passkey 会立即删除,服务器日志会在 90 天内清除。这些都不是真的。关闭账号会解除登录方式的绑定,并把资料匿名化,但推送 token 和 passkey 会一直留着,直到有人手动删除;日志归档目前也没有过期时间。所以整份政策根据代码实际做了什么的清单重写了一遍,那几行现在写的是"暂未自动删除,请发邮件给我们”。听起来没那么让人安心,但它是准确的。

现在代码仓库里有一条规则:政策描述的是代码做了什么,而不是我们打算做什么;任何在新地方收集、发送或保存个人数据的改动,都要在同一个 commit 里更新它。这包括应用里的摘要页面、删除账号页面,以及只存在于商店后台的应用商店隐私表单。正是崩溃上报这个改动,让我把这条规则写了下来。

还缺什么
#

符号化。 这是真正的缺口,也是 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 已经足够找到问题了。

接收端、后台页面和测试加起来代码量不大,在原本就在运行的 API 之外不额外花一分钱。难的部分由 SDK 完成:在手机上捕获崩溃,并在下次启动时把报告送出来。