<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:media="http://search.yahoo.com/mrss/">
  <channel>
    <title>Sentry · 仁才德</title>
    <link>https://jared.lynskey.co.nz/zh-cn/tags/sentry/</link>
    <description>关于软件工程与研发团队领导力的短文——绩效评估、招聘与团队扩张、CI/CD、DevOps 监控、智能体 AI。写于首尔。</description>
    <generator>Hugo</generator>
    <language>zh-CN</language>
    <copyright>© 2026 仁才德</copyright>
    <lastBuildDate>Sat, 07 Nov 2026 00:00:00 +1300</lastBuildDate>
    <atom:link href="https://jared.lynskey.co.nz/zh-cn/tags/sentry/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>在 Fly.io 上监控 Django：我留下的 DevOps 工具，以及弃用的那些</title>
      <link>https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-10-22-monitoring-tools-two-products/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-10-22-monitoring-tools-two-products/</guid>
      <pubDate>Thu, 22 Oct 2026 00:00:00 +1300</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>DevOps</category>
      <category>Monitoring</category>
      <category>Observability</category>
      <category>Site Reliability</category>
      <category>Fly.io</category>
      <category>Django</category>
      <category>Cloudflare</category>
      <category>Sentry</category>
      <category>Crash Reporting</category>
      <category>Small Teams</category>
      <category>Tech Stack</category>
      <description>我怎样用每月约 2 美元监控 Fly.io 上的两个小 Django 应用：错误发到聊天频道、日志存在 S3 用 DuckDB 查询、Fly 自带指标、崩溃报告，以及我弃用了什么。</description>
      <content:encoded><![CDATA[<p>我有两个小产品：一个我做的集市兼社区应用，一个给上门报价的手艺人用的估价应用，都跑在 Fly.io 最便宜的机器上。我的 DevOps 监控归结起来就五样：把 ERROR 日志发到一个聊天频道；把每一行日志都归档到对象存储里，用 DuckDB 查询；Fly 自带的 Prometheus 指标；手机上的崩溃报告，用 Sentry 的开源 SDK 发到我自己的 API；以及每天早上一条汇总。</p>
<p>没有托管的 APM，生产环境没有链路追踪，也没有需要记着去打开的仪表板。唯一额外花钱的是日志归档，每月大约 2 美元。下面是完整清单，以及我弃用或不再信任的东西，其中一样让我追了好几天一个根本没有用户碰到过的故障。</p>

<h2 class="relative group">我说的监控是什么，以及规模有多小
    <div id="我说的监控是什么以及规模有多小" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%88%91%e8%af%b4%e7%9a%84%e7%9b%91%e6%8e%a7%e6%98%af%e4%bb%80%e4%b9%88%e4%bb%a5%e5%8f%8a%e8%a7%84%e6%a8%a1%e6%9c%89%e5%a4%9a%e5%b0%8f" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>我说的 DevOps 监控指两件事：在别人告诉我之前就发现东西坏了，以及不靠猜就能查出原因。下面每个工具都按这两条来评价。</p>
<p>去年我写过一篇<a href="https://jared.lynskey.co.nz/zh-cn/posts/2025/2025-10-13-devops-monitoring/" >搭建开发者真正会用的监控</a>，那时主要考虑的是团队：小团队用云厂商自带的监控，服务上百个了再上托管的 APM。这篇是同一个思路里最小的那一端。两个产品都是 Django，跑在 Fly 的 shared-cpu-1x 机器上，<a href="https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-07-22-my-current-stack/" >我当前的技术栈</a>里写过，而且两边的开发者都只有我一个。如果说监控是一个产品、开发者是它的用户，那我这套监控的用户就只有一个人。九月我从 Fly 的指标里拉了一次基线，交易平台的 API 平均 CPU 占用是 1%。这不是企业级的技术栈，我也不打算把它包装成那样。</p>
<p>指标、日志、链路这三根支柱，我有两根。两个产品都没有分布式追踪：各自就是一套 Django 代码加一个数据库，没有服务边界让链路去跨。我平时看的链路只有一种，就是<a href="https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-10-01-cloudflare-warp-dev-network/" >我工作站</a>上反向代理的链路。</p>

<h2 class="relative group">清单
    <div id="清单" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%b8%85%e5%8d%95" aria-label="锚点">#</a>
    </span>
    
</h2>
<table>
	<thead>
			<tr>
					<th>工具</th>
					<th>盯的是什么</th>
					<th>保留还是弃用，为什么</th>
					<th>费用</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>团队聊天频道，由 Django 的日志 handler 推送</td>
					<td>API、WebSocket 服务器和 worker 的所有 ERROR；部署；集中出现的错误登录验证码</td>
					<td>保留。这是我不用别人提醒也会去看的唯一地方</td>
					<td>免费</td>
			</tr>
			<tr>
					<td>Fly 的日志转发器写到兼容 S3 的 bucket，用 DuckDB 查询</td>
					<td>交易平台各个应用的每一行日志</td>
					<td>保留。Fly 自己的缓冲只存几分钟，这里存全部，还能用 SQL 查</td>
					<td>每月约 2 美元，外加存储</td>
			</tr>
			<tr>
					<td>Fly 自带指标（Prometheus API）</td>
					<td>CPU、内存、突发额度、响应；托管 Postgres 的 exporter</td>
					<td>保留。是它证明了数据库卡顿不是我代码的问题</td>
					<td>Fly 自带</td>
			</tr>
			<tr>
					<td><code>/api/health/</code> 上的 Fly 健康检查（两个应用）</td>
					<td>某台机器该不该接流量</td>
					<td>用于路由，保留。当作“用户没事”的证据，弃用</td>
					<td>Fly 自带</td>
			</tr>
			<tr>
					<td>Sentry 的 SDK，DSN 指向我自己的 API</td>
					<td>手机上的应用崩溃</td>
					<td>SDK 保留。从没注册 sentry.io</td>
					<td>API 之外不额外花钱</td>
			</tr>
			<tr>
					<td>团队聊天里的早间汇总</td>
					<td>前一天的错误、使用量、搜索、聊天和看板</td>
					<td>保留。从 CI 的定时任务挪到了我的 Mac 上</td>
					<td>免费</td>
			</tr>
			<tr>
					<td>应用分析 SDK（两个应用）和搜索引擎的站长后台</td>
					<td>Web、iOS、Android 的使用情况；搜索引擎怎么看这个站</td>
					<td>保留，通过 API 读取</td>
					<td>免费</td>
			</tr>
			<tr>
					<td>Cloudflare 的 GraphQL 分析</td>
					<td>某个状态码或路径背后是谁</td>
					<td>保留，但相信它之前先按 user agent 分组</td>
					<td>免费套餐，每次查询一天</td>
			</tr>
			<tr>
					<td>一个托管 OTA 更新服务的仪表板</td>
					<td>手机上跑的是哪个 OTA 版本；崩溃回滚</td>
					<td>从一个什么都看不出来的后台列表换到了这里</td>
					<td>属于更新服务的一部分</td>
			</tr>
			<tr>
					<td>一个小的探测脚本</td>
					<td>部署或重启过程中失败的请求</td>
					<td>保留。直接打源站，缓存藏不住空档</td>
					<td>免费</td>
			</tr>
			<tr>
					<td><code>flyctl logs</code></td>
					<td>应用最近几分钟的输出</td>
					<td>当实时查看用，保留。当记录用，弃用</td>
					<td>Fly 自带</td>
			</tr>
			<tr>
					<td>Cloudflare Early Hints</td>
					<td>什么都不盯，它是个提速功能</td>
					<td>保留了，前提是让源站发出 <code>Link</code> 头。整个 zone 的 504 全是它的缓存未命中</td>
					<td>免费</td>
			</tr>
	</tbody>
</table>
<p>表里没写“两个应用”的，都只说交易平台。估价应用的东西更少：Fly 的健康检查、日志和指标，iOS 上的应用分析，再加一个每天把数据库导出成 Parquet、我用 DuckDB 查询的任务。它没有崩溃报告，也没有告警频道，这是这篇里最大的缺口。（关于更新服务那一行要说明一下：做这个服务的公司付钱请我给他们的博客写文章。）</p>

<h2 class="relative group">错误：把日志发到聊天频道的 logging handler
    <div id="错误把日志发到聊天频道的-logging-handler" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e9%94%99%e8%af%af%e6%8a%8a%e6%97%a5%e5%bf%97%e5%8f%91%e5%88%b0%e8%81%8a%e5%a4%a9%e9%a2%91%e9%81%93%e7%9a%84-logging-handler" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>九月之前，未处理的 500、发送失败的推送通知、坏掉的 WebSocket 帧，唯一会出现的地方是 <code>flyctl logs</code>，而那条流没人会主动去读。现在由一个日志 handler 把 ERROR 及以上的每条记录都发到聊天频道，不管来自 API、WebSocket 服务器还是后台 worker。WARNING 故意不告警。</p>
<p>handler 在后台线程里发送，所以绝不会拖慢请求。它内部不写任何日志，所以不会递归。同一个错误最多每十分钟发一次，每小时上限 30 条，所以出故障时你读到的是一条消息，而不是一千条。</p>
<p>这个频道里还有我想拿来和错误对照着看的东西：部署在开始、上线成功和失败的地方都会发消息，带上 commit 和执行人，OTA 更新和 <a href="https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-09-14-web-releases-without-redeploying/" >Web 发布</a>也一样。某个账号短时间内集中输错登录验证码，也会记成 ERROR，所以有人在乱猜的话，会和故障出现在同一个地方。</p>
<p>十月来过一条告警，任何日志里都找不到对应的行。从那以后，每条告警都带上一个短的随机 id、主机名和进程的启动命令，同一行也写进那个进程自己的日志。找不到 id，就说明它来自一个不往那里写日志的进程，比如一个 SSH shell。频道里的所有消息也会进一条信息流，和我一起干活的编程 agent 可以读，所以在改代码的 agent 和我同时看到这些错误。</p>

<h2 class="relative group">日志：用 Fly 的 log shipper 存到 S3，用 DuckDB 查询
    <div id="日志用-fly-的-log-shipper-存到-s3用-duckdb-查询" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%97%a5%e5%bf%97%e7%94%a8-fly-%e7%9a%84-log-shipper-%e5%ad%98%e5%88%b0-s3%e7%94%a8-duckdb-%e6%9f%a5%e8%af%a2" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>Fly 自己的缓冲只有几分钟的输出，大概最后一百行，盯部署够用，查昨天的事就没用了。所以第三个 Fly 应用跑着 Fly 的日志转发器，就是订阅了组织日志流的 Vector，我只改了它的 S3 sink：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-toml" data-lang="toml"><span class="line"><span class="cl"><span class="p">[</span><span class="nx">sinks</span><span class="p">.</span><span class="nx">aws_s3</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">  <span class="nx">type</span> <span class="p">=</span> <span class="s2">&#34;aws_s3&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">bucket</span> <span class="p">=</span> <span class="s2">&#34;myapp-logs&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">compression</span> <span class="p">=</span> <span class="s2">&#34;gzip&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">key_prefix</span> <span class="p">=</span> <span class="s2">&#34;logs/{{fly.app.name}}/%F/&#34;</span>   <span class="c"># one folder per app per day</span>
</span></span><span class="line"><span class="cl">  <span class="nx">batch</span><span class="p">.</span><span class="nx">timeout_secs</span> <span class="p">=</span> <span class="mi">300</span>                  <span class="c"># a quiet app still lands its lines</span></span></span></code></pre></div></div>
<p>交易平台各个应用的每一行日志，都以 gzip 压缩的 JSON 落进一个兼容 S3 的 bucket。转发器是一台 256 MB 的 shared-cpu-1x 机器，每月大约 2 美元，存储费可以忽略。DuckDB 用一个宏直接就地读取这些文件，一天的数据一秒左右就出来：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="cl"><span class="k">CREATE</span><span class="w"> </span><span class="n">MACRO</span><span class="w"> </span><span class="n">logs</span><span class="p">(</span><span class="n">app</span><span class="p">,</span><span class="w"> </span><span class="k">day</span><span class="w"> </span><span class="p">:</span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;*&#39;</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="k">TABLE</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">read_json_auto</span><span class="p">(</span><span class="s1">&#39;s3://myapp-logs/logs/&#39;</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="n">app</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="s1">&#39;/&#39;</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="k">day</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="s1">&#39;/*.log.gz&#39;</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">                               </span><span class="n">union_by_name</span><span class="w"> </span><span class="p">:</span><span class="o">=</span><span class="w"> </span><span class="k">true</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="k">timestamp</span><span class="p">,</span><span class="w"> </span><span class="n">message</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">logs</span><span class="p">(</span><span class="s1">&#39;myapp-api&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;2026-09-06&#39;</span><span class="p">)</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">message</span><span class="w"> </span><span class="k">LIKE</span><span class="w"> </span><span class="s1">&#39;% 500 %&#39;</span><span class="p">;</span></span></span></code></pre></div></div>
<p>我在<a href="https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-09-09-scale-to-zero-504s/" >缩容到零那篇</a>里测冷启动，用的就是这个宏：启动脚本每个阶段都会打一行，每行都带着机器 id，剩下的就是一个 <code>GROUP BY</code>。</p>
<p>所以同一条日志流按读者分成三层：归档保留 INFO 及以上（这也是生产环境的应用用 INFO 级别打日志的原因），实时查看脚本只显示警告和错误，聊天频道收 ERROR。不过三者都不知道是谁发的请求。访问日志里没有客户端 IP，也没有 user agent，这个得去 Cloudflare 查。</p>

<h2 class="relative group">指标：Fly 自带的 Prometheus
    <div id="指标fly-自带的-prometheus" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%8c%87%e6%a0%87fly-%e8%87%aa%e5%b8%a6%e7%9a%84-prometheus" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>Fly 给每个组织跑一个托管的 Prometheus，所以我自己不维护任何指标系统。有问题时直接查它的 API：每台机器的内存、CPU 和突发额度（共享 CPU 上 <code>fly_instance_cpu_balance</code> 很要紧），每个应用的响应数和并发数。有两件事我希望早点知道。用 <code>fly tokens create</code> 创建的 token 要以 <code>Authorization: FlyV1 &lt;token&gt;</code> 传入；<code>Bearer</code> 只对 <code>flyctl auth token</code> 给出的个人 token 有效，而只读 token 会返回 &ldquo;something went wrong resolving organization&rdquo;。另外数据大约只保留两周（Fly 的说法是 15 天左右）。</p>
<p>它在九月真正派上了用场。那时日志里一天好几次出现 &ldquo;Could not open a database connection&rdquo;，看上去完全就是应用的 bug。托管 Postgres 集群也在同一个 Prometheus 里，每次出现这个错误的那一分钟，主库的抓取耗时都从 80 毫秒左右跳到一到十秒，副本一切正常，Postgres 的启动时间也没变。Postgres 没重启过，是它底下的宿主机卡住了。我把时间戳发给了 Fly 的支持团队；十月初一次维护重启把主库挪到另一台宿主机之后，每天的卡顿就没了。</p>

<h2 class="relative group">崩溃报告：指向我自己 API 的 Sentry SDK
    <div id="崩溃报告指向我自己-api-的-sentry-sdk" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%b4%a9%e6%ba%83%e6%8a%a5%e5%91%8a%e6%8c%87%e5%90%91%e6%88%91%e8%87%aa%e5%b7%b1-api-%e7%9a%84-sentry-sdk" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>以前应用在手机上崩了，我能看到的地方一点痕迹都没有。最直接的答案是 Sentry，我只用了它的一半：应用里装的是 Sentry 的开源 SDK，但 DSN 指向我自己 Django API 里的一个接口，这个接口接收 Sentry 的 envelope 格式，把崩溃归类，再通过同一个聊天 handler 告警。链路追踪、会话和回放都关掉了，只收错误。</p>
<p>没注册 sentry.io，是因为一天就几条报告，为此多一家掌握用户数据的厂商、隐私政策里多一行、再多一张账单，不值得。代价是符号化（symbolication），这块我还没补上。堆栈要么是压缩过的 JavaScript，要么是原始的 native 地址，所以一个 issue 能告诉我崩的是什么、哪个版本、哪台设备、用户崩之前做了什么，但说不出是源码的哪一行。</p>

<h2 class="relative group">唯一的仪表板是一条消息
    <div id="唯一的仪表板是一条消息" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%94%af%e4%b8%80%e7%9a%84%e4%bb%aa%e8%a1%a8%e6%9d%bf%e6%98%af%e4%b8%80%e6%9d%a1%e6%b6%88%e6%81%af" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>两个应用我都没做仪表板。去年那篇里写过一个检验标准：没人主动打开的仪表板，就是没证明自己的价值。自己送上门的消息不用打开，所以我最接近仪表板的东西，是每天早上发到团队聊天里的一条汇总。</p>
<p>开头是过去 24 小时的生产错误，从日志归档里读，而不是从聊天频道里读，因为告警限了频率，说不出一件事到底发生了多少次。数字、id 和地址先打码再归类，所以同一个失败出现一百次也只是一行加个计数，邮箱地址也不会流进频道。</p>
<p>接着是失败的健康检查、昨天的使用量和上周同一天的对比（周日永远不会像周一），最近一周的搜索流量、等着回复的聊天、任务看板，还有开销。哪一部分加载不出来就照实写，汇总照样发出去。</p>
<p>通过 API 读应用分析数据，会让你对这些数字多一点怀疑。八月份，阿塞拜疆来了 121 次 Android 首次打开，随后是 119 次卸载。那个月的 Android 总数，我是扣掉这些来看的。</p>
<p>发布有自己的界面。以前 OTA 更新从我自己的服务器发的时候，我看一次发布的方式就是后台里一个带 &ldquo;is active&rdquo; 复选框的列表，根本不知道有多少台手机真的装上了。现在这个服务的仪表板能看到设备实际在跑哪个版本、安装情况如何，坏掉的 bundle 崩溃、SDK 退回上一个版本时还会显示这次崩溃回滚。<a href="https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-09-13-moving-to-codemagic-patch/" >迁移到托管更新服务的经过</a>我另外写过。</p>

<h2 class="relative group">弃用的，或者不再相信的
    <div id="弃用的或者不再相信的" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%bc%83%e7%94%a8%e7%9a%84%e6%88%96%e8%80%85%e4%b8%8d%e5%86%8d%e7%9b%b8%e4%bf%a1%e7%9a%84" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>这里面没有一样是付费工具。大多是我一直当成信号、其实不是信号的东西，还有一个放错了地方的任务。</p>

<h3 class="relative group">按 user agent 分组之前的 Cloudflare 504 比例
    <div id="按-user-agent-分组之前的-cloudflare-504-比例" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%8c%89-user-agent-%e5%88%86%e7%bb%84%e4%b9%8b%e5%89%8d%e7%9a%84-cloudflare-504-%e6%af%94%e4%be%8b" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>九月，Cloudflare 面板上显示一天里有 22% 的请求以 504 失败，集中在应用每 30 秒轮询一次的那几个接口上，而健康检查全是绿的，应用自己的日志也说这些请求很快。我在上面花了好几天，发现机器冷启动要 25 到 33 秒、一天要冷启动 92 次，修好了，然后在<a href="https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-09-09-scale-to-zero-504s/" >缩容到零那篇</a>里把它写成了原因。</p>
<p>第二天早上，我把同样的 504 按 user agent 分组。9 月 5 日到 9 日之间的 7,344 个 504，全部来自 Cloudflare 自家的 Early Hints 缓存查询（user agent 是 <code>bastion early hints</code> 和 <code>nginx-ssl early hints</code>），它们根本不会到达源站：这些行里的 504 记录的是一次缓存未命中，而不是超时。它们照搬真实请求的路径，所以都落在最忙的接口上，看起来和用户请求失败一模一样。应用、浏览器、curl 和搜索引擎爬虫一个 504 都没有。源站当时不发 <code>Link</code> 头，Early Hints 什么作用都没起，于是我先把它关了，后来才<a href="https://jared.lynskey.co.nz/zh-cn/posts/2026/cloudflare-504-early-hints/" >让它真正生效</a>。</p>
<p>冷启动的优化并没有白做，部署和唤醒都因此变快了，但我以为自己在修的那个故障，从来没有落到任何用户身上。</p>
<p>所以我最想分享的一条规则是：相信 CDN 的错误率之前，先按 user agent 分组，确认那不是 CDN 自己的机制产生的。在 Cloudflare 的免费套餐上，就是对 <code>httpRequestsAdaptiveGroups</code> 发 GraphQL 查询，一次查一天。下一次虚惊也是靠同一个查询解决的：有个轮询接口从每天 70 到 260 次请求涨到了 2,880 次。不是爬虫，是我自己的 Mac，测试完把应用开在 iOS 模拟器里忘了关。</p>

<h3 class="relative group">一路绿灯的健康检查
    <div id="一路绿灯的健康检查" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%b8%80%e8%b7%af%e7%bb%bf%e7%81%af%e7%9a%84%e5%81%a5%e5%ba%b7%e6%a3%80%e6%9f%a5" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>Fly 每 30 秒检查一次两个应用的 <code>/api/health/</code>，用结果决定哪些机器接流量，这件事它做得很好。但健康检查跑在已经起来的机器上，看不到机器起来之前发生的事：冷启动、代理前面的排队、根本没到达的请求。现在我只指望它做路由。</p>
<p>部署时改用探测脚本，它在整个部署过程中持续打源站，只要有一次失败就以退出码 1 结束。它抓到过一次普通的 <code>flyctl deploy</code> 留下了 7.3 秒的空档；换成先唤醒一台备用机器的部署脚本之后，2,514 个请求里一个失败都没有。</p>
<p>健康检查自己还造成过两次故障。七月，Django 的 <code>SECURE_SSL_REDIRECT</code> 给 Fly 的明文 HTTP 探测回了 301，所有机器检查失败，所有人都收到了 503。八月，我把健康检查视图里的 <code>except Exception</code> 收窄了，结果 &ldquo;connection already closed&rdquo; 错误以 500 漏了出去，Fly 把机器摘掉了，而视图里的重试恰恰就是为这种情况准备的。现在这个检查只问一件事：这个进程能不能处理请求。数据库卡住时，两个应用也不会再被一起摘出路由。</p>
<p>早间汇总在计数上也学到了同样的教训。交易平台会把空闲机器挂起，被请求唤醒的机器在启动后几百毫秒时第一次检查会失败，下一次就通过了。把这些都算上，每天早上的汇总读起来都像出了故障。所以现在一分钟内重新通过的失败会单独列出，标注为机器唤醒和部署。</p>

<h3 class="relative group">CI 上的定时任务
    <div id="ci-上的定时任务" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#ci-%e4%b8%8a%e7%9a%84%e5%ae%9a%e6%97%b6%e4%bb%bb%e5%8a%a1" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>早间汇总一开始是一个按 cron 定时跑的 CI workflow，不到一天就挪走了。它要读应用分析和搜索数据之后，需要的密钥就成了分析项目的服务账号密钥，这个密钥在那个项目里什么都能干，我不想把它放进 CI 的 secrets。现在由我 Mac 上的定时调度程序每 20 分钟运行一次，只在奥克兰时间早上八点以后、而且当天还没发过的时候才发，所以八点时在睡眠的 Mac 会在醒来时补发。workflow 留着当手动备用，只是不带应用分析和搜索那两部分。</p>

<h2 class="relative group">如果从头再来
    <div id="如果从头再来" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%a6%82%e6%9e%9c%e4%bb%8e%e5%a4%b4%e5%86%8d%e6%9d%a5" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>我会在第一个用户到来之前、而不是第一次故障之后，先搭好三样东西：把错误发到团队本来就在看的聊天频道，并且限流；一个能用 SQL 查询的对象存储日志归档；以及平台本来就在收集的指标。每月花几美元而已。应用上手机的那一周加上崩溃报告，不然手机上的崩溃谁也不会知道。等要查看的地方多到我干脆一个都不看了，再加早间汇总。</p>
<p>链路追踪，我会等到出现第二个服务、请求真的要跨服务时再上；托管的 APM，等到有一个需要共享仪表板、值班轮换、也有预算替别人付账单的团队时再说。去年那篇里提到的工具，是从那时候开始才回本的。在那之前，最有用的习惯不花钱：一个数字看起来很吓人时，先搞清楚是什么产生了它，再判断是什么坏了。这篇里的好几次虚惊，都出在监控本身，而不是产品。</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-10-22-monitoring-tools-two-products/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>不自建 Sentry，也能收 React Native 崩溃报告：保留 SDK，接自己的服务器</title>
      <link>https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-10-09-sentry-sdk-own-server/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-10-09-sentry-sdk-own-server/</guid>
      <pubDate>Fri, 09 Oct 2026 00:00:00 +1300</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>React Native</category>
      <category>Expo</category>
      <category>Sentry</category>
      <category>Django</category>
      <category>崩溃上报</category>
      <category>自托管</category>
      <category>iOS</category>
      <category>Android</category>
      <category>隐私</category>
      <description>不用自建 Sentry，也不用付 sentry.io 的钱：保留 Sentry 的开源 SDK，把 DSN 指向自己 Django API 里的接口，自己接收 React Native 应用的崩溃报告，并做分组、报警和脱敏。</description>
      <content:encoded><![CDATA[<p>如果你想自己接收 React Native 应用的崩溃报告，其实不必自建 Sentry 服务端，也不必给 sentry.io 付费。Sentry 的 SDK 是开源的，DSN 可以指向任何主机。我把它指向自己 Django API 里的一个接口，在那里接收、分组、报警和保存，除了本来就在跑的 API，没有额外成本。目前还没替代掉的是符号化，所以堆栈还对应不到源码的具体行。</p>
<p>直到这周之前，我知道 我们的应用崩了的唯一途径，是有人来告诉我。大多数人不会说。他们重新打开应用，如果又崩了，就不再打开了。API 那边是有覆盖的（所有 ERROR 日志都会发到团队聊天的一个频道），但手机上的一次崩溃，在我能看到的地方不留任何痕迹。</p>
<p>其实应用里已经有一个上报机制。应用每次把错误信息展示给用户时，都会把这条信息 POST 到 API 上的一个小接口，服务端以 ERROR 级别记录，所以同样会进聊天频道。加这个是因为有一次删除操作其实 <em>成功了</em> ，可那一行还留在列表里，再点一下就弹出 &ldquo;No Item matches the given query&rdquo;。服务端只记了两条普通的 404，什么也没说。它覆盖的是应用捕获并展示出来的错误。崩溃不一样：本该发送报告的那段 JavaScript，恰恰就是刚刚死掉的那个。</p>
<p>最直接的办法是用 Sentry。我很喜欢 Sentry 的 SDK，React Native 上的崩溃上报之所以算是已解决的问题，靠的就是它。我不想要的是：又多一家供应商持有用户数据，隐私政策里又多一行，又多一张账单，而以我们的规模，这东西一天顶多几条报告。所以我留下 SDK，换掉服务：应用的 DSN 指向我们自己的 API，由那边 Django 应用里的一小块代码负责接收、分组、报警和保存。</p>
<p>这篇文章讲它是怎么工作的，以及我怎么确认一次真实的原生崩溃确实能送到。</p>

<h2 class="relative group">DSN 的小把戏
    <div id="dsn-的小把戏" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#dsn-%e7%9a%84%e5%b0%8f%e6%8a%8a%e6%88%8f" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>Sentry 的 DSN 长这样：<code>https://&lt;public-key&gt;@&lt;host&gt;/&lt;project-id&gt;</code>。SDK 会把它变成一个发往 <code>https://&lt;host&gt;/api/&lt;project-id&gt;/envelope/</code> 的 POST 请求，key 放在 <code>X-Sentry-Auth</code> 头里。这里面没有任何东西是 sentry.io 专属的。所以打包进应用的 DSN 是：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">https://&lt;CRASH_REPORT_KEY&gt;@api.example.com/1</span></span></code></pre></div></div>
<p>Django 里加一条路由，正好匹配 SDK 会调用的地址：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">path</span><span class="p">(</span><span class="s2">&#34;api/&lt;int:project_id&gt;/envelope/&#34;</span><span class="p">,</span> <span class="n">crash_envelope</span><span class="p">,</span> <span class="n">name</span><span class="o">=</span><span class="s2">&#34;crash_envelope&#34;</span><span class="p">),</span></span></span></code></pre></div></div>
<p>这个 key 存在于每一个安装包里，所以它不是秘密。它的作用是：万一这个接口开始招来垃圾请求，我可以换掉它；另外服务端没配置 key 时会拒绝一切请求，而不是来者不拒：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">crash_envelope</span><span class="p">(</span><span class="n">request</span><span class="p">,</span> <span class="n">project_id</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">expected</span> <span class="o">=</span> <span class="n">settings</span><span class="o">.</span><span class="n">CRASH_REPORT_KEY</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">expected</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">Response</span><span class="p">({</span><span class="s2">&#34;detail&#34;</span><span class="p">:</span> <span class="s2">&#34;Crash reporting is not configured&#34;</span><span class="p">},</span> <span class="n">status</span><span class="o">=</span><span class="mi">503</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">hmac</span><span class="o">.</span><span class="n">compare_digest</span><span class="p">(</span><span class="n">_key</span><span class="p">(</span><span class="n">request</span><span class="p">)</span><span class="o">.</span><span class="n">encode</span><span class="p">(),</span> <span class="n">expected</span><span class="o">.</span><span class="n">encode</span><span class="p">()):</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">Response</span><span class="p">({</span><span class="s2">&#34;detail&#34;</span><span class="p">:</span> <span class="s2">&#34;Unknown key&#34;</span><span class="p">},</span> <span class="n">status</span><span class="o">=</span><span class="mi">401</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="o">...</span></span></span></code></pre></div></div>
<p><code>_key</code> 从 <code>X-Sentry-Auth</code> 头里读出 <code>sentry_key=</code>，读不到就退回到查询字符串，有些 SDK 是放在那里的。</p>

<h2 class="relative group">SDK 发的是什么：envelope
    <div id="sdk-发的是什么envelope" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#sdk-%e5%8f%91%e7%9a%84%e6%98%af%e4%bb%80%e4%b9%88envelope" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>Sentry 的传输格式叫 <a href="https://develop.sentry.dev/sdk/data-model/envelopes/"  target="_blank" rel="noreferrer">envelope</a>：先是一行 JSON 头，后面跟任意多个 item，每个 item 是一行 JSON 头加一段 payload。如果 item 头里有 <code>length</code>，payload 就恰好是这么多字节；否则一直读到下一个换行符。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">{&#34;event_id&#34;:&#34;9ec79c33...&#34;,&#34;dsn&#34;:&#34;https://...@api.example.com/1&#34;}
</span></span><span class="line"><span class="cl">{&#34;type&#34;:&#34;event&#34;,&#34;length&#34;:4123}
</span></span><span class="line"><span class="cl">{ ...the event JSON, 4123 bytes... }
</span></span><span class="line"><span class="cl">{&#34;type&#34;:&#34;client_report&#34;}
</span></span><span class="line"><span class="cl">{&#34;timestamp&#34;:1791400000.5,&#34;discarded_events&#34;:[]}</span></span></code></pre></div></div>
<p>有两个细节让我栽了跟头：</p>
<ul>
<li><strong>原生 SDK 会 gzip 请求体，JavaScript 的不会。</strong> <code>@sentry/react-native</code> 捕获的 JS 错误是明文发来的，而 sentry-cocoa 或 sentry-android 发来的原生崩溃带着 <code>Content-Encoding: gzip</code>。解码器能处理 gzip、zlib 包装的 deflate 和裸 deflate，解压到 1MB 就停，因为一个崩溃事件只有几 KB，再大就不是崩溃事件了。</li>
<li><strong>带长度前缀的 payload 里可以有换行符。</strong> 如果按 <code>\n</code> 切分，原生 SDK 发来的每个事件都会被切坏。有 <code>length</code> 的时候必须按它来读。</li>
</ul>
<p>解析器很短。它只保留 <code>event</code> 类型的 item；session、transaction、附件和 client report 照单全收，然后丢弃。</p>

<h2 class="relative group">用 SDK 听得懂的方式回应
    <div id="用-sdk-听得懂的方式回应" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e7%94%a8-sdk-%e5%90%ac%e5%be%97%e6%87%82%e7%9a%84%e6%96%b9%e5%bc%8f%e5%9b%9e%e5%ba%94" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>这一点比我预想的重要。SDK 在磁盘上维护一个队列，凡是不确定送达的都会重试，所以服务端怎么回应，决定了它会不会一直重试下去：</p>
<ul>
<li>存好了，<em>或者是故意丢弃的</em>，都回 <strong>200</strong>。如果我对 session item 回一个 4xx，SDK 就会留着它，永远重试。</li>
<li>被限流时回 <strong>429 加 <code>Retry-After</code></strong>。这个接口按 IP 限流（每小时 120 次），因为陷入崩溃循环的手机每次启动都会发一条报告，而 SDK 收到 429 会像你期望的那样退避。</li>
<li>envelope 格式不对回 <strong>400</strong>，记成 warning 而不是 error：一份坏掉的报告只说明某个客户端坏了，下一份正常的报告会告诉我崩在哪。</li>
</ul>
<p>SDK 如果不确定第一次是否送达，还会重发同一个事件，所以 <code>event_id</code> 上有唯一约束，重复的就悄悄跳过。</p>

<h2 class="relative group">没有 source map 时怎么分组
    <div id="没有-source-map-时怎么分组" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%b2%a1%e6%9c%89-source-map-%e6%97%b6%e6%80%8e%e4%b9%88%e5%88%86%e7%bb%84" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>自己写接收端，就得做一个 Sentry 替你做了的选择。Sentry 按堆栈给崩溃分组。但 release 版的 Hermes bundle 是压缩过的，函数名都是单个字母，行列号每次构建都会变。按堆栈帧分组，同一个 bug 每次发版都会开一个新 issue。</p>
<p>没有 source map（文末再说），JavaScript 错误里最稳定的就是它的类型和信息。所以指纹由异常类型加信息组成，并把每次出现都不一样的部分遮掉：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">_MASKS</span> <span class="o">=</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">    <span class="p">(</span><span class="n">re</span><span class="o">.</span><span class="n">compile</span><span class="p">(</span><span class="sa">r</span><span class="s2">&#34;0x[0-9a-fA-F]+&#34;</span><span class="p">),</span> <span class="s2">&#34;&lt;addr&gt;&#34;</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">    <span class="p">(</span><span class="n">re</span><span class="o">.</span><span class="n">compile</span><span class="p">(</span><span class="sa">r</span><span class="s2">&#34;\b[0-9a-fA-F]</span><span class="si">{8}</span><span class="s2">-[0-9a-fA-F]</span><span class="si">{4}</span><span class="s2">-[0-9a-fA-F]</span><span class="si">{4}</span><span class="s2">-[0-9a-fA-F]</span><span class="si">{4}</span><span class="s2">-[0-9a-fA-F]</span><span class="si">{12}</span><span class="s2">\b&#34;</span><span class="p">),</span> <span class="s2">&#34;&lt;uuid&gt;&#34;</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">    <span class="p">(</span><span class="n">re</span><span class="o">.</span><span class="n">compile</span><span class="p">(</span><span class="sa">r</span><span class="s2">&#34;\b[0-9a-fA-F]{16,}\b&#34;</span><span class="p">),</span> <span class="s2">&#34;&lt;hex&gt;&#34;</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">    <span class="p">(</span><span class="n">re</span><span class="o">.</span><span class="n">compile</span><span class="p">(</span><span class="sa">r</span><span class="s2">&#34;\d+&#34;</span><span class="p">),</span> <span class="s2">&#34;&lt;n&gt;&#34;</span><span class="p">),</span>
</span></span><span class="line"><span class="cl"><span class="p">]</span></span></span></code></pre></div></div>
<p><code>Cannot read property 'title' of undefined (reading index 3)</code> 和 <code>(reading index 17)</code> 会归为同一个 issue。如果 SDK 自己带了 <code>fingerprint</code> 且其中没有 <code>{{ default }}</code>，就以它为准，这样默认分组不对时我可以从客户端强制指定。没有异常的事件退而使用遮罩后的日志信息，两者都没有就用最上面的应用内堆栈帧。原生信号崩溃的信息大概是 <code>Signal 6, Code 0</code>，所以这类崩溃按信号分组，粒度很粗，但在有符号化之前，这样是诚实的。</p>

<h2 class="relative group">报警，但不刷屏
    <div id="报警但不刷屏" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%8a%a5%e8%ad%a6%e4%bd%86%e4%b8%8d%e5%88%b7%e5%b1%8f" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>每个指纹对应一个 issue，每份报告作为一个事件存在它下面。issue 只在以下几种情况下以 ERROR 级别记录（聊天报警监听的就是这个级别）：</p>
<ul>
<li><strong>新出现</strong>的 issue</li>
<li><strong>已解决</strong>的 issue <strong>又出现</strong>，此时会重新打开它</li>
<li>第 <strong>10、100、1,000、10,000</strong> 次出现</li>
</ul>
<p>普通的重复只会让计数器加一。标记为 <em>忽略</em> 的 issue 照样计数，但从不报警。再加上聊天 handler 自己的限流，坏版本上的崩溃循环在频道里只是几条消息，而不是一面墙。</p>
<p>存储也一样有上限：每个 issue 保留 <strong>最近 50 条</strong> 事件，新的进来就删掉旧的。这让第一版图表出了问题，因为 50 条事件说不出上周某件事发生了多少次。所以每个 issue 还另外记着 90 天的每日计数，和计数器加一放在同一个事务里更新。图表从这些计数取数；事件只用来阅读。</p>
<p>如果你也自己做一个，有两个小的竞态值得处理。一个全新的崩溃同时打到几台手机上时，两份报告可能都想创建这个 issue，所以给指纹加唯一约束，让输的那个重新读取已有的那一行。另外，90 天的窗口要从服务器的&quot;今天&quot;算起，而不是从最新一份报告算起，因为时钟设错的手机可能把报告的日期标成任何一天，包括明年。</p>
<p>payload 上限是 128KB。超出的事件先丢 breadcrumbs 和调试元数据；还超，就只保留异常、信息、版本和平台，并做个标记，让我知道它被截过。</p>

<h2 class="relative group">脱敏
    <div id="脱敏" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e8%84%b1%e6%95%8f" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>SDK 开着 <code>sendDefaultPii: false</code>，所以理论上不会发送 IP 地址、cookie 或请求体。服务端并不依赖这一点：存储之前，<code>user</code> 里只留 <code>id</code> 和 <code>username</code>，<code>request</code> 整个删掉，以防以后某个 SDK 版本或设置开始发送这些东西。</p>
<p>用真机测试又发现了两样需要去掉的东西：</p>
<ul>
<li><strong>breadcrumbs 记录的是完整 URL，包括查询字符串。</strong> 应用的搜索请求会把搜索词放在查询字符串里，所以搜索之后紧跟着的一次崩溃，会把用户搜了什么存下来。现在服务端会把每个 breadcrumb 的 URL 在 <code>?</code> 处截断。</li>
<li><strong>console 类 breadcrumb 带着传给 <code>console.log</code> 的原始参数</strong>，可能是应用恰好打印出来的任何对象。消息文本保留（它有用），参数丢掉。</li>
</ul>
<p>这两样都是看了一份真实报告才发现的，这也是下一节存在的主要理由。</p>

<h2 class="relative group">客户端
    <div id="客户端" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%ae%a2%e6%88%b7%e7%ab%af" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>应用里所有相关代码都走一个小模块，原因和我把 OTA 更新 SDK 包一层一样：OTA bundle 也会跑在原生模块还不存在时构建的安装包上。在没有 <code>RNSentry</code> 的安装包上，顶层写 <code>import * as Sentry from &quot;@sentry/react-native&quot;</code> 会在导入时就抛异常，结果就是白屏。所以 SDK 是延迟 require 的，而且要先确认原生模块真的存在：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ts" data-lang="ts"><span class="line"><span class="cl"><span class="kr">import</span> <span class="p">{</span> <span class="nx">NativeModules</span><span class="p">,</span> <span class="nx">Platform</span><span class="p">,</span> <span class="nx">TurboModuleRegistry</span> <span class="p">}</span> <span class="kr">from</span> <span class="s2">&#34;react-native&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="kr">import</span> <span class="nx">Constants</span> <span class="kr">from</span> <span class="s2">&#34;expo-constants&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">DSN</span> <span class="o">=</span> <span class="nx">process</span><span class="p">.</span><span class="nx">env</span><span class="p">.</span><span class="nx">EXPO_PUBLIC_CRASH_DSN</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kd">function</span> <span class="nx">nativeModulePresent</span><span class="p">()</span><span class="o">:</span> <span class="kr">boolean</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="k">try</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="nb">Boolean</span><span class="p">(</span><span class="nx">TurboModuleRegistry</span><span class="p">.</span><span class="kr">get</span><span class="p">(</span><span class="s2">&#34;RNSentry&#34;</span><span class="p">)</span> <span class="o">??</span> <span class="nx">NativeModules</span><span class="p">.</span><span class="nx">RNSentry</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span> <span class="k">catch</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="kc">false</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">export</span> <span class="kd">function</span> <span class="nx">initCrashReporting</span><span class="p">()</span><span class="o">:</span> <span class="kr">boolean</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">DSN</span> <span class="o">||</span> <span class="nx">Platform</span><span class="p">.</span><span class="nx">OS</span> <span class="o">===</span> <span class="s2">&#34;web&#34;</span> <span class="o">||</span> <span class="o">!</span><span class="nx">nativeModulePresent</span><span class="p">())</span> <span class="k">return</span> <span class="kc">false</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">Sentry</span> <span class="o">=</span> <span class="kr">require</span><span class="p">(</span><span class="s2">&#34;@sentry/react-native&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="nx">Sentry</span><span class="p">.</span><span class="nx">init</span><span class="p">({</span>
</span></span><span class="line"><span class="cl">    <span class="nx">dsn</span>: <span class="kt">DSN</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nx">release</span>: <span class="kt">Constants.expoConfig?.version</span> <span class="o">??</span> <span class="s2">&#34;unknown&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nx">sendDefaultPii</span>: <span class="kt">false</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nx">tracesSampleRate</span>: <span class="kt">0</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nx">enableAutoSessionTracking</span>: <span class="kt">false</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="c1">// ...turn off the other tracing options the same way
</span></span></span><span class="line"><span class="cl">    <span class="nx">maxBreadcrumbs</span>: <span class="kt">50</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nx">beforeSend</span><span class="p">(</span><span class="nx">event</span><span class="p">)</span> <span class="p">{</span> <span class="cm">/* tag the OTA bundle */</span> <span class="k">return</span> <span class="nx">event</span><span class="p">;</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">  <span class="p">});</span>
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="kc">true</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
<p>只上报错误。服务端不存 trace、session、profile 或 replay，所以这些一个都没开。没有 DSN（开发环境、E2E 构建）时 SDK 根本不会启动。构建 profile 会设置 DSN 和环境（<code>staging</code> 或 <code>production</code>），两类构建上报到同一个接口，又能区分开。登录用户用账号 ID 标识，绝不用名字或邮箱。</p>
<p>错误边界需要显式调用。Expo Router 的根 <code>ErrorBoundary</code> 会捕获渲染错误并显示自己的界面，所以这个错误根本到不了 SDK 挂钩的全局处理器。解决办法是一个小包装组件：在 <code>useEffect</code> 里调用 <code>captureException</code>，然后照旧渲染路由自带的 boundary，根 boundary 和每个路由的 boundary 都用它。放在 effect 里而不是渲染过程中，是为了只上报一次，而不是每次重新渲染都报。根组件还用 <code>Sentry.wrap</code> 包了一层，如果这一步抛异常，就退回到原始组件。</p>
<p>Web 端有一个 <code>.web.ts</code> 版本的模块，所有函数都是空操作。Metro 会选平台专属的文件，所以 SDK 根本不会进入 web bundle。Web 应用也没有原生模块可以运行它。</p>

<h2 class="relative group">证明原生崩溃能送到
    <div id="证明原生崩溃能送到" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e8%af%81%e6%98%8e%e5%8e%9f%e7%94%9f%e5%b4%a9%e6%ba%83%e8%83%bd%e9%80%81%e5%88%b0" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>单元测试 POST 的是手写的 envelope，只能证明解析器能处理我 <em>以为</em> SDK 会发的东西。发布两天后，生产环境一份崩溃报告都没有。当时只有三个人更新了，所以零是说得通的，但从服务端看，&ldquo;没人崩溃&quot;和&quot;崩溃送不到&quot;长得一模一样。我想亲眼看到一份真的。</p>
<p>于是我构建了一个 Release 版的模拟器包，把 DSN 指向本地 8000 端口上的 Django，用一个临时 key：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nv">EXPO_PUBLIC_CRASH_DSN</span><span class="o">=</span>http://localcrashtestkey0000@localhost:8000/1 <span class="se">\
</span></span></span><span class="line"><span class="cl"><span class="nv">EXPO_PUBLIC_CRASH_ENVIRONMENT</span><span class="o">=</span>local-verify <span class="se">\
</span></span></span><span class="line"><span class="cl">xcodebuild -workspace ios/MyApp.xcworkspace -scheme MyApp -configuration Release <span class="se">\
</span></span></span><span class="line"><span class="cl">  -destination <span class="s1">&#39;platform=iOS Simulator,id=&lt;udid&gt;&#39;</span> -derivedDataPath /tmp/iosbuild</span></span></code></pre></div></div>
<p>（用 Release 而不是 Debug：Debug 构建会链接 dev launcher，行为不一样，而用户跑的是 Release。）然后安装、启动，等几秒钟，再像真实故障那样把它杀掉：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">xcrun simctl install booted /tmp/iosbuild/Build/Products/Release-iphonesimulator/MyApp.app
</span></span><span class="line"><span class="cl">xcrun simctl launch booted com.example.myapp      <span class="c1"># prints the pid</span>
</span></span><span class="line"><span class="cl"><span class="nb">kill</span> -ABRT &lt;pid&gt;</span></span></code></pre></div></div>
<p>模拟器里的应用就是普通的 macOS 进程，所以信号到达 SentryCrash 处理器的方式和真机上完全一样。处理器把报告写到磁盘；进程临死时什么都不发送。<strong>下次启动</strong>时 SDK 找到它并发出去。我重新启动应用，一秒后模拟器的网络日志里就出现了一条到 <code>:8000</code> 的连接。</p>
<p>然后 Django 日志里什么都没有，这让我愣了一会儿：在我设的日志级别下，开发服务器只记录非 2xx 的响应。数据库里是有的：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">SIGABRT: Signal 6, Code 0 | cocoa 1.4.0 iOS 27.1 iPhone19,4 handled=False
</span></span><span class="line"><span class="cl">received 09:19:53, crashed 09:19:38
</span></span><span class="line"><span class="cl">contexts: app, device, os, ota_updates, trace · 24 breadcrumbs</span></span></code></pre></div></div>
<p>从崩溃到入库十五秒，大部分时间花在我手动重启上。而看真实的 payload，教会了我测试教不了的东西：</p>
<ul>
<li><strong>用户 ID 不是账号 ID。</strong> 测试包并没有真正登录，所以 sentry-cocoa 退而使用它自己的按安装生成的 ID。这一点值得知道，也值得写进隐私说明：登录时报告带的是账号 ID，未登录时是一个随机的安装 ID。</li>
<li><strong>没有时区。</strong> 我原以为 culture context 会有。device context 里有 <code>locale</code>、内存、屏幕尺寸、低电量模式和内核版本，就是没有时区。</li>
<li><strong>原生崩溃不经过 <code>beforeSend</code>。</strong> 我在 JS 的 <code>beforeSend</code> 里加了一个 <code>ota</code> 标签，标明正在运行的 OTA bundle，但原生崩溃是下次启动时由原生 SDK 组装的，根本不经过 JS 钩子。要给原生崩溃打标签，得在 bundle 确定后用 <code>setTag</code> 设到 scope 上，这样它才会同步到原生层。</li>
<li><strong>breadcrumbs 暴露了应用在做什么。</strong> console 警告、view controller 生命周期，还有 HTTP 请求，其中包括分析 SDK 自己发往它的数据收集接口的请求，这也提醒了我应用还在跟谁通信。而且这些 URL 带着查询字符串，前面说的搜索词问题就是这么发现的。</li>
</ul>

<h2 class="relative group">怎么看这些报告
    <div id="怎么看这些报告" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%80%8e%e4%b9%88%e7%9c%8b%e8%bf%99%e4%ba%9b%e6%8a%a5%e5%91%8a" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>报告在 Django 后台里看，页面大致仿照 Sentry 的 issue 页：顶部是计数和 Resolve / Ignore / Reopen 按钮，然后是每日柱状图、按版本、系统、设备和 OTA bundle 的分布，再往下一次展示一个事件，包括异常、堆栈帧（应用帧高亮，库的帧折叠起来）、breadcrumbs 和设备信息。不需要任何新依赖。</p>
<p>做这个页面时顺手修了一个问题：原生崩溃的&quot;culprit&quot;帧原本是二进制文件在设备上的路径，里面带着每次安装都不同的 UUID，结果同一个崩溃在每台手机上看起来都不一样。现在只显示地址和二进制名，比如 <code>0x1000a1b2c (MyApp)</code>。</p>

<h2 class="relative group">隐私政策，整份重写
    <div id="隐私政策整份重写" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e9%9a%90%e7%a7%81%e6%94%bf%e7%ad%96%e6%95%b4%e4%bb%bd%e9%87%8d%e5%86%99" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>崩溃报告差不多就是个人信息：一个账号 ID、一个手机型号，以及某人在应用崩溃前一分钟做了什么。所以隐私政策需要加一段。要写好，就得准确说明一份报告里有什么，于是我照着刚看过的 payload 列了出来：错误本身、应用版本和 OTA 更新、手机型号、系统和语言、可用内存和屏幕尺寸、最近几十步操作（打开的页面、去掉 <code>?</code> 之后部分的网址、应用自己的日志），以及账号 ID 或随机的安装 ID。政策里也写明了报告直接发到我们自己的服务器，不会发给 Sentry 或任何其他崩溃上报公司。保存期限在表格里单独占一行：每个问题最近 50 份报告，外加 90 天的每日计数。</p>
<p>一旦开始拿代码核对其中一段，就很难不去核对其余部分。原来的政策经不起核对。删除账号页面写着：关闭账号时，推送通知的 token 和 passkey 会立即删除，服务器日志会在 90 天内清除。这些都不是真的。关闭账号会解除登录方式的绑定，并把资料匿名化，但推送 token 和 passkey 会一直留着，直到有人手动删除；日志归档目前也没有过期时间。所以整份政策根据代码实际做了什么的清单重写了一遍，那几行现在写的是&quot;暂未自动删除，请发邮件给我们&rdquo;。听起来没那么让人安心，但它是准确的。</p>
<p>现在代码仓库里有一条规则：政策描述的是代码做了什么，而不是我们打算做什么；任何在新地方收集、发送或保存个人数据的改动，都要在同一个 commit 里更新它。这包括应用里的摘要页面、删除账号页面，以及只存在于商店后台的应用商店隐私表单。正是崩溃上报这个改动，让我把这条规则写了下来。</p>

<h2 class="relative group">还缺什么
    <div id="还缺什么" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e8%bf%98%e7%bc%ba%e4%bb%80%e4%b9%88" aria-label="锚点">#</a>
    </span>
    
</h2>
<p><strong>符号化。</strong> 这是真正的缺口，也是 sentry.io 能做而我还没替代的那件事。JavaScript 的堆栈是压缩过的，原生代码的堆栈没有符号化：</p>
<ul>
<li><strong>JavaScript：</strong> 堆栈帧指向 Hermes bundle 内部。要解决，就得为每个商店构建和每个 OTA bundle 保留合成后的 Hermes source map，按版本和 bundle 索引，在页面渲染时套用到 JS 帧上。这是下一步要做的，因为我们的崩溃大部分会是 JavaScript 的。</li>
<li><strong>iOS 原生：</strong> 指令地址需要该次构建的 dSYM。</li>
<li><strong>Android：</strong> 开启压缩时 Java 帧会被 R8 混淆（需要 <code>mapping.txt</code>），C++ 帧需要 NDK 符号。</li>
</ul>
<p>前面还发现了一个小一点的缺口：原生崩溃目前还不带 OTA 标签，因为它只在 <code>beforeSend</code> 里设置。在运行中的 bundle 确认之后把它设到 scope 上，只要几行代码，我还没写。</p>
<p>目前，一个 issue 能告诉我 <em>什么</em> 崩了、在哪个版本、哪个 OTA bundle、哪台设备上，以及用户崩溃前在做什么。它没法告诉我崩在源码的 <em>哪里</em> 。对一个一周发几次版的小应用来说，到目前为止，异常类型、信息和 breadcrumbs 已经足够找到问题了。</p>
<p>接收端、后台页面和测试加起来代码量不大，在原本就在运行的 API 之外不额外花一分钱。难的部分由 SDK 完成：在手机上捕获崩溃，并在下次启动时把报告送出来。</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-10-09-sentry-sdk-own-server/featured.jpg" medium="image" type="image/jpeg" />
    </item>
  </channel>
</rss>
