<?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>Observability · Jared Lynskey</title>
    <link>https://jared.lynskey.co.nz/en/tags/observability/</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/observability/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Cloudflare 504 Errors That Aren&#39;t Real: Early Hints and HTTP 103</title>
      <link>https://jared.lynskey.co.nz/en/posts/2026/cloudflare-504-early-hints/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/en/posts/2026/cloudflare-504-early-hints/</guid>
      <pubDate>Wed, 04 Nov 2026 00:00:00 +1300</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Cloudflare</category>
      <category>Early Hints</category>
      <category>HTTP 103</category>
      <category>504 Gateway Timeout</category>
      <category>Web Performance</category>
      <category>Preload</category>
      <category>Observability</category>
      <category>Debugging</category>
      <description>Thousands of Cloudflare 504 errors, and not one reached a user: they were Early Hints cache misses. How HTTP 103 works, how to tell a fake 504 from a real one, and the header that fixed it.</description>
      <content:encoded><![CDATA[<p>Cloudflare&rsquo;s dashboard said a quarter of my requests were 504s. Not one of them reached a user: they were cache misses in Cloudflare&rsquo;s Early Hints feature, logged as 504 with <code>ClientRequestSource = earlyHintsCache</code> and <code>OriginResponseStatus = 0</code>. Here&rsquo;s how to tell them from real 504s, and the one header that turns the misses into hits.</p>
<p>I first met them in September, when <a href="https://jared.lynskey.co.nz/en/posts/2026/2026-09-09-scale-to-zero-504s/" >a Cloudflare dashboard said 22% of my requests were failing</a> while every health check stayed green; I switched Early Hints off and moved on. In November the same zone showed another 2,600 504s in a day, with Early Hints on again, and this time I wanted to know exactly what they are and whether the right fix was to turn the feature off a second time or to make it do its job. The zone fronts one Fly.io origin serving a Django API, an Expo web app and a Hugo marketing site.</p>

<h2 class="relative group">The short answer
    <div id="the-short-answer" 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-short-answer" aria-label="Anchor">#</a>
    </span>
    
</h2>
<ul>
<li>Those 504s are written by Cloudflare&rsquo;s Early Hints cache, not by your visitors. In Cloudflare&rsquo;s logs they carry <code>ClientRequestSource = earlyHintsCache</code>.</li>
<li>A 504 there means a cache miss: Cloudflare had no hints stored for that URL. A hit is logged as a 2xx.</li>
<li>They never reach your origin. <code>OriginResponseStatus</code> is <code>0</code>.</li>
<li>Early Hints runs on the <code>Link</code> HTTP response header. <code>&lt;link rel=&quot;preload&quot;&gt;</code> tags in your HTML don&rsquo;t feed it.</li>
<li>Send the header, and those misses turn into hits and real <code>103 Early Hints</code> responses on the pages that have something to preload.</li>
</ul>
<p>Three more things worth knowing up front. Hint only the render-critical CSS, scripts and fonts, and keep <code>crossorigin</code> on the fonts. Verify with <code>curl -sv --http2</code>: the <code>103</code> should arrive before the <code>200</code>. And keep <code>earlyHintsCache</code> out of your alerting whatever else you decide; if you&rsquo;ll never send the header, turn the feature off rather than paying for it in log noise.</p>

<h2 class="relative group">How to tell a fake Cloudflare 504 from a real one
    <div id="how-to-tell-a-fake-cloudflare-504-from-a-real-one" 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="#how-to-tell-a-fake-cloudflare-504-from-a-real-one" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>If you use Logpush or Instant Logs, look at two fields before anything else. <code>ClientRequestSource</code> is <code>eyeball</code> for a real visitor&rsquo;s request and <code>earlyHintsCache</code> for the Early Hints lookups. <code>OriginResponseStatus</code> is <code>0</code> when the origin was never contacted.</p>
<p>In the GraphQL Analytics API the same split is the <code>requestSource</code> dimension. This is the query I now run before believing any 5xx spike:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-graphql" data-lang="graphql"><span class="line"><span class="cl"><span class="kd">query</span><span class="w"> </span><span class="p">(</span><span class="nv">$zone</span><span class="p">:</span><span class="w"> </span><span class="nc">String</span><span class="p">!,</span><span class="w"> </span><span class="nv">$since</span><span class="p">:</span><span class="w"> </span><span class="nc">Time</span><span class="p">!)</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="py">viewer</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="py">zones</span><span class="p">(</span><span class="py">filter</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="py">zoneTag</span><span class="p">:</span><span class="w"> </span><span class="nv">$zone</span><span class="w"> </span><span class="p">})</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="py">httpRequestsAdaptiveGroups</span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="py">limit</span><span class="p">:</span><span class="w"> </span><span class="nc">20</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="py">filter</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="py">datetime_geq</span><span class="p">:</span><span class="w"> </span><span class="nv">$since</span><span class="p">,</span><span class="w"> </span><span class="nc">edgeResponseStatus</span><span class="p">:</span><span class="w"> </span><span class="nc">504</span><span class="w"> </span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="py">orderBy</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="py">count_DESC</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="py">count</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="py">dimensions</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="py">requestSource</span><span class="w"> </span><span class="py">userAgent</span><span class="w"> </span><span class="py">clientRequestPath</span><span class="w"> </span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
<p>Grouping by user agent tells the same story. In September the Early Hints lookups showed up as <code>bastion early hints</code> and <code>nginx-ssl early hints</code>. In November they were <code>fl2 early hints</code> and <code>nginx-ssl early hints</code> (FL2 is the newer generation of Cloudflare&rsquo;s proxy). Across 24 hours, all 2,631 of the zone&rsquo;s 504s came from those user agents. None came from a browser, the app, curl or a search crawler.</p>
<p>A real 504 that a visitor actually saw almost always came from your side of Cloudflare. A proxy or load balancer in front of your application gave up waiting: nginx, a cloud load balancer, or a platform proxy waiting for a cold machine to boot. Cloudflare&rsquo;s own timeouts use different codes. <strong>522</strong> means it couldn&rsquo;t connect to your origin, and <strong>524</strong> means it connected but got no response within 125 seconds (the default proxy read timeout on plans below Enterprise).</p>
<p>So if you landed here searching for how to raise Cloudflare&rsquo;s timeout because of 504s: the timeout behind a 504 is almost never Cloudflare&rsquo;s. Check <code>proxy_read_timeout</code> in nginx, your load balancer&rsquo;s idle timeout, or how long your platform takes to wake a machine. A useful clue: if requests fail at a round number well under 125 seconds, such as 30 or 60, it&rsquo;s your own proxy&rsquo;s limit, not Cloudflare&rsquo;s. (Only Enterprise plans can raise Cloudflare&rsquo;s 125-second limit, up to 6,000 seconds, and Cloudflare itself treats that as a stopgap.)</p>

<h2 class="relative group">What those 504s actually are
    <div id="what-those-504s-actually-are" 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-those-504s-actually-are" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>On a zone with Early Hints enabled, every request for an HTML page or an extensionless URL (which, on an API-heavy zone, is most API calls) gets an extra internal lookup: Cloudflare checking its own cache for a set of hints to send. That lookup is logged as its own line, with <code>ClientRequestSource = earlyHintsCache</code>. It isn&rsquo;t an end-user request, and it is never sent to your origin.</p>
<p>The status on that line is the result of the cache lookup, not an error:</p>
<ul>
<li>2xx is a hit. Cloudflare had <code>Link</code> headers stored for that URL and sent them to the browser as a <code>103</code>. On my zone, hits show up as both <code>200</code> and <code>204</code>.</li>
<li>504 is a miss. Nothing was stored, so there was nothing to send early.</li>
</ul>
<p>If your origin never sends the header Early Hints runs on, every lookup is a miss and every request writes a 504 line. In the three hours before I changed anything, the zone logged 173 Early Hints lookups and all 173 were misses. Since the feature had been switched on, it had not served a single hint.</p>
<p>One correction to my September post: I described these as probes that &ldquo;get no response from the Fly origin&rdquo;. That isn&rsquo;t quite right. They never go to the origin at all. The 504 records a cache miss, not a timeout.</p>
<p>Cloudflare documents this, briefly, on its <a href="https://developers.cloudflare.com/cache/advanced-configuration/early-hints/"  target="_blank" rel="noreferrer">Early Hints page</a>: an Early Hints lookup logged with <code>ClientRequestSource: earlyHintsCache</code> is &ldquo;expected and benign&rdquo;, a 200 is a cache hit and a 504 a miss, and Log Explorer hides them with <code>ClientRequestSource != &quot;earlyHintsCache&quot;</code>. Cache Analytics already leaves them out. The HTTP traffic chart that scared me doesn&rsquo;t.</p>

<h2 class="relative group">These fake 504s talked me into a server upgrade
    <div id="these-fake-504s-talked-me-into-a-server-upgrade" 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="#these-fake-504s-talked-me-into-a-server-upgrade" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>This is why the split matters. On 3 November, the day before I started on this post, Cloudflare logged 3,292 504s against about 13,300 visitor requests: an apparent error rate of 25%. The API runs on Fly.io&rsquo;s <code>shared-cpu-1x</code> machines, which are burst-credited. They get a small guaranteed share of a CPU and spend credits to run faster, and a load test in August had shown them throttling to roughly 20 to 30 requests a second under sustained load. &ldquo;A quarter of requests timing out at peak&rdquo; looked exactly like that throttling, so I moved the API to <code>shared-cpu-2x</code>.</p>
<p>Every one of those 504s was an Early Hints cache miss. Here is what the <code>requestSource</code> split and the origin&rsquo;s own logs showed:</p>
<ul>
<li>Every Cloudflare 5xx from 28 October to 4 November was an <code>earlyHintsCache</code> lookup, apart from about one 525 a day (the one I traced came from a security scanner).</li>
<li>The origin&rsquo;s access logs for 24 October to 4 November had no 5xx responses at all, and one request slower than 10 seconds.</li>
<li>Fly&rsquo;s metrics never showed more than about 7 requests a second, and the machine never used more than a fifth of one CPU core. It never got near the throttling ceiling.</li>
</ul>
<p>The bigger machines fixed nothing, because nothing was broken. The API is back on <code>shared-cpu-1x</code>.</p>
<p>Don&rsquo;t make capacity decisions from a 5xx chart you haven&rsquo;t split by request source. Check it against your origin&rsquo;s own logs as well: if Cloudflare reports a quarter of requests failing and your server logged no errors, find out what Cloudflare is counting before you change the server.</p>
<p><em>Updated 5 November 2026: the first version of this section said the API had really fallen over on <code>shared-cpu-1x</code> that week. It hadn&rsquo;t. The numbers above are what I found when I checked.</em></p>

<h2 class="relative group">Is HTTP 103 an error?
    <div id="is-http-103-an-error" 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="#is-http-103-an-error" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>No. Status codes in the 1xx range are informational: interim responses a server can send before the real one. <code>103 Early Hints</code>, defined in RFC 8297, carries <code>Link</code> headers so the browser can start fetching critical CSS, JavaScript and fonts, or open connections to other origins, while the server is still building the page. The final <code>200</code> follows on the same request.</p>
<p>Support is uneven but safe. Chromium-based browsers (Chrome since version 103, and Edge) and current Firefox act on both <code>preload</code> and <code>preconnect</code> hints. Safari (17 and later) uses only <code>preconnect</code>. Browsers process <code>103</code> only over HTTP/2 and HTTP/3. A browser that ignores it simply waits for the <code>200</code> as it always did, so a <code>103</code> is a head start for most visitors and a no-op for the rest.</p>

<h2 class="relative group">Why mine was empty: the Link header vs <code>&lt;link rel=preload&gt;</code>
    <div id="why-mine-was-empty-the-link-header-vs-link-relpreload" 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-mine-was-empty-the-link-header-vs-link-relpreload" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>This is the part that cost me the most time, because it looks like it should already work.</p>
<p>Early Hints caches exactly one thing: the HTTP <strong><code>Link</code> response header</strong> with a <code>rel=preload</code> or <code>rel=preconnect</code> relation. Cloudflare reads it from your normal responses (status <code>200</code>, <code>301</code> or <code>302</code>, on HTML or extensionless URLs), stores it, and replays it to the next visitor as a <code>103</code> before your full response is ready.</p>
<p>My pages already had preloads, but as tags in the HTML <code>&lt;head&gt;</code>:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">link</span> <span class="na">rel</span><span class="o">=</span><span class="s">&#34;preload&#34;</span> <span class="na">href</span><span class="o">=</span><span class="s">&#34;/_expo/static/css/global-7d77bd.css&#34;</span> <span class="na">as</span><span class="o">=</span><span class="s">&#34;style&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">link</span> <span class="na">rel</span><span class="o">=</span><span class="s">&#34;preload&#34;</span> <span class="na">href</span><span class="o">=</span><span class="s">&#34;/_expo/static/js/web/entry-fd7fa4.js&#34;</span> <span class="na">as</span><span class="o">=</span><span class="s">&#34;script&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">link</span> <span class="na">rel</span><span class="o">=</span><span class="s">&#34;preload&#34;</span> <span class="na">href</span><span class="o">=</span><span class="s">&#34;/fonts/manrope-latin.woff2&#34;</span> <span class="na">as</span><span class="o">=</span><span class="s">&#34;font&#34;</span> <span class="na">type</span><span class="o">=</span><span class="s">&#34;font/woff2&#34;</span> <span class="na">crossorigin</span><span class="p">&gt;</span></span></span></code></pre></div></div>
<p>Those tags still do something: the browser acts on them. But it only sees them after it has received the HTML and started parsing it, which is exactly the moment Early Hints exists to beat. The Expo export and my Hugo theme both write these tags for you, and neither sets the header. So the feature was enabled, the preloads were declared, and the two never met.</p>

<h2 class="relative group">The fix: send the header you already have the data for
    <div id="the-fix-send-the-header-you-already-have-the-data-for" 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-fix-send-the-header-you-already-have-the-data-for" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>The cleanest fix doesn&rsquo;t involve maintaining a hand-written list of critical assets that goes stale every time a bundle hash changes. Each page already declares what it wants preloaded, in those tags. So mirror them into a <code>Link</code> header when you serve the page. The standard library&rsquo;s <code>html.parser</code> copes with quoted and unquoted attributes (Hugo&rsquo;s minifier strips the quotes), so there&rsquo;s no regex to get wrong:</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="kn">from</span> <span class="nn">html.parser</span> <span class="kn">import</span> <span class="n">HTMLParser</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">PreloadLinks</span><span class="p">(</span><span class="n">HTMLParser</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">def</span> <span class="fm">__init__</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="nb">super</span><span class="p">()</span><span class="o">.</span><span class="fm">__init__</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">hints</span> <span class="o">=</span> <span class="p">[]</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">def</span> <span class="nf">handle_starttag</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">tag</span><span class="p">,</span> <span class="n">attrs</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="n">a</span> <span class="o">=</span> <span class="nb">dict</span><span class="p">(</span><span class="n">attrs</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">tag</span> <span class="o">!=</span> <span class="s2">&#34;link&#34;</span> <span class="ow">or</span> <span class="s2">&#34;preload&#34;</span> <span class="ow">not</span> <span class="ow">in</span> <span class="p">(</span><span class="n">a</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;rel&#34;</span><span class="p">)</span> <span class="ow">or</span> <span class="s2">&#34;&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">split</span><span class="p">()</span> <span class="ow">or</span> <span class="ow">not</span> <span class="n">a</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;href&#34;</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">            <span class="k">return</span>
</span></span><span class="line"><span class="cl">        <span class="n">hint</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;&lt;</span><span class="si">{</span><span class="n">a</span><span class="p">[</span><span class="s1">&#39;href&#39;</span><span class="p">]</span><span class="si">}</span><span class="s2">&gt;; rel=preload&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">a</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;as&#34;</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">            <span class="n">hint</span> <span class="o">+=</span> <span class="sa">f</span><span class="s2">&#34;; as=</span><span class="si">{</span><span class="n">a</span><span class="p">[</span><span class="s1">&#39;as&#39;</span><span class="p">]</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">a</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;as&#34;</span><span class="p">)</span> <span class="o">==</span> <span class="s2">&#34;font&#34;</span> <span class="ow">or</span> <span class="s2">&#34;crossorigin&#34;</span> <span class="ow">in</span> <span class="n">a</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="n">hint</span> <span class="o">+=</span> <span class="s2">&#34;; crossorigin&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">hints</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="n">hint</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">link_header</span><span class="p">(</span><span class="n">html</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">limit</span><span class="p">:</span> <span class="nb">int</span> <span class="o">=</span> <span class="mi">12</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">str</span> <span class="o">|</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">parser</span> <span class="o">=</span> <span class="n">PreloadLinks</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="n">parser</span><span class="o">.</span><span class="n">feed</span><span class="p">(</span><span class="n">html</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="s2">&#34;, &#34;</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="nb">list</span><span class="p">(</span><span class="nb">dict</span><span class="o">.</span><span class="n">fromkeys</span><span class="p">(</span><span class="n">parser</span><span class="o">.</span><span class="n">hints</span><span class="p">))[:</span><span class="n">limit</span><span class="p">])</span> <span class="ow">or</span> <span class="kc">None</span></span></span></code></pre></div></div>
<p>And on the HTML response, in Django:</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">response</span> <span class="o">=</span> <span class="n">HttpResponse</span><span class="p">(</span><span class="n">html</span><span class="p">,</span> <span class="n">content_type</span><span class="o">=</span><span class="s2">&#34;text/html&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="n">link</span> <span class="o">:=</span> <span class="n">link_header</span><span class="p">(</span><span class="n">html</span><span class="p">))</span> <span class="ow">and</span> <span class="s2">&#34;Link&#34;</span> <span class="ow">not</span> <span class="ow">in</span> <span class="n">response</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">response</span><span class="p">[</span><span class="s2">&#34;Link&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="n">link</span></span></span></code></pre></div></div>
<p>One detail bit me, and it&rsquo;s worth keeping: keep <code>crossorigin</code> on fonts. A font preload without it is treated as a different request, so the font gets downloaded twice and the page gets slower, not faster.</p>
<p>You don&rsquo;t write the <code>103</code> yourself. There&rsquo;s no ASGI, Granian or uvicorn interim-response code to add. Cloudflare builds the <code>103</code> from your header; your only job is to send the header.</p>

<h2 class="relative group">On other stacks
    <div id="on-other-stacks" 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="#on-other-stacks" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>The rule is the same everywhere: put a <code>Link</code> header on the HTML response and let Cloudflare do the rest.</p>
<ul>
<li>
<p>nginx, on the location that serves your HTML:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-nginx" data-lang="nginx"><span class="line"><span class="cl"><span class="k">location</span> <span class="p">=</span> <span class="s">/</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="kn">add_header</span> <span class="s">Link</span> <span class="s">&#34;&lt;/css/app.css&gt;</span><span class="p">;</span> <span class="kn">rel=preload</span><span class="p">;</span> <span class="kn">as=style&#34;</span> <span class="s">always</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="kn">add_header</span> <span class="s">Link</span> <span class="s">&#34;&lt;/fonts/inter.woff2&gt;</span><span class="p">;</span> <span class="kn">rel=preload</span><span class="p">;</span> <span class="kn">as=font</span><span class="p">;</span> <span class="kn">crossorigin&#34;</span> <span class="s">always</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
</li>
<li>
<p>Laravel with Vite already has middleware for this, <code>AddLinkHeadersForPreloadedAssets</code>, which turns Vite&rsquo;s preloads into <code>Link</code> headers. Two issues reported against it are the same traps as above: its <code>modulepreload</code> hints have gone out without <code>crossorigin</code>, so browsers can discard them and download the script twice, and on large pages the header has grown past Apache&rsquo;s roughly 8 KB header limit. Check both on your version.</p>
</li>
<li>
<p>Anything else: set one <code>Link</code> header, comma-separated, or several <code>Link</code> headers. Either works.</p>
</li>
</ul>
<p>What to hint is where people overdo it. Hint the render-critical CSS, the entry script and the one or two fonts above the fold, not every chunk. A long list makes the browser fetch things it doesn&rsquo;t need yet, ahead of things it does. I cap mine at twelve, and only because the Expo export already curates its preload list.</p>
<p>If you&rsquo;re not behind Cloudflare, or your CDN doesn&rsquo;t do Early Hints, the <code>Link</code> header still helps a little: the browser can start those fetches as soon as the response headers arrive, before it parses the body. But no <code>103</code> is sent unless your server or CDN emits one.</p>

<h2 class="relative group">How to check it&rsquo;s working
    <div id="how-to-check-its-working" 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="#how-to-check-its-working" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p><code>curl</code> shows the <code>103</code> directly. Ask for HTTP/2 and keep only the status lines and <code>Link</code> headers:</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">$ curl -sv --http2 -o /dev/null https://example.com/register 2&gt;&amp;1 | grep -E &#39;^&lt; (HTTP|link)&#39;
</span></span><span class="line"><span class="cl">&lt; HTTP/2 103
</span></span><span class="line"><span class="cl">&lt; link: &lt;/_expo/static/css/reset-f5d396.css&gt;; as=style; rel=preload, …
</span></span><span class="line"><span class="cl">&lt; HTTP/2 200
</span></span><span class="line"><span class="cl">&lt; link: &lt;/_expo/static/css/reset-f5d396.css&gt;; rel=preload; as=style, …</span></span></code></pre></div></div>
<p>The <code>103</code> arrives first, then the <code>200</code>. Cloudflare reorders the attributes in the hint, which is harmless. Don&rsquo;t expect every request to get a <code>103</code>. Two of my first three did. The first visit after a change only stores the header, and Cloudflare skips the <code>103</code> when the final response headers are already available.</p>
<p>Then go back to the analytics query above and filter on <code>requestSource: &quot;earlyHintsCache&quot;</code>, grouped by status and path. On the pages you fixed, misses should give way to hits.</p>

<h2 class="relative group">What changed, and the caveat
    <div id="what-changed-and-the-caveat" 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-changed-and-the-caveat" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>Minutes after the header went live, the zone logged its first Early Hints hits ever, and <code>curl</code> showed real <code>103</code> responses on the web pages. One header fixed both problems: the feature started working, and a slice of the &ldquo;504s&rdquo; went away.</p>
<p>The caveat is that a JSON API has nothing to preload. The API endpoints keep missing and keep writing <code>504</code> lines, and on an API-heavy zone that&rsquo;s still most of the volume. You have three sensible options. Send the header on the pages that benefit and accept that API paths keep logging misses; the feature works, and the charts are quieter but not silent. Exclude <code>earlyHintsCache</code> from your 5xx alerting and dashboards whatever else you decide, because an internal cache lookup should never page anyone. Or turn Early Hints off (Speed → Settings → Content Optimization in the dashboard, or the zone&rsquo;s <code>early_hints</code> setting in the API) if you&rsquo;ll never send <code>Link</code> headers; a single-page app whose HTML is tiny may get little from it, and then every lookup stops and the noise goes with it.</p>
<p>I kept it on. The web pages now get a head start, the API&rsquo;s misses are filtered out of everything that alerts, and that&rsquo;s what I should have done in September instead of switching the feature off.</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/cloudflare-504-early-hints/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <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>Monitoring for Developers: Metrics, Logs, Traces, and CloudWatch vs Datadog</title>
      <link>https://jared.lynskey.co.nz/en/posts/2025/2025-10-13-devops-monitoring/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/en/posts/2025/2025-10-13-devops-monitoring/</guid>
      <pubDate>Sun, 12 Oct 2025 00:00:00 +0000</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>DevOps</category>
      <category>Monitoring</category>
      <category>Observability</category>
      <category>CloudWatch</category>
      <category>Datadog</category>
      <category>Developer Experience</category>
      <category>Site Reliability</category>
      <description>Monitoring for developers: how to link metrics, logs and traces, write alerts that say where to start looking, and when CloudWatch is enough and Datadog earns its price.</description>
      <content:encoded><![CDATA[<p>A monitoring system for developers has one job: when something breaks, tell someone fast, and show them what broke and where. That takes metrics, logs and traces linked to each other, alerts that point at a place to start, and dashboards people open without being asked. The tool comes second; on AWS, CloudWatch covers a small team, and Datadog earns its price once you&rsquo;re big.</p>
<p>There&rsquo;s a gap between &ldquo;works on my machine&rdquo; and &ldquo;works in production&rdquo;, and monitoring is supposed to bridge it. Most of the time it doesn&rsquo;t, because it gets built as an ops tool rather than something a developer reaches for when things break.</p>
<p>I&rsquo;ve just gone freelance doing DevOps and infrastructure work, and the monitoring setup is one of the first things I look at anywhere I land — it tells you more about a team than the README does. The failure mode is nearly always the same: the tools exist, but they don&rsquo;t connect to how developers actually work. Someone burns three hours on a bug that production logs would&rsquo;ve solved in five minutes. Failures arrive as support tickets instead of alerts. Or the opposite: 200 alerts a day, most of them noise, so everyone ignores all of them, and the first half hour of every incident is spent working out <em>what</em> broke rather than fixing it.</p>
<p>This is the general version. For the specifics, I&rsquo;ve since written up <a href="https://jared.lynskey.co.nz/en/posts/2026/2026-10-22-monitoring-tools-two-products/" >what I actually run today</a> on two small products.</p>

<h2 class="relative group">Metrics, logs, and traces — linked, or useless
    <div id="metrics-logs-and-traces--linked-or-useless" 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-logs-and-traces--linked-or-useless" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>Metrics tell you something is wrong (error rate spiked, latency jumped). Logs tell you what happened (stack traces, request payloads, error messages). Traces tell you where (which service, which endpoint, which database call). None of that is news. What matters is whether they&rsquo;re linked: when an alert fires, you should be able to click from the metric to the relevant logs to the trace. If developers are manually correlating timestamps across three tools, the setup has failed, no matter how good each tool looks on paper.</p>
<p>Dashboards deserve the same test. CPU, memory and disk I/O matter for capacity planning, but they don&rsquo;t help anyone debug a 500. The dashboards people open voluntarily show business metrics next to technical ones (signups per hour beside error rate), recent deployments overlaid on the timeline, the top five errors in the last hour with links to logs, and latency percentiles — p50, p95, p99 — rather than averages. If nobody opens a dashboard unprompted, it isn&rsquo;t earning its place.</p>
<p>Alerts, same idea. Every alert should answer two questions: what&rsquo;s broken, and where do I start looking?</p>
<p>Bad alert: &ldquo;High CPU on web-server-3&rdquo;</p>
<p>Good alert: &ldquo;Error rate &gt; 5% on /api/payments since 14:32. Last deploy: 14:15 by @sarah. [View logs] [View trace]&rdquo;</p>
<p>A few things push alerts toward the second kind: alert on deviation from a baseline rather than an arbitrary threshold, route by ownership (payment errors go to the payments team, not the whole org), and group related errors into one Slack message instead of 50. Keep the loop tight, too — the time between &ldquo;something broke&rdquo; and &ldquo;a developer knows&rdquo; should be under a minute, which means real-time log streaming rather than five-minute batches, deploy markers on every dashboard, and tracing that survives service boundaries.</p>

<h2 class="relative group">Build it for the people who&rsquo;ll use it
    <div id="build-it-for-the-people-wholl-use-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="#build-it-for-the-people-wholl-use-it" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>The biggest mistake ops teams make is building monitoring for themselves. The fix isn&rsquo;t sophisticated. Sit with a developer during a debugging session and watch where they get stuck. Ask what questions come up mid-incident — &ldquo;which service?&rdquo;, &ldquo;what changed?&rdquo;, &ldquo;what did the request look like?&rdquo;. Find out which metrics actually matter for the product; it&rsquo;s rarely CPU, and usually things like checkout completion rate or upload success rate.</p>
<p>Then remove friction. If instrumenting a new service takes a day, it won&rsquo;t happen. Provide libraries that auto-instrument the frameworks you already use (Django, Express, Spring), copy-paste templates for dashboards and alerts, and self-service metrics so nobody has to file a ticket. And treat the whole thing as code: config in version control, alert rules tested so you know they fire when they should, and multi-region considered early if that&rsquo;s where you&rsquo;re headed.</p>
<p>The same habits keep it alive afterwards. Instrumentation goes through code review like any other production code, and a custom metric gets a test. Every postmortem asks what monitoring would have caught this sooner, and the answer goes into the dashboards and alerts that week rather than into the write-up. And a few times a year I go through what&rsquo;s there and delete what nobody opens, because an alert nobody acts on is training people to ignore the next one.</p>

<h2 class="relative group">Small team on AWS? Start with CloudWatch
    <div id="small-team-on-aws-start-with-cloudwatch" 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="#small-team-on-aws-start-with-cloudwatch" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>If you&rsquo;re under 50 engineers and on AWS, CloudWatch is the right starting point, and I&rsquo;d resist buying anything fancier on day one. EC2, Lambda, RDS and ECS all report to it automatically, so you get visibility without writing instrumentation. It runs $10–50 a month for a small team with no platform fee, and metrics, logs, traces (via X-Ray) and alarms all live in one place. You can have meaningful alerts and dashboards up in an afternoon, not weeks.</p>
<p>CloudWatch Logs Insights is the underrated bit — you can query logs without standing up Elasticsearch:</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">fields @timestamp, @message
</span></span><span class="line"><span class="cl">| filter @message like /ERROR/
</span></span><span class="line"><span class="cl">| stats count() by bin(5m)</span></span></code></pre></div></div>
<p>Beyond that:</p>
<ul>
<li>Composite alarms cut noise — alert when error rate is high <em>and</em> response time is degraded, not on each condition separately.</li>
<li>Custom metrics are where the real value is. Push business metrics (signups, transactions, feature usage) through the CloudWatch SDK alongside the infrastructure ones.</li>
<li>CloudWatch Synthetics runs scheduled canaries through real user journeys, so you learn the checkout is broken before your users do.</li>
<li>X-Ray gives you distributed tracing with minimal setup — good enough for most microservice architectures.</li>
</ul>
<p>You&rsquo;ll know when you&rsquo;ve outgrown it: you go multi-cloud, you want proper anomaly detection, dashboard customisation starts to hurt, or the team passes 50 engineers and needs real collaboration features.</p>

<h2 class="relative group">Bigger teams: Datadog
    <div id="bigger-teams-datadog" 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="#bigger-teams-datadog" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>Datadog is expensive — $20–100K+ a year for a large org is normal — but at scale it earns its keep. One view across AWS, Azure, GCP, on-prem, containers, serverless, databases and frontend. Watchdog flags anomalies without hand-tuned thresholds, which matters because nobody can manually watch thousands of services. And you get the collaboration layer CloudWatch lacks: team dashboards, RBAC, shared investigation notebooks, PagerDuty/Opsgenie integration, multi-condition alerts, threshold forecasting, maintenance windows, and an APM that goes down to code-level profiling with auto-generated service maps.</p>
<p>Rolling it out, in roughly the order I&rsquo;d do it: instrument critical services first and tag everything by team and environment from day one. Agree naming conventions, dashboard templates, severity levels and SLO definitions early — retrofitting standards is miserable. Wire it into CI/CD deploy markers, incident management and Slack. Budget time for training, because Datadog is powerful but not self-explanatory. And watch the bill: filter noisy metrics, sample traces on high-volume services, and audit which features you&rsquo;re actually paying for.</p>
<p>Worth a look before committing: New Relic (similar, sometimes cheaper for high-volume tracing), Dynatrace (strong AIOps, popular in financial services), Splunk (best-in-class log analysis, especially if security already runs it), and Grafana Cloud (the natural home if you&rsquo;re already on Prometheus/Loki).</p>
<p>In practice a lot of teams land on a hybrid: CloudWatch for AWS-native services because it&rsquo;s automatic and cheap, Datadog for the application layer, with CloudWatch metrics ingested into Datadog for one unified view. It&rsquo;s unglamorous, and it works.</p>

<h2 class="relative group">CloudWatch vs Datadog at a glance
    <div id="cloudwatch-vs-datadog-at-a-glance" 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="#cloudwatch-vs-datadog-at-a-glance" aria-label="Anchor">#</a>
    </span>
    
</h2>
<table>
	<thead>
			<tr>
					<th></th>
					<th>CloudWatch</th>
					<th>Datadog</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Fits best</td>
					<td>Under ~50 engineers, all on AWS</td>
					<td>Bigger orgs, many services, more than one cloud</td>
			</tr>
			<tr>
					<td>Cost shape</td>
					<td>Pay as you go, no platform fee; roughly $10–50 a month for a small team</td>
					<td>$20–100K+ a year is normal for a large org, and the bill needs watching</td>
			</tr>
			<tr>
					<td>Setup effort</td>
					<td>EC2, Lambda, RDS and ECS report automatically; useful alerts in an afternoon</td>
					<td>Instrument critical services, agree tags and naming early, budget time for training</td>
			</tr>
			<tr>
					<td>Multi-cloud</td>
					<td>Built around AWS; going multi-cloud is a sign you&rsquo;ve outgrown it</td>
					<td>One view across AWS, Azure, GCP, on-prem, containers and serverless</td>
			</tr>
			<tr>
					<td>Anomaly detection</td>
					<td>Mostly alarms you tune yourself; wanting more is another sign</td>
					<td>Watchdog flags anomalies without hand-tuned thresholds</td>
			</tr>
			<tr>
					<td>Collaboration</td>
					<td>Thin; teams past ~50 engineers start to miss it</td>
					<td>Team dashboards, RBAC, shared notebooks, PagerDuty/Opsgenie</td>
			</tr>
			<tr>
					<td>Tracing</td>
					<td>X-Ray, minimal setup</td>
					<td>APM down to code-level profiling, auto-generated service maps</td>
			</tr>
	</tbody>
</table>

<h2 class="relative group">Starting from scratch: the build order
    <div id="starting-from-scratch-the-build-order" 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="#starting-from-scratch-the-build-order" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>The order I&rsquo;d do things in:</p>
<ol>
<li>Talk to developers about what hurts during incidents.</li>
<li>Define SLOs for the flows that matter. What does &ldquo;working&rdquo; mean for login, checkout, search?</li>
<li>Instrument that critical path first.</li>
<li>Add distributed tracing next; it has the best debugging ROI in a microservice setup.</li>
<li>Alert on those flows rather than on CPU, and send each alert to the team that owns it.</li>
<li>Write a runbook for every alert so 3am-you knows what to check.</li>
<li>Put a quarterly review in the calendar to delete stale alerts, fix drifted thresholds, and confirm the dashboards still match the architecture.</li>
</ol>
<p>And the traps I keep pulling teams out of: too many tools (three that work together beat six that don&rsquo;t), graphs with no baseline (is 500 rps normal or a 10x spike?), monitoring things users don&rsquo;t feel (disk I/O on a stateless container), and no redundancy for the monitoring itself — if your alerting dies during an incident, you&rsquo;re blind exactly when you can least afford it.</p>
<p>None of this needs the fanciest tooling. It needs developers to get the information they need, in the place they&rsquo;ll actually look, fast.</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2025/2025-10-13-devops-monitoring/featured.jpeg" medium="image" type="image/jpeg" />
    </item>
  </channel>
</rss>
