<?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 · Jared Lynskey</title>
    <link>https://jared.lynskey.co.nz/en/tags/sentry/</link>
    <description>Short stories of value on software engineering and engineering leadership — performance reviews, hiring and scaling teams, CI/CD, DevOps monitoring and agentic AI. Written from Seoul.</description>
    <generator>Hugo</generator>
    <language>en</language>
    <copyright>© 2026 Jared Lynskey</copyright>
    <lastBuildDate>Sat, 07 Nov 2026 00:00:00 +1300</lastBuildDate>
    <atom:link href="https://jared.lynskey.co.nz/en/tags/sentry/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Monitoring Django on Fly.io: The DevOps Tools I Kept and Dropped</title>
      <link>https://jared.lynskey.co.nz/en/posts/2026/2026-10-22-monitoring-tools-two-products/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/en/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>Monitoring two small Django apps on Fly.io for about US$2 a month: errors to chat, logs in S3 via DuckDB, Fly&#39;s metrics, crash reports, and what I dropped.</description>
      <content:encoded><![CDATA[<p>For two small products, a marketplace and community app I build and an estimating app for tradespeople, both on Fly.io&rsquo;s cheapest machines, my DevOps monitoring comes down to five things: ERROR logs posted to a chat channel, every log line archived in object storage and queried with DuckDB, Fly&rsquo;s built-in Prometheus metrics, phone crash reports sent through Sentry&rsquo;s open-source SDK to my own API, and one summary post each morning.</p>
<p>There&rsquo;s no hosted APM, no tracing in production and no dashboard I have to remember to open, and the only part that costs extra is the log archive, at about US$2 a month. Here&rsquo;s the whole list, and the things I dropped or stopped trusting, one of which had me chasing an outage no user ever saw.</p>

<h2 class="relative group">What I mean by monitoring, and how small this is
    <div id="what-i-mean-by-monitoring-and-how-small-this-is" 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="#what-i-mean-by-monitoring-and-how-small-this-is" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>By DevOps monitoring I mean finding out that something&rsquo;s broken before somebody tells me, and being able to work out why without guessing. Every tool below is judged on those two things.</p>
<p>Last year I wrote about <a href="https://jared.lynskey.co.nz/en/posts/2025/2025-10-13-devops-monitoring/" >setting up monitoring that developers actually use</a>, mostly with teams in mind: a cloud provider&rsquo;s built-in monitoring for a small team, a hosted APM once there are hundreds of services. This is the small end of the same idea. Both products are Django on Fly&rsquo;s shared-cpu-1x machines, as described in <a href="https://jared.lynskey.co.nz/en/posts/2026/2026-07-22-my-current-stack/" >My Current Stack</a>, and I&rsquo;m the only developer on either. If monitoring is a product and developers are its users, mine has a user base of one. A baseline I pulled from Fly&rsquo;s metrics in September had the marketplace&rsquo;s API averaging 1% CPU. This isn&rsquo;t an enterprise stack, and I won&rsquo;t dress it up as one.</p>
<p>Of the three pillars, metrics, logs and traces, I have two. Neither product has distributed tracing: each is one Django codebase and one database, with no service boundary for a trace to cross. The only traces I read are the reverse proxy&rsquo;s, on <a href="https://jared.lynskey.co.nz/en/posts/2026/2026-10-01-cloudflare-warp-dev-network/" >my workstation</a>.</p>

<h2 class="relative group">The list
    <div id="the-list" 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="#the-list" aria-label="Anchor">#</a>
    </span>
    
</h2>
<table>
	<thead>
			<tr>
					<th>Tool</th>
					<th>What it watches</th>
					<th>Kept or dropped, and why</th>
					<th>Cost</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>A team chat channel, fed by a Django logging handler</td>
					<td>ERRORs from the API, WebSocket server and worker; deploys; bursts of wrong sign-in codes</td>
					<td>Kept. It&rsquo;s the one place I look without being asked</td>
					<td>Free</td>
			</tr>
			<tr>
					<td>Fly&rsquo;s log shipper to an S3-compatible bucket, queried with DuckDB</td>
					<td>Every log line from the marketplace&rsquo;s apps</td>
					<td>Kept. Fly&rsquo;s own buffer holds minutes; this holds everything, in SQL</td>
					<td>About US$2 a month, plus storage</td>
			</tr>
			<tr>
					<td>Fly&rsquo;s built-in metrics (Prometheus API)</td>
					<td>CPU, memory, burst credit, responses; the managed Postgres exporter</td>
					<td>Kept. It showed the database stalls weren&rsquo;t my code</td>
					<td>Comes with Fly</td>
			</tr>
			<tr>
					<td>Fly health checks on <code>/api/health/</code> (both apps)</td>
					<td>Whether a machine should get traffic</td>
					<td>Kept for routing. Dropped as proof that users are fine</td>
					<td>Comes with Fly</td>
			</tr>
			<tr>
					<td>Sentry&rsquo;s SDK, its DSN pointed at my own API</td>
					<td>App crashes on phones</td>
					<td>Kept the SDK. Never signed up for sentry.io</td>
					<td>Nothing on top of the API</td>
			</tr>
			<tr>
					<td>A morning summary in the team chat</td>
					<td>Yesterday&rsquo;s errors, usage, search, chats and board</td>
					<td>Kept. Moved off a CI cron job onto my Mac</td>
					<td>Free</td>
			</tr>
			<tr>
					<td>An app analytics SDK (both apps), and a search engine&rsquo;s webmaster console</td>
					<td>Usage on web, iOS and Android; how search sees the site</td>
					<td>Kept, read through their APIs</td>
					<td>Free</td>
			</tr>
			<tr>
					<td>Cloudflare&rsquo;s GraphQL analytics</td>
					<td>Who is behind a status code or a path</td>
					<td>Kept, grouped by user agent before I believe it</td>
					<td>Free plan, one day per query</td>
			</tr>
			<tr>
					<td>A hosted over-the-air update service&rsquo;s dashboard</td>
					<td>Which over-the-air release phones run; crash rollbacks</td>
					<td>Moved to it from an admin list that couldn&rsquo;t tell me</td>
					<td>Part of the update service</td>
			</tr>
			<tr>
					<td>A small probe script</td>
					<td>Failed requests during a deploy or restart</td>
					<td>Kept. It hits the origin, so a cache can&rsquo;t hide a gap</td>
					<td>Free</td>
			</tr>
			<tr>
					<td><code>flyctl logs</code></td>
					<td>The last few minutes of an app&rsquo;s output</td>
					<td>Kept as a live tail. Dropped as the record</td>
					<td>Comes with Fly</td>
			</tr>
			<tr>
					<td>Cloudflare Early Hints</td>
					<td>Nothing; it&rsquo;s a speed feature</td>
					<td>Kept, once the origin sent a <code>Link</code> header. Its cache misses were every 504 on the zone</td>
					<td>Free</td>
			</tr>
	</tbody>
</table>
<p>Everything in the table is the marketplace unless it says both apps. The estimating app has less: Fly&rsquo;s health check, logs and metrics, app analytics on iOS, and a daily Parquet export of its database that I query with DuckDB. It has no crash reporting and no alerts channel, which is the biggest gap here. (A disclosure for the update-service row: the company behind it pays me to write for their blog.)</p>

<h2 class="relative group">Errors: a logging handler posting to chat
    <div id="errors-a-logging-handler-posting-to-chat" 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="#errors-a-logging-handler-posting-to-chat" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>Until September, <code>flyctl logs</code> was the only place an unhandled 500, a failed push notification or a broken WebSocket frame showed up, and nobody reads that stream unprompted. Now a logging handler sends every record at ERROR or above to a chat channel, from the API, the WebSocket server and the background worker. Warnings deliberately don&rsquo;t alert.</p>
<p>The handler posts on a background thread, so it never slows a request. Nothing in it logs, so it can&rsquo;t recurse. And the same error goes out at most once every ten minutes, with a cap of 30 an hour, so an outage reads as one message rather than a thousand.</p>
<p>The channel also carries what I want to read errors against: deploys post when they start, when they&rsquo;re up and where they failed, with the commit and who ran it, and so do over-the-air updates and <a href="https://jared.lynskey.co.nz/en/posts/2026/2026-09-14-web-releases-without-redeploying/" >web releases</a>. A burst of wrong sign-in codes on one account logs at ERROR too, so someone guessing shows up next to the outages.</p>
<p>In October an alert arrived that matched no line in any log. Now each alert carries a short random id, the hostname and the process&rsquo;s start command, and writes the same line to that process&rsquo;s log, so an id I can&rsquo;t find means a process that doesn&rsquo;t log there, like an SSH shell. Everything in the channel also goes into a feed that a coding agent I work with can read, so the agent working on the code sees the errors when I do.</p>

<h2 class="relative group">Logs: Fly&rsquo;s shipper to S3, queried with DuckDB
    <div id="logs-flys-shipper-to-s3-queried-with-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="#logs-flys-shipper-to-s3-queried-with-duckdb" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>Fly&rsquo;s own buffer holds a few minutes of output, roughly the last hundred lines, which is fine for watching a deploy and no use for yesterday. So a third Fly app runs Fly&rsquo;s log shipper, which is Vector subscribed to the organisation&rsquo;s log stream, with one change to its 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>Every line from the marketplace&rsquo;s apps lands in an S3-compatible bucket as gzipped JSON. The shipper is one shared-cpu-1x machine with 256 MB, about US$2 a month plus pennies of storage. DuckDB reads the files where they sit through one macro, and a day comes back in about a second:</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>That macro is how I measured the cold starts in <a href="https://jared.lynskey.co.nz/en/posts/2026/2026-09-09-scale-to-zero-504s/" >the scale-to-zero post</a>: the startup script announces each phase, every line carries the machine&rsquo;s id, and the rest is a <code>GROUP BY</code>.</p>
<p>So it&rsquo;s three levels of one stream, by who reads them: the archive keeps INFO and up, which is why the apps log at INFO in production, a live-tail script shows warnings and errors, and the chat channel gets ERROR. None of them knows the caller, though. The access log has no client IP or user agent, and for that I go to Cloudflare.</p>

<h2 class="relative group">Metrics: Fly&rsquo;s built-in Prometheus
    <div id="metrics-flys-built-in-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="#metrics-flys-built-in-prometheus" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>Fly runs a managed Prometheus for each organisation, so I don&rsquo;t run a metrics stack of my own. I query its API when I have a question: memory, CPU and burst credit per machine (<code>fly_instance_cpu_balance</code> matters on a shared CPU), responses and concurrency per app. Two things I wish I&rsquo;d known first. A token from <code>fly tokens create</code> goes in as <code>Authorization: FlyV1 &lt;token&gt;</code>; <code>Bearer</code> only works with the personal token from <code>flyctl auth token</code>, and a read-only token gets &ldquo;something went wrong resolving organization&rdquo;. And it keeps about two weeks (Fly says roughly 15 days).</p>
<p>It earned its place in September, when the logs had &ldquo;Could not open a database connection&rdquo; several times a day, which reads exactly like an app bug. The managed Postgres cluster is in the same Prometheus, and at every one of those minutes the primary&rsquo;s scrape time jumped from about 80 milliseconds to between one and ten seconds, while the replica was fine and Postgres&rsquo;s start time never moved. Postgres hadn&rsquo;t restarted; the host under it was stalling. I sent Fly&rsquo;s support the timestamps, and after a maintenance restart in early October moved the primary to another host, the daily stalls stopped.</p>

<h2 class="relative group">Crash reports: Sentry&rsquo;s SDK, pointed at my own API
    <div id="crash-reports-sentrys-sdk-pointed-at-my-own-api" 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="#crash-reports-sentrys-sdk-pointed-at-my-own-api" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>A crash on a phone used to leave no trace anywhere I could see. The obvious answer was Sentry, and I took half of it: the app carries Sentry&rsquo;s open-source SDK, but its DSN points at an endpoint in my own Django API that accepts Sentry&rsquo;s envelope format, groups the crashes, and alerts through the same chat handler. Tracing, sessions and replays are off. It&rsquo;s errors only.</p>
<p>I didn&rsquo;t sign up for sentry.io because, at a few reports a day, it would have been another vendor holding user data, another line in the privacy policy and another bill. The price is symbolication, which I haven&rsquo;t replaced. Stacks are minified JavaScript or raw native addresses, so an issue tells me what crashed, on which release and device, and what the person did just before, but not where in the source.</p>

<h2 class="relative group">The only dashboard is a message
    <div id="the-only-dashboard-is-a-message" 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="#the-only-dashboard-is-a-message" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>I haven&rsquo;t built a dashboard for either app. Last year&rsquo;s post had a test for them: if nobody opens one unprompted, it isn&rsquo;t earning its place. A message that arrives on its own doesn&rsquo;t need opening, so the closest thing I have is a post in the team chat every morning.</p>
<p>It starts with the last 24 hours of production errors, read from the log archive rather than from the chat channel, because throttled alerts can&rsquo;t say how often something happened. Numbers, ids and addresses are masked before grouping, so a hundred copies of one failure are one row with a count and no email reaches the channel.</p>
<p>Then failed health checks, yesterday&rsquo;s usage against the same weekday a week earlier (a Sunday is never like a Monday), the last week of search traffic, chats waiting on a reply, the task board and what I&rsquo;ve spent. A section that can&rsquo;t load says so, and the post still goes out.</p>
<p>Reading app analytics through an API teaches you to distrust them a little. In August, 121 Android first-opens from Azerbaijan were followed by 119 uninstalls, and I read that month&rsquo;s Android totals with them taken out.</p>
<p>Releases have their own screen. When over-the-air updates came from my own server, my view of a release was an admin list with an &ldquo;is active&rdquo; checkbox, and I had no idea how many phones had taken one. The hosted service&rsquo;s dashboard shows which release devices run, how installs went, and a crash rollback when a bad bundle crashes and the SDK restores the previous one. I wrote up <a href="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/" >the move to a hosted update service</a> separately.</p>

<h2 class="relative group">What I dropped, or stopped believing
    <div id="what-i-dropped-or-stopped-believing" 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="#what-i-dropped-or-stopped-believing" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>None of this was a paid tool. Mostly it was things I&rsquo;d been treating as signals that weren&rsquo;t, and one job running in the wrong place.</p>

<h3 class="relative group">Cloudflare&rsquo;s 504 rate, until I grouped it by user agent
    <div id="cloudflares-504-rate-until-i-grouped-it-by-user-agent" 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="#cloudflares-504-rate-until-i-grouped-it-by-user-agent" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>In September the Cloudflare dashboard showed what looked like 22% of a day&rsquo;s requests failing with 504s, concentrated on the endpoints the app polls every 30 seconds, while every health check was green and the app&rsquo;s own logs said those requests were fast. I spent days on it, found that machines took 25 to 33 seconds to cold-start and did it 92 times a day, fixed that, and wrote it up in <a href="https://jared.lynskey.co.nz/en/posts/2026/2026-09-09-scale-to-zero-504s/" >the scale-to-zero post</a> as the cause.</p>
<p>The next morning I grouped the same 504s by user agent. All 7,344 of them between 5 and 9 September came from Cloudflare&rsquo;s own Early Hints cache lookups (user agents <code>bastion early hints</code> and <code>nginx-ssl early hints</code>), which never reach the origin: a 504 on one of those lines is a cache miss, not a timeout. They copy real request paths, so they landed on the busiest endpoints and looked exactly like users failing. The app, browsers, curl and search crawlers had none. Early Hints wasn&rsquo;t doing anything for me because the origin sent no <code>Link</code> header, so I turned it off, and later <a href="https://jared.lynskey.co.nz/en/posts/2026/cloudflare-504-early-hints/" >made it work</a>.</p>
<p>The cold-start work still paid off, since deploys and wakes are faster for it, but the outage I thought I was fixing never reached a user.</p>
<p>So this is the rule I&rsquo;d most like to pass on: before you believe a CDN&rsquo;s error rate, group it by user agent and check it isn&rsquo;t the CDN&rsquo;s own machinery. On Cloudflare&rsquo;s free plan that&rsquo;s a GraphQL query on <code>httpRequestsAdaptiveGroups</code>, a day at a time. The same query settled the next scare, when one polling endpoint went from 70–260 requests a day to 2,880. It wasn&rsquo;t a scraper. It was my own Mac, with the app left running in the iOS simulator after testing.</p>

<h3 class="relative group">A green health check
    <div id="a-green-health-check" 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="#a-green-health-check" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>Fly checks <code>/api/health/</code> on both apps every 30 seconds and uses the answer to decide which machines get traffic, which it&rsquo;s good at. But a health check runs against a machine that&rsquo;s already up, so it can&rsquo;t see what happens before one is: a cold start, a queue at the proxy, a request that never arrives. Routing is all I ask of it now.</p>
<p>Deploys get the probe script instead, which hits the origin throughout and exits 1 if anything failed. It caught a plain <code>flyctl deploy</code> leaving a 7.3-second gap, then counted no failures in 2,514 requests through the deploy script, which wakes a spare machine first.</p>
<p>The health check has also caused two outages of its own. In July, Django&rsquo;s <code>SECURE_SSL_REDIRECT</code> answered Fly&rsquo;s plain-HTTP probe with a 301, every machine failed its check, and everyone got a 503. In August, narrowing an <code>except Exception</code> in the health view let a &ldquo;connection already closed&rdquo; error escape as a 500, and Fly pulled the machine in exactly the case the view&rsquo;s retry was there for. Now the check only asks whether the process can serve, and a database stall no longer takes both apps out of routing.</p>
<p>The morning summary had to learn the same lesson about counting. The marketplace suspends idle machines, so a machine woken by a request fails its first check a few hundred milliseconds after starting and passes the next. Counting those made every morning read like an outage, so a failure that passes again within a minute is now listed separately, as machines waking up and deploys.</p>

<h3 class="relative group">A scheduled job in CI
    <div id="a-scheduled-job-in-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="#a-scheduled-job-in-ci" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>The morning summary started as a CI workflow on a cron schedule and moved within a day. Once it read the analytics and search data, the key it needed was the analytics project&rsquo;s service-account key, which can do anything in that project, and I didn&rsquo;t want that sitting in CI secrets. Now a scheduler on my Mac runs it every 20 minutes, and it posts once it&rsquo;s past eight in Auckland and today&rsquo;s post hasn&rsquo;t gone out, so a Mac asleep at eight posts when it wakes. The workflow stays as a manual fallback, minus the analytics and search sections.</p>

<h2 class="relative group">If I were starting again
    <div id="if-i-were-starting-again" 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="#if-i-were-starting-again" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>I&rsquo;d set up three things before the first user rather than after the first incident: errors to a chat channel the team already reads, with a rate limit; a log archive in object storage I can query with SQL; and whatever metrics the platform already collects. That&rsquo;s a couple of dollars a month. Crash reporting comes the week an app goes onto phones, because otherwise a crash on a phone tells nobody anything. A morning summary comes once there are enough places to check that I&rsquo;d stop checking them.</p>
<p>I&rsquo;d leave tracing until there&rsquo;s a second service for a request to cross, and a hosted APM until there&rsquo;s a team that needs shared dashboards, an on-call rota and a budget for someone else&rsquo;s bill. That&rsquo;s when the tools in last year&rsquo;s post start paying for themselves. Before then the most useful habit is free: when a number looks alarming, find out what produced it before deciding what broke. Several of the scares in this post came from the monitoring, not the product.</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>Self-Hosted Sentry Alternative: Sentry&#39;s SDK, My Own Server</title>
      <link>https://jared.lynskey.co.nz/en/posts/2026/2026-10-09-sentry-sdk-own-server/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/en/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>Crash Reporting</category>
      <category>Self-Hosted</category>
      <category>iOS</category>
      <category>Android</category>
      <category>Privacy</category>
      <description>A self-hosted Sentry alternative for React Native crash reporting: keep Sentry&#39;s open-source SDK, point its DSN at your own API, and group, alert on and scrub crashes yourself.</description>
      <content:encoded><![CDATA[<p>If you want self-hosted crash reporting for a React Native app, you don&rsquo;t have to run Sentry&rsquo;s own server or pay for sentry.io. Sentry&rsquo;s SDK is open source and its DSN can point at any host, so I aimed it at an endpoint in my own Django API, which receives the reports, groups them, alerts on them and keeps them, at no cost beyond the API that was already running. The one thing I haven&rsquo;t replaced yet is symbolication, so stack traces don&rsquo;t point to source lines.</p>
<p>Until this week, the only way I found out our app had crashed was when someone told me. Most people don&rsquo;t. They reopen the app, and if it happens again they stop opening it. The API side was covered (every ERROR log already lands in a team chat channel), but a crash on a phone left no trace anywhere I could see.</p>
<p>The app did have one reporter already. When it puts an error message in front of someone, it posts that message to a small endpoint on the API, which logs it at ERROR, so it reaches the chat channel too. I added it after a delete that had <em>worked</em> left the row in the list, and tapping it again showed &ldquo;No Item matches the given query&rdquo;. The server had logged two ordinary 404s and said nothing. That covers errors the app catches and shows. A crash is different: the JavaScript that would send the report is the thing that just died.</p>
<p>The obvious fix is Sentry. I like Sentry&rsquo;s SDK a lot; it&rsquo;s the reason crash reporting on React Native is a solved problem. What I didn&rsquo;t want was another vendor holding user data, another privacy-policy line, and another bill for something that, at our size, is a few reports a day at most. So I kept the SDK and replaced the service: the app&rsquo;s DSN points at our own API, and a small piece of the Django app there receives what it sends, groups it, alerts on it, and keeps it.</p>
<p>This post is how that works, and how I checked that a real native crash actually makes it through.</p>

<h2 class="relative group">The DSN trick
    <div id="the-dsn-trick" 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="#the-dsn-trick" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>A Sentry DSN looks like <code>https://&lt;public-key&gt;@&lt;host&gt;/&lt;project-id&gt;</code>. The SDK turns that into a POST to <code>https://&lt;host&gt;/api/&lt;project-id&gt;/envelope/</code>, with the key in an <code>X-Sentry-Auth</code> header. Nothing about it is specific to sentry.io. So the DSN baked into the app is:</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>and Django has a route that matches what the SDK will call:</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>The key is in every binary, so it isn&rsquo;t a secret. It&rsquo;s there so that if the endpoint ever starts attracting junk I can change it, and so that an unset key on the server refuses everything rather than accepting the world:</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> reads <code>sentry_key=</code> out of the <code>X-Sentry-Auth</code> header, falling back to the query string, which some SDKs use instead.</p>

<h2 class="relative group">What the SDK sends: envelopes
    <div id="what-the-sdk-sends-envelopes" 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="#what-the-sdk-sends-envelopes" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>Sentry&rsquo;s wire format is the <a href="https://develop.sentry.dev/sdk/data-model/envelopes/"  target="_blank" rel="noreferrer">envelope</a>: one JSON header line, then any number of items, each one a JSON header line followed by a payload. If the item header has a <code>length</code>, the payload is exactly that many bytes; otherwise it runs to the next newline.</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>Two details caught me out:</p>
<ul>
<li><strong>The native SDKs gzip the body; the JavaScript one doesn&rsquo;t.</strong> A JS error captured by <code>@sentry/react-native</code> arrives as plain text, while a native crash from sentry-cocoa or sentry-android arrives with <code>Content-Encoding: gzip</code>. The decoder handles gzip, zlib-wrapped deflate and raw deflate, and stops decompressing at 1MB, because a crash event is a few kilobytes and anything bigger isn&rsquo;t one.</li>
<li><strong>Length-prefixed payloads can contain newlines.</strong> If you split on <code>\n</code> you&rsquo;ll mangle every event the native SDKs send. You have to honour <code>length</code> when it&rsquo;s there.</li>
</ul>
<p>The parser is short. It only keeps <code>event</code> items; sessions, transactions, attachments and client reports are accepted and dropped.</p>

<h2 class="relative group">Answering in a way the SDK understands
    <div id="answering-in-a-way-the-sdk-understands" 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="#answering-in-a-way-the-sdk-understands" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>This matters more than I expected. The SDK keeps a queue on disk and retries anything it isn&rsquo;t sure was delivered, so what the server answers decides whether it keeps trying:</p>
<ul>
<li><strong>200</strong> once stored, <em>or deliberately dropped</em>. If I answered a session item with a 4xx, the SDK would keep it and retry forever.</li>
<li><strong>429 with <code>Retry-After</code></strong> when throttled. The endpoint has a per-IP throttle (120 an hour), because a phone in a crash loop sends one report per launch, and the SDK backs off on a 429 the way you&rsquo;d hope.</li>
<li><strong>400</strong> for a malformed envelope, logged as a warning, not an error: a broken report says a client is broken, and the next good one will say what crashed.</li>
</ul>
<p>The SDK also resends the same event if it isn&rsquo;t sure the first send arrived, so <code>event_id</code> has a unique constraint and a duplicate is a quiet no-op.</p>

<h2 class="relative group">Grouping without source maps
    <div id="grouping-without-source-maps" 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="#grouping-without-source-maps" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>This is where a self-built receiver has to make a choice Sentry makes for you. Sentry groups crashes by stack trace. But a release Hermes bundle is minified, so frame names are single letters and line/column positions change with every build. Group by frames, and the same bug opens a new issue every release.</p>
<p>Without source maps (more on that at the end), the most stable thing in a JavaScript error is its type and message. So the fingerprint is the exception type plus the message, with the parts that differ between sightings masked out:</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> and <code>(reading index 17)</code> become the same issue. If the SDK supplies its own <code>fingerprint</code> without <code>{{ default }}</code> in it, that wins, so I can force grouping from the client when the default is wrong. An event with no exception falls back to its masked log message, and one with neither falls back to the top in-app frame. For a native signal crash the message is something like <code>Signal 6, Code 0</code>, so those group by signal, which is coarse but honest until there&rsquo;s symbolication.</p>

<h2 class="relative group">Alerting without flooding the channel
    <div id="alerting-without-flooding-the-channel" 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="#alerting-without-flooding-the-channel" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>Each fingerprint becomes one issue, and each report is stored as an event under it. The issue only logs at ERROR (which is what the chat alerts listen for) in exactly these cases:</p>
<ul>
<li>a <strong>new</strong> issue</li>
<li>a <strong>resolved</strong> issue that <strong>comes back</strong>, which reopens it</li>
<li>the <strong>10th, 100th, 1,000th and 10,000th</strong> sighting</li>
</ul>
<p>An ordinary repeat only increments a counter. An issue marked <em>ignored</em> keeps counting and never alerts. With the chat handler&rsquo;s own rate limit on top, a crash loop on a bad release reads as a handful of messages rather than a wall.</p>
<p>Storage is bounded the same way: each issue keeps its <strong>50 most recent</strong> events and deletes older ones as new ones arrive. That broke my first chart, because 50 events can&rsquo;t tell you how often something happened last week, so each issue also keeps a running count per day for 90 days, updated in the same transaction as the counter. The chart draws from the counts; the events are only for reading.</p>
<p>Two small races are worth handling if you build one of these. When a brand-new crash hits several phones at once, two reports can both try to create the issue, so make the fingerprint unique and have the loser re-read the existing row. And measure the 90-day window from the server&rsquo;s today, not from the newest report, because a phone with its clock set wrong can date a report anywhere, including next year.</p>
<p>Payloads are capped at 128KB. An event over that loses its breadcrumbs and debug metadata first, and if it&rsquo;s still too big only the exception, message, release and platform are kept, with a flag so I know it was cut.</p>

<h2 class="relative group">Scrubbing
    <div id="scrubbing" 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="#scrubbing" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>The SDK runs with <code>sendDefaultPii: false</code>, so in principle no IP address, cookies or request bodies are sent. The server doesn&rsquo;t rely on that: before storing anything it keeps only <code>id</code> and <code>username</code> from <code>user</code> and drops <code>request</code> entirely, in case a later SDK version or setting starts sending them.</p>
<p>Testing against a real device turned up two more things to strip:</p>
<ul>
<li><strong>Breadcrumbs record full URLs, query strings included.</strong> The app&rsquo;s search requests carry the search term in the query string, so a crash right after a search would have stored what the person searched for. The server now cuts every breadcrumb URL at the <code>?</code>.</li>
<li><strong>Console breadcrumbs carry the raw arguments</strong> that were passed to <code>console.log</code>, which can be any object the app happened to log. The message text stays (it&rsquo;s useful), and the arguments are dropped.</li>
</ul>
<p>I only found those by looking at an actual report, which is the main argument for the next section.</p>

<h2 class="relative group">The client side
    <div id="the-client-side" 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="#the-client-side" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>On the app, everything goes through one small module, for the same reason I wrap the over-the-air update SDK: an OTA bundle also runs on binaries built before the native module existed. A top-level <code>import * as Sentry from &quot;@sentry/react-native&quot;</code> on a binary without <code>RNSentry</code> throws on import, and that&rsquo;s a blank screen. So the SDK is required lazily, and only after checking the native module is actually there:</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>Errors only. The server doesn&rsquo;t store traces, sessions, profiles or replays, so none of them are switched on. No DSN (development, E2E builds) means the SDK never starts. The build profiles set the DSN and an environment (<code>staging</code> or <code>production</code>), so both kinds of build report to the same endpoint and can be told apart. The signed-in user is set by account ID, never a name or an email.</p>
<p>The error boundaries needed an explicit call. Expo Router&rsquo;s root <code>ErrorBoundary</code> catches a render error and shows its own screen, so that error never reaches the global handler the SDK hooks into. The fix is a small wrapper that calls <code>captureException</code> in a <code>useEffect</code> and then renders the router&rsquo;s boundary as before, used for the root and per-route boundaries alike. It&rsquo;s in an effect rather than in the render so it reports once rather than on every re-render. The root component is also wrapped with <code>Sentry.wrap</code>, and if that throws it falls back to the bare component.</p>
<p>For the web there&rsquo;s a <code>.web.ts</code> version of the module with every function switched off. Metro picks the platform file, so the SDK never ends up in the web bundle at all. The web app has no native module to run on.</p>

<h2 class="relative group">Proving a native crash arrives
    <div id="proving-a-native-crash-arrives" 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="#proving-a-native-crash-arrives" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>The unit tests post hand-written envelopes, which proves the parser handles what I <em>think</em> the SDK sends. Two days after release, production had zero crash reports. Only three people had updated yet, so zero was plausible, but &ldquo;nobody has crashed&rdquo; and &ldquo;crashes don&rsquo;t arrive&rdquo; look identical from the server. I wanted to see a real one.</p>
<p>So I built a Release simulator binary with the DSN pointed at a local Django on port 8000 and a throwaway 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, not Debug: Debug builds link the dev launcher and behave differently, and Release is what people run.) Then installed it, launched it, waited a few seconds, and killed it the way a real fault would:</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>Simulator apps are ordinary macOS processes, so a signal reaches SentryCrash&rsquo;s handler exactly as it would on a device. The handler writes the report to disk; nothing is sent while the process is dying. On the <strong>next launch</strong> the SDK finds it and sends it. I relaunched, and the simulator&rsquo;s network log showed a connection to <code>:8000</code> a second later.</p>
<p>Then nothing in the Django log, which had me confused for a minute: the dev server only logs non-2xx responses at the level I had it on. The database had it:</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>Fifteen seconds from crash to stored, most of which was me relaunching. And looking at the real payload is what taught me things the tests couldn&rsquo;t:</p>
<ul>
<li><strong>The user ID wasn&rsquo;t the account ID.</strong> The test build isn&rsquo;t really signed in, so sentry-cocoa fell back to its own per-install ID. Worth knowing, and worth saying in the privacy note: a report carries the account&rsquo;s ID if you&rsquo;re signed in and a random install ID if you&rsquo;re not.</li>
<li><strong>There&rsquo;s no time zone.</strong> I&rsquo;d assumed the culture context would be there. The device context has <code>locale</code>, memory, screen size, low-power mode and the kernel version, but no time zone.</li>
<li><strong>Native crashes skip <code>beforeSend</code>.</strong> My JS <code>beforeSend</code> adds an <code>ota</code> tag with the running OTA bundle, but a native crash is assembled by the native SDK on the next launch and never passes through the JS hook. To tag native crashes, the tag has to be set on the scope with <code>setTag</code> once the bundle is known, so it syncs down to the native layer.</li>
<li><strong>The breadcrumbs showed what the app does.</strong> Console warnings, view-controller lifecycle, and HTTP calls, including the analytics SDK&rsquo;s own requests to its collection endpoint, which is a good reminder of what else the app talks to. And the URLs had query strings, which is how I found the search-term problem above.</li>
</ul>

<h2 class="relative group">Reading them
    <div id="reading-them" 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="#reading-them" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>The reports are read in the Django admin, on an issue page loosely modelled on Sentry&rsquo;s: counts and Resolve / Ignore / Reopen at the top, a bar per day, the split by release, OS, device and OTA bundle, then one event at a time with its exception, its frames (app frames highlighted, library frames folded away), the breadcrumbs and the device context. It didn&rsquo;t need a new dependency.</p>
<p>One fix came out of building it: for a native crash, the &ldquo;culprit&rdquo; frame was the binary&rsquo;s path on the device, which includes a per-install UUID, so the same crash looked different on every phone. It now shows only the address and the binary name, like <code>0x1000a1b2c (MyApp)</code>.</p>

<h2 class="relative group">The privacy policy, all of it
    <div id="the-privacy-policy-all-of-it" 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="#the-privacy-policy-all-of-it" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>Crash reports are personal information, near enough: an account ID, a phone model, and what someone did in the minute before the app died. So the privacy policy needed a paragraph. Writing it properly meant saying exactly what a report carries, so I listed it from the payload I&rsquo;d just looked at: the error, app version and OTA update, phone model, OS and language, free memory and screen size, the last few dozen steps (screens, web addresses with anything after a <code>?</code> removed, the app&rsquo;s own log lines), and the account ID or a random install ID. The policy also says the reports come to our server and not to Sentry or any other crash-reporting company. Retention gets its own line in the table: the 50 most recent reports of each problem, plus a daily count for 90 days.</p>
<p>Once I was checking one paragraph against the code, it was hard not to check the rest. The existing policy didn&rsquo;t hold up. The delete-account page said push-notification tokens and passkeys were deleted immediately when you close your account, and that server logs were gone within 90 days. None of that was true. Closing an account unlinks the sign-in methods and anonymises the profile, but the push tokens and passkeys stay until someone deletes them by hand, and the log archive has no expiry yet. So the whole policy was rewritten from an inventory of what the code actually does, and those lines now say &ldquo;not yet removed automatically; email us&rdquo;. That&rsquo;s less reassuring, and it&rsquo;s accurate.</p>
<p>The repo now has a rule for it: the policy describes what the code does, not what we intend, and a change that collects, sends or keeps personal data somewhere new updates it in the same commit. That includes the summary screen in the app, the delete-account page, and the app-store privacy forms, which only live in the store consoles. Crash reporting was the change that made me write that down.</p>

<h2 class="relative group">What&rsquo;s missing
    <div id="whats-missing" 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="#whats-missing" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p><strong>Symbolication.</strong> This is the real gap, and the one thing sentry.io does that I haven&rsquo;t replaced. Stacks are minified for JavaScript and unsymbolicated for native code:</p>
<ul>
<li><strong>JavaScript:</strong> frames point into the Hermes bundle. Fixing it means keeping the composed Hermes source map for every store build and every OTA bundle, keyed by release and bundle, and applying it to JS frames when the page renders. That&rsquo;s the next piece, because most of our crashes will be JavaScript.</li>
<li><strong>iOS native:</strong> instruction addresses need the build&rsquo;s dSYMs.</li>
<li><strong>Android:</strong> Java frames are obfuscated by R8 when minification is on (they need <code>mapping.txt</code>), and C++ frames need NDK symbols.</li>
</ul>
<p>There&rsquo;s also a smaller gap I found above: native crashes don&rsquo;t carry the OTA tag yet, because it&rsquo;s only set in <code>beforeSend</code>. Setting it on the scope once the running bundle is confirmed is a few lines I haven&rsquo;t written yet.</p>
<p>For now, an issue tells me <em>what</em> crashed, on which release, OTA bundle and device, and what the person was doing just before. It doesn&rsquo;t tell me <em>where</em> in the source. For a small app that ships a few times a week, the type, the message and the breadcrumbs have been enough to find things so far.</p>
<p>The receiver, the admin page and the tests aren&rsquo;t a lot of code, and it costs nothing on top of the API that was already running. The SDK does the hard part, which is catching a crash on a phone and getting the report off it on the next launch.</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>
