<?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>Small Teams · Jared Lynskey</title>
    <link>https://jared.lynskey.co.nz/en/tags/small-teams/</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/small-teams/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Monitoring Django on Fly.io: The DevOps Tools I Kept and Dropped</title>
      <link>https://jared.lynskey.co.nz/en/posts/2026/2026-10-22-monitoring-tools-two-products/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/en/posts/2026/2026-10-22-monitoring-tools-two-products/</guid>
      <pubDate>Thu, 22 Oct 2026 00:00:00 +1300</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>DevOps</category>
      <category>Monitoring</category>
      <category>Observability</category>
      <category>Site Reliability</category>
      <category>Fly.io</category>
      <category>Django</category>
      <category>Cloudflare</category>
      <category>Sentry</category>
      <category>Crash Reporting</category>
      <category>Small Teams</category>
      <category>Tech Stack</category>
      <description>Monitoring two small Django apps on Fly.io for about US$2 a month: errors to chat, logs in S3 via DuckDB, Fly&#39;s metrics, crash reports, and what I dropped.</description>
      <content:encoded><![CDATA[<p>For two small products, a marketplace and community app I build and an estimating app for tradespeople, both on Fly.io&rsquo;s cheapest machines, my DevOps monitoring comes down to five things: ERROR logs posted to a chat channel, every log line archived in object storage and queried with DuckDB, Fly&rsquo;s built-in Prometheus metrics, phone crash reports sent through Sentry&rsquo;s open-source SDK to my own API, and one summary post each morning.</p>
<p>There&rsquo;s no hosted APM, no tracing in production and no dashboard I have to remember to open, and the only part that costs extra is the log archive, at about US$2 a month. Here&rsquo;s the whole list, and the things I dropped or stopped trusting, one of which had me chasing an outage no user ever saw.</p>

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

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

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

<h2 class="relative group">Logs: Fly&rsquo;s shipper to S3, queried with DuckDB
    <div id="logs-flys-shipper-to-s3-queried-with-duckdb" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#logs-flys-shipper-to-s3-queried-with-duckdb" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>Fly&rsquo;s own buffer holds a few minutes of output, roughly the last hundred lines, which is fine for watching a deploy and no use for yesterday. So a third Fly app runs Fly&rsquo;s log shipper, which is Vector subscribed to the organisation&rsquo;s log stream, with one change to its S3 sink:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-toml" data-lang="toml"><span class="line"><span class="cl"><span class="p">[</span><span class="nx">sinks</span><span class="p">.</span><span class="nx">aws_s3</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">  <span class="nx">type</span> <span class="p">=</span> <span class="s2">&#34;aws_s3&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">bucket</span> <span class="p">=</span> <span class="s2">&#34;myapp-logs&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">compression</span> <span class="p">=</span> <span class="s2">&#34;gzip&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">key_prefix</span> <span class="p">=</span> <span class="s2">&#34;logs/{{fly.app.name}}/%F/&#34;</span>   <span class="c"># one folder per app per day</span>
</span></span><span class="line"><span class="cl">  <span class="nx">batch</span><span class="p">.</span><span class="nx">timeout_secs</span> <span class="p">=</span> <span class="mi">300</span>                  <span class="c"># a quiet app still lands its lines</span></span></span></code></pre></div></div>
<p>Every line from the marketplace&rsquo;s apps lands in an S3-compatible bucket as gzipped JSON. The shipper is one shared-cpu-1x machine with 256 MB, about US$2 a month plus pennies of storage. DuckDB reads the files where they sit through one macro, and a day comes back in about a second:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="cl"><span class="k">CREATE</span><span class="w"> </span><span class="n">MACRO</span><span class="w"> </span><span class="n">logs</span><span class="p">(</span><span class="n">app</span><span class="p">,</span><span class="w"> </span><span class="k">day</span><span class="w"> </span><span class="p">:</span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;*&#39;</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="k">TABLE</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">read_json_auto</span><span class="p">(</span><span class="s1">&#39;s3://myapp-logs/logs/&#39;</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="n">app</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="s1">&#39;/&#39;</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="k">day</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="s1">&#39;/*.log.gz&#39;</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">                               </span><span class="n">union_by_name</span><span class="w"> </span><span class="p">:</span><span class="o">=</span><span class="w"> </span><span class="k">true</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="k">timestamp</span><span class="p">,</span><span class="w"> </span><span class="n">message</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">logs</span><span class="p">(</span><span class="s1">&#39;myapp-api&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;2026-09-06&#39;</span><span class="p">)</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">message</span><span class="w"> </span><span class="k">LIKE</span><span class="w"> </span><span class="s1">&#39;% 500 %&#39;</span><span class="p">;</span></span></span></code></pre></div></div>
<p>That macro is how I measured the cold starts in <a href="https://jared.lynskey.co.nz/en/posts/2026/2026-09-09-scale-to-zero-504s/" >the scale-to-zero post</a>: the startup script announces each phase, every line carries the machine&rsquo;s id, and the rest is a <code>GROUP BY</code>.</p>
<p>So it&rsquo;s three levels of one stream, by who reads them: the archive keeps INFO and up, which is why the apps log at INFO in production, a live-tail script shows warnings and errors, and the chat channel gets ERROR. None of them knows the caller, though. The access log has no client IP or user agent, and for that I go to Cloudflare.</p>

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

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

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

<h2 class="relative group">What I dropped, or stopped believing
    <div id="what-i-dropped-or-stopped-believing" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#what-i-dropped-or-stopped-believing" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>None of this was a paid tool. Mostly it was things I&rsquo;d been treating as signals that weren&rsquo;t, and one job running in the wrong place.</p>

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

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

<h3 class="relative group">A scheduled job in CI
    <div id="a-scheduled-job-in-ci" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#a-scheduled-job-in-ci" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>The morning summary started as a CI workflow on a cron schedule and moved within a day. Once it read the analytics and search data, the key it needed was the analytics project&rsquo;s service-account key, which can do anything in that project, and I didn&rsquo;t want that sitting in CI secrets. Now a scheduler on my Mac runs it every 20 minutes, and it posts once it&rsquo;s past eight in Auckland and today&rsquo;s post hasn&rsquo;t gone out, so a Mac asleep at eight posts when it wakes. The workflow stays as a manual fallback, minus the analytics and search sections.</p>

<h2 class="relative group">If I were starting again
    <div id="if-i-were-starting-again" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#if-i-were-starting-again" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>I&rsquo;d set up three things before the first user rather than after the first incident: errors to a chat channel the team already reads, with a rate limit; a log archive in object storage I can query with SQL; and whatever metrics the platform already collects. That&rsquo;s a couple of dollars a month. Crash reporting comes the week an app goes onto phones, because otherwise a crash on a phone tells nobody anything. A morning summary comes once there are enough places to check that I&rsquo;d stop checking them.</p>
<p>I&rsquo;d leave tracing until there&rsquo;s a second service for a request to cross, and a hosted APM until there&rsquo;s a team that needs shared dashboards, an on-call rota and a budget for someone else&rsquo;s bill. That&rsquo;s when the tools in last year&rsquo;s post start paying for themselves. Before then the most useful habit is free: when a number looks alarming, find out what produced it before deciding what broke. Several of the scares in this post came from the monitoring, not the product.</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-10-22-monitoring-tools-two-products/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>Visual Regression Testing with Playwright for Small Teams</title>
      <link>https://jared.lynskey.co.nz/en/posts/2024/2024-06-09-playwright-tests/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/en/posts/2024/2024-06-09-playwright-tests/</guid>
      <pubDate>Sun, 09 Jun 2024 00:30:03 +0000</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Visual Testing</category>
      <category>Small Teams</category>
      <category>UI Testing</category>
      <category>Playwright</category>
      <description>Visual regression testing with Playwright: why screenshot comparisons are the highest-value tests my small team runs.</description>
      <content:encoded><![CDATA[<p>You push a one-line CSS change, and a button on a completely different page quietly falls apart. No test fails, review didn&rsquo;t catch it, and you hear about it from a user a week later. Every small team knows this bug.</p>
<p>The textbook answer is more unit tests. I manage a small dev team, and honestly, unit-testing every component and layout state is a luxury we don&rsquo;t have. What we lean on instead is visual testing: screenshot the pages, compare them against known-good baselines, and flag the build when pixels move that shouldn&rsquo;t. For the effort involved, nothing else I&rsquo;ve tried comes close.</p>

<h2 class="relative group">Why screenshots earn their keep
    <div id="why-screenshots-earn-their-keep" 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-screenshots-earn-their-keep" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>The appeal is everything you don&rsquo;t have to write. A visual test doesn&rsquo;t assert on individual elements; it judges the page as a whole. Layout shifts, colour changes, broken responsive behaviour, overlapping elements — exactly the things that slip through code review and are miserable to cover with unit tests — all show up as a diff right in the PR.</p>
<p>Maintenance is close to nil, too. You set a baseline once, then it runs on every PR with the same result every time. Intentional UI change? Update the baseline and move on. Unintentional one? The diff is sitting in the PR, and nobody had to click through half the site to find it.</p>

<h2 class="relative group">It doesn&rsquo;t replace unit tests
    <div id="it-doesnt-replace-unit-tests" 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="#it-doesnt-replace-unit-tests" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>Unit tests verify logic; screenshots verify appearance. You want both eventually. But if you&rsquo;re small and have to choose where the first hours go, visual tests buy more coverage per hour of setup than anything else. My rule of thumb: unit tests for the business logic that would genuinely hurt, visual tests for the rest.</p>
<p>That&rsquo;s the whole system — baselines, diffs on every PR, and the occasional &ldquo;yes, that was intentional&rdquo; click. It catches more than it has any right to.</p>

<h2 class="relative group">More information and useful links
    <div id="more-information-and-useful-links" 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="#more-information-and-useful-links" aria-label="Anchor">#</a>
    </span>
    
</h2>

<h3 class="relative group">Visual Testing with Playwright
    <div id="visual-testing-with-playwright" 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="#visual-testing-with-playwright" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>Playwright&rsquo;s snapshot testing docs: capture full pages or individual elements, compare against a baseline, and manage baseline updates when the UI changes on purpose.
<a href="https://playwright.dev/docs/test-snapshots"  target="_blank" rel="noreferrer">Visual Testing with Playwright</a></p>

<h3 class="relative group">Best Practices for Testing with Playwright
    <div id="best-practices-for-testing-with-playwright" 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="#best-practices-for-testing-with-playwright" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>Playwright&rsquo;s advice for tests that touch a database: isolate test data, clean up afterwards, and keep tests independent so the suite stays reliable.
<a href="https://playwright.dev/docs/best-practices#testing-with-a-database"  target="_blank" rel="noreferrer">Best Practices for Testing with Playwright</a></p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2024/2024-06-09-playwright-tests/featured.jpg" medium="image" type="image/jpeg" />
    </item>
  </channel>
</rss>
