<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Slim</title>
    <description>The latest articles on DEV Community by Slim (@slima4).</description>
    <link>https://dev.to/slima4</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1192518%2F65a9f756-9f19-4409-8172-e6c6a681cfdc.png</url>
      <title>DEV Community: Slim</title>
      <link>https://dev.to/slima4</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/slima4"/>
    <language>en</language>
    <item>
      <title>ClickHouse system tables ate my disk (and the fix)</title>
      <dc:creator>Slim</dc:creator>
      <pubDate>Thu, 09 Jul 2026 15:13:13 +0000</pubDate>
      <link>https://dev.to/slima4/clickhouse-system-tables-ate-my-disk-and-the-fix-827</link>
      <guid>https://dev.to/slima4/clickhouse-system-tables-ate-my-disk-and-the-fix-827</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A monitoring alert said I was dropping check results. The disk was 100% full. My actual data was 20 MB. ClickHouse had quietly written 12 GB of logs about itself, mostly &lt;code&gt;system.text_log&lt;/code&gt; and &lt;code&gt;system.trace_log&lt;/code&gt;. Those same logs also burn CPU at idle. The fix is a few lines of ClickHouse config that disable the noisy logs and slow the metrics collector. Full config is below.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The alert
&lt;/h2&gt;

&lt;p&gt;One morning a critical alert fired: &lt;code&gt;UptimepageResultsLost&lt;/code&gt;. Its description is blunt: storage write failures or dropped results, checks run but results are not persisting. Then it did something worse than fire once. It cleared, fired again, cleared, and fired again, over and over.&lt;/p&gt;

&lt;p&gt;I run Uptimepage, an uptime monitoring service. "Results not persisting" means the one thing customers pay for, recording whether their sites are up, might be failing. So it had my full attention.&lt;/p&gt;

&lt;p&gt;The good news first: no data was lost. The write path retries, and every failed write was caught by a retry. I keep a counter for results that actually get dropped, and it stayed at zero the whole time. But something was clearly wrong underneath.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real signal: a full disk
&lt;/h2&gt;

&lt;p&gt;The first real signal came from Postgres, which had crashed and restarted a couple of minutes earlier:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;FATAL: could not write lock file "postmaster.pid": No space left on device
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The disk was 100% full. Postgres could not write its lock file, crashed, and recovered on its own through WAL replay. ClickHouse, &lt;a href="https://uptimepage.dev/blog/postgres-vs-clickhouse-uptime-monitor" rel="noopener noreferrer"&gt;where I store raw check results&lt;/a&gt;, was rejecting inserts with its own version of the same complaint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Code: 243. DB::Exception: Cannot reserve 1.00 MiB, not enough space:
While executing WaitForAsyncInsert. (NOT_ENOUGH_SPACE)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the alert was a symptom. The real problem was a full disk. That reframes the question: I do not store much, so what filled it?&lt;/p&gt;

&lt;h2&gt;
  
  
  Leak one: old Docker images
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;docker system df&lt;/code&gt; gave the first half of the answer:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2qai3wbkmvs4chrtqc14.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2qai3wbkmvs4chrtqc14.webp" alt="Docker disk usage broken down: images 16.2 GB, volumes 13.8 GB (Postgres and ClickHouse data), build cache 3.8 GB, containers 2.5 GB"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;88 Docker images, only two of them in use. Every deploy pulls a fresh image, and nothing pruned the old ones, so they piled up for weeks. A &lt;code&gt;docker image prune -af&lt;/code&gt; reclaimed about 10 GB and took the disk off the ceiling.&lt;/p&gt;

&lt;p&gt;That stopped the bleeding. But 13.8 GB of volumes is a lot for a service whose data I thought was tiny. That number turned out to be the real story.&lt;/p&gt;

&lt;h2&gt;
  
  
  Leak two: ClickHouse logging about itself
&lt;/h2&gt;

&lt;p&gt;I went into ClickHouse and asked the obvious question, which table is big:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;database&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;formatReadableSize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bytes_on_disk&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;rows&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="k"&gt;system&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;active&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;database&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bytes_on_disk&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The answer stopped me:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Database&lt;/th&gt;
&lt;th&gt;Size&lt;/th&gt;
&lt;th&gt;Rows&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;system&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;11.8 GiB&lt;/td&gt;
&lt;td&gt;577,340,927&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;monitor&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;19.7 MiB&lt;/td&gt;
&lt;td&gt;1,813,884&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;monitor&lt;/code&gt; is my data: every check result and rollup I keep. About 20 MB. The &lt;code&gt;system&lt;/code&gt; database, ClickHouse's own diagnostic tables, was 11.8 GiB. Nearly all of the storage was ClickHouse logging about itself.&lt;/p&gt;

&lt;p&gt;Breaking &lt;code&gt;system&lt;/code&gt; down by table:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fanojqdlwd6lrdhlj4d4n.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fanojqdlwd6lrdhlj4d4n.webp" alt="ClickHouse system tables ranked by on-disk size: text_log 5.29 GiB, trace_log 3.02 GiB, part_log 1.11 GiB and smaller logs, next to the actual data table at 2.8 MiB"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two tables did most of the damage. &lt;code&gt;system.text_log&lt;/code&gt; (5.29 GiB) is a copy of the server's own log output written into a table. &lt;code&gt;system.trace_log&lt;/code&gt; (3.02 GiB) is the query profiler, which samples running queries. Both are handy when you are actively debugging ClickHouse. Neither is worth multiple gigabytes when I am not. And &lt;code&gt;text_log&lt;/code&gt; is off by default, so something in my setup had switched it on.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Two parts: reclaim the space now, and stop it coming back.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reclaim now.&lt;/strong&gt; The system log tables are throwaway diagnostics, not real data. Truncate them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;TRUNCATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="k"&gt;system&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text_log&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;TRUNCATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="k"&gt;system&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;trace_log&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- and the rest of the system.*_log tables&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That gave back 12 GB at once and took the disk from 74% down to 41%.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stop the regrowth.&lt;/strong&gt; Truncating is a one-time cleanup. Without a cap, the logs fill right back up. The durable fix is ClickHouse config, added under &lt;code&gt;/etc/clickhouse-server/config.d/&lt;/code&gt;. I watch ClickHouse through Grafana, not these tables. So I disable almost all of them and keep only &lt;code&gt;query_log&lt;/code&gt; and &lt;code&gt;part_log&lt;/code&gt;, both bounded, for the rare hands-on debugging session:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;clickhouse&amp;gt;&lt;/span&gt;
    &lt;span class="c"&gt;&amp;lt;!-- Sample async metrics every 60s, not every second. --&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;asynchronous_metrics_update_period_s&amp;gt;&lt;/span&gt;60&lt;span class="nt"&gt;&amp;lt;/asynchronous_metrics_update_period_s&amp;gt;&lt;/span&gt;

    &lt;span class="c"&gt;&amp;lt;!-- Disable the log tables. remove="1" on an absent table is a no-op,
         so this list is safe to paste as-is across ClickHouse versions. --&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;asynchronous_metric_log&lt;/span&gt; &lt;span class="na"&gt;remove=&lt;/span&gt;&lt;span class="s"&gt;"1"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;asynchronous_insert_log&lt;/span&gt; &lt;span class="na"&gt;remove=&lt;/span&gt;&lt;span class="s"&gt;"1"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;backup_log&lt;/span&gt; &lt;span class="na"&gt;remove=&lt;/span&gt;&lt;span class="s"&gt;"1"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;error_log&lt;/span&gt; &lt;span class="na"&gt;remove=&lt;/span&gt;&lt;span class="s"&gt;"1"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;crash_log&lt;/span&gt; &lt;span class="na"&gt;remove=&lt;/span&gt;&lt;span class="s"&gt;"1"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;metric_log&lt;/span&gt; &lt;span class="na"&gt;remove=&lt;/span&gt;&lt;span class="s"&gt;"1"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;query_metric_log&lt;/span&gt; &lt;span class="na"&gt;remove=&lt;/span&gt;&lt;span class="s"&gt;"1"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;query_thread_log&lt;/span&gt; &lt;span class="na"&gt;remove=&lt;/span&gt;&lt;span class="s"&gt;"1"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;query_views_log&lt;/span&gt; &lt;span class="na"&gt;remove=&lt;/span&gt;&lt;span class="s"&gt;"1"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;session_log&lt;/span&gt; &lt;span class="na"&gt;remove=&lt;/span&gt;&lt;span class="s"&gt;"1"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;text_log&lt;/span&gt; &lt;span class="na"&gt;remove=&lt;/span&gt;&lt;span class="s"&gt;"1"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;trace_log&lt;/span&gt; &lt;span class="na"&gt;remove=&lt;/span&gt;&lt;span class="s"&gt;"1"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;opentelemetry_span_log&lt;/span&gt; &lt;span class="na"&gt;remove=&lt;/span&gt;&lt;span class="s"&gt;"1"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;zookeeper_log&lt;/span&gt; &lt;span class="na"&gt;remove=&lt;/span&gt;&lt;span class="s"&gt;"1"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;processors_profile_log&lt;/span&gt; &lt;span class="na"&gt;remove=&lt;/span&gt;&lt;span class="s"&gt;"1"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;latency_log&lt;/span&gt; &lt;span class="na"&gt;remove=&lt;/span&gt;&lt;span class="s"&gt;"1"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;background_schedule_pool_log&lt;/span&gt; &lt;span class="na"&gt;remove=&lt;/span&gt;&lt;span class="s"&gt;"1"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

    &lt;span class="c"&gt;&amp;lt;!-- Keep query_log and part_log, bounded, for on-hand debugging. --&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;query_log&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;ttl&amp;gt;&lt;/span&gt;event_date + INTERVAL 3 DAY DELETE&lt;span class="nt"&gt;&amp;lt;/ttl&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;max_size_rows&amp;gt;&lt;/span&gt;1048576&lt;span class="nt"&gt;&amp;lt;/max_size_rows&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/query_log&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;part_log&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;ttl&amp;gt;&lt;/span&gt;event_date + INTERVAL 3 DAY DELETE&lt;span class="nt"&gt;&amp;lt;/ttl&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;max_size_rows&amp;gt;&lt;/span&gt;1048576&lt;span class="nt"&gt;&amp;lt;/max_size_rows&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/part_log&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/clickhouse&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;remove="1"&lt;/code&gt; disables a system log completely. The &lt;code&gt;&amp;lt;ttl&amp;gt;&lt;/code&gt; element on &lt;code&gt;query_log&lt;/code&gt; and &lt;code&gt;part_log&lt;/code&gt; adds a TTL and keeps the table's default partitioning, so you do not have to restate the whole engine. ClickHouse picks up the change after a restart. Altinity's "system tables ate my disk" note covers the same ground and is worth a read. If you would rather keep the diagnostics, give every log a short &lt;code&gt;&amp;lt;ttl&amp;gt;&lt;/code&gt; instead of disabling it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Bonus: lower CPU, not just disk&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Those log tables are written constantly, and on a small server that steady write load shows up as CPU. A long-running ClickHouse issue tracks the server using noticeable CPU at zero load, &lt;a href="https://github.com/ClickHouse/ClickHouse/issues/60016" rel="noopener noreferrer"&gt;#60016&lt;/a&gt;. People there report dropping from 40 to 70% CPU down to about 1.5% after disabling the logs and slowing the async-metrics collector. So those two settings, the &lt;code&gt;asynchronous_metrics_update_period_s&lt;/code&gt; line and the &lt;code&gt;remove="1"&lt;/code&gt; block, pay off twice: less disk and less CPU.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The real lesson: alert on the cause, not the effect
&lt;/h2&gt;

&lt;p&gt;Here is the part that stings. I had an alert for "results are being dropped." I did not have an alert for "the disk is filling up." So a full disk is a slow, predictable problem that builds over days. But it only reached me as a sudden downstream symptom, after Postgres had already crashed once.&lt;/p&gt;

&lt;p&gt;A downstream alert like "results lost" is not a substitute for watching the resource that actually runs out. I added the missing one: a plain host disk-space alert on &lt;code&gt;node_filesystem_avail_bytes&lt;/code&gt;, firing at 80% and 90% used, well before anything starts failing. That is the alert that should have caught this on day one.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Key takeaways&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;ClickHouse system log tables are unbounded by default and can dwarf your real data. Mine were 11.8 GB against 20 MB of actual data.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;system.text_log&lt;/code&gt; and &lt;code&gt;system.trace_log&lt;/code&gt; are the usual offenders. &lt;code&gt;text_log&lt;/code&gt; is off by default, so check whether something enabled it.&lt;/li&gt;
&lt;li&gt;Cap them in config: &lt;code&gt;remove="1"&lt;/code&gt; to disable a log, or &lt;code&gt;&amp;lt;ttl&amp;gt;&lt;/code&gt; plus &lt;code&gt;&amp;lt;max_size_rows&amp;gt;&lt;/code&gt; to bound the ones you keep. Truncate to reclaim space right away.&lt;/li&gt;
&lt;li&gt;It is not just disk. The same logs burn CPU at idle on small servers. Disabling them, plus &lt;code&gt;asynchronous_metrics_update_period_s = 60&lt;/code&gt;, took reporters in ClickHouse issue #60016 from 40 to 70% CPU down to about 1.5%.&lt;/li&gt;
&lt;li&gt;Anything that pulls artifacts on a schedule, Docker images in my case, needs matching cleanup or it becomes a slow disk leak.&lt;/li&gt;
&lt;li&gt;Alert on the cause (disk space), not only the effect (dropped writes). The cause gives you days of warning; the effect gives you minutes.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;p&gt;This happened on &lt;a href="https://uptimepage.dev/blog/clickhouse-system-tables-filled-disk" rel="noopener noreferrer"&gt;Uptimepage&lt;/a&gt;, the uptime monitor I run and dogfood.&lt;/p&gt;

</description>
      <category>clickhouse</category>
      <category>database</category>
      <category>observability</category>
      <category>devops</category>
    </item>
    <item>
      <title>Postgres vs ClickHouse? I use both. 4 tricks from the split.</title>
      <dc:creator>Slim</dc:creator>
      <pubDate>Thu, 09 Jul 2026 05:55:03 +0000</pubDate>
      <link>https://dev.to/slima4/postgres-vs-clickhouse-i-use-both-4-tricks-from-the-split-4420</link>
      <guid>https://dev.to/slima4/postgres-vs-clickhouse-i-use-both-4-tricks-from-the-split-4420</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR.&lt;/strong&gt; My uptime monitor keeps config and incidents in Postgres and check results in ClickHouse. The split is one rule: does a row ever change? Config gets edited, so it wants Postgres transactions and constraints. A check result is written once and never touched again, so it goes to ClickHouse, where the right codec, a careful sort key, and a per-row TTL make billions of rows cheap. Four tricks and a bonus below, useful even if you only ever run one database.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Every monitor I run writes a row every 20 seconds, from every region, and never stops. One monitor is about 4,300 rows a day, per region. Multiply by every monitor on the platform and the rows only ever go up.&lt;/p&gt;

&lt;p&gt;That stream would slowly crush a normal Postgres table, and watching it is the whole product. So the check results do not live in Postgres. They live in ClickHouse. Everything else, the monitors and incidents and teams, lives in Postgres. The interesting part is the line between them.&lt;/p&gt;

&lt;p&gt;People ask why not one database. "Postgres vs ClickHouse" is the wrong question, because the two are not fighting over the same job. Here is the line I draw, and the one rule under all of it: split your data by how it is written, not by which engine is faster.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Does the row change? That picks the database
&lt;/h2&gt;

&lt;p&gt;Forget benchmarks for a second. One question sorts a table into one store or the other: after you write a row, will you ever change it?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;one monitor, two stores:

  you edit a monitor  -&amp;gt;  Postgres   (targets, incidents, team)
                          the row changes, has constraints, lives in a transaction

  a probe checks it   -&amp;gt;  ClickHouse (check_results)
                          one row, written once, never updated
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A monitor is a row that changes. You toggle it off, edit the URL, change the interval. So it lives in Postgres:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;targets&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt;            &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;          &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;check_spec&lt;/span&gt;    &lt;span class="n"&gt;JSONB&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;interval_secs&lt;/span&gt; &lt;span class="nb"&gt;INTEGER&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;interval_secs&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;enabled&lt;/span&gt;       &lt;span class="nb"&gt;BOOLEAN&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;updated_at&lt;/span&gt;    &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;CHECK&lt;/code&gt; means a 3-second interval can never reach the table, no matter which part of the app tried to write it. This is Postgres doing the thing it is best at: a small set of rows that must stay correct while many callers change them at once.&lt;/p&gt;

&lt;p&gt;A check result is the opposite. It is written once when a probe finishes, and then it never changes. Nothing ever updates it, so it does not need any of that:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;check_results&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;org_id&lt;/span&gt;      &lt;span class="n"&gt;UUID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;target_id&lt;/span&gt;   &lt;span class="n"&gt;UUID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;region&lt;/span&gt;      &lt;span class="n"&gt;LowCardinality&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nb"&gt;timestamp&lt;/span&gt;   &lt;span class="nb"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'UTC'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;CODEC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DoubleDelta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ZSTD&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
    &lt;span class="n"&gt;status&lt;/span&gt;      &lt;span class="n"&gt;Enum8&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'up'&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'down'&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'degraded'&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'error'&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;duration_ms&lt;/span&gt; &lt;span class="n"&gt;UInt32&lt;/span&gt; &lt;span class="n"&gt;CODEC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;T64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ZSTD&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;ENGINE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;MergeTree&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;updated_at&lt;/code&gt;, no foreign key, no transaction. Just an append-only stream that grows forever. That is the shape ClickHouse is built for and the shape that slowly hurts Postgres.&lt;/p&gt;

&lt;p&gt;Notice the types too. &lt;code&gt;status&lt;/code&gt; is an &lt;code&gt;Enum8&lt;/code&gt;, one byte on disk, not the string &lt;code&gt;"up"&lt;/code&gt;. &lt;code&gt;region&lt;/code&gt; is &lt;code&gt;LowCardinality&lt;/code&gt;, stored once in a dictionary and referenced by a small id instead of repeating the text on every row. On a table that only ever grows, a byte saved per row is a byte saved times billions.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The right codec turns a day of timestamps into almost nothing
&lt;/h2&gt;

&lt;p&gt;Once the results are in a column store, the type on each column is most of the compression, and the default setting wastes a lot of space.&lt;/p&gt;

&lt;p&gt;Look at that timestamp again. A monitor checks every 20 seconds, so for one monitor a day of timestamps is a run of 20, 20, 20, 20, over and over. &lt;code&gt;DoubleDelta&lt;/code&gt; stores the change in the change. For a steady interval the first delta is a constant 20 and the second delta is zero, so each row after the first packs down to about a bit. The codec is not a ClickHouse invention: it comes from Facebook's Gorilla time-series paper, built for exactly this, measurements taken at a steady rate.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="nb"&gt;timestamp&lt;/span&gt;   &lt;span class="nb"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'UTC'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="n"&gt;CODEC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DoubleDelta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ZSTD&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
&lt;span class="n"&gt;duration_ms&lt;/span&gt; &lt;span class="n"&gt;UInt32&lt;/span&gt;           &lt;span class="n"&gt;CODEC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;T64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ZSTD&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
&lt;span class="n"&gt;dns_ms&lt;/span&gt;      &lt;span class="k"&gt;Nullable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;UInt16&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;CODEC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;T64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ZSTD&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I also store the timestamp at whole seconds on purpose. The smallest interval is 20 seconds, so no two checks for one monitor ever land in the same second, and the sub-second detail lives in &lt;code&gt;duration_ms&lt;/code&gt; where it belongs. Whole seconds that step by a fixed amount compress far harder than milliseconds would.&lt;/p&gt;

&lt;p&gt;The latency columns get &lt;code&gt;T64&lt;/code&gt; instead, because they are small integers that stay in a small range, a different shape from a steady clock. &lt;code&gt;T64&lt;/code&gt; crops the unused high bits off a block of values, so a &lt;code&gt;UInt32&lt;/code&gt; that never climbs past a few thousand milliseconds stops paying for all 32 bits. That is the trick worth copying, and it is not the specific codec names. It is that a timestamp ticking by a fixed step and a latency staying in a small range are two different shapes, and telling the database which is which does the work.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Put the tenant in the sort key, not the partition
&lt;/h2&gt;

&lt;p&gt;I got this one wrong first, so learn it from my mistake instead of your own.&lt;/p&gt;

&lt;p&gt;This is a multi-tenant app, so every row carries an &lt;code&gt;org_id&lt;/code&gt; and almost every query filters by it. My first schema partitioned by org, one slot per customer, because it felt tidy. ClickHouse turned that into a flood of tiny parts, the merges could not keep up, and startup got slower the more customers I had. I ripped it out. A partition is not a folder for tidiness. It is a physical unit ClickHouse merges and expires, and you want few large ones, not many small ones.&lt;/p&gt;

&lt;p&gt;Here is what it should be:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;ENGINE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;MergeTree&lt;/span&gt;
&lt;span class="k"&gt;PARTITION&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;toYYYYMMDD&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;org_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;region&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;org_id&lt;/code&gt; leads the &lt;code&gt;ORDER BY&lt;/code&gt;, so a per-org query walks the sorted primary index and reads only that org's slice. But it stays out of &lt;code&gt;PARTITION BY&lt;/code&gt;. The rule I follow now: partition by something low-cardinality that you also delete by, here the day, and put the high-cardinality tenant key in the sort order. Sort key answers "find this org fast". Partition answers "drop old data cheaply", which is the next trick.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Make retention a number in a row, not a schema change
&lt;/h2&gt;

&lt;p&gt;Different plans keep history for different lengths of time. The clumsy way is a migration or a cleanup job per plan. The clean way is to make the retention window a column:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;ttl_days&lt;/span&gt; &lt;span class="n"&gt;UInt16&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt; &lt;span class="n"&gt;CODEC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ZSTD&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;ENGINE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;MergeTree&lt;/span&gt;
&lt;span class="k"&gt;PARTITION&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;toYYYYMMDD&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;org_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;region&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;TTL&lt;/span&gt; &lt;span class="nb"&gt;timestamp&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;toIntervalDay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ttl_days&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each row carries its own &lt;code&gt;ttl_days&lt;/code&gt;, stamped from the org's plan at the moment it is written. A free plan keeps 30 days, a paid plan keeps more, and changing that needs no &lt;code&gt;ALTER&lt;/code&gt;, no migration, no backfill. The next write just stores a different number. The daily partitions line up with the TTL, so ClickHouse expires old data by dropping whole parts, close to free, and a whole column of the same &lt;code&gt;30&lt;/code&gt; compresses away to nothing. The flexibility costs no space.&lt;/p&gt;

&lt;p&gt;One more piece makes reading that history cheap. A materialized view rolls raw checks into per-minute and per-hour summaries as they land:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;MATERIALIZED&lt;/span&gt; &lt;span class="k"&gt;VIEW&lt;/span&gt; &lt;span class="n"&gt;check_results_1m&lt;/span&gt;
&lt;span class="n"&gt;ENGINE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;AggregatingMergeTree&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;org_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;region&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;minute&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt;
    &lt;span class="n"&gt;org_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;region&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;toStartOfMinute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;minute&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;countState&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;                &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;total_checks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;countIfState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'up'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;up_checks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;avgState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;duration_ms&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;       &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;avg_duration_ms&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;check_results&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;org_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;region&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;minute&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So a dashboard showing 30 days across a thousand monitors reads a few thousand minute-buckets, not millions of raw rows. Recent views read the minute rollup, long history reads an hour rollup, and the raw rows underneath expire on their own TTL. The firehose is there when you need to drill into one bad minute, and left alone the rest of the time.&lt;/p&gt;

&lt;h2&gt;
  
  
  One more, because everyone hits it: make inserts idempotent
&lt;/h2&gt;

&lt;p&gt;Here is the trick I wish someone had told me first. My agents batch check results and send them to ClickHouse. A network blip after the server commits but before my side gets the ack means the batch retries and sends the exact same block again. Without protection, that double-counts every row in it, and your uptime numbers quietly drift.&lt;/p&gt;

&lt;p&gt;ClickHouse has a fix built in, but for a plain (non-Replicated) MergeTree it is off until you turn it on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;SETTINGS&lt;/span&gt; &lt;span class="n"&gt;index_granularity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8192&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="n"&gt;non_replicated_deduplication_window&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The server hashes each inserted block and remembers the last 1,000 hashes. A retry sends an identical block, the hash matches, and the server drops it instead of appending it again. The retry becomes safe to do blindly, so the client stays simple: send, and if unsure, send again. Make the window bigger than the most blocks a single retry could resend, and you stop having to worry about duplicate writes.&lt;/p&gt;

&lt;h2&gt;
  
  
  So, one database or two?
&lt;/h2&gt;

&lt;p&gt;For almost every app, one. If your data fits in Postgres and stays fast, a second database is a cost you should not pay: two schemas, two clients, two things to back up and think about.&lt;/p&gt;

&lt;p&gt;Reach for the second store only when one table stops looking like the rest of your tables. For me that table was &lt;code&gt;check_results&lt;/code&gt;. It is written once and never edited, it grows without end, and every question I ask it is a summary over a time range. That is a different shape from my monitors and my incidents, so it wanted a different database.&lt;/p&gt;

&lt;p&gt;The rule I would give my past self: do not split by "which database is faster". Split by how the data is written. Rows that change and must stay correct want Postgres. An append-only stream you only ever summarize wants a column store like ClickHouse. Most of the tricks above are that one idea pushed down into the schema.&lt;/p&gt;

&lt;p&gt;Uptimepage is open source, AGPL-3.0, and both schemas are in the repo: &lt;a href="https://github.com/uptimepage/uptimepage" rel="noopener noreferrer"&gt;github.com/uptimepage/uptimepage&lt;/a&gt;. The probe that writes those rows is &lt;a href="https://uptimepage.dev/blog/http-prober-in-rust-no-reqwest" rel="noopener noreferrer"&gt;its own post&lt;/a&gt;, and the wider build story, one binary and two databases, is &lt;a href="https://uptimepage.dev/blog/building-an-uptime-monitor-in-rust" rel="noopener noreferrer"&gt;here&lt;/a&gt;. Or &lt;a href="https://uptimepage.dev" rel="noopener noreferrer"&gt;start free on the hosted tier&lt;/a&gt; and point a check at something.&lt;/p&gt;

</description>
      <category>rust</category>
      <category>database</category>
      <category>postgres</category>
      <category>clickhouse</category>
    </item>
    <item>
      <title>4 things writing an HTTP prober in Rust taught me</title>
      <dc:creator>Slim</dc:creator>
      <pubDate>Tue, 07 Jul 2026 14:07:03 +0000</pubDate>
      <link>https://dev.to/slima4/4-things-writing-an-http-prober-in-rust-taught-me-gn6</link>
      <guid>https://dev.to/slima4/4-things-writing-an-http-prober-in-rust-taught-me-gn6</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR.&lt;/strong&gt; An uptime prober is a strange HTTP client, so I swapped reqwest for raw hyper. It needs the opposite of what a normal client gives you: no connection pool, so the cold path gets measured instead of hidden; every phase timed, even when connect fails; a hard SSRF block on the URL the user typed; and exact failure reasons instead of one flat error. Four tricks below, each worth copying even if you never build a monitor.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I built an uptime checker in Rust. Like most people, I used &lt;a href="https://github.com/seanmonstar/reqwest" rel="noopener noreferrer"&gt;reqwest&lt;/a&gt; first. Then I dropped it and moved down to raw &lt;a href="https://github.com/hyperium/hyper" rel="noopener noreferrer"&gt;hyper&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Not because reqwest is slow. It is great, and for app code you should use it. But a prober is a strange kind of HTTP client. It makes one request, and its whole job is to measure that request and report exactly what happened. A normal client is built to do the opposite: make requests fast and hide the messy details. Every trick below is a messy detail I needed to keep.&lt;/p&gt;

&lt;p&gt;Here are four things building it taught me. Each one is a habit worth copying, even if you never write a monitor.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. A connection pool lies to a monitor
&lt;/h2&gt;

&lt;p&gt;The first thing reqwest gives you is a connection pool, and it is the first thing I had to remove.&lt;/p&gt;

&lt;p&gt;A pool keeps TCP connections open and reuses them. The second request to a host skips DNS, skips the TCP handshake, skips TLS, and comes back much faster. For app code that is a free speed boost. For a monitor it is a lie. If I reuse a warm connection, the "connect time" I report is near zero, because I did not connect, I borrowed. A user watching a slow TLS handshake slowly rise in one region would see nothing, because the pool hid the handshake.&lt;/p&gt;

&lt;p&gt;So the connector makes exactly one request per connection, then drops it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="cd"&gt;/// No pooling: the caller drives exactly one request over the returned&lt;/span&gt;
&lt;span class="cd"&gt;/// stream, then drops it.&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;crate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;timed_connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;ConnectParams&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nv"&gt;'_&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;u16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;is_https&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;TimedConnection&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ConnectError&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every check pays the full cost of a fresh connection, because the full cost is the thing I am trying to measure. The lesson is general: if you are timing a request, a warm pool is measuring the wrong thing. Reuse is a feature that erases exactly the numbers a monitor exists to show.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Put the timings inside the error
&lt;/h2&gt;

&lt;p&gt;Splitting a request into DNS, TCP, TLS, and time to first byte is easy on the happy path. The interesting question is what happens when connect fails.&lt;/p&gt;

&lt;p&gt;The naive design loses everything on failure. You get an &lt;code&gt;Err&lt;/code&gt; and no numbers, so a hung TLS handshake looks the same as a dead DNS server. Both are just "failed." That is the worst moment to have no data.&lt;/p&gt;

&lt;p&gt;The fix is small, and I now use it everywhere: make the error carry the phases that finished before the one that broke.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;pub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;crate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;enum&lt;/span&gt; &lt;span class="n"&gt;ConnectError&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;Dns&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;anyhow&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;NoAddrs&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;dns_ms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;u16&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;Connect&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nn"&gt;io&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dns_ms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;u16&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;Tls&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nn"&gt;io&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dns_ms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;u16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;connect_ms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;u16&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A TLS failure still reports how long DNS and TCP took. So when a cert handshake hangs, the chart still shows DNS was 12ms, connect was 45ms, and then TLS took the rest. The shape of the error tells you what broke. You are never left guessing which layer stalled, even on the path where everything went wrong. reqwest gives you one flat &lt;code&gt;Error&lt;/code&gt; at the very end. Here the error type holds the answer.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;one probe, one fresh connection, each phase on its own clock:

  DNS         TCP connect      TLS handshake     request + TTFB
  +-----------+----------------+-----------------+----------------&amp;gt;
   12ms        45ms             (hung)            never reached

  TLS failed. The error still carries dns_ms=12, connect_ms=45,
  so you know exactly which layer stalled, not just "it failed".
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3. The SSRF check goes after DNS, not before
&lt;/h2&gt;

&lt;p&gt;This is the one most people building a fetch-any-URL feature get wrong, and it is the scary one.&lt;/p&gt;

&lt;p&gt;A checker fetches whatever URL the user typed. That is the feature. It is also a classic &lt;a href="https://owasp.org/www-community/attacks/Server_Side_Request_Forgery" rel="noopener noreferrer"&gt;SSRF&lt;/a&gt; engine. Someone signs up, points a check at &lt;code&gt;http://169.254.169.254/latest/meta-data/&lt;/code&gt;, and now your prober is reading cloud credentials from inside your own network and showing them the reply. Point it at &lt;code&gt;http://10.0.0.5:6379&lt;/code&gt; and it is a port scanner for your private subnet.&lt;/p&gt;

&lt;p&gt;The trap is thinking you can block this by checking the hostname. You cannot. The classic bypass is DNS rebinding: the name looks public and passes your check, but it resolves to &lt;code&gt;127.0.0.1&lt;/code&gt;. The block has to happen after resolution, on the real IP you are about to dial:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;addrs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;SocketAddr&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;
    &lt;span class="py"&gt;.resolver&lt;/span&gt;
    &lt;span class="nf"&gt;.resolve_addrs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;.await&lt;/span&gt;
    &lt;span class="nf"&gt;.map_err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;ConnectError&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Dns&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;
    &lt;span class="nf"&gt;.into_iter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nf"&gt;.filter&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="py"&gt;.ssrf_guard&lt;/span&gt;&lt;span class="nf"&gt;.allow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;   &lt;span class="c1"&gt;// block loopback, private, link-local, metadata&lt;/span&gt;
    &lt;span class="nf"&gt;.map&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="nn"&gt;SocketAddr&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="nf"&gt;.collect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Resolve first, filter every resolved IP, then connect only to what survives. And because each redirect hop reconnects through this same guarded path, a &lt;code&gt;Location:&lt;/code&gt; header pointing at &lt;code&gt;169.254.169.254&lt;/code&gt; gets rejected exactly like a directly typed one. The safety sits in the single place all connections pass through, so there is nothing to forget on the redirect path. If you ever fetch a user-supplied URL server-side, this is the check that matters, and the hostname version is not it.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Stop grepping your error strings
&lt;/h2&gt;

&lt;p&gt;When a check goes red the user gets one line, and that line has to be right. "Something went wrong" trains people to ignore alerts. "Certificate expired" tells them what to fix in five seconds.&lt;/p&gt;

&lt;p&gt;The tempting way to produce that line is to match on error message text. It is a trap: those strings change between library versions and read differently on every OS. The message is not an API. So instead the connector downcasts to the real error and reads the reason straight out of it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;tls_reason&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nn"&gt;io&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;'static&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;rustls&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;CertificateError&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;rustls&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;InvalidCertificate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cert&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
        &lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="nf"&gt;.get_ref&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.and_then&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="py"&gt;.downcast_ref&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nn"&gt;rustls&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;"tls"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="n"&gt;cert&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nn"&gt;CertificateError&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Expired&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s"&gt;"certificate expired"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nn"&gt;CertificateError&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;UnknownIssuer&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s"&gt;"certificate not trusted"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nn"&gt;CertificateError&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;NotValidForName&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s"&gt;"certificate hostname mismatch"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nn"&gt;CertificateError&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Revoked&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s"&gt;"certificate revoked"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s"&gt;"certificate invalid"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An expired cert, an untrusted issuer, and a hostname mismatch are three different problems with three different fixes, and the user should never have to guess which one they have. The &lt;code&gt;io::ErrorKind&lt;/code&gt; and the rustls error already carry the truth. The trick is to refuse to flatten it into a string and then re-parse the string you just made.&lt;/p&gt;

&lt;h2&gt;
  
  
  So, reqwest or not?
&lt;/h2&gt;

&lt;p&gt;For your program? Almost certainly reqwest. Going down to hyper means you own redirects, decompression with a size cap, timeout budgeting per hop, and the request-target rules for HTTP/1 versus HTTP/2. That is real code with real edge cases, and every one is a place reqwest would have been correct for free. If I needed to fetch a config file at startup, writing this connector would be a mistake.&lt;/p&gt;

&lt;p&gt;It is worth it here for one reason: measuring and guarding the request is the product, not a detail of it. When the request itself is the thing you sell, you want to own every millisecond and every IP it touches.&lt;/p&gt;

&lt;p&gt;Uptimepage is open source, AGPL-3.0, and the whole probe path is in the repo: &lt;a href="https://github.com/uptimepage/uptimepage" rel="noopener noreferrer"&gt;github.com/uptimepage/uptimepage&lt;/a&gt;. If you want the wider build story, one binary and two databases, &lt;a href="https://uptimepage.dev/blog/building-an-uptime-monitor-in-rust" rel="noopener noreferrer"&gt;that is a separate post&lt;/a&gt;. Or just &lt;a href="https://uptimepage.dev" rel="noopener noreferrer"&gt;start free on the hosted tier&lt;/a&gt; and point a check at something.&lt;/p&gt;

</description>
      <category>rust</category>
      <category>programming</category>
      <category>webdev</category>
      <category>devops</category>
    </item>
    <item>
      <title>Email bombing through uptime monitoring pages</title>
      <dc:creator>Slim</dc:creator>
      <pubDate>Mon, 06 Jul 2026 13:14:34 +0000</pubDate>
      <link>https://dev.to/slima4/email-bombing-through-uptime-monitoring-pages-4m5g</link>
      <guid>https://dev.to/slima4/email-bombing-through-uptime-monitoring-pages-4m5g</guid>
      <description>&lt;p&gt;&lt;em&gt;Cover photo by &lt;a href="https://unsplash.com/photos/a-close-up-of-a-cell-phone-with-various-app-icons-X8ejw0g0C_g" rel="noopener noreferrer"&gt;Brian J. Tromp&lt;/a&gt; on Unsplash.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I build an uptime monitoring tool. Part of my job is to attack it before someone else does. This post is about one attack that most people in this space do not talk about: using monitoring tools to flood someone's inbox with email.&lt;/p&gt;

&lt;p&gt;I will show you how it works from the attacker side. Then I will show you how to stop it from the builder side. If you run any tool that sends email to an address a user typed in, this is your problem too.&lt;/p&gt;

&lt;h2&gt;
  
  
  What email bombing is
&lt;/h2&gt;

&lt;p&gt;Email bombing is simple. You flood one inbox with so many messages that the person cannot use it. Hundreds of emails in a few minutes. When the attacker uses signup forms to do it, people call it &lt;a href="https://www.m3aawg.org/rel-WebFormHeader" rel="noopener noreferrer"&gt;subscription bombing or list bombing&lt;/a&gt;. Same idea. Anti-abuse groups describe it as a denial-of-service attack on your inbox, and &lt;a href="https://www.itbrew.com/stories/2026/03/20/attacking-the-inbox-it-pros-seeing-rise-in-subscription-bombing" rel="noopener noreferrer"&gt;IT teams saw a rise in 2026&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The goal is usually not the flood itself. The flood is a cover.&lt;/p&gt;

&lt;p&gt;Say an attacker just stole your card and bought something. Your bank sends an email or a text: "Did you make this payment?" If your inbox has hundreds of new emails in a few minutes, you will never see that one bank message. By the time you dig it out, the money is gone. The flood buys the attacker time and quiet. Security teams have &lt;a href="https://www.proofpoint.com/us/blog/email-and-cloud-threats/subscription-bombing-hides-real-cyberattacks" rel="noopener noreferrer"&gt;watched attackers use this trick to hide real fraud&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;It is not always about money. Some ransomware crews use email bombing as step one. They flood a worker with mail, then call and pretend to be the IT help desk: "We see the spam, let us help you fix it." The panic is the way in. The flood softens the target for the real attack. The US cyber agency CISA &lt;a href="https://www.cisa.gov/news-events/cybersecurity-advisories/aa24-131a" rel="noopener noreferrer"&gt;documented this play&lt;/a&gt;: email bombing plus a fake help-desk chat on Microsoft Teams, used by the Black Basta ransomware crew to get in. &lt;a href="https://thehackernews.com/2025/06/former-black-basta-members-use.html" rel="noopener noreferrer"&gt;Former members kept using it through 2025&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why uptime pages make a good weapon
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7vk5l1snyd8s04ra84jr.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7vk5l1snyd8s04ra84jr.webp" alt="Green binary code glowing on a screen next to a laptop keyboard in the dark" width="800" height="536"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Photo by &lt;a href="https://unsplash.com/photos/black-laptop-computer-turned-on-with-green-screen-QHDVlXhIacg" rel="noopener noreferrer"&gt;Moritz Erken&lt;/a&gt; on Unsplash.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Here is the part that surprised me the first time I looked.&lt;/p&gt;

&lt;p&gt;Most uptime and status tools let you add an email address for alerts. You type an address, the tool sends a "confirm your email" message, and you click a link to turn alerts on. Normal stuff.&lt;/p&gt;

&lt;p&gt;Now look at it as an attacker. The tool will send an email to any address I type. I do not need to own that address. I type the victim's email and hit save. The confirm email goes to the victim, not to me.&lt;/p&gt;

&lt;p&gt;One email is not a bomb. But there are many of these tools. And inside each tool I can make many monitors, each with its own alert email. So I write a small script. Sign up, add the victim's address to fifty monitors, move to the next tool, repeat. The victim gets a wave of "please confirm" emails from services they have never heard of.&lt;/p&gt;

&lt;p&gt;The mean part is where these emails come from. They come from real companies with a good name. They pass the checks that tell Gmail a sender is real (SPF and DKIM, if you want the terms). So they land in the main inbox, not in spam. &lt;a href="https://cacm.acm.org/practice/subscription-bombing-email-under-attack/" rel="noopener noreferrer"&gt;One study in 2026&lt;/a&gt; found that most of these emails were not marked as spam and reached the inbox. A normal spam filter usually will not save the victim, because on paper none of it is spam.&lt;/p&gt;

&lt;h2&gt;
  
  
  The builder side: how not to be the tool that floods people
&lt;/h2&gt;

&lt;p&gt;I will be honest. When I first tested my own tool, it had this hole. The confirm email had no real limit and no clear owner. So I fixed it. Here is what actually helped, in order.&lt;/p&gt;

&lt;p&gt;Rate limits are the floor. Cap how many confirm emails one address can get per day across your whole platform, not just per account. If one attacker opens twenty accounts, a per-account limit does nothing. Count by the target address.&lt;/p&gt;

&lt;p&gt;Say who and why. My old confirm email just said "confirm this address." Now it says who added it and to what: "Acme Inc added this address to a monitor." If a stranger gets one, at least they can see what is happening.&lt;/p&gt;

&lt;p&gt;Give a fast way out. Put a one-click "this was not me, stop" link in the email. One click kills that address on your tool. No login, no support ticket. There is a standard for this, &lt;a href="https://www.rfc-editor.org/rfc/rfc8058" rel="noopener noreferrer"&gt;RFC 8058&lt;/a&gt;, so mail apps can show a stop button right in the message.&lt;/p&gt;

&lt;p&gt;Block the obvious traps. Do not allow role addresses like postmaster@ or &lt;a href="mailto:abuse@"&gt;abuse@&lt;/a&gt;. They are shared mailboxes, not a person who asked for alerts. And do not allow your own company domains, because a tool that emails itself can start a mail loop.&lt;/p&gt;

&lt;p&gt;Skip the email when you can. If the person adding the address is logged in with that same email through Google or GitHub, you already know they own it. Mark it confirmed and send nothing.&lt;/p&gt;

&lt;p&gt;None of this is clever. It is boring plumbing. But boring plumbing is what keeps your name off the list of services used in someone's email bomb.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you are the one getting bombed
&lt;/h2&gt;

&lt;p&gt;Do not delete everything in a panic. That is exactly what the attacker wants. Somewhere in that pile there may be one real email that matters, like a bank alert or a password reset you did not ask for.&lt;/p&gt;

&lt;p&gt;First, search your inbox for words like "payment", "login", "password", or your bank's name. Handle the real one first. Call your bank if you see a charge you did not make.&lt;/p&gt;

&lt;p&gt;Then use the stop and unsubscribe links in the junk. Most real services put them there. It is slow work, but it cuts the flood at the source.&lt;/p&gt;

&lt;p&gt;If it keeps coming, tell your email provider. Gmail and others can filter a flood once they see the pattern. And if someone calls you "from IT" right after the flood starts, be careful. That call can be part of the attack.&lt;/p&gt;

&lt;h2&gt;
  
  
  One last thing
&lt;/h2&gt;

&lt;p&gt;I like uptime tools. I build one. But any tool that will email a stranger on request can be turned into a small weapon, and most builders never test for it. If you run one of these, spend an afternoon trying to bomb yourself. You will learn more from that hour than from any security checklist, and you will find the holes before someone else does.&lt;/p&gt;

&lt;p&gt;More reading: &lt;a href="https://uptimepage.dev/blog/building-an-uptime-monitor-in-rust" rel="noopener noreferrer"&gt;how I built this monitor&lt;/a&gt;, and &lt;a href="https://uptimepage.dev/abuse-policy" rel="noopener noreferrer"&gt;how to report abuse to us&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>security</category>
      <category>cybersecurity</category>
      <category>monitoring</category>
      <category>devops</category>
    </item>
    <item>
      <title>Building an uptime monitor in Rust: one binary, two databases, 130K checks/sec per core</title>
      <dc:creator>Slim</dc:creator>
      <pubDate>Tue, 30 Jun 2026 14:22:09 +0000</pubDate>
      <link>https://dev.to/slima4/building-an-uptime-monitor-in-rust-one-binary-two-databases-130k-checkssec-per-core-5g25</link>
      <guid>https://dev.to/slima4/building-an-uptime-monitor-in-rust-one-binary-two-databases-130k-checkssec-per-core-5g25</guid>
      <description>&lt;p&gt;I spent the last few months building &lt;a href="https://uptimepage.dev" rel="noopener noreferrer"&gt;Uptimepage&lt;/a&gt;, an open-source uptime monitor and status page written in Rust. This post is the build story: the decisions that shaped it, the parts I rewrote, and the numbers that came out the other side.&lt;/p&gt;

&lt;p&gt;The whole thing ships as one self-contained binary of about 23 MB, plus Postgres and ClickHouse. You can &lt;code&gt;docker compose up&lt;/code&gt; and self-host it, or use the hosted tier. Source is AGPL-3.0 on &lt;a href="https://github.com/uptimepage/uptimepage" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why one binary
&lt;/h2&gt;

&lt;p&gt;A status page is a small surface with a lot of moving parts behind it: a scheduler, probe workers, an HTTP client, a time-series writer, an incident detector, an alerting fan-out, a web UI, a JSON API. The usual answer is a handful of services and a message bus between them.&lt;/p&gt;

&lt;p&gt;I went the other way. One process, one binary, the same image whether it runs the control plane or a remote probe. No queue to operate, no version skew between services, no "which container is wedged" at 3am. The cost is that you have to be careful about what shares a thread and what can stall what. Most of the engineering below is about keeping those boundaries clean inside a single process.&lt;/p&gt;

&lt;p&gt;Stack: Rust 1.95 (edition 2024), Tokio, Axum 0.8, Askama for compile-time HTML templates, HTMX 2 for partial swaps so there is no SPA framework to ship. The API stays the single source of truth because every UI mutation hits the same &lt;code&gt;/api/v1/*&lt;/code&gt; endpoint a script would.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two databases, on purpose
&lt;/h2&gt;

&lt;p&gt;Monitors are low-cardinality relational data that gets mutated by API calls: targets, regions, channels, incidents, plans. That is Postgres. Check results are append-only, high-cardinality, and almost always queried by time range. That is ClickHouse.&lt;/p&gt;

&lt;p&gt;Trying to force one of those into the other is where uptime monitors usually fall over. Putting billions of check results in Postgres turns every dashboard query into a sequential scan. Putting your relational config in ClickHouse means fighting its update model forever. So: Postgres 18 for the world, ClickHouse 26.3 for the firehose. Both run their migrations at process startup, so there is no separate migrator to forget.&lt;/p&gt;

&lt;h2&gt;
  
  
  The HTTP client I did not want to write
&lt;/h2&gt;

&lt;p&gt;The first version used a popular high-level HTTP client. It worked, but a monitor is a weird HTTP workload: you connect once per target per interval, you never reuse the connection, and you care about the timing of each phase more than the body.&lt;/p&gt;

&lt;p&gt;So I dropped down to &lt;code&gt;hyper&lt;/code&gt; and &lt;code&gt;hyper-util&lt;/code&gt; with &lt;code&gt;rustls&lt;/code&gt; and built a connector that times DNS resolution, TCP connect, and the TLS handshake as separate numbers, then runs the request over &lt;code&gt;hyper::client::conn&lt;/code&gt; and aborts the connection task the moment the body is read. Each result carries &lt;code&gt;dns_ms&lt;/code&gt;, &lt;code&gt;connect_ms&lt;/code&gt;, &lt;code&gt;tls_ms&lt;/code&gt;, and &lt;code&gt;ttfb_ms&lt;/code&gt; as distinct columns, which is what makes "it got slow but it is the DNS, not your server" a thing the dashboard can actually say.&lt;/p&gt;

&lt;p&gt;The rewrite paid for itself. On a single core the client sustains around 130K checks/sec at saturation, roughly 7.7 microseconds per check. That was a 44 to 56 percent throughput gain over the old path. A chunk of it was a &lt;code&gt;url::parse&lt;/code&gt; call hiding in the redirect policy that cost 7.5 percent on its own and just vanished. Two cores get to about 153K. Scaling goes sub-linear past four cores because of shared HTTP/2 connection state and the pool mutex, which is a fine problem to have for this workload.&lt;/p&gt;

&lt;p&gt;These numbers come from a laptop and a &lt;code&gt;loadtest&lt;/code&gt; binary, so I treat them as regression detection, not capacity planning. The headline in-process run pushed 252K requests/sec sustained at p99 393ms. The design goal is around 50K concurrent in-flight checks per node with under 50ms p99 of per-check overhead on top of the network.&lt;/p&gt;

&lt;h2&gt;
  
  
  One heap, not a timer per target
&lt;/h2&gt;

&lt;p&gt;The naive scheduler spawns a timer task per monitor. That falls apart at fleet size: thousands of tasks, thousands of wakeups, memory that grows with the number of targets.&lt;/p&gt;

&lt;p&gt;Instead there is a single driver task that owns one &lt;code&gt;BinaryHeap&amp;lt;Reverse&amp;lt;Due&amp;gt;&amp;gt;&lt;/code&gt;, a min-heap keyed by the next due &lt;code&gt;Instant&lt;/code&gt; for the whole fleet. Memory stays flat in fleet size. Each target gets a deterministic jitter offset hashed from its UUID so a thousand monitors on a 60s interval do not all fire on the same tick. Generation and sequence counters mean a re-scheduled target supersedes its stale heap entry instead of double-firing. The registry refresh that pulls config from Postgres runs on its own task with exponential backoff, so a Postgres hiccup never stalls dispatch: the scheduler keeps running on what it last knew.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure isolation inside one process
&lt;/h2&gt;

&lt;p&gt;Because everything is one process, one bad target cannot be allowed to take down the rest. Three patterns do most of that work. Per-host circuit breakers trip when a host keeps failing, so it fails fast with &lt;code&gt;circuit_open&lt;/code&gt; instead of tying up a worker on a timeout, then probes half-open after a cooldown. A per-tenant host throttle bulkhead caps how many checks can be in flight against one host at once; over the cap, a check is recorded as throttled and degraded rather than piling on, and it never pages. And singleflight on RDAP collapses domain-expiry checks for the same domain across many tenants into one upstream probe, with sticky last-good state so a flaky registrar does not flip the monitor red.&lt;/p&gt;

&lt;p&gt;The worker pool itself is a task-per-dispatch gated by a semaphore sized to a max-concurrency setting, with an SSRF guard filtering resolved IPs before any connect.&lt;/p&gt;

&lt;h2&gt;
  
  
  Modeling time series in ClickHouse
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;check_results&lt;/code&gt; table is a &lt;code&gt;MergeTree&lt;/code&gt; ordered by &lt;code&gt;(org_id, target_id, region, timestamp)&lt;/code&gt;. The &lt;code&gt;org_id&lt;/code&gt; leads the sort key so each tenant gets a sparse-index slice, but it is deliberately kept out of the partition key (partition is by day) so we do not end up with millions of partitions.&lt;/p&gt;

&lt;p&gt;The columns are where the storage savings live:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Timestamps are &lt;code&gt;DateTime('UTC')&lt;/code&gt; with &lt;code&gt;CODEC(DoubleDelta, ZSTD(1))&lt;/code&gt;. Check intervals are near-constant, so DoubleDelta crushes the gaps to almost nothing.&lt;/li&gt;
&lt;li&gt;Numeric phase columns (&lt;code&gt;duration_ms&lt;/code&gt;, &lt;code&gt;dns_ms&lt;/code&gt;, &lt;code&gt;connect_ms&lt;/code&gt;, &lt;code&gt;tls_ms&lt;/code&gt;, &lt;code&gt;ttfb_ms&lt;/code&gt;, response code and size) use &lt;code&gt;CODEC(T64, ZSTD(1))&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;region&lt;/code&gt; and &lt;code&gt;agent_id&lt;/code&gt; are &lt;code&gt;LowCardinality(String)&lt;/code&gt;, status is an &lt;code&gt;Enum8&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Retention is per row. There is a &lt;code&gt;ttl_days&lt;/code&gt; column with &lt;code&gt;TTL timestamp + toIntervalDay(ttl_days)&lt;/code&gt;, stamped from the org's plan at write time. A plan that buys longer history needs zero schema change: new rows just carry a bigger number.&lt;/p&gt;

&lt;p&gt;On top of the raw table sit two &lt;code&gt;AggregatingMergeTree&lt;/code&gt; materialized views: a per-minute rollup kept 30 days and an hourly rollup kept 13 months, both holding &lt;code&gt;quantilesState&lt;/code&gt; for p50/p95/p99 and per-status counts. Reads route by range: anything inside 30 days hits the minute rollup, older ranges hit the hour rollup, and raw reads are capped at a 90-day span. A dashboard asking for "last 24h p99 latency" never touches a raw row.&lt;/p&gt;

&lt;h2&gt;
  
  
  Regional probes without a second brain
&lt;/h2&gt;

&lt;p&gt;You can run probes in multiple regions, but I did not want each region to be its own little system. So an agent is the same binary in agent mode, running as a stateless probe with no database, no web, no alerting. Adding a region adds execution capacity, never a second control plane.&lt;/p&gt;

&lt;p&gt;An agent pulls its region's config from the control plane with ETag/304 handling, serves its last-known config if the control plane blips, and pauses if its token is revoked. It ships results back in batches that reuse one UUID across retries so a lost ack cannot double-count. Region and agent identity are derived server-side from the bearer token, never sent in the payload, so a probe cannot claim to be somewhere it is not. A separate long-poll loop handles interactive "check now" so a button press in the UI runs on a real remote probe within milliseconds.&lt;/p&gt;

&lt;p&gt;Region is the partition dimension end to end, so every read can slice by region: per-region latency series, per-region incident scope, "down in Singapore, up in Helsinki."&lt;/p&gt;

&lt;h2&gt;
  
  
  Incidents as a follower, not a gatekeeper
&lt;/h2&gt;

&lt;p&gt;The incident detector is a background task that follows the &lt;code&gt;check_results&lt;/code&gt; stream and writes into the Postgres &lt;code&gt;incidents&lt;/code&gt; table. The rule I held to: it never touches the hot write path, never gates check execution, and never produces alerts directly.&lt;/p&gt;

&lt;p&gt;The detection itself is boring on purpose: two or more consecutive unhealthy results with no open incident opens one, two or more consecutive healthy results closes it. A small flap threshold absorbs single-result blips. The cross-tenant walk is keyset-paginated so memory stays bounded as tenants grow, and a unique index on open incidents resolves the race when two ticks try to open the same one: only the winner pages.&lt;/p&gt;

&lt;p&gt;Opening or resolving fires a non-blocking signal to the escalation engine, which does repeat-until-acknowledged paging on a per-monitor cadence across about fourteen transports (Slack, generic signed webhooks, Telegram, PagerDuty, ntfy, Pushover, Discord, email, and more), with sharded per-incident locks so a reconcile and an inbound signal can never double-page the same episode. Channel secrets are sealed at rest with AES-GCM and never echoed back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Automation as a first-class surface
&lt;/h2&gt;

&lt;p&gt;Because the API is the single source of truth, the rest came almost for free:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A self-describing OpenAPI 3.1 spec with Swagger UI.&lt;/li&gt;
&lt;li&gt;An official &lt;a href="https://registry.terraform.io/providers/uptimepage/uptimepage" rel="noopener noreferrer"&gt;Terraform provider&lt;/a&gt; so you can manage monitors and notification channels as code.&lt;/li&gt;
&lt;li&gt;An &lt;a href="https://uptimepage.dev" rel="noopener noreferrer"&gt;MCP server&lt;/a&gt; so an LLM client can query your monitors and incidents over OAuth 2.1, scope-gated and audited, with per-action confirmation on the few write tools.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where it is
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://uptimepage.dev" rel="noopener noreferrer"&gt;Uptimepage&lt;/a&gt; is live, free to start with no card, and AGPL-3.0 open source. The core is not paywalled: checks, status pages, subscribers, the API, and every alert channel are in the free tier. It monitors itself and serves its own status badges from the running binary, which is the most honest dogfood I could think of.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Site and hosted tier: &lt;a href="https://uptimepage.dev" rel="noopener noreferrer"&gt;https://uptimepage.dev&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Source: &lt;a href="https://github.com/uptimepage/uptimepage" rel="noopener noreferrer"&gt;https://github.com/uptimepage/uptimepage&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;More build notes: &lt;a href="https://uptimepage.dev/blog" rel="noopener noreferrer"&gt;https://uptimepage.dev/blog&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Happy to answer anything about the Rust internals in the comments.&lt;/p&gt;

</description>
      <category>rust</category>
      <category>clickhouse</category>
      <category>webdev</category>
      <category>devops</category>
    </item>
    <item>
      <title>99.9% uptime is 43 minutes a month. Do you know your number?</title>
      <dc:creator>Slim</dc:creator>
      <pubDate>Wed, 24 Jun 2026 14:45:04 +0000</pubDate>
      <link>https://dev.to/slima4/999-uptime-is-43-minutes-a-month-do-you-know-your-number-1p9g</link>
      <guid>https://dev.to/slima4/999-uptime-is-43-minutes-a-month-do-you-know-your-number-1p9g</guid>
      <description>&lt;p&gt;"Three nines" sounds like the service is basically always up. It is not. 99.9% uptime gives you 43 minutes of downtime every month, and one bad deploy on a Tuesday can spend the whole budget before lunch.&lt;/p&gt;

&lt;p&gt;Most people never do the arithmetic, so the percentages stay abstract. Here is every common availability target turned into actual time you are allowed to be down.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;99.9% is 43 minutes a month. 99.99% is 4 minutes. Every extra nine costs you roughly 10x the effort.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The nines, in plain time
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Availability&lt;/th&gt;
&lt;th&gt;Per year&lt;/th&gt;
&lt;th&gt;Per month&lt;/th&gt;
&lt;th&gt;Per week&lt;/th&gt;
&lt;th&gt;Per day&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;99% (two nines)&lt;/td&gt;
&lt;td&gt;3.65 days&lt;/td&gt;
&lt;td&gt;7h 12m&lt;/td&gt;
&lt;td&gt;1h 41m&lt;/td&gt;
&lt;td&gt;14m 24s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;99.5%&lt;/td&gt;
&lt;td&gt;1.83 days&lt;/td&gt;
&lt;td&gt;3h 36m&lt;/td&gt;
&lt;td&gt;50m 24s&lt;/td&gt;
&lt;td&gt;7m 12s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;99.9% (three nines)&lt;/td&gt;
&lt;td&gt;8h 46m&lt;/td&gt;
&lt;td&gt;43m 12s&lt;/td&gt;
&lt;td&gt;10m 5s&lt;/td&gt;
&lt;td&gt;1m 26s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;99.95%&lt;/td&gt;
&lt;td&gt;4h 23m&lt;/td&gt;
&lt;td&gt;21m 36s&lt;/td&gt;
&lt;td&gt;5m 2s&lt;/td&gt;
&lt;td&gt;43s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;99.99% (four nines)&lt;/td&gt;
&lt;td&gt;52m 34s&lt;/td&gt;
&lt;td&gt;4m 19s&lt;/td&gt;
&lt;td&gt;60s&lt;/td&gt;
&lt;td&gt;8.6s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;99.999% (five nines)&lt;/td&gt;
&lt;td&gt;5m 15s&lt;/td&gt;
&lt;td&gt;26s&lt;/td&gt;
&lt;td&gt;6s&lt;/td&gt;
&lt;td&gt;0.9s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Rounded, assuming a 365-day year and a 30-day month. The math is just &lt;code&gt;(1 - availability) * length of the window&lt;/code&gt;. That is the whole formula. 99.9% means 0.1% of the window is allowed to be downtime, so over a 30-day month that is 0.001 times 43,200 minutes, which lands at 43.2 minutes.&lt;/p&gt;

&lt;p&gt;The actual times land harder than the percentages do.&lt;/p&gt;

&lt;p&gt;99% looks respectable as a number and is genuinely bad as a service. Three and a half days a year is a long weekend of your product being down. Nobody would tolerate that, yet "99%" reads fine in a slide.&lt;/p&gt;

&lt;p&gt;The jump from three nines to four nines is brutal. You go from 43 minutes a month to 4 minutes a month. That last 0.09% costs you redundancy, failover, on-call rotations, and a lot of money. Each extra nine roughly multiplies the engineering effort while shrinking your room for error by 10x.&lt;/p&gt;

&lt;p&gt;Five nines is 26 seconds a month. At that point a single slow deploy or one bad health check blows your entire budget. Most teams that claim five nines are either measuring something narrow or not measuring honestly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap: the number you advertise is not the number you measure
&lt;/h2&gt;

&lt;p&gt;There are two different uptime numbers and they almost never match.&lt;/p&gt;

&lt;p&gt;The first is the SLA you promise, a yearly figure, the one on the pricing page. The second is what you actually measured this month. They drift apart for reasons that have nothing to do with how reliable your service really was.&lt;/p&gt;

&lt;p&gt;Window matters more than the percentage. A single 50-minute outage is a non-event against a yearly 99.9% budget of nearly 9 hours. That exact same outage blows straight through a monthly 99.9% budget of 43 minutes. Same outage, same service, two different verdicts, purely because of which window you measured. Vendors love quoting the yearly number because it hides the months that hurt.&lt;/p&gt;

&lt;p&gt;What counts as "down" matters just as much. Was a single failed health check downtime, or noise? Did a 30-second blip in one region count against you? If your monitor records every flap as an outage, your measured uptime will look worse than the service actually was. If it averages everything into a smooth ratio, it will look better. Neither is the real number, and the gap between them is the difference between a monitor you trust and one you have learned to ignore.&lt;/p&gt;

&lt;h2&gt;
  
  
  Treat your downtime allowance as a budget
&lt;/h2&gt;

&lt;p&gt;Think of your monthly allowance as money you get to spend rather than a target to perfect.&lt;/p&gt;

&lt;p&gt;99.9% monthly gives you 43 minutes. That is your error budget, and everything draws it down: planned maintenance, a failed deploy, a dependency that went slow for an afternoon. Frame it that way and the question stops being "how do we never go down" and becomes "what do we want to spend these 43 minutes on," which is a far more honest conversation and the one good SRE teams actually have.&lt;/p&gt;

&lt;p&gt;It also kills the instinct to chase more nines than you need. A side project does not need four nines. An internal tool does not need four nines. Adding a nine you will never use is just paying for redundancy nobody asked for. Pick the target your users actually feel, then measure against it on the window they actually live in, which is usually the month.&lt;/p&gt;

&lt;h2&gt;
  
  
  So what is your number
&lt;/h2&gt;

&lt;p&gt;Find your real measured uptime for last month, not the aspirational figure from the pricing page. Then check it against the table. If you do not know it to the minute, that is the actual thing to fix first, because you cannot spend a budget you never counted.&lt;/p&gt;




&lt;p&gt;I build &lt;a href="https://uptimepage.dev" rel="noopener noreferrer"&gt;uptimepage&lt;/a&gt;, an open source, self-hostable uptime and status page monitor. It computes uptime from confirmed incidents over whatever window you pick, so the number you see is the one your users actually felt, not a ratio of green checks. The source is right there if you want to see how the math is done.&lt;/p&gt;

</description>
      <category>monitoring</category>
      <category>devops</category>
      <category>sre</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How I got my AI agents to communicate across repos — and shipped SAMP doing it</title>
      <dc:creator>Slim</dc:creator>
      <pubDate>Sat, 25 Apr 2026 06:18:01 +0000</pubDate>
      <link>https://dev.to/slima4/how-i-got-my-ai-agents-to-communicate-across-repos-and-shipped-samp-doing-it-p6</link>
      <guid>https://dev.to/slima4/how-i-got-my-ai-agents-to-communicate-across-repos-and-shipped-samp-doing-it-p6</guid>
      <description>&lt;p&gt;&lt;strong&gt;Situation.&lt;/strong&gt; I was working on &lt;a href="https://github.com/lumen-argus/lumen-argus" rel="noopener noreferrer"&gt;&lt;strong&gt;lumen-argus&lt;/strong&gt;&lt;/a&gt;, a project that runs across three Claude Code sessions in three different repos. They needed to share context — "I just refactored the auth module," "the schema migration landed, pull main." For weeks I was copy-pasting between terminal windows like a caveman.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Task.&lt;/strong&gt; I wanted cross-session, cross-repo messaging for AI agents. Cheap. Fast. Local. Ideally something I could read with &lt;code&gt;cat&lt;/code&gt; if anything went wrong.&lt;/p&gt;

&lt;p&gt;Three constraints:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Token cost per message had to be near zero.&lt;/li&gt;
&lt;li&gt;No new servers, no daemons, no MCP handshake.&lt;/li&gt;
&lt;li&gt;It had to work for &lt;em&gt;any&lt;/em&gt; agent — Claude Code today, Cursor or my next project tomorrow.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Action.&lt;/strong&gt; I surveyed the existing options. &lt;a href="https://github.com/Dicklesworthstone/mcp_agent_mail" rel="noopener noreferrer"&gt;&lt;code&gt;mcp_agent_mail&lt;/code&gt;&lt;/a&gt;, Agent Teams, broker daemons — all of them spin up an HTTP server, register identities, run a SQLite store, and burn tokens on polling hooks. Overkill for three agents swapping a dozen messages a day.&lt;/p&gt;

&lt;p&gt;So I borrowed Linus Torvalds' playbook from &lt;code&gt;git&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Per-writer append-only logs.&lt;/strong&gt; One file per agent: &lt;code&gt;log-&amp;lt;alias&amp;gt;.jsonl&lt;/code&gt;. No file ever has two writers, so there's no locking, no interleave, and Syncthing / Dropbox / iCloud can't cause merge conflicts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Content-addressed IDs.&lt;/strong&gt; &lt;code&gt;id = sha256({ts, from, to, thread, body})[:16]&lt;/code&gt;. Same content → same id, so dedup after sync is automatic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;mtime&lt;/code&gt; short-circuit.&lt;/strong&gt; Before parsing anything, stat the log files; if nothing changed, exit immediately.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I packaged it as &lt;strong&gt;SAMP&lt;/strong&gt; (Simple Agent Message Protocol) — a vendor-neutral spec — and shipped a reference implementation, &lt;strong&gt;agent-message&lt;/strong&gt;: three Claude Code slash commands (&lt;code&gt;/message-send&lt;/code&gt;, &lt;code&gt;/message-inbox&lt;/code&gt;, &lt;code&gt;/message-reply&lt;/code&gt;), a &lt;code&gt;msg&lt;/code&gt; shell helper for humans, and a tiny Python wrapper any other agent CLI can spawn.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Result.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;1 Bash tool call per send/receive&lt;/strong&gt; from Claude Code. No MCP init, no ack roundtrip.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;0 LLM tokens&lt;/strong&gt; when humans (or &lt;code&gt;cron&lt;/code&gt;, or scripts) read/send via the shell helper. The model is never in the loop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;~30 ms latency&lt;/strong&gt; — that's &lt;code&gt;python3&lt;/code&gt; startup. Everything else is a file append.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Works offline. Syncs across machines via Syncthing / Dropbox / iCloud&lt;/strong&gt; with zero conflicts by construction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One install script.&lt;/strong&gt; No pip, no npm, no Docker.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Docs: &lt;a href="https://slima4.github.io/agent-message/" rel="noopener noreferrer"&gt;https://slima4.github.io/agent-message/&lt;/a&gt;&lt;br&gt;
Repo: &lt;a href="https://github.com/slima4/agent-message" rel="noopener noreferrer"&gt;https://github.com/slima4/agent-message&lt;/a&gt;&lt;br&gt;
Spec: &lt;a href="https://github.com/slima4/agent-message/blob/main/SPEC.md" rel="noopener noreferrer"&gt;https://github.com/slima4/agent-message/blob/main/SPEC.md&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you're juggling multiple agent sessions and copy-pasting between them, give it a try. PRs and issues welcome.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tags:&lt;/strong&gt; &lt;code&gt;ai&lt;/code&gt;, &lt;code&gt;claudecode&lt;/code&gt;, &lt;code&gt;anthropic&lt;/code&gt;, &lt;code&gt;aiagents&lt;/code&gt;, &lt;code&gt;SAMP&lt;/code&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>claude</category>
      <category>programming</category>
      <category>mcp</category>
    </item>
    <item>
      <title>Sniffing Claude Code's API Calls: What Your IDE Is Really Sending</title>
      <dc:creator>Slim</dc:creator>
      <pubDate>Mon, 16 Mar 2026 05:05:11 +0000</pubDate>
      <link>https://dev.to/slima4/sniffing-claude-codes-api-calls-what-your-ide-is-really-sending-5fnl</link>
      <guid>https://dev.to/slima4/sniffing-claude-codes-api-calls-what-your-ide-is-really-sending-5fnl</guid>
      <description>&lt;p&gt;Every time you press Enter in Claude Code, something interesting happens behind the scenes. Your full conversation — system prompt, message history, tool definitions, everything — gets packaged into an API call and sent to Anthropic's servers.&lt;/p&gt;

&lt;p&gt;But you never get to see those calls. Claude Code logs a JSONL transcript of what it &lt;em&gt;did&lt;/em&gt; (tool calls, responses, thinking blocks), but not the raw API traffic that made it happen. The system prompt, HTTP headers, request parameters, latency per call, and one entirely hidden API call — all invisible.&lt;/p&gt;

&lt;p&gt;So we built a way to see everything.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Trick: One Environment Variable
&lt;/h2&gt;

&lt;p&gt;Claude Code officially supports &lt;code&gt;ANTHROPIC_BASE_URL&lt;/code&gt; — an environment variable that redirects API traffic to a custom endpoint. It's meant for enterprise proxies, but it works perfectly for local interception:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Claude Code  ──plain HTTP──▶  Sniffer (localhost:7735)  ──HTTPS──▶  api.anthropic.com
                                    │
                                    ▼
                          ~/.claude/api-sniffer/*.jsonl
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Start the sniffer in one terminal, launch Claude Code in another:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Terminal 1&lt;/span&gt;
claudetui sniffer

&lt;span class="c"&gt;# Terminal 2&lt;/span&gt;
claudetui sniff
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;claudetui sniff&lt;/code&gt; auto-detects the sniffer port and launches Claude Code through the proxy. If the sniffer isn't running, it falls back to launching Claude Code directly — so you never get stuck with a &lt;code&gt;ConnectionRefused&lt;/code&gt; retry loop.&lt;/p&gt;

&lt;p&gt;Every API call now flows through the proxy and gets logged. Claude Code works identically — it doesn't know (or care) that traffic is being captured.&lt;/p&gt;

&lt;p&gt;No TLS interception. No certificates. No patching binaries. Just a localhost HTTP server that forwards to the real API over HTTPS.&lt;/p&gt;

&lt;h2&gt;
  
  
  What You See
&lt;/h2&gt;

&lt;p&gt;The sniffer prints one line per API call as it happens:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;  ClaudeTUI API Sniffer — listening on http://127.0.0.1:7735

  Use:  ANTHROPIC_BASE_URL=http://localhost:7735 claude
  Log:  ~/.claude/api-sniffer/sniffer-20260314-103000.jsonl

&lt;/span&gt;&lt;span class="gp"&gt;  #&lt;/span&gt;1   POST /v1/messages  opus-4-6  45.2k-&amp;gt;1.5k  &lt;span class="nv"&gt;$0&lt;/span&gt;.120  2312ms  740KB/4.2KB  98%c  &lt;span class="o"&gt;[&lt;/span&gt;Tt]
&lt;span class="gp"&gt;  #&lt;/span&gt;2   POST /v1/messages  opus-4-6  48.1k-&amp;gt;0.8k  &lt;span class="nv"&gt;$0&lt;/span&gt;.094  1134ms  741KB/2.1KB  99%c  &lt;span class="o"&gt;[&lt;/span&gt;TU]  Edit
&lt;span class="gp"&gt;  #&lt;/span&gt;3   POST /v1/messages  opus-4-6  50.3k-&amp;gt;52    &lt;span class="nv"&gt;$0&lt;/span&gt;.081  1823ms  742KB/0.3KB  100%c  &lt;span class="o"&gt;[&lt;/span&gt;U]  Glob,Grep
&lt;span class="gp"&gt;  #&lt;/span&gt;4   POST /v1/messages  opus-4-6  12.3k-&amp;gt;2.1k  &lt;span class="nv"&gt;$0&lt;/span&gt;.041  3412ms  42KB/6.8KB   95%c  &lt;span class="o"&gt;[&lt;/span&gt;Tt]  compaction
&lt;span class="gp"&gt;  #&lt;/span&gt;5   POST /v1/messages  sonnet-4-6  14.3k-&amp;gt;2.1k  &lt;span class="nv"&gt;$0&lt;/span&gt;.008  2341ms  42KB/6.8KB  &lt;span class="o"&gt;[&lt;/span&gt;Tt]  +agent.1
&lt;span class="go"&gt;
&lt;/span&gt;&lt;span class="gp"&gt;  Summary: 5 requests | $&lt;/span&gt;0.344 | 170k &lt;span class="k"&gt;in&lt;/span&gt; | 5.6k out | 2.3MB sent | 18KB recv | 1 sub-agent
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each line shows the model, input-&amp;gt;output tokens, estimated cost, latency, traffic size, cache hit ratio, content block types, tool names, and sub-agent tracking. Compaction events get flagged automatically.&lt;/p&gt;

&lt;p&gt;The content blocks tell you what Claude is doing: &lt;code&gt;T&lt;/code&gt; = thinking, &lt;code&gt;t&lt;/code&gt; = text, &lt;code&gt;U&lt;/code&gt; = tool use, &lt;code&gt;S&lt;/code&gt; = server-side tool (like WebSearch). Cache ratio (&lt;code&gt;98%c&lt;/code&gt;) shows how much you're saving — a &lt;code&gt;0%c&lt;/code&gt; (shown in red) means a cache miss, 12.5x more expensive.&lt;/p&gt;

&lt;p&gt;Meanwhile, every request and response is logged as structured JSONL for later analysis.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Transcripts Don't Tell You
&lt;/h2&gt;

&lt;p&gt;Claude Code's JSONL transcripts are useful, but they omit a lot. Here's what the sniffer captures that transcripts don't:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Data&lt;/th&gt;
&lt;th&gt;In Transcript?&lt;/th&gt;
&lt;th&gt;In Sniffer?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Token usage (input/output/cache)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Raw system prompt&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Full conversation history per request&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Request parameters (max_tokens, temperature)&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HTTP headers (anthropic-beta, version)&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Request/response latency&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hidden compaction API call&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Error response bodies&lt;/td&gt;
&lt;td&gt;Partial&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Streaming SSE events&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tool definitions (full JSON schema)&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The most interesting items on this list are the system prompt and the hidden compaction call.&lt;/p&gt;

&lt;h2&gt;
  
  
  The System Prompt
&lt;/h2&gt;

&lt;p&gt;Claude Code's system prompt is sent on every single API call. It contains:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Claude Code's internal instructions and behavioral guidelines&lt;/li&gt;
&lt;li&gt;Tool definitions (Read, Write, Edit, Bash, Glob, Grep, etc.) with full JSON schemas&lt;/li&gt;
&lt;li&gt;Your CLAUDE.md project instructions&lt;/li&gt;
&lt;li&gt;Memory files, hooks output, and other injected context&lt;/li&gt;
&lt;li&gt;Safety and permission guidelines&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With &lt;code&gt;--full&lt;/code&gt; mode, the sniffer captures the complete system prompt text. In our sessions, it consistently measures &lt;strong&gt;~14k tokens&lt;/strong&gt; — a fixed tax on every API call.&lt;/p&gt;

&lt;p&gt;This is useful for understanding exactly what Claude Code "knows" about your project. Your CLAUDE.md, your hooks output, your memory files — it's all there in the system prompt, and now you can read it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Hidden Compaction Call
&lt;/h2&gt;

&lt;p&gt;This is the one we were most curious about.&lt;/p&gt;

&lt;p&gt;When Claude Code's context window fills up (~167k of 200k tokens), it triggers compaction. The entire conversation gets compressed into a summary, and the next turn starts fresh with just the system prompt + summary.&lt;/p&gt;

&lt;p&gt;But here's the thing: &lt;strong&gt;the API call that generates the compaction summary doesn't appear in the transcript.&lt;/strong&gt; Claude Code makes it, receives the summary, and continues — but the JSONL transcript shows nothing. You see a &lt;code&gt;compact_boundary&lt;/code&gt; marker, but not the actual summarization call.&lt;/p&gt;

&lt;p&gt;The sniffer catches it because it's just another &lt;code&gt;POST /v1/messages&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;  #&lt;/span&gt;12  POST /v1/messages  opus-4-6  12.3k-&amp;gt;2.1k  &lt;span class="nv"&gt;$0&lt;/span&gt;.041  3412ms  compaction
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The sniffer detects compaction by comparing consecutive requests. When the message count drops by more than 50% or the total content size drops by more than 70% compared to the previous request, it flags it as post-compaction. The dramatic shrinkage — from 167k tokens of conversation down to a ~15k summary — is unmistakable.&lt;/p&gt;

&lt;p&gt;This is the only way to observe the compaction call's actual cost, latency, and output tokens. In our sessions, compaction summary generation takes 2-4 seconds and produces 11-19k tokens of compressed context.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Tool Use Loop
&lt;/h2&gt;

&lt;p&gt;When you see a line like this in the sniffer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;  #&lt;/span&gt;17  POST /v1/messages  opus-4-6  114.3k-&amp;gt;531  &lt;span class="nv"&gt;$0&lt;/span&gt;.217  16047ms  &lt;span class="o"&gt;[&lt;/span&gt;U]  tool
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's 114k tokens in but only 531 out. Why so few output tokens? Because Claude isn't writing prose — it's calling a tool. The response is just a small JSON block:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tool_use"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Read"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"input"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"file_path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/src/app.py"&lt;/span&gt;&lt;span class="p"&gt;}}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's the full cycle for a single tool call:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Claude Code sends the full conversation&lt;/strong&gt; to the API (114.3k input tokens — system prompt, message history, tool definitions, everything)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;API responds with a &lt;code&gt;tool_use&lt;/code&gt; block&lt;/strong&gt; — just the tool name and parameters (531 output tokens)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Claude Code executes the tool locally&lt;/strong&gt; — reads the file, runs the command, whatever the tool does&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Claude Code sends another request&lt;/strong&gt; with the tool result appended as a &lt;code&gt;tool_result&lt;/code&gt; message — now input tokens are higher because the file contents (or command output) are part of the conversation&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's why you see rapid back-to-back requests in the sniffer. A single "read this file and edit it" from the user might generate 5+ API calls:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;  #&lt;/span&gt;17  POST /v1/messages  opus-4-6  114.3k-&amp;gt;531   &lt;span class="o"&gt;[&lt;/span&gt;U]  tool     ← decide to &lt;span class="nb"&gt;read &lt;/span&gt;file
&lt;span class="gp"&gt;  #&lt;/span&gt;18  POST /v1/messages  opus-4-6  116.1k-&amp;gt;204   &lt;span class="o"&gt;[&lt;/span&gt;U]  tool     ← decide to edit file
&lt;span class="gp"&gt;  #&lt;/span&gt;19  POST /v1/messages  opus-4-6  117.8k-&amp;gt;1.2k  &lt;span class="o"&gt;[&lt;/span&gt;Tt]          ← respond to user
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each round-trip adds the tool result to the conversation, growing the input tokens. This is why context fills up faster than you'd expect — tool results (file contents, command output, search results) are often much larger than the tool call itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  SSE Streaming Under the Hood
&lt;/h2&gt;

&lt;p&gt;Claude Code uses Server-Sent Events (SSE) for streaming responses. The API returns &lt;code&gt;text/event-stream&lt;/code&gt; and sends data in chunks as the model generates tokens.&lt;/p&gt;

&lt;p&gt;The sniffer handles this transparently — it forwards each chunk to Claude Code as it arrives (so you don't notice any delay), while capturing the entire stream for logging.&lt;/p&gt;

&lt;p&gt;After streaming completes, it reassembles the SSE events to extract structured data: model, usage, stop reason, and content block types (text, thinking, tool_use). This is what makes the one-line terminal output possible — you get clean &lt;code&gt;45.2k-&amp;gt;1.5k $0.120 2312ms&lt;/code&gt; instead of raw SSE data.&lt;/p&gt;

&lt;p&gt;The key technical detail: we use &lt;code&gt;response.read1(8192)&lt;/code&gt; instead of &lt;code&gt;response.read(8192)&lt;/code&gt;. The &lt;code&gt;read1()&lt;/code&gt; method reads whatever data is currently available without waiting for the full buffer to fill — critical for streaming, where you need to forward partial data immediately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sub-Agent Tracking
&lt;/h2&gt;

&lt;p&gt;When Claude Code spawns a sub-agent (via the Agent tool), the sub-agent makes its own API calls — often using a different model. The sniffer tracks these by session ID:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;  #&lt;/span&gt;8   POST /v1/messages  opus-4-6    89.1k-&amp;gt;3.2k  &lt;span class="nv"&gt;$0&lt;/span&gt;.182  8234ms  99%c  &lt;span class="o"&gt;[&lt;/span&gt;TU]  Agent
&lt;span class="gp"&gt;  #&lt;/span&gt;9   POST /v1/messages  sonnet-4-6  14.3k-&amp;gt;2.1k  &lt;span class="nv"&gt;$0&lt;/span&gt;.008  2341ms         &lt;span class="o"&gt;[&lt;/span&gt;Tt]  +agent.1
&lt;span class="gp"&gt;  #&lt;/span&gt;10  POST /v1/messages  sonnet-4-6  16.5k-&amp;gt;1.2k  &lt;span class="nv"&gt;$0&lt;/span&gt;.006  1823ms         &lt;span class="o"&gt;[&lt;/span&gt;TU]  Read  agent.1
&lt;span class="gp"&gt;  #&lt;/span&gt;11  POST /v1/messages  sonnet-4-6  22.8k-&amp;gt;0.5k  &lt;span class="nv"&gt;$0&lt;/span&gt;.009  1243ms         &lt;span class="o"&gt;[&lt;/span&gt;t]   agent.1
&lt;span class="gp"&gt;  #&lt;/span&gt;12  POST /v1/messages  opus-4-6    92.3k-&amp;gt;1.5k  &lt;span class="nv"&gt;$0&lt;/span&gt;.152  4312ms  99%c  &lt;span class="o"&gt;[&lt;/span&gt;Tt]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;+agent.1&lt;/code&gt; marks the first request from a new sub-agent. Subsequent requests from the same agent show &lt;code&gt;agent.1&lt;/code&gt;. The main session has no label.&lt;/p&gt;

&lt;p&gt;This reveals things you can't see from the transcript: sub-agents often use Sonnet (cheaper, faster) for research tasks while the main session runs on Opus. You can see exactly how many API calls each sub-agent makes, their cost, and how they overlap with the main session.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cache Misses — The Silent Cost Spike
&lt;/h2&gt;

&lt;p&gt;The cache ratio (&lt;code&gt;98%c&lt;/code&gt;, &lt;code&gt;100%c&lt;/code&gt;) shows what percentage of input tokens were cache reads. Most of the time it's near 100% — great, you're paying the cheap rate.&lt;/p&gt;

&lt;p&gt;But leave your session idle for ~5 minutes and watch what happens:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;  #&lt;/span&gt;6   POST /v1/messages  opus-4-6  129.4k-&amp;gt;15   &lt;span class="nv"&gt;$0&lt;/span&gt;.199   3336ms  100%c  &lt;span class="o"&gt;[&lt;/span&gt;t]
&lt;span class="gp"&gt;  #&lt;/span&gt;7   POST /v1/messages  opus-4-6  129.5k-&amp;gt;428  &lt;span class="nv"&gt;$2&lt;/span&gt;.460  16108ms  0%c    &lt;span class="o"&gt;[&lt;/span&gt;Tt]
&lt;span class="gp"&gt;  #&lt;/span&gt;8   POST /v1/messages  opus-4-6  130.0k-&amp;gt;600  &lt;span class="nv"&gt;$0&lt;/span&gt;.248  18310ms  100%c  &lt;span class="o"&gt;[&lt;/span&gt;Tt]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Request #7 cost &lt;strong&gt;$2.46&lt;/strong&gt; — 12.5x more than usual — because the cache expired. All 129.5k tokens went through &lt;code&gt;cache_creation&lt;/code&gt; at $18.75/M instead of &lt;code&gt;cache_read&lt;/code&gt; at $1.50/M. Same data, same tokens, wildly different price.&lt;/p&gt;

&lt;p&gt;The sniffer shows &lt;code&gt;0%c&lt;/code&gt; in red to make these cache misses impossible to miss.&lt;/p&gt;

&lt;h2&gt;
  
  
  Per-Request Cost Tracking
&lt;/h2&gt;

&lt;p&gt;The sniffer calculates cost per API call using the token breakdown from the response:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"usage"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"input_tokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"cache_read_input_tokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;45000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"cache_creation_input_tokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;800&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"output_tokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1500&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With model-specific pricing (Opus: $15/$1.50/$18.75/$75 per 1M tokens for input/cache-read/cache-write/output), each line shows the exact cost of that call. No estimation, no averaging — real cost per request.&lt;/p&gt;

&lt;p&gt;This revealed something we didn't expect: the variance between calls is huge. A simple response might cost $0.03, while a long code generation can cost $0.50+ — in the same session, same model.&lt;/p&gt;

&lt;h2&gt;
  
  
  What We Learned
&lt;/h2&gt;

&lt;p&gt;After running the sniffer on real sessions, a few things stood out:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The system prompt is remarkably stable.&lt;/strong&gt; It barely changes between calls within a session. The ~14k tokens are almost entirely cached after the first call, making them cheap ($1.50/M vs $15/M for Opus). But they still consume context window space.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Compaction is expensive in latency, not just tokens.&lt;/strong&gt; The summary generation call takes 2-4 seconds — during which Claude Code is unresponsive. On a long session with 3 compactions, that's 6-12 seconds of dead time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Cache hit rates are extraordinary.&lt;/strong&gt; In typical sessions, 95-98% of input tokens are cache reads. The stateless-API design sounds expensive, but caching makes it practical.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Error responses are more informative than you'd think.&lt;/strong&gt; When Claude Code encounters a 429 (rate limit) or 529 (overloaded), the response body often includes retry-after headers and detailed error messages. These are swallowed by Claude Code's retry logic and never shown to you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Beta headers reveal feature flags.&lt;/strong&gt; The &lt;code&gt;anthropic-beta&lt;/code&gt; header shows which experimental features are active. Watching this change across Claude Code versions is interesting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security Notes
&lt;/h2&gt;

&lt;p&gt;The sniffer is designed to be safe by default:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Localhost only&lt;/strong&gt; — binds to &lt;code&gt;127.0.0.1&lt;/code&gt;, never &lt;code&gt;0.0.0.0&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;API keys redacted&lt;/strong&gt; — &lt;code&gt;x-api-key&lt;/code&gt; and &lt;code&gt;authorization&lt;/code&gt; headers stripped from logs by default (use &lt;code&gt;--no-redact&lt;/code&gt; to override)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restricted permissions&lt;/strong&gt; — log files created with &lt;code&gt;0o600&lt;/code&gt; (owner read/write only)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Local plaintext&lt;/strong&gt; — the API key transits in plain text only over the loopback interface, which is standard for local proxy patterns&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;

&lt;p&gt;The sniffer is part of &lt;a href="https://github.com/slima4/claude-tui" rel="noopener noreferrer"&gt;ClaudeTUI&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Install&lt;/span&gt;
brew tap slima4/claude-tui &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; brew &lt;span class="nb"&gt;install &lt;/span&gt;claude-tui &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; claudetui setup

&lt;span class="c"&gt;# Or&lt;/span&gt;
curl &lt;span class="nt"&gt;-sSL&lt;/span&gt; https://raw.githubusercontent.com/slima4/claude-tui/main/install.sh | bash

&lt;span class="c"&gt;# Run&lt;/span&gt;
claudetui sniffer              &lt;span class="c"&gt;# Terminal 1: start proxy&lt;/span&gt;
claudetui sniff                &lt;span class="c"&gt;# Terminal 2: launch claude through proxy&lt;/span&gt;
claudetui sniff &lt;span class="nt"&gt;--resume&lt;/span&gt; abc   &lt;span class="c"&gt;# or resume a session through proxy&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Options:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;--port PORT     Custom port (default: 7735)
--full          Log complete request/response bodies (warning: large files)
--no-redact     Include API keys in logs (use with caution)
--quiet         Suppress terminal output, log only
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Python 3.8+, stdlib only — no external dependencies.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;The sniffer captures data that was previously invisible. Combined with ClaudeTUI's existing &lt;a href="https://github.com/slima4/claude-tui" rel="noopener noreferrer"&gt;context efficiency analysis&lt;/a&gt;, this gives a complete picture of what Claude Code is doing under the hood — from high-level token waste tracking down to raw HTTP traffic.&lt;/p&gt;

&lt;p&gt;Some things we're exploring:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Replaying captured sessions&lt;/strong&gt; for cost modeling ("what would this session cost on Sonnet vs Opus?")&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Diffing system prompts&lt;/strong&gt; across Claude Code versions to track changes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Correlating latency with context size&lt;/strong&gt; — does response time scale linearly with input tokens?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Analyzing compaction summaries&lt;/strong&gt; — what gets preserved and what gets lost?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're curious about what your Claude Code sessions actually look like at the API level, point the sniffer at a session and watch the data flow.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;ClaudeTUI is open source and MIT licensed. Stdlib-only Python, zero external dependencies.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;GitHub: &lt;a href="https://github.com/slima4/claude-tui" rel="noopener noreferrer"&gt;github.com/slima4/claude-tui&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>claude</category>
      <category>ai</category>
      <category>sniffer</category>
      <category>monitoring</category>
    </item>
    <item>
      <title>Where Do Your Claude Code Tokens Actually Go? We Traced Every Single One</title>
      <dc:creator>Slim</dc:creator>
      <pubDate>Sat, 14 Mar 2026 06:25:43 +0000</pubDate>
      <link>https://dev.to/slima4/where-do-your-claude-code-tokens-actually-go-we-traced-every-single-one-423e</link>
      <guid>https://dev.to/slima4/where-do-your-claude-code-tokens-actually-go-we-traced-every-single-one-423e</guid>
      <description>&lt;p&gt;You're paying for 200,000 tokens of context. But how many of those tokens are actually doing useful work?&lt;/p&gt;

&lt;p&gt;We built &lt;a href="https://github.com/slima4/claude-tui" rel="noopener noreferrer"&gt;ClaudeTUI&lt;/a&gt; — a set of monitoring tools for Claude Code — and dug into the raw JSONL transcript data to trace every token. What we found surprised us: there are four distinct categories of token usage, and only one of them is your actual work.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Happens When You Press Enter
&lt;/h2&gt;

&lt;p&gt;Here's something most Claude Code users don't realize: &lt;strong&gt;every time you press Enter, the entire conversation is sent from scratch.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The Claude API is stateless. It doesn't remember your previous messages. So every single keystroke triggers an API call that includes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;System prompt&lt;/strong&gt; (~14k tokens) — Claude Code's instructions, tool definitions, your CLAUDE.md&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Full conversation history&lt;/strong&gt; — every message, every tool call, every tool result since the last compaction&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Your new message&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;On turn 1, that's maybe 15k tokens. By turn 15, it's 100k. By turn 30, it's 167k — and then compaction fires.&lt;/p&gt;

&lt;p&gt;This is why Claude gets slower and more expensive as your session goes on. Each Enter keystroke processes more tokens than the last. And it's why compaction exists: without it, you'd hit the 200k wall and the session would simply stop.&lt;/p&gt;

&lt;p&gt;The good news: Anthropic's &lt;strong&gt;prompt caching&lt;/strong&gt; makes this less painful than it sounds. But it's worth understanding how.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Cache Lives on Anthropic's Servers
&lt;/h3&gt;

&lt;p&gt;Your machine sends the full conversation on every request — the same bytes go over the network every time. The optimization happens server-side: Anthropic checks "have I seen this exact prefix of tokens recently?" If yes, it skips re-processing them and charges the cheaper &lt;strong&gt;cache read&lt;/strong&gt; rate ($1.50/M instead of $15/M for Opus — a 10x discount).&lt;/p&gt;

&lt;p&gt;In a 157-turn session, we measured &lt;strong&gt;98% of all tokens as cache reads&lt;/strong&gt;. That makes sense: by turn 100, you're re-sending 99 turns of history that are already cached. Only the newest content goes through the expensive &lt;code&gt;cache_creation&lt;/code&gt; path.&lt;/p&gt;

&lt;p&gt;The cache has a TTL — likely ~5 minutes for conversation content. If you pause too long between turns, the cache expires and the next call pays full input price for everything. This is also why compaction is expensive: it blows away the entire cached conversation and replaces it with a brand new summary that goes through &lt;code&gt;cache_creation&lt;/code&gt; from scratch.&lt;/p&gt;

&lt;p&gt;One more thing: &lt;strong&gt;the tokens still count toward your 200k context window&lt;/strong&gt;, even when cached. Caching saves money, not space.&lt;/p&gt;

&lt;p&gt;Now let's look at what those tokens actually are.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Four Types of Tokens
&lt;/h2&gt;

&lt;p&gt;Every API call Claude Code makes has a token usage breakdown in its transcript. By parsing thousands of these calls across real sessions, we identified four categories:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. System Prompt (~14k tokens) — The Constant Tax
&lt;/h3&gt;

&lt;p&gt;Every single API call includes a system prompt: Claude Code's internal instructions, tool definitions, safety guidelines, and your CLAUDE.md file. In our sessions, this was consistently &lt;strong&gt;~14,328 tokens&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This isn't something you can avoid. It's infrastructure. But it means that out of your 200k window, only ~186k is ever available for actual conversation.&lt;/p&gt;

&lt;p&gt;We discovered this by looking at &lt;code&gt;cache_read_input_tokens&lt;/code&gt; after compaction events. The value &lt;strong&gt;resets to exactly 14,328&lt;/strong&gt; every time — that's the system prompt floor. During normal operation, &lt;code&gt;cache_read&lt;/code&gt; grows from 14k to 167k as your conversation accumulates in the cache.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Compaction Summary (~11-19k tokens) — The Rebuild Cost
&lt;/h3&gt;

&lt;p&gt;When compaction fires, Claude Code compresses your entire conversation into a summary. The next API call has to read that summary to reconstruct context. This is the &lt;strong&gt;real overhead of compaction&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;From a real 3-compaction session:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Compaction&lt;/th&gt;
&lt;th&gt;Summary Size&lt;/th&gt;
&lt;th&gt;What It Costs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;#1&lt;/td&gt;
&lt;td&gt;18.8k tokens&lt;/td&gt;
&lt;td&gt;$0.47 (Opus)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;#2&lt;/td&gt;
&lt;td&gt;10.6k tokens&lt;/td&gt;
&lt;td&gt;$0.22 (Opus)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;#3&lt;/td&gt;
&lt;td&gt;17.8k tokens&lt;/td&gt;
&lt;td&gt;$0.37 (Opus)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These summaries are lossy. Your 167k of rich context — exact error messages, file contents, code snippets — gets compressed into 11-19k tokens. Details are lost.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Useful Work — What You Actually Paid For
&lt;/h3&gt;

&lt;p&gt;This is everything else: your prompts, Claude's responses, tool calls, file reads, code edits, test output. The actual productive content of your session.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Headroom (~33k tokens) — The Unused Buffer
&lt;/h3&gt;

&lt;p&gt;Claude Code doesn't wait until 200k to compact. It triggers at roughly &lt;strong&gt;83% capacity (~167k tokens)&lt;/strong&gt;, reserving ~33k tokens as a buffer for the compaction process itself.&lt;/p&gt;

&lt;p&gt;That means ~16.5% of your context window is never available for useful work. You're paying for 200k but only getting ~167k.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Real Session, Dissected
&lt;/h2&gt;

&lt;p&gt;Here's an actual 4-segment session from our monitoring data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  Seg 1  ▒▒▓▓████████████████████████████████████████████████░░░░░  200.0k
         14.3k system │ 152.7k useful │ 33.0k headroom │ → compacted

  Seg 2  ▒▒▓▓▓████████████████████████████████████░░░░░░░░░░░░░░░  200.0k
         14.3k system │ 18.8k summary │ 114.4k useful │ 52.5k headroom │ → compacted

  Seg 3  ▒▒▓▓▓████████████████████████████████████████████████░░░  200.0k
         14.3k system │ 17.8k summary │ 141.2k useful │ 33.9k headroom │ → compacted

→ Seg 4  ▒▒▓▓██████                                                44.8k
         14.3k system │ 10.6k summary │ 12.7k useful

  Efficiency: 76%  │  Wasted: 166.5k/644.8k
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;76% efficiency&lt;/strong&gt; means 76% of the total tokens went to useful work. The other 24% went to compaction summaries and headroom.&lt;/p&gt;

&lt;p&gt;Notice how Seg 1 has no summary — it's the first segment, nothing to rebuild from. But starting from Seg 2, every segment pays the summary tax.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Hidden API Call
&lt;/h2&gt;

&lt;p&gt;One thing we couldn't find in the transcript: &lt;strong&gt;the compaction summary generation itself&lt;/strong&gt;. Claude Code makes a hidden API call that reads your ~167k context and produces the summary, but this call is not logged in the JSONL transcript.&lt;/p&gt;

&lt;p&gt;Based on the &lt;code&gt;preTokens&lt;/code&gt; metadata we found in compaction events, this hidden call reads the full pre-compaction context (~167k tokens). At Opus pricing ($1.50/M for cached reads), that's roughly $0.25 per compaction just for the summary generation — on top of the rebuild cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Means for Your Wallet
&lt;/h2&gt;

&lt;p&gt;Let's do the math for a long Opus session with 3 compactions:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Token budget: 644.8k total&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;Tokens&lt;/th&gt;
&lt;th&gt;Cost (Opus)&lt;/th&gt;
&lt;th&gt;% of Total&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Useful work&lt;/td&gt;
&lt;td&gt;490k&lt;/td&gt;
&lt;td&gt;~$8.50&lt;/td&gt;
&lt;td&gt;76%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Compaction summaries&lt;/td&gt;
&lt;td&gt;47k&lt;/td&gt;
&lt;td&gt;~$0.85&lt;/td&gt;
&lt;td&gt;7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Headroom (unused)&lt;/td&gt;
&lt;td&gt;108k&lt;/td&gt;
&lt;td&gt;$0 (not billed)&lt;/td&gt;
&lt;td&gt;17%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;System prompt (constant)&lt;/td&gt;
&lt;td&gt;~43k&lt;/td&gt;
&lt;td&gt;~$0.06 (cached)&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hidden summary generation&lt;/td&gt;
&lt;td&gt;~500k reads&lt;/td&gt;
&lt;td&gt;~$0.75&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The headroom tokens aren't billed directly — they represent capacity you couldn't use. But the summaries and hidden calls are real costs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;With Sonnet 4.6&lt;/strong&gt; the same session would be dramatically cheaper. Sonnet supports up to 1M context, so with 644k tokens you'd hit &lt;strong&gt;zero compactions&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;All tokens are useful work&lt;/li&gt;
&lt;li&gt;Efficiency: 100%&lt;/li&gt;
&lt;li&gt;Cost: ~$5.50 (vs ~$10+ on Opus)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The System Prompt Discovery
&lt;/h2&gt;

&lt;p&gt;Perhaps the most interesting finding: the system prompt is a &lt;strong&gt;constant ~14k tax&lt;/strong&gt; on every segment.&lt;/p&gt;

&lt;p&gt;Before our investigation, we were counting the full post-compaction context as "rebuild waste." A segment showing &lt;code&gt;33.1k rebuild&lt;/code&gt; looked like 33.1k of compaction overhead. But 14.3k of that is system prompt — you'd pay it regardless.&lt;/p&gt;

&lt;p&gt;The actual compaction overhead (the summary) is only &lt;code&gt;33.1k - 14.3k = 18.8k&lt;/code&gt;. That's a 43% difference in how you measure waste.&lt;/p&gt;

&lt;p&gt;How we detected it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;After compaction #1: cache_read = 14,328  ← system prompt
After compaction #2: cache_read = 14,328  ← same
After compaction #3: cache_read = 14,328  ← same

During normal operation: cache_read grows from 14k → 167k
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;cache_read&lt;/code&gt; value tells you exactly what's already cached. After compaction, only the system prompt survives in cache — everything else (the compaction summary) goes through &lt;code&gt;cache_creation&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Compaction Cache Structure
&lt;/h2&gt;

&lt;p&gt;Here's how token caching works across a compaction boundary:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before compaction&lt;/strong&gt; (normal operation):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cache_read: 166,575    ← almost everything is cached
cache_creation: 312    ← tiny new content
input_tokens: 3        ← negligible
output_tokens: 126
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;First call after compaction&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cache_read: 14,328     ← only system prompt survives
cache_creation: 18,793 ← compaction summary, written fresh
input_tokens: 3
output_tokens: 1,249
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cache gets blown away by compaction. Everything that was cached (your conversation, tool results, file contents) is gone. Only the system prompt persists because it's on a separate, longer-lived cache (likely a 1-hour TTL vs the 5-minute conversation cache).&lt;/p&gt;

&lt;h2&gt;
  
  
  7 Things You Can Do Right Now
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Use &lt;code&gt;/compact&lt;/code&gt; manually at logical breakpoints&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Don't wait for auto-compaction at 167k. After finishing a feature or fixing a bug, compact yourself. You can guide what gets preserved:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;/compact Preserve all file paths, error messages, and the list of modified files
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. Use &lt;code&gt;/clear&lt;/code&gt; between distinct tasks&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Switching from implementation to debugging? Starting a new feature? A fresh 186k of clean context beats 80k of stale context with irrelevant history.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Delegate verbose work to subagents&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Each subagent gets its own isolated 200k context window. Running tests, searching large codebases, or fetching documentation in subagents keeps verbose output from bloating your main session.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Read files with line ranges&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Instead of reading entire files, specify what you need: "Read lines 40-90 of handler.ts." Especially critical in debugging loops where you might read the same file repeatedly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Disable unused MCP servers&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Each MCP server loads its full tool schema into context on every request. A 20-tool server can consume 5-10k tokens just by existing. That's on top of the 14k system prompt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Keep CLAUDE.md under 200 lines&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;CLAUDE.md is part of that ~14k system prompt. It loads on every single API call and survives all compaction cycles. If it's bloated, you're paying on every call.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;7. Monitor your efficiency&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Install ClaudeTUI and watch the numbers in real-time. Seeing "Efficiency: 76%" drop to "Efficiency: 68%" after a compaction changes how you think about context management.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to See This Yourself
&lt;/h2&gt;

&lt;p&gt;Install ClaudeTUI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Via Homebrew&lt;/span&gt;
brew &lt;span class="nb"&gt;install &lt;/span&gt;slima4/claude-tui/claude-tui &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; claudetui setup

&lt;span class="c"&gt;# Or directly&lt;/span&gt;
curl &lt;span class="nt"&gt;-sSL&lt;/span&gt; https://raw.githubusercontent.com/slima4/claude-tui/main/install.sh | bash
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open a second terminal and run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claudetui monitor    &lt;span class="c"&gt;# live dashboard&lt;/span&gt;
claudetui chart      &lt;span class="c"&gt;# efficiency chart (standalone)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The efficiency chart shows the 4-component breakdown for every segment in your session — updated live as you work. Press &lt;code&gt;w&lt;/code&gt; in the monitor to open it, or &lt;code&gt;v&lt;/code&gt; to toggle between horizontal and vertical views.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bottom Line
&lt;/h2&gt;

&lt;p&gt;Every Claude Code session has four types of token usage:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;System prompt&lt;/strong&gt; (~14k) — constant tax, can't avoid it, but it's cheap (cached)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compaction summaries&lt;/strong&gt; (~11-19k each) — the real cost of compaction, lossy compression of your work&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Useful work&lt;/strong&gt; — what you actually paid for&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Headroom&lt;/strong&gt; (~33k) — buffer that's never available for work&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In a typical 3-compaction Opus session, about &lt;strong&gt;76% of tokens are useful work&lt;/strong&gt;. The rest is overhead. Making this visible — and understanding what each component actually is — is the first step to spending tokens more intentionally.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;ClaudeTUI is open source and MIT licensed. Stdlib-only Python, zero external dependencies.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;GitHub: &lt;a href="https://github.com/slima4/claude-tui" rel="noopener noreferrer"&gt;github.com/slima4/claude-tui&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>claude</category>
      <category>ai</category>
      <category>statusline</category>
      <category>productivity</category>
    </item>
    <item>
      <title>ClaudeTUI v0.3: Claude Code statusline gets a unified CLI, interactive configurator, and a proper splash screen</title>
      <dc:creator>Slim</dc:creator>
      <pubDate>Wed, 11 Mar 2026 07:27:08 +0000</pubDate>
      <link>https://dev.to/slima4/claudetui-v03-claude-code-statusline-gets-a-unified-cli-interactive-configurator-and-a-proper-4h7c</link>
      <guid>https://dev.to/slima4/claudetui-v03-claude-code-statusline-gets-a-unified-cli-interactive-configurator-and-a-proper-4h7c</guid>
      <description>&lt;p&gt;A couple of days ago I shared &lt;a href="https://github.com/slima4/claude-tui" rel="noopener noreferrer"&gt;ClaudeTUI&lt;/a&gt; — a real-time statusline, live monitor, and session analytics for Claude Code. Since then, a lot has changed. Here's what's new in v0.3.&lt;/p&gt;

&lt;h2&gt;
  
  
  One command to rule them all
&lt;/h2&gt;

&lt;p&gt;The biggest change: instead of six separate CLI commands for managing the Claude Code statusline, monitor, and analytics, there's now a single &lt;code&gt;claudetui&lt;/code&gt; dispatcher.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# before&lt;/span&gt;
claude-ui-monitor
claude-stats &lt;span class="nt"&gt;--days&lt;/span&gt; 7
claude-sessions list
claude-ui-mode compact

&lt;span class="c"&gt;# after&lt;/span&gt;
claudetui monitor
claudetui stats &lt;span class="nt"&gt;--days&lt;/span&gt; 7
claudetui sessions list
claudetui mode compact
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every subcommand passes arguments straight through — &lt;code&gt;claudetui&lt;/code&gt; is just a 60-line Python script that resolves the right tool and &lt;code&gt;exec&lt;/code&gt;s it. No overhead, no framework. If you type &lt;code&gt;claudetui --help&lt;/code&gt;, you get the full menu:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;claudetui — CLI for ClaudeTUI (Claude Code utilities)

Commands:
  monitor     Live session dashboard (separate terminal)
  stats       Post-session analytics
  sessions    Browse, compare, resume, export sessions
  mode        Switch statusline mode (full/compact/custom)
  setup       Configure statusline, hooks, and commands
  uninstall   Remove ClaudeTUI configuration
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The old command names (&lt;code&gt;claude-ui-monitor&lt;/code&gt;, &lt;code&gt;claude-stats&lt;/code&gt;, etc.) are gone. Clean break, clean namespace.&lt;/p&gt;

&lt;h2&gt;
  
  
  Interactive statusline configurator
&lt;/h2&gt;

&lt;p&gt;The Claude Code statusline has 20+ components across three lines — context usage, cost, model, git stats, tool trace, and more. Previously you could only choose between "everything" (full mode) and "almost nothing" (compact mode). Now there's a third option:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claudetui mode custom
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This opens a curses TUI where you can toggle individual components with arrow keys and spacebar:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F14bnm0k416xcwcvt7ng0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F14bnm0k416xcwcvt7ng0.png" alt="Statusline Configurator" width="800" height="863"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Each component shows a &lt;strong&gt;live preview&lt;/strong&gt; of what it looks like — colored progress bars, sparklines, git stats — right in the menu. You can pick from five widget styles for the matrix rain area, or apply presets (&lt;code&gt;all&lt;/code&gt;, &lt;code&gt;minimal&lt;/code&gt;, &lt;code&gt;focused&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Don't like interactive menus? Use flags:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claudetui mode custom &lt;span class="nt"&gt;--hide&lt;/span&gt; model,cost,session_id
claudetui mode custom &lt;span class="nt"&gt;-w&lt;/span&gt; hex &lt;span class="nt"&gt;-p&lt;/span&gt; focused
claudetui mode custom &lt;span class="nt"&gt;-l&lt;/span&gt;   &lt;span class="c"&gt;# show what's currently hidden&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Config saves to &lt;code&gt;~/.claude/claudeui.json&lt;/code&gt; and hot-reloads — no restart needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Monitor got a facelift
&lt;/h2&gt;

&lt;p&gt;The live monitor (&lt;code&gt;claudetui monitor&lt;/code&gt;) picked up several improvements:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Splash screen&lt;/strong&gt; — the monitor now shows an ASCII art logo while it loads the session in the background. Looks cool, masks the 1-2 second discovery time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pinned layout&lt;/strong&gt; — the header (context, cost, stats) and footer (hotkey bar) are now fixed. Only the log section scrolls. No more hunting for the help bar when the log fills up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Configurable log size&lt;/strong&gt; — add &lt;code&gt;"monitor": {"log_lines": 12}&lt;/code&gt; to your &lt;code&gt;claudeui.json&lt;/code&gt;, or set it to &lt;code&gt;0&lt;/code&gt; to hide the log entirely. Default is 8. Adjustable from the settings panel too (press &lt;code&gt;c&lt;/code&gt; in the monitor).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Responsive footer&lt;/strong&gt; — the hotkey bar adapts to terminal width. Full labels at 60+ columns, abbreviated at 40+, minimal below that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rebrand
&lt;/h2&gt;

&lt;p&gt;You might have noticed: it's ClaudeTUI now, not ClaudeUI. The old name was too generic and clashed with other projects. Everything got renamed — repo, Homebrew tap, slash commands (&lt;code&gt;/ui:*&lt;/code&gt; → &lt;code&gt;/tui:*&lt;/code&gt;), CLI tools, docs, landing page, and all the screenshots.&lt;/p&gt;

&lt;p&gt;The installer handles migration automatically — if you have the old &lt;code&gt;claudeui&lt;/code&gt; Homebrew tap, it untaps it and points you to the new one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew tap slima4/claude-tui
brew &lt;span class="nb"&gt;install &lt;/span&gt;claude-tui
claudetui setup
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or the one-liner:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sSL&lt;/span&gt; https://raw.githubusercontent.com/slima4/claude-tui/main/install.sh | bash
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;The transcript parsing approach works surprisingly well, but it's still reverse-engineering an undocumented format. Every Claude Code update is a small gamble. I'd love to see an official API for session metadata — even just a stable JSON schema for the transcript would help.&lt;/p&gt;

&lt;p&gt;In the meantime, if you use Claude Code and want a statusline with real-time context tracking, cost monitoring, and session analytics: &lt;a href="https://github.com/slima4/claude-tui" rel="noopener noreferrer"&gt;github.com/slima4/claude-tui&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Stars and issues welcome.&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>ai</category>
      <category>python</category>
      <category>cli</category>
    </item>
    <item>
      <title>I built a real-time dashboard for Claude Code because I kept losing track of my sessions</title>
      <dc:creator>Slim</dc:creator>
      <pubDate>Mon, 09 Mar 2026 08:04:57 +0000</pubDate>
      <link>https://dev.to/slima4/i-built-a-real-time-dashboard-for-claude-code-because-i-kept-losing-track-of-my-sessions-2m54</link>
      <guid>https://dev.to/slima4/i-built-a-real-time-dashboard-for-claude-code-because-i-kept-losing-track-of-my-sessions-2m54</guid>
      <description>&lt;p&gt;Claude Code has a 200k token context window but gives you zero visibility into how much of it you've used — until auto-compaction kicks in and wipes half your context. I got tired of that surprise, so I built ClaudeTUI.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;If you use Claude Code daily, you've probably hit these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Auto-compaction fires mid-task and you lose context&lt;/li&gt;
&lt;li&gt;No idea how much a session is costing you&lt;/li&gt;
&lt;li&gt;Can't tell which files Claude has been touching&lt;/li&gt;
&lt;li&gt;No way to compare sessions or track patterns over time&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Claude Code is a powerful tool, but it's a black box. You type, it works, and you hope for the best.&lt;/p&gt;

&lt;h2&gt;
  
  
  What ClaudeTUI does
&lt;/h2&gt;

&lt;p&gt;It's a collection of tools that plug into Claude Code and give you full visibility:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Statusline&lt;/strong&gt; — a real-time status bar that sits at the bottom of Claude Code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; 0110100 Opus 4.6 │ ████████░░░░░░░░░░░░ 42% 65.5k/200.0k │ ~24 turns left │ $2.34 │ 12m │ 0x compact
 1001011 my-project │ main +42 -17 │ 18 turns │ 5 files │ 0 err │ 82% cache │ 4x think │ ~$0.13/turn
 0110010 read config.ts → edit config.ts → bash npm test → edit README.md │ config.ts×2 README.md×1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Context usage, cost, cache ratio, git diff, tool trace, compaction prediction — all live. There's also a compact 1-line mode if you prefer minimal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Live Monitor&lt;/strong&gt; — open a second terminal and get a full dashboard:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;claude-ui-monitor
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6mp0v25w11e0dog6oozt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6mp0v25w11e0dog6oozt.png" alt="ClaudeTUI Monitor — live session dashboard" width="800" height="698"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Context sparkline with compaction history, cost breakdown with cache savings, per-turn activity (tools, files, errors), session-wide stats, and a scrollable log viewer with filters. It even tracks agent spawns and their results. The matrix rain header pauses when Claude is idle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hooks&lt;/strong&gt; — automatic context injected into your sessions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Session start: shows which files you've been editing recently across sessions&lt;/li&gt;
&lt;li&gt;After edit: warns you about reverse dependencies ("4 files import this module")&lt;/li&gt;
&lt;li&gt;Before edit: flags high-churn files ("config.ts edited 43 times in 5 sessions — maybe refactor?")&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Session Stats&lt;/strong&gt; — post-session analytics:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;claude-stats &lt;span class="nt"&gt;--days&lt;/span&gt; 7 &lt;span class="nt"&gt;-s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cost breakdown, token sparklines, tool usage charts, file activity heatmaps. See which sessions burned the most tokens and why.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Session Manager&lt;/strong&gt; — browse and compare sessions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;claude-sessions list
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;claude-sessions diff abc123 def456
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Side-by-side comparison of cost, duration, tools used, and file activity between any two sessions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Slash Commands&lt;/strong&gt; — deep reports without leaving Claude Code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;/ui:session    #&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;full session report
&lt;span class="gp"&gt;/ui:cost       #&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;cost deep dive
&lt;span class="gp"&gt;/ui:perf       #&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;tool efficiency analysis
&lt;span class="gp"&gt;/ui:context    #&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;context window predictions
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;p&gt;Everything runs by parsing Claude Code's transcript JSONL files from &lt;code&gt;~/.claude/projects/&lt;/code&gt;. No API keys, no external services, no dependencies — just Python 3.8+ and the standard library.&lt;/p&gt;

&lt;p&gt;The statusline uses Claude Code's &lt;code&gt;statusLine&lt;/code&gt; config to run a Python script that reads session metadata from stdin and the transcript file from disk. It does two passes: a reverse pass to find current context size, and a forward pass to accumulate costs and activity.&lt;/p&gt;

&lt;p&gt;The hooks use Claude Code's hooks system to run scripts on events like &lt;code&gt;SessionStart&lt;/code&gt;, &lt;code&gt;PreToolUse&lt;/code&gt;, and &lt;code&gt;PostToolUse&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The monitor watches the transcript file for changes and redraws when it detects new content.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install
&lt;/h2&gt;

&lt;p&gt;One command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sSL&lt;/span&gt; https://raw.githubusercontent.com/slima4/claude-tui/main/install.sh | bash
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It sets up everything — statusline, hooks, slash commands, and CLI tools. The installer asks you to pick full or compact mode for the statusline. You can switch anytime with &lt;code&gt;claude-ui-mode compact&lt;/code&gt; or &lt;code&gt;claude-ui-mode full&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;To uninstall: &lt;code&gt;claude-ui-uninstall&lt;/code&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;p&gt;Building tools that parse another tool's internal format is fragile by nature. Claude Code's transcript format isn't documented, so I had to reverse-engineer it by reading JSONL files and figuring out the structure. It works well today, but could break with any Claude Code update.&lt;/p&gt;

&lt;p&gt;The other challenge was performance. The statusline runs on every refresh, so it needs to parse the transcript fast. For long sessions with thousands of entries, the reverse-pass-first approach helps — you find the current context size quickly without reading the entire file sequentially.&lt;/p&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Website&lt;/strong&gt;: &lt;a href="https://slima4.github.io/claude-tui/" rel="noopener noreferrer"&gt;slima4.github.io/claude-tui&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub&lt;/strong&gt;: &lt;a href="https://github.com/slima4/claude-tui" rel="noopener noreferrer"&gt;github.com/slima4/claude-tui&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you use Claude Code and want more visibility into your sessions, give it a try. Issues and PRs welcome.&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>ai</category>
      <category>python</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
