<?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>IOS · Jared Lynskey</title>
    <link>https://jared.lynskey.co.nz/en/tags/ios/</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/ios/index.xml" rel="self" type="application/rss+xml" />
    <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>
    <item>
      <title>How to Debug an iOS App in Xcode: Breakpoints, LLDB, Instruments and the Simulator</title>
      <link>https://jared.lynskey.co.nz/en/posts/2026/2026-02-26-debugging-ios-mobile-apps/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/en/posts/2026/2026-02-26-debugging-ios-mobile-apps/</guid>
      <pubDate>Thu, 26 Feb 2026 00:00:00 +0000</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>iOS</category>
      <category>Xcode</category>
      <category>Debugging</category>
      <category>Swift</category>
      <category>iOS Simulator</category>
      <category>Mobile Development</category>
      <description>How to debug an iOS app in Xcode: conditional and symbolic breakpoints, the LLDB commands worth knowing, the view and memory debuggers, Instruments, the sanitizers, crash logs, and what the simulator misses that a real iPhone catches.</description>
      <content:encoded><![CDATA[<p>To debug an iOS app in Xcode, add an exception breakpoint so a crash stops on the line that threw, reproduce the bug, and inspect state in the LLDB console with <code>po</code>. Then match the tool to the symptom: the view debugger for layout problems, the memory graph and Instruments for leaks and slowness, and the Address and Thread Sanitizers for crashes that only happen sometimes. Finish on a real iPhone, because the simulator hides a whole class of bugs.</p>
<p>I spend way too much time debugging. Everyone does. These days most of mine happens in Xcode and the simulator, chasing bugs in my own app, and the thing that keeps striking me is how much debugging machinery Xcode ships with that hardly anyone touches. This is the workflow I&rsquo;ve settled into over the years, the parts that actually save time when something breaks.</p>

<h2 class="relative group">Breakpoints Are More Powerful Than You Think
    <div id="breakpoints-are-more-powerful-than-you-think" 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="#breakpoints-are-more-powerful-than-you-think" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>Most people click the gutter to set a breakpoint and leave it at that. Right-click one, though, and there&rsquo;s a whole menu of options that changes how you debug.</p>

<h3 class="relative group">Conditional Breakpoints
    <div id="conditional-breakpoints" 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="#conditional-breakpoints" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>Say you&rsquo;ve got a loop processing 1000 items and only item 347 misbehaves. Hitting the breakpoint 346 times first is a waste of an afternoon.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-swift" data-lang="swift"><span class="line"><span class="cl"><span class="k">for</span> <span class="n">i</span> <span class="k">in</span> <span class="mf">0.</span><span class="p">.&lt;</span><span class="n">users</span><span class="p">.</span><span class="bp">count</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">processUser</span><span class="p">(</span><span class="n">users</span><span class="p">[</span><span class="n">i</span><span class="p">])</span>  <span class="c1">// Set breakpoint here with condition: i == 347</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
<p>Right-click the breakpoint, add the condition <code>i == 347</code>, and Xcode only stops when it&rsquo;s true. Works just as well with things like <code>userId == &quot;abc123&quot;</code> or <code>error != nil</code>.</p>

<h3 class="relative group">Symbolic Breakpoints
    <div id="symbolic-breakpoints" 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="#symbolic-breakpoints" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>These are for when you don&rsquo;t know where a method is being called from. Open the breakpoint navigator (Cmd+8), click the + button, and choose &ldquo;Symbolic Breakpoint&rdquo;.</p>
<p>Want to see every time any view controller loads? Set the symbol to <code>viewDidLoad</code> and Xcode pauses on every single one. Too noisy? Narrow it down: <code>MyViewController.viewDidLoad</code>.</p>
<p>The ones I keep around permanently:</p>
<ul>
<li><code>UIViewAlertForUnsatisfiableConstraints</code> catches Auto Layout conflicts the moment they happen</li>
<li><code>objc_exception_throw</code> breaks on any Objective-C exception before the crash</li>
<li><code>malloc_error_break</code> finds memory allocation problems</li>
</ul>

<h3 class="relative group">Exception Breakpoints
    <div id="exception-breakpoints" 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="#exception-breakpoints" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>This should be the first thing you set up in any new project. Add one (breakpoint navigator, + → Exception Breakpoint) and Xcode pauses the moment something throws, instead of after the app has already died.</p>
<p>It&rsquo;s caught more nil-unwrapping bugs for me than I&rsquo;d like to admit, the kind that otherwise just show up as mysterious crashes in the console.</p>

<h3 class="relative group">Breakpoint Actions
    <div id="breakpoint-actions" 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="#breakpoint-actions" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>Breakpoints don&rsquo;t have to stop execution at all. Edit one, click &ldquo;Add Action&rdquo;, and it can:</p>
<ul>
<li>Print variables without stopping: <code>&quot;User count: @(users.count)@&quot;</code></li>
<li>Run LLDB commands automatically</li>
<li>Execute shell scripts</li>
<li>Play a sound (I use this for rare code paths I want to know about)</li>
</ul>
<p>Tick &ldquo;Automatically continue after evaluating actions&rdquo; and the breakpoint becomes a logger that never interrupts you.</p>

<h2 class="relative group">LLDB: The Console Commands You&rsquo;ll Actually Use
    <div id="lldb-the-console-commands-youll-actually-use" 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="#lldb-the-console-commands-youll-actually-use" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>When you hit a breakpoint, the console at the bottom isn&rsquo;t just for reading crash logs. It&rsquo;s LLDB, and it&rsquo;s genuinely useful.</p>

<h3 class="relative group">The Basics
    <div id="the-basics" 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-basics" aria-label="Anchor">#</a>
    </span>
    
</h3>
<div class="highlight-wrapper"><pre tabindex="0"><code class="language-lldb" data-lang="lldb">(lldb) po user
▿ User
  - id: &#34;123&#34;
  - name: &#34;John Doe&#34;
  - email: &#34;john@example.com&#34;</code></pre></div>
<p><code>po</code> prints objects in a readable format. That&rsquo;s 90% of my LLDB usage right there.</p>
<p>Need more detail? Try <code>p</code> instead:</p>
<div class="highlight-wrapper"><pre tabindex="0"><code class="language-lldb" data-lang="lldb">(lldb) p user.name
(String) $R0 = &#34;John Doe&#34;</code></pre></div>
<p>And to see every local variable at once:</p>
<div class="highlight-wrapper"><pre tabindex="0"><code class="language-lldb" data-lang="lldb">(lldb) frame variable</code></pre></div>

<h3 class="relative group">Changing Things at Runtime
    <div id="changing-things-at-runtime" 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="#changing-things-at-runtime" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>This is where LLDB earns its keep: you can modify variables without recompiling.</p>
<div class="highlight-wrapper"><pre tabindex="0"><code class="language-lldb" data-lang="lldb">(lldb) expr user.name = &#34;Jane Doe&#34;
(lldb) expr index = 0</code></pre></div>
<p>Found a bug and want to try a fix without rebuilding? Change the variable and continue. I use this constantly when narrowing down edge cases.</p>
<p>You can call methods too:</p>
<div class="highlight-wrapper"><pre tabindex="0"><code class="language-lldb" data-lang="lldb">(lldb) po self.refreshUI()
(lldb) expr navigationController?.popViewController(animated: true)</code></pre></div>

<h3 class="relative group">Navigation
    <div id="navigation" 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="#navigation" aria-label="Anchor">#</a>
    </span>
    
</h3>
<div class="highlight-wrapper"><pre tabindex="0"><code class="language-lldb" data-lang="lldb">(lldb) bt              # Show the call stack
(lldb) frame select 3  # Jump to a different stack frame
(lldb) continue        # Keep running (or just &#39;c&#39;)
(lldb) n               # Step over (next line)
(lldb) s               # Step into function</code></pre></div>

<h3 class="relative group">Watchpoints
    <div id="watchpoints" 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="#watchpoints" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>Want to know when a variable changes? Set a watchpoint:</p>
<div class="highlight-wrapper"><pre tabindex="0"><code class="language-lldb" data-lang="lldb">(lldb) watchpoint set variable user.isLoggedIn</code></pre></div>
<p>Execution now pauses whenever that value changes, no matter where in your code it happens. Gold for tracking down unexpected state mutations.</p>

<h2 class="relative group">Console.app: The Hidden Debugging Tool
    <div id="consoleapp-the-hidden-debugging-tool" 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="#consoleapp-the-hidden-debugging-tool" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>Xcode&rsquo;s console is fine until you need to see everything: system logs, crash reports, the lot. That&rsquo;s when I open Console.app.</p>
<p>If you&rsquo;re using OSLog (you should be), Console.app makes those logs searchable and filterable:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-swift" data-lang="swift"><span class="line"><span class="cl"><span class="kd">import</span> <span class="nc">os</span><span class="p">.</span><span class="nc">log</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kd">let</span> <span class="nv">logger</span> <span class="p">=</span> <span class="n">Logger</span><span class="p">(</span><span class="n">subsystem</span><span class="p">:</span> <span class="s">&#34;com.example.app&#34;</span><span class="p">,</span> <span class="n">category</span><span class="p">:</span> <span class="s">&#34;networking&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">logger</span><span class="p">.</span><span class="n">info</span><span class="p">(</span><span class="s">&#34;Starting API request&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">logger</span><span class="p">.</span><span class="n">debug</span><span class="p">(</span><span class="s">&#34;Request URL: </span><span class="si">\(</span><span class="n">url</span><span class="p">.</span><span class="n">absoluteString</span><span class="si">)</span><span class="s">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">logger</span><span class="p">.</span><span class="n">error</span><span class="p">(</span><span class="s">&#34;Failed to decode: </span><span class="si">\(</span><span class="n">error</span><span class="p">.</span><span class="n">localizedDescription</span><span class="si">)</span><span class="s">&#34;</span><span class="p">)</span></span></span></code></pre></div></div>
<p>Then in Console.app:</p>
<ol>
<li>Select your simulator or connected device</li>
<li>Filter by your subsystem: <code>subsystem:com.example.app</code></li>
<li>Filter by level: <code>subsystem:com.example.app AND level:error</code></li>
</ol>
<p>You can save filter predicates for the sessions you run often. I&rsquo;ve got one for network requests, one for database operations, and one that shows nothing but errors and warnings.</p>

<h3 class="relative group">Why OSLog Over print()?
    <div id="why-oslog-over-print" 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="#why-oslog-over-print" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>OSLog beats scattering <code>print()</code> statements everywhere. It&rsquo;s structured, so you can filter by level and category. It&rsquo;s fast enough that logging won&rsquo;t slow the app down. It redacts sensitive data in production automatically, and the logs survive your app crashing.</p>
<p><code>print()</code> is still fine for quick throwaway checks. Anything you might want to look at later belongs in OSLog.</p>

<h3 class="relative group">Advanced Console.app Filtering
    <div id="advanced-consoleapp-filtering" 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="#advanced-consoleapp-filtering" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>Console.app supports proper predicate queries. The ones I use regularly:</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"># All errors and faults in your app
</span></span><span class="line"><span class="cl">subsystem:com.example.app AND (level:error OR level:fault)
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"># Network requests that failed
</span></span><span class="line"><span class="cl">subsystem:com.example.app AND category:networking AND eventMessage CONTAINS &#34;failed&#34;
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"># Everything from the last 5 minutes
</span></span><span class="line"><span class="cl">subsystem:com.example.app AND timestamp &gt;= now(-5m)</span></span></code></pre></div></div>
<p>You can also export logs to attach to bug reports, which beats copy-pasting out of Xcode&rsquo;s console.</p>

<h2 class="relative group">View Debugging
    <div id="view-debugging" 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="#view-debugging" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>When the UI is broken and you can&rsquo;t see why, the view debugger is the fastest way out.</p>
<p>Run the app, navigate to the broken screen, and click the &ldquo;Debug View Hierarchy&rdquo; button in Xcode&rsquo;s debug bar (or press Cmd+Shift+D). You get a 3D exploded view of the whole hierarchy.</p>
<p>Rotate it around and things jump out:</p>
<ul>
<li>Views rendering behind other views</li>
<li>Views sitting way off-screen</li>
<li>Views with zero size</li>
<li>The full constraint chain for whatever you select</li>
</ul>
<p>I&rsquo;ve used it to find invisible buttons swallowing tap events, labels rendering at 0x0, and an image that was accidentally 10,000 pixels wide.</p>

<h3 class="relative group">Auto Layout Debugging
    <div id="auto-layout-debugging" 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="#auto-layout-debugging" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>Purple warning icons in the view hierarchy mean constraint conflicts. Click one and Xcode shows exactly which constraints are fighting.</p>
<p>Give your constraints identifiers:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-swift" data-lang="swift"><span class="line"><span class="cl"><span class="n">heightConstraint</span><span class="p">.</span><span class="n">identifier</span> <span class="p">=</span> <span class="s">&#34;ProfileImageHeight&#34;</span></span></span></code></pre></div></div>
<p>When a constraint breaks, you&rsquo;ll see <code>&quot;ProfileImageHeight&quot;</code> in the error instead of a memory address, which makes the log actually readable.</p>
<p>From LLDB you can also print the constraint tree:</p>
<div class="highlight-wrapper"><pre tabindex="0"><code class="language-lldb" data-lang="lldb">(lldb) po view.hasAmbiguousLayout
(lldb) po view._autolayoutTrace()</code></pre></div>

<h2 class="relative group">Simulator Features That Save Time
    <div id="simulator-features-that-save-time" 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="#simulator-features-that-save-time" aria-label="Anchor">#</a>
    </span>
    
</h2>

<h3 class="relative group">Slow Animations
    <div id="slow-animations" 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="#slow-animations" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>Debug → Slow Animations runs everything at 1/10 speed. Perfect for seeing what&rsquo;s actually happening during a transition, or why an animation looks off.</p>

<h3 class="relative group">Simulate Memory Warnings
    <div id="simulate-memory-warnings" 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="#simulate-memory-warnings" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>Debug → Simulate Memory Warning tests how your app copes with low memory. I&rsquo;ve found plenty of image caching bugs this way: everything works fine until the warning fires and suddenly all your images vanish.</p>

<h3 class="relative group">Network Link Conditioner
    <div id="network-link-conditioner" 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="#network-link-conditioner" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>Xcode → Open Developer Tools → Network Link Conditioner</p>
<p>Simulate 3G, LTE, high packet loss, or complete offline mode. If an API request has ever worked fine on WiFi and timed out on cellular, this is how you reproduce it.</p>
<p>I keep a &ldquo;Bad Network&rdquo; profile with 500ms latency and 10% packet loss. It surfaces timeout bugs and loading-state problems you&rsquo;d never see on fast office WiFi.</p>

<h3 class="relative group">Status Bar Overrides
    <div id="status-bar-overrides" 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="#status-bar-overrides" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>Right-click the status bar in the simulator and you can override the time, battery level, signal strength, and carrier. Handy for consistent screenshots, or for checking how the UI looks at different battery levels.</p>

<h3 class="relative group">Location Simulation
    <div id="location-simulation" 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="#location-simulation" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>Debug → Location simulates different locations without leaving your desk: custom coordinates, city walks, freeway drives. Very useful for location-based features, or bugs that only show up in certain regions.</p>

<h2 class="relative group">Memory Debugging
    <div id="memory-debugging" 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="#memory-debugging" aria-label="Anchor">#</a>
    </span>
    
</h2>

<h3 class="relative group">Debug Memory Graph
    <div id="debug-memory-graph" 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="#debug-memory-graph" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>Click the Debug Memory Graph button (Cmd+Shift+M) and Xcode shows every object in memory, their relationships, and the part you actually care about: which ones are leaking.</p>
<p>Purple exclamation marks mean leaks. Click one and you can see the retain cycle.</p>
<p>The most common leak I see:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-swift" data-lang="swift"><span class="line"><span class="cl"><span class="kd">class</span> <span class="nc">ViewController</span><span class="p">:</span> <span class="n">UIViewController</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="kd">var</span> <span class="nv">onComplete</span><span class="p">:</span> <span class="p">(()</span> <span class="p">-&gt;</span> <span class="nb">Void</span><span class="p">)?</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="kd">func</span> <span class="nf">setupHandler</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="n">onComplete</span> <span class="p">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="kc">self</span><span class="p">.</span><span class="n">dismiss</span><span class="p">(</span><span class="n">animated</span><span class="p">:</span> <span class="kc">true</span><span class="p">)</span>  <span class="c1">// ❌ Captures self strongly</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 class="p">}</span></span></span></code></pre></div></div>
<p>Fix it with weak self:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-swift" data-lang="swift"><span class="line"><span class="cl"><span class="n">onComplete</span> <span class="p">=</span> <span class="p">{</span> <span class="p">[</span><span class="kr">weak</span> <span class="kc">self</span><span class="p">]</span> <span class="k">in</span>
</span></span><span class="line"><span class="cl">    <span class="kc">self</span><span class="p">?.</span><span class="n">dismiss</span><span class="p">(</span><span class="n">animated</span><span class="p">:</span> <span class="kc">true</span><span class="p">)</span>  <span class="c1">// ✓ No retain cycle</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
<p>Delegates need to be weak too:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-swift" data-lang="swift"><span class="line"><span class="cl"><span class="kr">weak</span> <span class="kd">var</span> <span class="nv">delegate</span><span class="p">:</span> <span class="n">ManagerDelegate</span><span class="p">?</span>  <span class="c1">// Not just &#39;var&#39;</span></span></span></code></pre></div></div>

<h3 class="relative group">Instruments
    <div id="instruments" 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="#instruments" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>For serious memory investigation, use Instruments (Product → Profile, or Cmd+I).</p>
<p>The Allocations instrument shows every object allocation, memory growth over time, which classes are using the most memory, and stack traces for where each allocation happened.</p>
<p>What I&rsquo;m usually looking for: memory that grows linearly over time (probably a leak), unexpectedly large allocations (did I just load all 1000 images at once?), and objects that should have been deallocated but weren&rsquo;t.</p>
<p>Mark generations (the little flag button) before and after an action to see what&rsquo;s persisting when it shouldn&rsquo;t.</p>

<h2 class="relative group">Performance Profiling
    <div id="performance-profiling" 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="#performance-profiling" aria-label="Anchor">#</a>
    </span>
    
</h2>

<h3 class="relative group">Time Profiler
    <div id="time-profiler" 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="#time-profiler" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>Product → Profile → Time Profiler. Record while doing the slow thing in your app, then read the call tree.</p>
<p>Sort by &ldquo;Self Weight&rdquo; to find the bottlenecks. If a function shows 40% self weight, that&rsquo;s where your time is going.</p>
<p>I once found a JSON parsing function being called 1000 times a second. Moved it to a background queue and the UI stopped stuttering.</p>

<h3 class="relative group">Main Thread Checker
    <div id="main-thread-checker" 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="#main-thread-checker" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>Xcode automatically catches UI updates on background threads. If you see this:</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">Main Thread Checker: UI API called on a background thread: -[UILabel setText:]</span></span></code></pre></div></div>
<p>you did something like this:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-swift" data-lang="swift"><span class="line"><span class="cl"><span class="n">URLSession</span><span class="p">.</span><span class="n">shared</span><span class="p">.</span><span class="n">dataTask</span><span class="p">(</span><span class="n">with</span><span class="p">:</span> <span class="n">url</span><span class="p">)</span> <span class="p">{</span> <span class="n">data</span><span class="p">,</span> <span class="n">response</span><span class="p">,</span> <span class="n">error</span> <span class="k">in</span>
</span></span><span class="line"><span class="cl">    <span class="kc">self</span><span class="p">.</span><span class="n">label</span><span class="p">.</span><span class="n">text</span> <span class="p">=</span> <span class="s">&#34;Loaded&#34;</span>  <span class="c1">// ❌ Crash!</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
<p>Fix it:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-swift" data-lang="swift"><span class="line"><span class="cl"><span class="n">URLSession</span><span class="p">.</span><span class="n">shared</span><span class="p">.</span><span class="n">dataTask</span><span class="p">(</span><span class="n">with</span><span class="p">:</span> <span class="n">url</span><span class="p">)</span> <span class="p">{</span> <span class="n">data</span><span class="p">,</span> <span class="n">response</span><span class="p">,</span> <span class="n">error</span> <span class="k">in</span>
</span></span><span class="line"><span class="cl">    <span class="n">DispatchQueue</span><span class="p">.</span><span class="n">main</span><span class="p">.</span><span class="n">async</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="kc">self</span><span class="p">.</span><span class="n">label</span><span class="p">.</span><span class="n">text</span> <span class="p">=</span> <span class="s">&#34;Loaded&#34;</span>  <span class="c1">// ✓</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></code></pre></div></div>

<h2 class="relative group">Runtime Diagnostics
    <div id="runtime-diagnostics" 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="#runtime-diagnostics" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>Edit Scheme → Run → Diagnostics. There&rsquo;s a row of checkboxes in here that catch bugs you&rsquo;d never find by hand.</p>

<h3 class="relative group">Address Sanitizer
    <div id="address-sanitizer" 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="#address-sanitizer" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>Finds memory corruption: use-after-free errors, buffer overflows, memory leaks. It makes the app 2-3x slower, but it catches bugs that are near impossible to track down any other way. I turn it on when chasing crashes that only happen sometimes.</p>

<h3 class="relative group">Thread Sanitizer
    <div id="thread-sanitizer" 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="#thread-sanitizer" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>Catches data races and threading bugs. Enable it, run the app, and use it normally; if there&rsquo;s a race condition in there, Thread Sanitizer will find it.</p>
<p>You can&rsquo;t run Address Sanitizer and Thread Sanitizer at the same time, so I alternate between them when a crash is being weird.</p>

<h3 class="relative group">Zombie Objects
    <div id="zombie-objects" 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="#zombie-objects" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>Catches messages sent to deallocated objects. With it enabled, Xcode tells you exactly when you&rsquo;re touching freed memory:</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">*** -[MyViewController viewDidLoad]: message sent to deallocated instance 0x600001234000</span></span></code></pre></div></div>
<p>Don&rsquo;t leave it on permanently. It stops objects from being deallocated, so memory climbs forever. But for pinning down a specific use-after-free bug, it&rsquo;s great.</p>

<h2 class="relative group">Common Debugging Scenarios
    <div id="common-debugging-scenarios" 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="#common-debugging-scenarios" aria-label="Anchor">#</a>
    </span>
    
</h2>

<h3 class="relative group">App Crashes on Launch
    <div id="app-crashes-on-launch" 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="#app-crashes-on-launch" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>Add an exception breakpoint first; that usually catches it.</p>
<p>If not, the usual suspects are missing keys in Info.plist, force-unwrapped optionals in initialisation code, or dependency injection failing.</p>

<h3 class="relative group">UI Not Updating
    <div id="ui-not-updating" 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="#ui-not-updating" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>Every. Single. Time. It&rsquo;s one of:</p>
<ol>
<li>The update isn&rsquo;t on the main thread</li>
<li>The outlet isn&rsquo;t connected</li>
<li>The view isn&rsquo;t actually visible (check with <code>po view.window</code> in LLDB; if it returns nil, the view isn&rsquo;t in the hierarchy)</li>
</ol>
<p>And for table or collection views: you forgot to call <code>reloadData()</code>.</p>

<h3 class="relative group">Memory Warnings Crashing the App
    <div id="memory-warnings-crashing-the-app" 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="#memory-warnings-crashing-the-app" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>Use Instruments Allocations to see what&rsquo;s holding memory. It&rsquo;s usually images never released from a cache, view controllers that won&rsquo;t deallocate (retain cycle), or some array or dictionary growing forever.</p>
<p>Simulate memory warnings in the simulator to test your cleanup code.</p>

<h3 class="relative group">Mysterious Crashes
    <div id="mysterious-crashes" 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="#mysterious-crashes" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>Turn on the sanitizers. Intermittent usually means threading, so run with Thread Sanitizer. Crashing inside system frameworks usually means memory corruption, so run with Address Sanitizer.</p>

<h3 class="relative group">Crashes You Can&rsquo;t Reproduce
    <div id="crashes-you-cant-reproduce" 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="#crashes-you-cant-reproduce" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>When the crash happened on someone else&rsquo;s phone, the report usually still exists. For TestFlight and App Store builds, Xcode&rsquo;s Organizer (Window → Organizer → Crashes) collects crash reports from users who share analytics, symbolicated if the dSYMs went up with the build. For a tester&rsquo;s phone, the reports are on the device itself under Settings → Privacy &amp; Security → Analytics &amp; Improvements → Analytics Data, as <code>.ips</code> files starting with the app&rsquo;s name, and they can AirDrop one over. Start from the crashed thread&rsquo;s stack trace, then work through the scenarios above.</p>

<h3 class="relative group">Debugging OTA Updates in the Simulator
    <div id="debugging-ota-updates-in-the-simulator" 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="#debugging-ota-updates-in-the-simulator" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>My app ships over-the-air updates, and the simulator is the easiest place to debug that whole flow.</p>
<p>I start by watching the network traffic. Console.app filtered to the app&rsquo;s subsystem shows the manifest requests and responses in real time, so you can see exactly which headers go out and what the server sends back.</p>
<p>Then I make the network bad on purpose. Network Link Conditioner shows you how the update flow copes with slow connections and packet loss. Does the app hang? Show a loading indicator? Fall back gracefully?</p>
<p>Around the update check itself, I log everything: when the check starts, when an update is available, when the download begins, and when the new bundle loads.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-swift" data-lang="swift"><span class="line"><span class="cl"><span class="kd">import</span> <span class="nc">os</span><span class="p">.</span><span class="nc">log</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kd">let</span> <span class="nv">logger</span> <span class="p">=</span> <span class="n">Logger</span><span class="p">(</span><span class="n">subsystem</span><span class="p">:</span> <span class="s">&#34;com.example.app&#34;</span><span class="p">,</span> <span class="n">category</span><span class="p">:</span> <span class="s">&#34;updates&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">logger</span><span class="p">.</span><span class="n">info</span><span class="p">(</span><span class="s">&#34;Checking for updates...&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="kd">let</span> <span class="nv">update</span> <span class="p">=</span> <span class="k">try</span> <span class="n">await</span> <span class="n">Updates</span><span class="p">.</span><span class="n">checkForUpdateAsync</span><span class="p">()</span>
</span></span><span class="line"><span class="cl"><span class="n">logger</span><span class="p">.</span><span class="n">info</span><span class="p">(</span><span class="s">&#34;Update available: </span><span class="si">\(</span><span class="n">update</span><span class="p">.</span><span class="n">isAvailable</span><span class="si">)</span><span class="s">&#34;</span><span class="p">)</span></span></span></code></pre></div></div>
<p>Finally, it&rsquo;s worth testing the manifest endpoint on its own. You can curl it with the same headers the app sends:</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">curl -H <span class="s2">&#34;expo-protocol-version: 1&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">     -H <span class="s2">&#34;expo-platform: ios&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">     -H <span class="s2">&#34;expo-runtime-version: 1.0.0&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">     https://updates.example.com/manifest</span></span></code></pre></div></div>
<p>That confirms the server is returning the right data before you go digging into the client.</p>

<h2 class="relative group">Third-Party Tools
    <div id="third-party-tools" 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="#third-party-tools" aria-label="Anchor">#</a>
    </span>
    
</h2>

<h3 class="relative group">Charles Proxy / Proxyman
    <div id="charles-proxy--proxyman" 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="#charles-proxy--proxyman" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>See all your network traffic, modify requests and responses, test error conditions. Essential for API debugging.</p>
<p>Proxyman has a nicer UI than Charles and feels more at home on macOS. I switched last year and haven&rsquo;t looked back.</p>

<h3 class="relative group">Reveal
    <div id="reveal" 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="#reveal" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>Like Xcode&rsquo;s view debugger, but more capable. It works on physical devices without being attached to Xcode, which makes it great for chasing layout issues on testers&rsquo; devices.</p>

<h2 class="relative group">Simulator vs Real Device
    <div id="simulator-vs-real-device" 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="#simulator-vs-real-device" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>Most of my debugging happens in the simulator because it&rsquo;s quick: no cable, no signing, no waiting for an install. But the simulator is an iOS build running on your Mac&rsquo;s processor, with your Mac&rsquo;s memory and your Mac&rsquo;s network connection, and some bugs only exist on a phone.</p>
<table>
	<thead>
			<tr>
					<th></th>
					<th>Simulator</th>
					<th>Real iPhone</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Speed</td>
					<td>Your Mac&rsquo;s CPU, usually faster than a phone</td>
					<td>Real numbers; profile performance here</td>
			</tr>
			<tr>
					<td>Memory</td>
					<td>No real limit, so you simulate memory warnings</td>
					<td>The system kills apps that use too much</td>
			</tr>
			<tr>
					<td>Network</td>
					<td>Your Mac&rsquo;s connection</td>
					<td>Cellular, weak signal, switching between Wi-Fi and mobile data</td>
			</tr>
			<tr>
					<td>Camera, Bluetooth, NFC</td>
					<td>Not available</td>
					<td>Available</td>
			</tr>
			<tr>
					<td>Push notifications</td>
					<td>Can be faked with <code>xcrun simctl push</code></td>
					<td>Real delivery, the way users get them</td>
			</tr>
			<tr>
					<td>Iteration</td>
					<td>Seconds</td>
					<td>Slower: signing, install, cable or Wi-Fi</td>
			</tr>
	</tbody>
</table>
<p>So I build and debug logic in the simulator, and before a release I run the app on the oldest iPhone I still support. Performance, memory pressure and anything that touches hardware haven&rsquo;t really been tested until they&rsquo;ve run on a device.</p>

<h3 class="relative group">Running on a Device
    <div id="running-on-a-device" 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="#running-on-a-device" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>Plug the iPhone in, pick it as the run destination, and set a team under Signing &amp; Capabilities (a free Apple ID is enough for your own phone). Since iOS 16 the phone also needs Developer Mode turned on under Settings → Privacy &amp; Security, which restarts it the first time. Once it&rsquo;s on, the phone&rsquo;s own Settings → Developer menu has a Network Link Conditioner with the same bad-network profiles as the Mac&rsquo;s.</p>

<h3 class="relative group">Wireless Debugging
    <div id="wireless-debugging" 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="#wireless-debugging" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>Plug the device in over USB once, go to Window → Devices and Simulators, tick &ldquo;Connect via network&rdquo;, then unplug. The device stays available in Xcode as long as you&rsquo;re on the same WiFi.</p>

<h3 class="relative group">Device Console
    <div id="device-console" 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="#device-console" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>Window → Devices and Simulators → Open Console. This shows everything: system logs, app logs, crash reports. Far more detail than Xcode&rsquo;s own console.</p>
<p>When a tester tells me &ldquo;the app crashed&rdquo;, I always ask them to plug their device in so I can grab the console logs. There&rsquo;s often a system-level error sitting right there that explains the whole thing.</p>

<h2 class="relative group">What I Actually Do When Debugging
    <div id="what-i-actually-do-when-debugging" 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-actually-do-when-debugging" aria-label="Anchor">#</a>
    </span>
    
</h2>
<ol>
<li>Add an exception breakpoint if there isn&rsquo;t one already</li>
<li>Reproduce the bug</li>
<li>Set a breakpoint near where I think it&rsquo;s going wrong</li>
<li>Check values with <code>po</code> in LLDB</li>
<li>Modify variables to test fixes without recompiling</li>
<li>Check the view hierarchy if it&rsquo;s UI-related</li>
<li>Profile with Instruments if it&rsquo;s performance-related</li>
<li>Turn on the sanitizers if it&rsquo;s memory or threading</li>
</ol>
<p>The trick is working backwards from the symptom. Crash? Exception breakpoint. Slow? Time Profiler. Memory? Instruments. Weird UI? View debugger.</p>

<h2 class="relative group">Tips I Wish I&rsquo;d Known Earlier
    <div id="tips-i-wish-id-known-earlier" 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="#tips-i-wish-id-known-earlier" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>Use assertions. They catch logic errors before they turn into bugs:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-swift" data-lang="swift"><span class="line"><span class="cl"><span class="bp">assert</span><span class="p">(</span><span class="n">users</span><span class="p">.</span><span class="bp">count</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">,</span> <span class="s">&#34;Users array should never be empty here&#34;</span><span class="p">)</span></span></span></code></pre></div></div>
<p>Commit before you start debugging. Once you&rsquo;re changing things to test theories, you&rsquo;ll want a clean point to revert to.</p>
<p>Delete your print statements when you&rsquo;re done, or at least wrap them in <code>#if DEBUG</code>. Old debug logs make the next bug harder to see.</p>
<p>Learn LLDB properly. It&rsquo;s faster than rebuilding your app twenty times.</p>
<p>And remember the simulator is not a real phone. Bugs that only appear on device are almost always threading or memory. Or performance, because the simulator runs on your Mac&rsquo;s CPU rather than an actual iPhone chip.</p>

<h2 class="relative group">Tools I Keep Open
    <div id="tools-i-keep-open" 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="#tools-i-keep-open" aria-label="Anchor">#</a>
    </span>
    
</h2>
<ul>
<li>Xcode, obviously</li>
<li>Console.app filtered to my app&rsquo;s subsystem</li>
<li>Proxyman for network debugging</li>
<li>Instruments when things get serious</li>
</ul>
<hr>
<p>No single tool covers everything. The view debugger won&rsquo;t find a memory leak, and Instruments won&rsquo;t untangle an Auto Layout conflict, so most of the skill is matching the tool to the symptom. That part just comes with repetition: after enough retain cycles you start to smell them from the stack trace alone. Debugging never becomes fun exactly, but it does stop being miserable.</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-02-26-debugging-ios-mobile-apps/featured.jpg" medium="image" type="image/jpeg" />
    </item>
  </channel>
</rss>
