<?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>Fri, 09 Oct 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>用 Sentry 的 SDK，接自己的服务器：约 650 行 Django 搞定 React Native 应用的崩溃上报</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.io，而是保留 Sentry 的开源 SDK，把 DSN 指向我们自己 Django API 里的一个接口。本文讲 envelope 格式、没有 source map 时如何给压缩过的崩溃分组、怎样报警又不刷屏 Discord、数据脱敏、怎么证明 iOS 上一次原生 SIGABRT 真的能送到，以及为什么最后连隐私政策都重写了。</description>
      <content:encoded><![CDATA[<p>直到这周之前，我知道 我们的应用崩了的唯一途径，是有人来告诉我。大多数人不会说。他们重新打开应用，如果又崩了，就不再打开了。API 那边是有覆盖的（所有 ERROR 日志都会发到一个 Discord 频道），但手机上的一次崩溃，在我能看到的地方不留任何痕迹。</p>
<p>其实应用里已经有一个上报机制。应用每次把错误信息展示给用户时，都会把这条信息 POST 到 <code>/client-errors/</code>，服务端以 ERROR 级别记录，所以同样会进 Discord。加这个是因为有一次删除操作其实 <em>成功了</em> ，可那一行还留在列表里，再点一下就弹出 &ldquo;No Item matches the given query&rdquo;。服务端只记了两条普通的 404，什么也没说。它覆盖的是应用捕获并展示出来的错误。崩溃不一样：本该发送报告的那段 JavaScript，恰恰就是刚刚死掉的那个。</p>
<p>最直接的办法是用 Sentry。我很喜欢 Sentry 的 SDK，React Native 上的崩溃上报之所以算是已解决的问题，靠的就是它。我不想要的是：又多一家供应商持有用户数据，隐私政策里又多一行，又多一张账单，而以我们的规模，这东西一天顶多几条报告。所以我留下 SDK，换掉服务：应用的 DSN 指向我们自己的 API，大约 650 行 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>每个指纹对应一行 <code>CrashIssue</code>，每份报告是它下面的一个 <code>CrashEvent</code>。issue 只在以下几种情况下以 ERROR 级别记录（Discord handler 监听的就是这个级别）：</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 照样计数，但从不报警。再加上 Discord handler 自己的限流（同一条消息十分钟最多一次，每小时最多 30 条报警），坏版本上的崩溃循环在频道里只是几条消息，而不是一面墙。</p>
<p>存储也一样有上限：每个 issue 保留 <strong>最近 50 条</strong> 事件，新的进来就删掉旧的。这让后台图表的第一版出了问题，因为 50 条事件说不出上周某件事发生了多少次。所以 issue 行上还存了一个小小的 <code>daily_counts</code> 字典，<code>{&quot;2026-10-09&quot;: 3, ...}</code>，只保留 90 天，在给计数器加一的同一个行锁里更新。图表从它取数；事件只用来阅读。</p>
<p>有两个小的竞态需要处理。一个全新的崩溃同时打到几台手机上时，两份报告可能都想创建这个 issue。输的那个会因为指纹唯一约束拿到 <code>IntegrityError</code>，然后用 <code>select_for_update</code> 重新读取那一行。90 天的窗口从服务器的&quot;今天&quot;算起，而不是从字典里最新的日期算起，因为时钟设错的手机可能把报告的日期标成任何一天，包括明年。</p>
<p>存储的 payload 上限是 128KB。超出的话，先丢 breadcrumbs 和 <code>debug_meta</code>；还超，就只保留异常、信息、版本和平台，并加一个 <code>_truncated</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="#%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> 应用的搜索请求长这样：<code>/api/v1/items/?search=...</code>，所以搜索之后紧跟着的一次崩溃，会把用户搜了什么存下来。现在服务端会把每个 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>应用里所有相关代码都走一个文件 <code>services/crashReporting.ts</code>，原因和 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="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="nx">enableAutoPerformanceTracing</span>: <span class="kt">false</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nx">enableAppStartTracking</span>: <span class="kt">false</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nx">enableNativeFramesTracking</span>: <span class="kt">false</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nx">enableStallTracking</span>: <span class="kt">false</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nx">enableUserInteractionTracing</span>: <span class="kt">false</span><span class="p">,</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 根本不会启动。EAS 的构建 profile 会设置 DSN 和环境（<code>staging</code> 或 <code>production</code>），两类构建上报到同一个接口，后台能把它们区分开。登录用户用 Firebase UID 标识，绝不用名字或邮箱。</p>
<p>错误边界需要显式调用。Expo Router 的根 <code>ErrorBoundary</code> 会捕获渲染错误并显示自己的界面，所以这个错误根本到不了 SDK 挂钩的全局处理器。现在 layout 导出一个小包装组件，在 <code>useEffect</code> 里调用 <code>captureException</code>，然后照旧渲染路由自带的 boundary。每个路由的 boundary 也是同样做法。放在 effect 里而不是渲染过程中，是为了只上报一次，而不是每次重新渲染都报。根组件还用 <code>Sentry.wrap</code> 包了一层，如果这一步抛异常，就退回到原始组件。</p>
<p>Web 端旁边有一个 <code>crashReporting.web.ts</code>，所有函数都是空操作。Metro 会选平台专属的文件，所以 SDK 根本不会进入 web bundle。Web 应用也没有原生模块可以运行它。</p>
<p>原来的 <code>/client-errors/</code> 上报保留。在 2.5.9 之前的安装包上，它仍然是唯一的上报渠道，而且它覆盖的是那些不算崩溃的错误。</p>
<p>没有 <code>sentry-cli</code>，没有 build phase，也没有 <code>expo prebuild</code>；这个项目的 <code>ios/</code> 和 <code>android/</code> 目录是手工编辑并提交的，autolinking 自己就找到了原生模块。这个功能随 2.5.9 发布。</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 会发的东西。发布两天后，生产环境一份崩溃报告都没有。当时只有三个人用上了 2.5.9，所以零是说得通的，但从服务端看，&ldquo;没人崩溃&quot;和&quot;崩溃送不到&quot;长得一模一样。我想亲眼看到一份真的。</p>
<p>于是我构建了一个 Release 版的模拟器包，把 DSN 指向本地 8002 端口上的 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:8002/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>:8002</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 2.5.9 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 不是 Firebase UID。</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 请求，其中包括 Firebase Analytics 自己发往 <code>app-analytics-services.com</code> 的请求，这也提醒了我应用还在跟谁通信。而且这些 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 按钮；下面是根据 <code>daily_counts</code> 画出的 30 天每日柱状图；保留事件按版本、系统、设备、OTA bundle、环境以及致命/已处理分别统计的占比（每项列出前四个值，其余归为&quot;other&rdquo;）；然后一次展示一个事件，包括异常、堆栈帧（应用帧高亮，库的帧折叠起来）、breadcrumb 时间线和设备信息，最底下是原始 JSON。issue 列表每行有一条 14 天的迷你走势图。整个页面就是一个模板加一个读取已存 payload 的模块，不需要任何新依赖。</p>
<p>做这个页面时顺手修了一个问题：原生崩溃存下来的&quot;culprit&quot;帧原本是 <code>? (/private/var/containers/Bundle/Application/&lt;uuid&gt;/MyApp.app/MyApp)</code>，也就是二进制文件在设备上的路径，里面带着每次安装都不同的 UUID。现在显示为 <code>0x1000a1b2c (MyApp)</code>。单份报告的后台 URL 会重定向到所属 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="#%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;暂未自动删除，请发邮件给我们&quot;。听起来没那么让人安心，但它是准确的。</p>
<p>现在代码仓库里有一条规则：政策描述的是代码做了什么，而不是我们打算做什么；任何在新地方收集、发送或保存个人数据的改动，都要在同一个 commit 里更新它。这包括应用里的摘要页面、删除账号页面，以及只存在于商店后台的 App Store 和 Play 隐私表单。正是崩溃上报这个改动，让我把这条规则写了下来。</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>接收端大约 650 行（解析器、分组、报警、模型和视图），后台页面另外 700 行左右，测试大约 400 行，在原本就在运行的 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>
