<?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: אחיה כהן</title>
    <description>The latest articles on DEV Community by אחיה כהן (@achiya-automation).</description>
    <link>https://dev.to/achiya-automation</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%2F3810102%2Fefb43e59-992c-4f8b-91df-ee602c7c853f.jpg</url>
      <title>DEV Community: אחיה כהן</title>
      <link>https://dev.to/achiya-automation</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/achiya-automation"/>
    <language>en</language>
    <item>
      <title>I Stopped Merging Pull Requests to My Browser Tool. Contributors Still Ship.</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Wed, 28 Oct 2026 04:38:01 +0000</pubDate>
      <link>https://dev.to/achiya-automation/i-stopped-merging-pull-requests-to-my-browser-tool-contributors-still-ship-4gf7</link>
      <guid>https://dev.to/achiya-automation/i-stopped-merging-pull-requests-to-my-browser-tool-contributors-still-ship-4gf7</guid>
      <description>&lt;p&gt;I maintain &lt;a href="https://github.com/achiya-automation/safari-mcp" rel="noopener noreferrer"&gt;safari-mcp&lt;/a&gt;, an MCP server that lets AI agents drive Safari on macOS. Not a fresh browser in a sandbox. The user's own Safari, with their Gmail session, their bank tab, their cookies. That is the whole point of the tool, and it is also why I no longer merge anyone's pull request.&lt;/p&gt;

&lt;p&gt;People still contribute, and their ideas still ship. Today's release, v2.23.0, has four of them. But none of the contributor's commits are on &lt;code&gt;main&lt;/code&gt;. The ideas are there, rewritten line by line in my own commit, with the contributor credited in the changelog.&lt;/p&gt;

&lt;p&gt;Here is why I ended up there, and what it costs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pull request that said one thing and did another
&lt;/h2&gt;

&lt;p&gt;On August 27 an account I had never seen opened a PR titled "add x86_64 safari-helper binary variant". The description said it added an x86 build next to the existing arm64 binary and updated the CI workflow to build both.&lt;/p&gt;

&lt;p&gt;The file list said something else:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;modified  safari-helper      +0 -0
added     safari-helper-x86  +0 -0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No workflow change. And the first line is the one that matters: it &lt;em&gt;modified&lt;/em&gt; &lt;code&gt;safari-helper&lt;/code&gt;, the signed native binary that ships in the npm package and posts clicks and keystrokes into Safari. A binary shows up in a diff as &lt;code&gt;+0 -0&lt;/code&gt;. There is nothing to read. If I had merged it because the description sounded reasonable and CI was green, every user's next &lt;code&gt;npm install&lt;/code&gt; would have run a binary I had never seen, with the Accessibility permission they granted to mine.&lt;/p&gt;

&lt;p&gt;I closed it the next day. I never found out what the binary did, and I don't want to. Finding out would have meant running it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The review that ran a stranger's code on my Mac
&lt;/h2&gt;

&lt;p&gt;The second lesson was my own fault, and the code involved was clean.&lt;/p&gt;

&lt;p&gt;On September 28 I reviewed an outside PR the usual way: check out the branch, run the test suite, see if it passes. It passed. Then it occurred to me what "run the test suite" means on this machine. The tests are JavaScript that runs as my user. It can read &lt;code&gt;~/Library&lt;/code&gt;, the Safari profile, and every token in my environment. &lt;code&gt;npm test&lt;/code&gt; on a contributor's branch is just "run this stranger's program", with a green check at the end.&lt;/p&gt;

&lt;p&gt;Nothing bad happened. But "nothing bad happened" is not a review process.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rules now
&lt;/h2&gt;

&lt;p&gt;I wrote them down so I'd follow them on autopilot, including when I'm in a hurry:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Outside code never runs on my machine.&lt;/strong&gt; I review by reading: &lt;code&gt;gh pr diff &amp;lt;n&amp;gt;&lt;/code&gt; and the PR's file list from the API. No &lt;code&gt;gh pr checkout&lt;/code&gt;, no fetching &lt;code&gt;pull/&amp;lt;n&amp;gt;/head&lt;/code&gt;, no cloning the fork to "just try it".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Their code runs only in GitHub CI.&lt;/strong&gt; A fork PR gets a read-only token, and the repo has no Actions secrets for it to reach. I check that this stays true on every run: collaborators, deploy keys, webhooks, secrets, &lt;code&gt;pull_request_target&lt;/code&gt;, and who published the latest npm version.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The file list is read before the description.&lt;/strong&gt; A binary, a lockfile with a new &lt;code&gt;resolved&lt;/code&gt; host, a new &lt;code&gt;postinstall&lt;/code&gt; script, a change under &lt;code&gt;.github/&lt;/code&gt;, or encoded strings ends the review. A description that doesn't match the diff ends it too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Taking an idea means rewriting it.&lt;/strong&gt; I read every line, then write the change myself in my own commit, and my tests run on my commit. I never take binaries, lockfiles, workflows, &lt;code&gt;package.json&lt;/code&gt; scripts or generated code from a PR, even if they look identical to what I'd produce.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Rule 4 is the one people find odd, so here is what it looked like this week.&lt;/p&gt;

&lt;h2&gt;
  
  
  Seven PRs in 45 seconds
&lt;/h2&gt;

&lt;p&gt;On October 20 one contributor opened seven PRs within 45 seconds. That pattern is exactly how a payload would hide: PR #N+1 "builds on" PR #N and carries a copy of its commits. You review the first one carefully, skim the rest, and the change you missed lives only in the copy.&lt;/p&gt;

&lt;p&gt;So every PR got its own full diff read. The account was eleven years old. No URLs beyond test fixtures, no &lt;code&gt;child_process&lt;/code&gt;, no encoded strings, nothing touching the install path. Everything was clean. The ideas were good, too: a diff mode for snapshots, a way to load only the core tools, compacting table layouts. But the stacked PRs couldn't land as they were, so I asked for standalone versions on top of &lt;code&gt;main&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The contributor sent standalone versions the same day I asked. I read the seven new diffs, wrote four of them again from scratch, and shipped them today:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;safari_snapshot({ diff: true })&lt;/code&gt; returns only the lines that changed since that tab's previous snapshot. Lines are compared with their element refs stripped, because refs are numbered in page order and one inserted element would otherwise renumber everything after it.&lt;/li&gt;
&lt;li&gt;Snapshots drop bare &lt;code&gt;div&lt;/code&gt;/&lt;code&gt;span&lt;/code&gt;/&lt;code&gt;table&lt;/code&gt;/&lt;code&gt;tr&lt;/code&gt;/&lt;code&gt;td&lt;/code&gt;/&lt;code&gt;th&lt;/code&gt; wrappers and show same-origin links as paths. On a table layout those tags were most of the output.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;SAFARI_MCP_TOOLS=core&lt;/code&gt; registers only the everyday tools, for clients that load every tool definition up front.&lt;/li&gt;
&lt;li&gt;When an HTTP session ends, it closes the tabs it opened.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The other three change how the extension handles windows and tabs. I want to see those in a real Safari with the extension rebuilt before they ship, not only in jsdom and stubs, so they wait.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this costs
&lt;/h2&gt;

&lt;p&gt;It isn't free, and I'm not going to pretend it is.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It's slower.&lt;/strong&gt; Rewriting four PRs, with tests, took about an hour, and the full suite (962 tests) runs on my commit, not theirs. Merging would have taken minutes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Contributors lose the commit.&lt;/strong&gt; GitHub's contributor graph counts commits, so someone whose idea shipped doesn't appear there. The changelog credits them by PR number and handle, and I say so on the PR. It's still less than they would get elsewhere, and some people will reasonably go contribute somewhere that merges.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;My rewrite can be worse than their original.&lt;/strong&gt; When I re-implement, I can introduce a bug they didn't have. The tests catch some of that. The rest is on me, which is at least where the responsibility belongs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who should do this
&lt;/h2&gt;

&lt;p&gt;Most projects shouldn't. If your library formats dates, merging a reviewed PR is fine, and making contributors watch their work get retyped is just rude.&lt;/p&gt;

&lt;p&gt;But some tools hold the keys: browser automation that runs in a logged-in profile, anything with Accessibility or Full Disk Access, password managers, CI plugins, anything shipping a native binary. For those, "the diff looked fine and CI passed" checks the wrong thing. The question isn't whether the change is correct. It's whether you would run that code as yourself, on the machine where your sessions live, without reading it. For me the answer is no, so I don't.&lt;/p&gt;

&lt;p&gt;Out of 32 outside PRs to this repo so far, five were merged as written, all before these rules existed. The ideas from at least seven more shipped as rewrites. That ratio might change. The rule that outside code never runs on my Mac won't.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>security</category>
      <category>mcp</category>
      <category>github</category>
    </item>
    <item>
      <title>n8n's Error Trigger Is Opt-In. My Bot Gate Failed for 71 Hours and Alerted Nobody.</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Thu, 22 Oct 2026 16:04:14 +0000</pubDate>
      <link>https://dev.to/achiya-automation/n8ns-error-trigger-is-opt-in-my-bot-gate-failed-for-71-hours-and-alerted-nobody-57pf</link>
      <guid>https://dev.to/achiya-automation/n8ns-error-trigger-is-opt-in-my-bot-gate-failed-for-71-hours-and-alerted-nobody-57pf</guid>
      <description>&lt;p&gt;One of my own workflows — the gate that decides whether a new WhatsApp contact gets the bot or gets me — failed 24 times between Thursday and Sunday. Seventy-one hours. I found out on Monday morning, by opening the executions list for an unrelated reason.&lt;/p&gt;

&lt;p&gt;That is embarrassing for someone who sells automation. So instead of fixing the one workflow and moving on, I went and checked error-handling coverage across my production instance. It was far lower than the number in my head.&lt;/p&gt;

&lt;p&gt;n8n 2.35.5, queue mode, Postgres.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why silence is the default
&lt;/h2&gt;

&lt;p&gt;Error handling in n8n is opt-in, per workflow. You build one workflow whose trigger is the &lt;strong&gt;Error Trigger&lt;/strong&gt; node, then in every &lt;em&gt;other&lt;/em&gt; workflow you open Settings → Error Workflow and point it at that one. There is no switch that applies it to everything you have already built, or to everything you build next.&lt;/p&gt;

&lt;p&gt;That is a per-workflow checkbox on a list that grows every week. Nobody forgets on purpose. You just build yet another workflow at 11pm, it works, you activate it, and you move on. The failure mode of an opt-in safety net is that coverage decays quietly while the number in your head stays at "yeah, we have error handling."&lt;/p&gt;

&lt;p&gt;Mine had decayed further than I would have guessed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the gap actually cost
&lt;/h2&gt;

&lt;p&gt;I pulled the failures of my own internal workflows, grouped by workflow, with a column for whether that workflow had an error handler attached:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Workflow&lt;/th&gt;
&lt;th&gt;Guarded&lt;/th&gt;
&lt;th&gt;Failures&lt;/th&gt;
&lt;th&gt;Window&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;WhatsApp group lead detection (AI)&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;34&lt;/td&gt;
&lt;td&gt;36.5 h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Personal-line bot gate&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;24&lt;/td&gt;
&lt;td&gt;70.9 h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Campaign-failure watchdog&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;8.7 h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Website lead intake form&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;53.0 h&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read the last column, not the failure counts. The longest is 70.9 hours — a WhatsApp bot gate that failed 24 times over three days while I was working on other things. Nothing was on fire. No customer wrote in. The bot simply did not answer new contacts, and the only reason I found it is that I went looking.&lt;/p&gt;

&lt;p&gt;The workflows that did have an error handler attached failed in the same week too, and none of them stayed broken for long, because something told me.&lt;/p&gt;

&lt;p&gt;That is the whole value proposition of the Error Trigger, and it is not "fewer failures." It is &lt;strong&gt;failures that end in hours instead of days&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that actually scared me
&lt;/h2&gt;

&lt;p&gt;A few of my workflows contain an Error Trigger node. Those are the handlers — the things that send the Telegram alert. Every one of them was itself unguarded, and the one covering the most workflows failed during that same week, leaving everything it guarded with no alerting at all and no way to find out. The error handler cannot report its own errors, because the thing that reports errors is the error handler. Quis custodiet ipsos custodes, in YAML.&lt;/p&gt;

&lt;p&gt;You cannot fix this by pointing handler A at handler B, either. That just moves the single point of silence one hop and adds a cycle you will forget about. The catcher has to be watched from &lt;strong&gt;outside n8n&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern, in four parts
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. One handler, not one per project.&lt;/strong&gt; Fan-in beats fan-out. A single Error Trigger workflow that formats &lt;code&gt;{{ $json.workflow.name }}&lt;/code&gt;, &lt;code&gt;{{ $json.execution.id }}&lt;/code&gt; and &lt;code&gt;{{ $json.execution.error.message }}&lt;/code&gt; into one alert is easier to keep correct than six near-copies.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Audit coverage with a query, not with your memory.&lt;/strong&gt; This is the whole audit — run it against your n8n 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;SELECT&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'errorWorkflow'&lt;/span&gt; &lt;span class="k"&gt;IS&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;AND&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'errorWorkflow'&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;''&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;guarded&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;workflow_entity&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;active&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;guarded&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything with &lt;code&gt;guarded = f&lt;/code&gt; is a workflow that can fail into the void. If you are on n8n Cloud, the same check runs off &lt;code&gt;GET /api/v1/workflows?active=true&lt;/code&gt; and reading &lt;code&gt;settings.errorWorkflow&lt;/code&gt; per item.&lt;/p&gt;

&lt;p&gt;That query is now the first line of my weekly runbook, next to the backup check. A new &lt;code&gt;f&lt;/code&gt; row gets treated the way a failing test does.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Make the handler loud about workflows, not just about errors.&lt;/strong&gt; The first thing I changed in mine was putting the workflow name in the subject line instead of in the body. That sounds trivial. It is the difference between "an automation failed" (which you snooze) and "the lead intake failed" (which you do not).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Guard the guard from outside.&lt;/strong&gt; The Error Trigger has a blind spot that no amount of coverage fixes: it fires when an execution &lt;em&gt;fails&lt;/em&gt;. It does not fire when an execution never &lt;em&gt;starts&lt;/em&gt; — a deactivated trigger, a dead cron, a webhook whose URL changed, a worker that is not consuming the queue. A workflow that stopped running entirely produces zero failed executions and therefore zero alerts. It looks exactly like a quiet week.&lt;/p&gt;

&lt;p&gt;So the handler pings a dead man's switch on every run, and a plain cron on a different machine screams if the ping stops:&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;# in the error handler workflow, and in one heartbeat workflow per instance&lt;/span&gt;
curl &lt;span class="nt"&gt;-fsS&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; 10 &lt;span class="s2"&gt;"https://your-monitor/ping/&lt;/span&gt;&lt;span class="nv"&gt;$SLUG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;

&lt;span class="c"&gt;# elsewhere — not on the n8n box&lt;/span&gt;
&lt;span class="nv"&gt;LAST&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;stat&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; %m /var/lib/heartbeat/n8n1 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo &lt;/span&gt;0&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="k"&gt;$((&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; LAST &lt;span class="k"&gt;))&lt;/span&gt; &lt;span class="nt"&gt;-gt&lt;/span&gt; 3600 &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;notify &lt;span class="s2"&gt;"n8n1 heartbeat is &lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; LAST&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="m"&gt;60&lt;/span&gt; &lt;span class="k"&gt;))&lt;/span&gt;&lt;span class="s2"&gt;m stale"&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;|| true&lt;/code&gt; matters: a monitoring call that can fail the run it is monitoring is worse than no monitoring.&lt;/p&gt;

&lt;p&gt;That is it. Four parts, none clever. The reason it is worth writing down is that I had parts 1 and 3 for as long as I have run this instance and still ate a three-day outage, because I never had part 2 and never imagined I needed part 4.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I landed
&lt;/h2&gt;

&lt;p&gt;I am not going to pretend this is finished. As I write this, coverage is still well short of complete. What changed is that I now have the query that produces the number and a list of unguarded workflows in the order I am going to fix them, longest silent window first. The handler-watching-the-handler problem is the part I am fixing today, because it is one workflow and a lot depends on it.&lt;/p&gt;

&lt;p&gt;The honest reason I am writing this before finishing it: the audit took twenty minutes and the three-day outage took three days. Those twenty minutes are the cheapest thing in the entire stack I maintain — cheaper than the &lt;a href="https://achiya-automation.com/services/whatsapp-bot/" rel="noopener noreferrer"&gt;WhatsApp bots I run in production for clients&lt;/a&gt;, and a rounding error against the numbers in &lt;a href="https://achiya-automation.com/blog/business-automation-complete-guide/" rel="noopener noreferrer"&gt;this breakdown of what business automation actually costs&lt;/a&gt;. Run the query before you finish reading this post and you will probably learn something uncomfortable too.&lt;/p&gt;

&lt;p&gt;One genuine question, because I do not think I have solved this part: &lt;strong&gt;how do you monitor the trigger that never fires?&lt;/strong&gt; A heartbeat proves the instance is alive and proves &lt;em&gt;that&lt;/em&gt; workflow ran. It does not prove that a webhook from a third party still arrives, or that a specific cron survived the last upgrade. Per-workflow "expected minimum run rate" alerting is the obvious answer and it also sounds like a maintenance burden that decays exactly like error-workflow coverage did.&lt;/p&gt;

&lt;p&gt;If you run n8n in production: do you alert on absence of runs, and if so — how do you keep the thresholds from rotting?&lt;/p&gt;

</description>
      <category>n8n</category>
      <category>devops</category>
      <category>automation</category>
      <category>discuss</category>
    </item>
    <item>
      <title>WAHA vs Twilio: The SQL That Finds Your Real WhatsApp Bill</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Thu, 22 Oct 2026 16:04:09 +0000</pubDate>
      <link>https://dev.to/achiya-automation/waha-vs-twilio-the-sql-that-finds-your-real-whatsapp-bill-3jam</link>
      <guid>https://dev.to/achiya-automation/waha-vs-twilio-the-sql-that-finds-your-real-whatsapp-bill-3jam</guid>
      <description>&lt;p&gt;My WhatsApp stack runs on self-hosted WAHA, and I get no per-message invoice for any of it.&lt;/p&gt;

&lt;p&gt;That is not a brag, it is the setup for an honest accounting. Because "self-hosting is cheaper" is the least interesting sentence in infrastructure, and it is usually said by someone who has never been paged at 7am by a bot that went quiet at 2am. I want to put a real number on both sides of that trade: the money Twilio would have charged, and the money self-hosting quietly takes back.&lt;/p&gt;

&lt;p&gt;All the rates below were fetched on &lt;strong&gt;August 27, 2026&lt;/strong&gt;. The rate cards move quarterly, so check yours.&lt;/p&gt;

&lt;h2&gt;
  
  
  The traffic, modeled
&lt;/h2&gt;

&lt;p&gt;Take a hypothetical WhatsApp line bridged from &lt;a href="https://waha.devlike.pro/" rel="noopener noreferrer"&gt;WAHA&lt;/a&gt; into a self-hosted Chatwoot, moving 10,000 messages a month, about half inbound and half outbound (illustrative numbers, not a real deployment).&lt;/p&gt;

&lt;p&gt;Most benchmarks stop here, multiply by a per-message rate, and publish. That answer is wrong, because &lt;strong&gt;Meta does not charge per message. It charges per template sent outside an open customer service window.&lt;/strong&gt; Multiplying all 10,000 by a template rate overstates the Meta line several times over. Multiplying just the outbound half still overstates it.&lt;/p&gt;

&lt;p&gt;Since November 1, 2024 non-template messages are free. Since July 1, 2025 utility templates &lt;em&gt;answering a user inside an open 24-hour window&lt;/em&gt; are also free. So the only line that costs money is the outbound message that goes out when nobody has written to you in the last day.&lt;/p&gt;

&lt;p&gt;Which means the number you actually need is not "how many messages," it is "how many outbound messages had no inbound message from that contact in the preceding 24 hours."&lt;/p&gt;

&lt;h2&gt;
  
  
  The query that produces the real bill
&lt;/h2&gt;

&lt;p&gt;Here it is against Chatwoot's schema. It uses a window function rather than a correlated &lt;code&gt;NOT EXISTS&lt;/code&gt;, because on a messages table of any size the correlated version will happily eat your connection pool.&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;WITH&lt;/span&gt; &lt;span class="n"&gt;src&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;conversation_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;message_type&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;messages&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;inbox_id&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="cm"&gt;/* your WhatsApp inbox ids */&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'33 days'&lt;/span&gt;
    &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;message_type&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&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="c1"&gt;-- 0 = incoming, 1 = outgoing&lt;/span&gt;
&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;message_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;OVER&lt;/span&gt; &lt;span class="p"&gt;(&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;conversation_id&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;
      &lt;span class="k"&gt;ROWS&lt;/span&gt; &lt;span class="k"&gt;BETWEEN&lt;/span&gt; &lt;span class="n"&gt;UNBOUNDED&lt;/span&gt; &lt;span class="k"&gt;PRECEDING&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;PRECEDING&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;last_in&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;src&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;message_type&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="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;outbound&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;message_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
        &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;last_in&lt;/span&gt; &lt;span class="k"&gt;IS&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;AND&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;last_in&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'24 hours'&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;inside_window&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;message_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
        &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;last_in&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
             &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;last_in&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'24 hours'&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;outside_window&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'30 days'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details worth stealing: pull &lt;strong&gt;33&lt;/strong&gt; days of history but count only the last &lt;strong&gt;30&lt;/strong&gt;, otherwise messages near the boundary look like they have no preceding inbound and you overstate your bill. And &lt;code&gt;ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING&lt;/code&gt; is what keeps a message from counting itself as its own window opener.&lt;/p&gt;

&lt;p&gt;Run it and you get three numbers: &lt;code&gt;outbound&lt;/code&gt;, &lt;code&gt;inside_window&lt;/code&gt; and &lt;code&gt;outside_window&lt;/code&gt;. Only the last one costs you anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  What that costs on Twilio
&lt;/h2&gt;

&lt;p&gt;Twilio's WhatsApp pricing is two layers. Its own fee is &lt;strong&gt;$0.005 per message, inbound or outbound&lt;/strong&gt;, and that one applies to every message. On top sits Meta's template fee, passed straight through, which varies by the recipient's country calling code.&lt;/p&gt;

&lt;p&gt;I'm in Israel. Meta's USD rate card effective July 1, 2026:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Template category&lt;/th&gt;
&lt;th&gt;Israel&lt;/th&gt;
&lt;th&gt;UK&lt;/th&gt;
&lt;th&gt;Germany&lt;/th&gt;
&lt;th&gt;India&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Marketing&lt;/td&gt;
&lt;td&gt;$0.0353&lt;/td&gt;
&lt;td&gt;$0.0635&lt;/td&gt;
&lt;td&gt;$0.1365&lt;/td&gt;
&lt;td&gt;$0.0118&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Utility&lt;/td&gt;
&lt;td&gt;$0.0053&lt;/td&gt;
&lt;td&gt;$0.0220&lt;/td&gt;
&lt;td&gt;$0.0550&lt;/td&gt;
&lt;td&gt;$0.0014&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Authentication&lt;/td&gt;
&lt;td&gt;$0.0053&lt;/td&gt;
&lt;td&gt;$0.0220&lt;/td&gt;
&lt;td&gt;$0.0550&lt;/td&gt;
&lt;td&gt;$0.0014&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Israel is a cheap market for utility and a mid market for marketing. Germany is over ten times Israel's utility rate. If you are benchmarking, your country's row is the whole story. A US-centric blog post about WhatsApp costs is nearly useless to you.&lt;/p&gt;

&lt;p&gt;The hypothetical month, priced out. Say half of the 5,000 outbound messages (2,500) go out with no open window:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Twilio handling   10,000 × $0.005   = $50.00
Meta (utility)     2,500 × $0.0053  = $13.25
                                    ---------
                                      $63.25

Meta (marketing)   2,500 × $0.0353  = $88.25
                                    ---------
                                      $138.25
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;About $63 if every billable template is a utility template. About $138 if they are marketing.&lt;/strong&gt; The gap between those two numbers is not a pricing question, it is a template-categorization question, and Meta decides the category, not you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other side of the ledger changed in June, quietly
&lt;/h2&gt;

&lt;p&gt;If you last looked at WAHA a year ago, you priced it wrong. WAHA used to sell a "Plus" license for the features anyone running it in production actually needs. In release &lt;strong&gt;2026.6.1&lt;/strong&gt;, announced June 21, 2026, every Plus feature moved into the free Core image and the tiers collapsed into a single &lt;strong&gt;optional&lt;/strong&gt; $5/month Community subscription. The project's own words: &lt;em&gt;"100% free and open source, with no limits on messages or time, and no license expiration."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;So the software line is now genuinely $0.&lt;/p&gt;

&lt;p&gt;The hardware line: WAHA is running on a 2 vCPU / 4 GB / 40 GB x86 VPS. It is not alone on that box: Redis and a second internal service share it. Under day-to-day traffic the CPU sits close to idle. This workload is not compute-bound, it is session-bound. Whatever your provider charges for the cheapest 4 GB instance, single digits a month, it does not move the comparison. Round the infra to $10 and self-hosting is still several times cheaper than the utility-rate scenario at this volume, and the gap widens with traffic.&lt;/p&gt;

&lt;p&gt;Which is exactly the point at which an honest post has to stop being a sales pitch.&lt;/p&gt;

&lt;h2&gt;
  
  
  What self-hosting actually charges you
&lt;/h2&gt;

&lt;p&gt;It bills in incidents, not invoices. Here is last week's, in full.&lt;/p&gt;

&lt;p&gt;On August 23 I rotated &lt;code&gt;WAHA_API_KEY&lt;/code&gt;. The rotation updated &lt;code&gt;/opt/waha/.env&lt;/code&gt; and the nginx snippet that injects the header. What it did not update was a second vhost, an internal tailnet-only copy created weeks earlier by duplicating the public one, where the key had been written as a &lt;strong&gt;literal&lt;/strong&gt; instead of the &lt;code&gt;$waha_api_key&lt;/code&gt; variable.&lt;/p&gt;

&lt;p&gt;Result: every &lt;code&gt;GET /api/files/…&lt;/code&gt; over the internal path started returning 401. Bots that download an image, OCR it, and act on the contents stopped being able to fetch anything. Of the three code nodes involved, one threw and killed its run outright; the other two caught the error and skipped silently, so those pipelines produced output — just output with no images in it. The public vhost was untouched, so every check that went through the domain came back green.&lt;/p&gt;

&lt;p&gt;The failure surfaced the next morning, because nobody had sent a photo overnight. A one-line fix, roughly twelve hours of quiet wrongness, and a monitoring path that was structurally incapable of seeing it.&lt;/p&gt;

&lt;p&gt;Twilio would have absorbed that entire class of problem. That is what the Twilio bill buys. Not messages. The absence of a category of 2am.&lt;/p&gt;

&lt;p&gt;And there is a risk that no amount of care removes. WAHA drives a real WhatsApp Web session. Its own homepage says it plainly: &lt;em&gt;"WhatsApp does not allow bots or unofficial clients on their platform, so this shouldn't be considered totally safe."&lt;/em&gt; A banned number is not a support ticket, it is a dead asset. That is a business risk, not a technical one, and it does not belong in a cost table. It belongs in whatever conversation you have before you build.&lt;/p&gt;

&lt;h2&gt;
  
  
  The line I actually draw
&lt;/h2&gt;

&lt;p&gt;After enough of these deployments, the rule I use with clients is not about volume at all:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The number is the business.&lt;/strong&gt; A business's only phone line, a store's published contact. Official API, pay Twilio. The ban risk is unpriceable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Internal, operational, or a secondary channel.&lt;/strong&gt; Courier groups, staff notifications, an intake line that can be replaced in an afternoon. WAHA. The savings are real and the downside is survivable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Marketing blasts at any volume.&lt;/strong&gt; Official API, and check the marketing rate for your country before you promise anyone a budget. At $0.0353 a message in Israel and $0.1365 in Germany, the same campaign has wildly different economics depending on who receives it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Volume is the wrong axis. Replaceability is the right one. This is roughly the split behind &lt;a href="https://achiya-automation.com/en/pricing/" rel="noopener noreferrer"&gt;the pricing I quote Israeli businesses for WhatsApp automation&lt;/a&gt;: the channel decision comes first, and the bill follows from it.&lt;/p&gt;

&lt;p&gt;One more thing worth knowing: Meta has further pricing changes landing &lt;strong&gt;October 1, 2026&lt;/strong&gt;, covering service and utility messages. Whatever you calculate today has a shelf life of about five weeks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run the query
&lt;/h2&gt;

&lt;p&gt;The number that decides this for you is your own &lt;code&gt;outside_window&lt;/code&gt; count, and it is the one input a vendor pricing page cannot give you.&lt;/p&gt;

&lt;p&gt;So: &lt;strong&gt;run it against your own inboxes and post the three numbers.&lt;/strong&gt; Outbound, inside window, outside window. I am specifically curious what share of your outbound lands outside the window, and whether it holds steady across use cases. If that ratio is stable, it is a much better rule of thumb than anything on a vendor pricing page.&lt;/p&gt;

</description>
      <category>whatsapp</category>
      <category>devops</category>
      <category>automation</category>
      <category>discuss</category>
    </item>
    <item>
      <title>What Self-Hosted Chatwoot Actually Costs to Run</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Thu, 22 Oct 2026 15:50:00 +0000</pubDate>
      <link>https://dev.to/achiya-automation/what-self-hosted-chatwoot-actually-costs-to-run-15ed</link>
      <guid>https://dev.to/achiya-automation/what-self-hosted-chatwoot-actually-costs-to-run-15ed</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure: I run a small automation shop, I set Chatwoot up for clients, and &lt;code&gt;ACHIYAVS&lt;/code&gt; is my affiliate code — if you use it on Chatwoot Cloud you pay 5% less and I earn a commission. Self-hosting, which is most of this post, earns me nothing.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Every "Chatwoot pricing" article copies the same four numbers off the pricing page. None of them say what the free option actually consumes, because none of them are running it.&lt;/p&gt;

&lt;p&gt;I am. So here is what it takes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The install
&lt;/h2&gt;

&lt;p&gt;One box. Not a cluster, not a managed database, no autoscaling group.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it uses
&lt;/h2&gt;

&lt;p&gt;A self-hosted install is four containers: Rails, Postgres, Sidekiq and Redis.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A typical small-business install fits comfortably on a 4 GB VPS.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The structural point: &lt;strong&gt;that footprint tracks message volume, not seat count.&lt;/strong&gt; Adding an agent to a self-hosted install costs nothing. On Cloud, every seat is another line on the bill, every month, for as long as you have it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The line item nobody quotes
&lt;/h2&gt;

&lt;p&gt;I went looking for what my install would cost on Cloud, and found the thing that actually decides the tier — and it is not the price.&lt;/p&gt;

&lt;p&gt;Each Cloud tier caps how long your conversation history is kept. Thirty days on the free tier, a year on the cheapest paid one, two and three years above that.&lt;/p&gt;

&lt;p&gt;If your history goes back further than a year, on the entry paid tier the older conversations would already be gone — not archived, gone.&lt;/p&gt;

&lt;p&gt;A support inbox is a record. "You told me when we signed up that this was included" is a question someone has to be able to answer. That constraint, not the sticker price, is what pushes a team up a tier.&lt;/p&gt;

&lt;p&gt;I put the full tier-by-tier breakdown, with the retention caps and the crossover math at different team sizes, on my own site: &lt;a href="https://achiya-automation.com/en/blog/chatwoot-pricing/" rel="noopener noreferrer"&gt;what Chatwoot actually costs&lt;/a&gt;. It is the version I wish I had found.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cost that is not in anyone's table
&lt;/h2&gt;

&lt;p&gt;Self-hosting is not free. It costs the thing hardest to price: your attention, at inconvenient hours.&lt;/p&gt;

&lt;p&gt;In the months I have run this I have handled Postgres connection-pool exhaustion, a Redis eviction policy that quietly dropped background jobs, a log rotation config that corrupted an access log badly enough to break my own monitoring, and an upgrade that reverted files I had mounted into the container.&lt;/p&gt;

&lt;p&gt;None of that was Chatwoot's fault. All of it was the price of the $0 licence.&lt;/p&gt;

&lt;p&gt;If your team does not already have someone comfortable in a terminal at 11pm, the honest answer is that Cloud is cheaper for you than self-hosting — because the alternative is not "a server," it is "a server plus a person."&lt;/p&gt;

&lt;h2&gt;
  
  
  So which one
&lt;/h2&gt;

&lt;p&gt;Self-host if you have ops capacity, more than a handful of seats, and you want history you control. The economics improve with every seat and never get worse.&lt;/p&gt;

&lt;p&gt;Take Cloud if support is not your engineering problem and you would rather pay for that boundary. That is a legitimate purchase, not a defeat.&lt;/p&gt;

&lt;p&gt;And if you take Cloud, the code is &lt;code&gt;ACHIYAVS&lt;/code&gt; for 5% off — which is the disclosure at the top of this post, and the real number. Search for a Chatwoot code and you will find "70% off" and "$100 off" on half a dozen coupon sites. The &lt;a href="https://www.chatwoot.com/affiliate-program" rel="noopener noreferrer"&gt;published affiliate terms&lt;/a&gt; give the customer 5%. Anything larger is expired or was never real.&lt;/p&gt;

&lt;p&gt;On a bigger team that discount adds up. It is not a reason to choose Cloud. It is just the correct number, which turns out to be surprisingly hard to find.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;If you self-host Chatwoot — what does your install actually use, and did the retention cap ever catch you out?&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>selfhosted</category>
      <category>chatwoot</category>
      <category>devops</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Reinstalling a Safari extension doesn't replace it. I found a dozen copies still running.</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Mon, 19 Oct 2026 03:24:31 +0000</pubDate>
      <link>https://dev.to/achiya-automation/reinstalling-a-safari-extension-doesnt-replace-it-i-found-a-dozen-copies-still-running-44g3</link>
      <guid>https://dev.to/achiya-automation/reinstalling-a-safari-extension-doesnt-replace-it-i-found-a-dozen-copies-still-running-44g3</guid>
      <description>&lt;p&gt;For two weeks, a bug report on my open-source project said the same thing in slightly different words: a tab receipt worked, and a minute later it didn't.&lt;/p&gt;

&lt;p&gt;The project is &lt;a href="https://github.com/achiya-automation/safari-mcp" rel="noopener noreferrer"&gt;Safari MCP&lt;/a&gt;, an MCP server that lets AI agents drive your real, logged-in Safari on macOS. When an agent opens a tab, the Safari Web Extension hands it a &lt;em&gt;receipt&lt;/em&gt;, an opaque token that pins every later call to that exact tab. A receipt is documented to survive reconnects and restarts. In &lt;a href="https://github.com/achiya-automation/safari-mcp/issues/143" rel="noopener noreferrer"&gt;issue #143&lt;/a&gt; it didn't survive two minutes:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Tab safety: this extension has no record of that receipt.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The tab was still open. The agent just couldn't reach it anymore, and it couldn't close it either, because closing a tab you can't prove is yours is how you close the user's tab instead.&lt;/p&gt;

&lt;p&gt;I had a theory, and it was wrong. The real cause was something I had never seen described anywhere: &lt;strong&gt;Safari keeps the extension context of every app generation it has ever loaded.&lt;/strong&gt; Reinstalling the extension app doesn't replace the running extension. It adds another one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The theory that didn't hold
&lt;/h2&gt;

&lt;p&gt;A Safari Web Extension's background worker is not long-lived. Safari stops it when idle and starts it again on the next event. My extension keeps its receipts in &lt;code&gt;storage.local&lt;/code&gt;, tagged with a "browser session epoch" so that receipts from a previous Safari launch are thrown away. The epoch lived in &lt;code&gt;storage.session&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So the first theory was: Safari clears &lt;code&gt;storage.session&lt;/code&gt; every time it terminates the worker, the epoch rotates, and every receipt from the old epoch is dropped.&lt;/p&gt;

&lt;p&gt;That's partly true. Every worker instance does get its own &lt;code&gt;storage.session&lt;/code&gt;. But it didn't explain the data. I read the profile's &lt;code&gt;LocalStorage.db&lt;/code&gt; and its WAL read-only and listed every envelope the extension wrote. In five minutes on 13 October there were four different epochs. Four envelopes were written in the same millisecond, each under a different epoch.&lt;/p&gt;

&lt;p&gt;One worker restarting doesn't write four envelopes in one millisecond. Several workers alive at the same time do.&lt;/p&gt;

&lt;h2&gt;
  
  
  What was actually running
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;safari_doctor&lt;/code&gt; (the project's diagnostic tool) already counted worker connections. In one five-minute window, 16 workers had connected to the daemon. The extension keeps a one-minute &lt;code&gt;keepalive&lt;/code&gt; alarm, so I expected roughly one worker per minute. I was getting three.&lt;/p&gt;

&lt;p&gt;Then I looked at what the Safari process had mapped. It held resources from about a dozen different builds of the extension app. Some of those builds had already been deleted from disk.&lt;/p&gt;

&lt;p&gt;That machine had been through a lot of extension reinstalls, because I build the extension from source while developing it. Each reinstall registered a new app. Safari started the new one and never stopped the old ones. Every one of those contexts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;wakes on its own one-minute alarm as a fresh worker instance,&lt;/li&gt;
&lt;li&gt;connects to the local daemon,&lt;/li&gt;
&lt;li&gt;verifies itself, and&lt;/li&gt;
&lt;li&gt;competes for the daemon's single lease, the right to receive commands.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;About half of them were older generations, running code I had long since fixed. The other half were current, and even those couldn't agree with each other: each context has tab and window ids of its own. A receipt minted in one context resolves only in that context. When a different context wrote the shared envelope, it wrote back only the records it could resolve, which could be none. I watched the envelope go empty after a single &lt;code&gt;close_tab&lt;/code&gt; from a different session.&lt;/p&gt;

&lt;p&gt;So a receipt "expired" whenever the lease moved to a context that had never heard of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix that didn't work
&lt;/h2&gt;

&lt;p&gt;My first fix was the obvious one: when a worker that doesn't announce the current build takes the lease, tell it to reload. A reload restarts the extension from the app on disk, so the old code should be gone.&lt;/p&gt;

&lt;p&gt;It wasn't. Reloading an old context restarts &lt;em&gt;that same old generation&lt;/em&gt;. In 90 minutes the daemon sent 45 reloads and never ran out of old contexts to reload.&lt;/p&gt;

&lt;h2&gt;
  
  
  What shipped instead
&lt;/h2&gt;

&lt;p&gt;The daemon now decides who leads, instead of hoping the old contexts go away. This is in &lt;a href="https://github.com/achiya-automation/safari-mcp/releases/tag/v2.22.17" rel="noopener noreferrer"&gt;v2.22.17&lt;/a&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The extension announces its build&lt;/strong&gt; when it connects. A worker that announces no build never takes the lease while a current one has verified in the last 15 minutes, and a current worker takes the lease from an old holder at once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Current contexts are told apart by extension origin.&lt;/strong&gt; The first current context to lead becomes the home context, and no other origin takes the lease while the home context is alive. The daemon logs every handover, so the next time this happens there is a line that says which context lost.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The new build stores its state under storage keys no older build touches.&lt;/strong&gt; An old context that is still alive can't overwrite what the current one stored.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The daemon names the browser session&lt;/strong&gt;, not &lt;code&gt;storage.session&lt;/code&gt;. It sends the identity of the running Safari process with every response, so every worker instance agrees on the epoch and only a real Safari restart changes it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And the honest part: the only thing that actually removes the old contexts is quitting Safari and opening it again. Reinstalling, reloading, and disabling and re-enabling the extension don't.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's still open
&lt;/h2&gt;

&lt;p&gt;The issue is still open. One context can still write an envelope without records it can't resolve. The real fix is merge-on-write, so a write never drops what it doesn't understand, and I'm keeping #143 open until that lands and a full day passes with no refusal on release code.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you ship a Safari Web Extension
&lt;/h2&gt;

&lt;p&gt;I can't tell you this is how Safari behaves on every machine. I measured it on one Mac after many developer reinstalls. A user who installs once from the App Store may never see two generations side by side. But a few things are worth checking in your own extension:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Don't assume one background context.&lt;/strong&gt; If you share state through &lt;code&gt;storage.local&lt;/code&gt;, more than one live instance of your extension may be writing it, some running older code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Version your storage keys&lt;/strong&gt; when the shape changes, so an older generation can't overwrite a newer one's data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't trust &lt;code&gt;storage.session&lt;/code&gt; as a session identity.&lt;/strong&gt; Each worker instance gets its own.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If your extension talks to a native app or a local server, let that side decide&lt;/strong&gt; which instance is authoritative. It's the only party that sees all of them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tell users who see strange state after an update to quit Safari once.&lt;/strong&gt; It sounds like folk advice. Here it's the documented fix.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The full timeline, with the storage epochs and the daemon log lines, is in &lt;a href="https://github.com/achiya-automation/safari-mcp/issues/143" rel="noopener noreferrer"&gt;issue #143&lt;/a&gt;, and the changes are in the &lt;a href="https://github.com/achiya-automation/safari-mcp/blob/main/CHANGELOG.md" rel="noopener noreferrer"&gt;changelog&lt;/a&gt;. If you've seen the same thing in your own extension, or you know where Safari documents this, I'd like to hear about it in the comments.&lt;/p&gt;

</description>
      <category>safari</category>
      <category>webdev</category>
      <category>macos</category>
      <category>mcp</category>
    </item>
    <item>
      <title>Safari gave my blank tab no URL. My security check read that as "not yours."</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Sat, 10 Oct 2026 19:28:10 +0000</pubDate>
      <link>https://dev.to/achiya-automation/safari-gave-my-blank-tab-no-url-my-security-check-read-that-as-not-yours-p4d</link>
      <guid>https://dev.to/achiya-automation/safari-gave-my-blank-tab-no-url-my-security-check-read-that-as-not-yours-p4d</guid>
      <description>&lt;p&gt;I maintain &lt;a href="https://github.com/achiya-automation/safari-mcp" rel="noopener noreferrer"&gt;safari-mcp&lt;/a&gt;, an MCP server that lets AI agents drive the real Safari on a Mac, logged-in sessions included. On October 7 an agent opened a blank tab, and its very next command on that tab was refused:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;receipt is not valid for this origin
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The agent had done everything right. It called &lt;code&gt;safari_new_tab()&lt;/code&gt; without a URL, kept the receipt the tool returned, and passed it to &lt;code&gt;safari_navigate&lt;/code&gt;. The tab could not be navigated, the receipt could not be rotated, and the tab could not be closed. It sat there in a named Safari profile, and nothing the agent was allowed to do could touch it.&lt;/p&gt;

&lt;p&gt;I shipped two releases that day, and each changed one line of code. Both lines were in a guard I had built to be strict on purpose.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a receipt is
&lt;/h2&gt;

&lt;p&gt;When the safari-mcp Safari extension opens a tab for an agent, it mints an opaque receipt for that tab. Every later command carries the receipt, and before it runs anything the extension checks two things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;the receipt belongs to this tab, and&lt;/li&gt;
&lt;li&gt;the tab is still on the origin the receipt was minted on.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The second check is the one that matters. An agent works in a browser that also holds the user's email, bank and admin panels. If a page redirects the agent's tab somewhere else, I want the agent's next &lt;code&gt;evaluate&lt;/code&gt; refused, not run against whatever is loaded there now. To keep working, the agent has to ask for a fresh receipt on purpose (&lt;code&gt;getReceipt&lt;/code&gt;), and the extension issues one only when it can read an http(s) origin, or &lt;code&gt;about:blank&lt;/code&gt;, for the tab.&lt;/p&gt;

&lt;p&gt;Fail closed. On paper, that is exactly the behavior you want.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug was one missing value
&lt;/h2&gt;

&lt;p&gt;This is how the extension derived a tab's origin before the fix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;_receiptOrigin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawUrl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawUrl&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;about:blank&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sr"&gt;/^https&lt;/span&gt;&lt;span class="se"&gt;?&lt;/span&gt;&lt;span class="sr"&gt;:$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;protocol&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;""&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;A blank tab's receipt is minted on &lt;code&gt;about:blank&lt;/code&gt;. I assumed Safari would report a blank tab's URL as &lt;code&gt;about:blank&lt;/code&gt;. It doesn't. It leaves &lt;code&gt;url&lt;/code&gt; off the tab entirely.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;String(undefined || "")&lt;/code&gt; is &lt;code&gt;""&lt;/code&gt;. &lt;code&gt;new URL("")&lt;/code&gt; throws. The function returns &lt;code&gt;""&lt;/code&gt;, which in this code means "no origin at all". &lt;code&gt;"about:blank" !== ""&lt;/code&gt;, so the check refused the tab's own receipt. The escape hatch called the same function, got the same &lt;code&gt;""&lt;/code&gt;, and refused as well: &lt;code&gt;Cannot issue a receipt for this tab URL&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The guard did what I built it to do. It just could not tell "this tab moved somewhere else" from "Safari didn't tell me where this tab is", and it treated both as an attack.&lt;/p&gt;

&lt;h2&gt;
  
  
  The sibling bug, one day earlier
&lt;/h2&gt;

&lt;p&gt;On October 6 a different run left two tabs open in a named profile, both on claude.ai/new. In both cases the page itself had moved the tab, through a redirect or a login bounce. Rotation only works when the new page has an http(s) origin the extension can read; a tab that lands on a page without one, or on a page whose URL Safari doesn't show the extension, can't be rotated.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;close_tab&lt;/code&gt; was held to the receipt's origin like every other command. The refusal suggested rotating the receipt, which was exactly what these two tabs couldn't do. Nothing could close them.&lt;/p&gt;

&lt;p&gt;Same shape as the blank tab: a rule that is right for commands that run code in a page, applied to a command that doesn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two one-line fixes
&lt;/h2&gt;

&lt;p&gt;v2.22.12, the moved tab:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;allowReceiptOriginChange&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;get_tab_receipt&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;close_tab&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Closing a tab runs nothing in the page, so a close now finds its tab the way &lt;code&gt;getReceipt&lt;/code&gt; does, by the receipt rather than by the origin. Every other command stays bound to the receipt's origin, and a receipt the extension never issued still closes nothing.&lt;/p&gt;

&lt;p&gt;v2.22.13, the blank tab:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;about:blank&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;about:blank&lt;/span&gt;&lt;span class="dl"&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 tab Safari shows no URL for now counts as blank. A receipt minted on a web origin still takes no page command there, so a tab that went from a real site to a blank page is still refused.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I got wrong
&lt;/h2&gt;

&lt;p&gt;The origin binding was fine. Two assumptions inside it were not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I treated "unknown" as "mismatch".&lt;/strong&gt; A fail-closed check has at least three outcomes: it matches, it doesn't match, or the platform didn't give you enough to decide. My code folded the third into the second, because the function that read the URL returned the same empty string for "no URL reported" and for "an origin I won't trust". Once those two share a value, the guard will sooner or later lock out the owner. The fix was to decide, in code, what a missing URL means for a tab the extension created: it is blank.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I held every command to the same rule.&lt;/strong&gt; The origin check exists to stop code from running in the wrong page. &lt;code&gt;close_tab&lt;/code&gt; runs no code in any page. Binding it to the origin added no protection and took away the only way out. A guard should be as narrow as the harm it prevents, and every guard needs an exit that doesn't depend on the thing it guards.&lt;/p&gt;

&lt;p&gt;A test that mocks the extension API with a tidy &lt;code&gt;url: "about:blank"&lt;/code&gt; could never have caught the first bug. Both showed up the first time a real Safari profile did something slightly unusual, which is the reason this project drives the real browser in the first place.&lt;/p&gt;

&lt;p&gt;If you use safari-mcp's Safari extension, both fixes live in the extension, so they take effect once you rebuild the extension app from 2.22.13 or later (the README's "Installing the Extension" section has the steps).&lt;/p&gt;

&lt;p&gt;How do your own guards represent "I couldn't tell"? Does it get its own value, or does it quietly share one with "no"?&lt;/p&gt;

</description>
      <category>security</category>
      <category>javascript</category>
      <category>opensource</category>
      <category>ai</category>
    </item>
    <item>
      <title>Cupom Chatwoot 2026: o único código real dá 5% (e onde colocar)</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Tue, 06 Oct 2026 08:13:33 +0000</pubDate>
      <link>https://dev.to/achiya-automation/cupom-chatwoot-2026-o-unico-codigo-real-da-5-e-onde-colocar-41i4</link>
      <guid>https://dev.to/achiya-automation/cupom-chatwoot-2026-o-unico-codigo-real-da-5-e-onde-colocar-41i4</guid>
      <description>&lt;p&gt;Aviso antes de tudo: &lt;code&gt;ACHIYAVS&lt;/code&gt; é o meu código de afiliado do Chatwoot. Se você usar, paga 5% a menos no Chatwoot Cloud e o Chatwoot me paga uma comissão. O acordo é só esse, e é por isso que este texto existe.&lt;/p&gt;

&lt;p&gt;Escrevo em português porque o Chatwoot é muito usado no Brasil e, quando procurei "cupom chatwoot", não achei nenhuma página em português com um código que funcione no Chatwoot Cloud. Achei o guia oficial que mostra onde digitar um cupom, uma oferta de parceiro para outro produto e listas de códigos que não existem.&lt;/p&gt;

&lt;h2&gt;
  
  
  O resumo
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Código:&lt;/strong&gt; &lt;code&gt;ACHIYAVS&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Desconto:&lt;/strong&gt; 5% em todos os pagamentos da assinatura, mensal ou anual, segundo as regras do &lt;a href="https://www.chatwoot.com/affiliate-program" rel="noopener noreferrer"&gt;programa de afiliados do Chatwoot&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vale para:&lt;/strong&gt; os planos pagos do Chatwoot Cloud (Startups, Business e Enterprise)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Não vale para:&lt;/strong&gt; o plano gratuito Hacker e o Chatwoot auto-hospedado&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Onde colocar:&lt;/strong&gt; dentro da conta, no portal de faturamento. No cadastro não existe campo de cupom&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Quanto isso dá em reais
&lt;/h2&gt;

&lt;p&gt;Os preços são os da &lt;a href="https://www.chatwoot.com/pt-BR/pricing/" rel="noopener noreferrer"&gt;página de preços do Chatwoot em português&lt;/a&gt;, por agente, por mês:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Plano&lt;/th&gt;
&lt;th&gt;Preço&lt;/th&gt;
&lt;th&gt;Com ACHIYAVS&lt;/th&gt;
&lt;th&gt;Diferença por agente&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Startups&lt;/td&gt;
&lt;td&gt;R$ 119&lt;/td&gt;
&lt;td&gt;R$ 113,05&lt;/td&gt;
&lt;td&gt;R$ 5,95 por mês&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Business&lt;/td&gt;
&lt;td&gt;R$ 249&lt;/td&gt;
&lt;td&gt;R$ 236,55&lt;/td&gt;
&lt;td&gt;R$ 12,45 por mês&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Enterprise&lt;/td&gt;
&lt;td&gt;R$ 639&lt;/td&gt;
&lt;td&gt;R$ 607,05&lt;/td&gt;
&lt;td&gt;R$ 31,95 por mês&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;É pouco. Numa equipe de cinco atendentes no Business, a diferença fica em R$ 62,25 por mês. Prefiro mostrar esse número pequeno e verdadeiro a prometer um desconto grande que não existe.&lt;/p&gt;

&lt;h2&gt;
  
  
  Onde digitar o código
&lt;/h2&gt;

&lt;p&gt;O cupom entra depois que a conta já existe. O teste grátis de 15 dias vem primeiro:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Entre no Chatwoot Cloud e abra &lt;strong&gt;Configurações → Faturamento&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Clique em &lt;strong&gt;Ir para o portal de faturamento&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Escolha o plano, clique em &lt;strong&gt;Adicionar código promocional&lt;/strong&gt;, digite &lt;code&gt;ACHIYAVS&lt;/code&gt; e clique em &lt;strong&gt;Aplicar&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Confira o resumo do pedido antes de clicar em &lt;strong&gt;Enviar&lt;/strong&gt;. O valor precisa aparecer com o desconto; se não aparecer, o código não foi aplicado.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;O próprio Chatwoot tem um &lt;a href="https://www.chatwoot.com/hc/user-guide/articles/1777049615-como-usar-seu-codigo-de-cupom-no-chatwoot" rel="noopener noreferrer"&gt;guia em português sobre como usar um cupom&lt;/a&gt;, com as telas.&lt;/p&gt;

&lt;p&gt;Vale saber também: um link de afiliado (aquele com &lt;code&gt;?via=&lt;/code&gt; no final) não dá desconto nenhum sozinho. O Chatwoot registra a indicação só pelo cupom. Sem digitar o código, você paga o preço cheio.&lt;/p&gt;

&lt;h2&gt;
  
  
  E os cupons de 50%, 60%, 70%?
&lt;/h2&gt;

&lt;p&gt;Quem procura cupom do Chatwoot encontra SINTU, PINTU, START, KUMAR, MALIK, SAVE55, GET50 e outros, prometendo de 20% a 70% de desconto. Nenhum corresponde a um desconto que o Chatwoot oferece. O programa de afiliados dá os mesmos 5% para todos (&lt;a href="https://www.chatwoot.com/affiliate-program" rel="noopener noreferrer"&gt;as regras estão publicadas&lt;/a&gt;), então um código que promete mais do que isso não é de afiliado: foi inventado, é de outro produto ou expirou há muito tempo.&lt;/p&gt;

&lt;h2&gt;
  
  
  E os 15% da fazer.ai?
&lt;/h2&gt;

&lt;p&gt;Esse desconto existe, mas é para outro produto. A &lt;a href="https://fazer.ai/parceria-chatwoot" rel="noopener noreferrer"&gt;fazer.ai&lt;/a&gt; é a parceira oficial do Chatwoot no Brasil e oferece 15% na &lt;strong&gt;licença Enterprise do Chatwoot auto-hospedado&lt;/strong&gt;. O cupom é aplicado no painel de Super Admin da sua própria instalação e vale só para operações no Brasil. Se você roda o Chatwoot no seu servidor e precisa do Enterprise, é a melhor oferta que conheço. No Chatwoot Cloud, o desconto disponível é o de afiliado, de 5%.&lt;/p&gt;

&lt;h2&gt;
  
  
  O que pesa mais do que o cupom
&lt;/h2&gt;

&lt;p&gt;Cada plano do Chatwoot Cloud guarda o histórico de conversas por um tempo limitado e apaga o que passa disso: 30 dias no Hacker, 1 ano no Startups, 2 anos no Business e 3 anos no Enterprise. Se a sua equipe precisa consultar conversas de mais de um ano atrás, o plano certo é o Business, e a diferença de R$ 119 para R$ 249 por agente pesa muito mais do que qualquer cupom. O Business também traz voz, times e regras de automação, que o Startups não tem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Se você prefere auto-hospedar
&lt;/h2&gt;

&lt;p&gt;O Chatwoot Community Edition é gratuito e roda no seu servidor sem limite de agentes, então ali o cupom não se aplica, e nem precisa. Eu rodo Chatwoot auto-hospedado para clientes, ligado ao WhatsApp, e sei que cuidar do servidor dá trabalho. O Cloud faz sentido para quem não quer esse trabalho, e é aí que o código vale.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Última verificação: 6 de outubro de 2026, no painel de afiliados do Chatwoot, onde o &lt;code&gt;ACHIYAVS&lt;/code&gt; aparece como "5% off on all payments". A versão em inglês desta página fica em &lt;a href="https://achiya-automation.com/en/blog/chatwoot-promo-code/?utm_source=devto&amp;amp;utm_medium=article" rel="noopener noreferrer"&gt;achiya-automation.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>chatwoot</category>
      <category>braziliandevs</category>
      <category>portugues</category>
      <category>saas</category>
    </item>
    <item>
      <title>n8n's Postgres Node Doesn't Send Parameters. My Dry-Run Did.</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Mon, 05 Oct 2026 05:49:09 +0000</pubDate>
      <link>https://dev.to/achiya-automation/n8ns-postgres-node-doesnt-send-parameters-my-dry-run-did-2j5b</link>
      <guid>https://dev.to/achiya-automation/n8ns-postgres-node-doesnt-send-parameters-my-dry-run-did-2j5b</guid>
      <description>&lt;p&gt;The n8n docs for the &lt;a href="https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.postgres/" rel="noopener noreferrer"&gt;Postgres node&lt;/a&gt; say you can use &lt;code&gt;$1&lt;/code&gt;, &lt;code&gt;$2&lt;/code&gt; and &lt;code&gt;$3&lt;/code&gt; in a query "to build prepared statements to use with query parameters." On September 18 I found out what that means in practice, from a failure that my own dry-run had told me couldn't happen.&lt;/p&gt;

&lt;h2&gt;
  
  
  The query that passed in psql
&lt;/h2&gt;

&lt;p&gt;I was running an end-to-end test of a workflow I maintain that books a callback. One Postgres node stores when the callback is due, and the number of minutes comes from earlier in the flow. Sometimes there is no number, so the value is an empty string. Simplified, the node's query looked like this:&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;CASE&lt;/span&gt;
  &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
  &lt;span class="k"&gt;ELSE&lt;/span&gt; &lt;span class="n"&gt;make_interval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mins&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;END&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before deploying, I had tested it in psql the way I'd test any parameterized query:&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;PREPARE&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;text&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="k"&gt;CASE&lt;/span&gt; &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
         &lt;span class="k"&gt;ELSE&lt;/span&gt; &lt;span class="n"&gt;make_interval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mins&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;END&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;EXECUTE&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;-- returns NULL&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;NULL, as expected. The CASE sees the empty string and never reaches the cast.&lt;/p&gt;

&lt;p&gt;In n8n, the same node failed with &lt;code&gt;invalid input syntax for type integer: ""&lt;/code&gt;. I caught it only because I was watching that test step by step. The node had &lt;strong&gt;On Error&lt;/strong&gt; set to &lt;em&gt;Continue (using regular output)&lt;/em&gt;, so the execution finished green. No retry, no alert, and no callback saved.&lt;/p&gt;

&lt;h2&gt;
  
  
  What n8n actually sends
&lt;/h2&gt;

&lt;p&gt;The Postgres node runs on &lt;a href="https://github.com/vitaly-t/pg-promise/tree/11.9.1#query-formatting" rel="noopener noreferrer"&gt;pg-promise&lt;/a&gt;, pinned at 11.9.1 in n8n 2.41.6, the latest stable release as I write this. pg-promise has its own formatting engine, and its README says the engine is "used by default with all query methods, unless you opt out of it entirely via option &lt;code&gt;pgFormatting&lt;/code&gt;." n8n &lt;a href="https://github.com/n8n-io/n8n/blob/n8n%402.41.6/packages/nodes-base/nodes/Postgres/transport/index.ts#L122-L129" rel="noopener noreferrer"&gt;creates its pg-promise instance&lt;/a&gt; without that option.&lt;/p&gt;

&lt;p&gt;So the values never travel as bind parameters. pg-promise escapes them and writes them into the SQL text on the client, and Postgres receives one plain string. I reproduced it this morning against a local Postgres 16.15 with the same pg-promise version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pgp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pg-promise&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sql&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SELECT CASE WHEN $1 = '' THEN NULL ELSE make_interval(mins =&amp;gt; $1::int) END AS delay&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pgp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;as&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;]));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;CASE&lt;/span&gt; &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;ELSE&lt;/span&gt; &lt;span class="n"&gt;make_interval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mins&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;END&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;delay&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run that against the database and you get error &lt;code&gt;22P02&lt;/code&gt;, &lt;code&gt;invalid input syntax for type integer: ""&lt;/code&gt;. Send the same SQL with the same value as a real parameter (pg-promise's &lt;code&gt;ParameterizedQuery&lt;/code&gt;) and it returns &lt;code&gt;{"delay": null}&lt;/code&gt;, exactly like my psql test.&lt;/p&gt;

&lt;p&gt;To be fair to n8n, the part of the docs that says it "sanitizes data in query parameters, which prevents SQL injection" holds up. The escaping is real. What changes is how Postgres types the value.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the CASE didn't help
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;$1::int&lt;/code&gt; on a real parameter is a run-time cast. It runs when that branch runs, and in my test that branch never ran.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;''::int&lt;/code&gt; is something else. The Postgres docs on &lt;a href="https://www.postgresql.org/docs/current/sql-expressions.html#SQL-SYNTAX-TYPE-CASTS" rel="noopener noreferrer"&gt;type casts&lt;/a&gt; put it this way: "A cast applied to an unadorned string literal represents the initial assignment of a type to a literal constant value." Postgres hands the string to the integer input routine while it is still analyzing the statement, before it evaluates anything. An empty string isn't valid integer input, so the statement fails before the CASE gets a say. psql points the caret right at it:&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;ERROR&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="n"&gt;invalid&lt;/span&gt; &lt;span class="k"&gt;input&lt;/span&gt; &lt;span class="n"&gt;syntax&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="nb"&gt;integer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;""&lt;/span&gt;
&lt;span class="n"&gt;LINE&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="k"&gt;WHEN&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;ELSE&lt;/span&gt; &lt;span class="n"&gt;make_interval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mins&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;int&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="o"&gt;^&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The section on &lt;a href="https://www.postgresql.org/docs/current/sql-expressions.html#SYNTAX-EXPRESS-EVAL" rel="noopener noreferrer"&gt;expression evaluation rules&lt;/a&gt; says it plainly: CASE "is not a cure-all," and it "does not prevent early evaluation of constant subexpressions." Once your parameters are pasted in as literals, a lot more of your query is constant than it looks.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first half of the lesson: commas
&lt;/h2&gt;

&lt;p&gt;That workflow had already burned me once through the same Query Parameters field. Earlier, a free-text value with a comma in it shifted every parameter after it. The row was saved without an error, and every value after the comma had moved one column over.&lt;/p&gt;

&lt;p&gt;The reason is in &lt;a href="https://github.com/n8n-io/n8n/blob/n8n%402.41.6/packages/nodes-base/nodes/Postgres/v2/actions/database/executeQuery.operation.ts#L88-L114" rel="noopener noreferrer"&gt;executeQuery.operation.ts&lt;/a&gt;. For node version 2.5 and up (new nodes get 2.7), n8n evaluates each &lt;code&gt;{{ }}&lt;/code&gt; in the Query Parameters field separately. If the result is an array, it takes the items one by one. Anything else becomes a string, and unless that string happens to parse as JSON, it goes through &lt;a href="https://github.com/n8n-io/n8n/blob/n8n%402.41.6/packages/nodes-base/nodes/Postgres/v2/helpers/utils.ts#L46-L52" rel="noopener noreferrer"&gt;&lt;code&gt;stringToArray&lt;/code&gt;&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&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="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Split on commas, drop the empty pieces, trim the rest. The docs do describe the field as "a comma-separated list of values." They don't mention that the split happens after your expressions are evaluated, inside your data.&lt;/p&gt;

&lt;p&gt;Here is what that logic does to one item. I ran a copy of those exact functions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Query Parameters: {{ $json.minutes }},{{ $json.note }},{{ $json.id }}&lt;/span&gt;
&lt;span class="c1"&gt;// item: { minutes: '', note: 'call back, after 5pm', id: 7 }&lt;/span&gt;
&lt;span class="c1"&gt;// values -&amp;gt; ["call back", "after 5pm", "7"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The empty minutes disappeared, and the note became two values. One value vanished and one appeared, so the count still matched three placeholders and nothing failed:&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="s1"&gt;'call back'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;minutes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'after 5pm'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;note&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'7'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="c1"&gt;-- {"minutes":"call back","note":"after 5pm","id":7}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the version that worries me more. The &lt;code&gt;''::int&lt;/code&gt; error at least stops the node. A shifted row looks fine until somebody reads it.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;1. Query Parameters is always one array expression.&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;{{ [ $json.minutes, $json.note, $json.id ] }}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the whole field is one expression that returns an array, n8n uses that array as the parameter list and nothing gets split. Commas stay inside their value, and empty strings keep their position. It's also the form the n8n docs themselves use for &lt;code&gt;IN (...)&lt;/code&gt; lists.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Every cast on a value that can be empty goes through NULLIF.&lt;/strong&gt;&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="n"&gt;make_interval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mins&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;NULLIF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&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;''&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;int&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;delay&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After formatting, the empty case becomes &lt;code&gt;make_interval(mins =&amp;gt; NULLIF('', '')::int)&lt;/code&gt;, which returns NULL, and with &lt;code&gt;'15'&lt;/code&gt; it returns a 15-minute interval. Both ran clean in the reproduction. NULLIF turns the empty string into NULL before the cast, and NULL cast to an integer is just NULL.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Dry-runs test the SQL that n8n will send, not a PREPARE.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I format the query with pg-promise first and run the output inside a transaction that I roll back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pgp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pg-promise&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)();&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pgp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;as&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="c1"&gt;// run it in psql between BEGIN; and ROLLBACK;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My &lt;code&gt;PREPARE&lt;/code&gt; test was correct about a query that n8n never runs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. On Error → Continue stays off the node that writes the thing that matters.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The node that saves the callback now fails loudly, with Retry On Fail set to three tries. Continue is fine on a step whose failure can't hide anything downstream. On that node, it turned a failure into a quiet success.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;p&gt;If you write SQL in n8n's Postgres node, assume your values get pasted into the text as quoted literals, because they do. A cast on one of those literals happens when Postgres analyzes the statement, whatever branch it sits in. And a comma inside a value is a parameter boundary unless you pass an array.&lt;/p&gt;

&lt;p&gt;I build and maintain n8n integrations for businesses at &lt;a href="https://achiya-automation.com/en/services/integrations/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=n8n-postgres-query-parameters-literals" rel="noopener noreferrer"&gt;Achiya Automation&lt;/a&gt;, and these four rules now get checked before any Postgres node goes live.&lt;/p&gt;

&lt;p&gt;How do you fill Query Parameters in your n8n workflows, comma string or array? If it's the comma string, have you checked what happens when one of the values contains a comma?&lt;/p&gt;

</description>
      <category>n8n</category>
      <category>postgres</category>
      <category>automation</category>
      <category>discuss</category>
    </item>
    <item>
      <title>I filed it as a flaky test. It was a crash that try/catch can't reach.</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Sun, 04 Oct 2026 08:46:12 +0000</pubDate>
      <link>https://dev.to/achiya-automation/i-filed-it-as-a-flaky-test-it-was-a-crash-that-trycatch-cant-reach-5k5</link>
      <guid>https://dev.to/achiya-automation/i-filed-it-as-a-flaky-test-it-was-a-crash-that-trycatch-cant-reach-5k5</guid>
      <description>&lt;p&gt;&lt;a href="https://github.com/achiya-automation/safari-mcp" rel="noopener noreferrer"&gt;Safari MCP&lt;/a&gt;, the open-source server I maintain, runs one process that owns a local bridge port. Any other instance becomes a secondary: it proxies through that primary, and if the primary goes away, it takes the port over. One test covers exactly that handoff. Start a primary, start a secondary, stop the primary, wait for the secondary to say it's listening.&lt;/p&gt;

&lt;p&gt;On October 1 that test failed once on the Node 24 macOS runner, in a pull request run. The rerun passed, Node 20 and 22 passed, and I filed &lt;a href="https://github.com/achiya-automation/safari-mcp/issues/140" rel="noopener noreferrer"&gt;issue #140&lt;/a&gt; as a CI flake. The last line I wrote there was: "Filed to track the flake and the Node version it needs; no code change in this issue yet."&lt;/p&gt;

&lt;p&gt;Two days later the same test failed again, this time on main, a few minutes after the pull request with the same code had gone green on the same Node version.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the secondary died of
&lt;/h2&gt;

&lt;p&gt;The secondary never printed its listening line. Its stderr ended like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Uncaught: Error: setTypeOfService EINVAL
    at Socket.setTypeOfService (node:net:911:13)
    at writeH1 (node:internal/deps/undici/undici:8022:16)
    at Object.write (node:internal/deps/undici/undici:7758:18)
    at _resume (node:internal/deps/undici/undici:9656:54)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The code that was supposed to handle a missing primary looks like every health check you have written:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`http://127.0.0.1:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;port&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/proxy-check`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AbortSignal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Nothing answers on the bridge port: the primary is gone. Take it over.&lt;/span&gt;
  &lt;span class="nf"&gt;takeOverThePort&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;When the primary is gone, &lt;code&gt;fetch()&lt;/code&gt; should reject and the catch should run. Instead the process ended.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the catch never ran
&lt;/h2&gt;

&lt;p&gt;The undici that ships inside Node 24 calls &lt;code&gt;socket.setTypeOfService()&lt;/code&gt; on every HTTP/1.1 request it writes. It's a quality-of-service hint and harmless almost everywhere. On macOS, though, the kernel refuses that option on a socket whose peer has just reset the connection. The call fails with &lt;code&gt;EINVAL&lt;/code&gt;, and Node turns that into a thrown error.&lt;/p&gt;

&lt;p&gt;The throw happens inside undici's write path, which runs from a socket I/O callback. Nothing there routes the error into the promise that &lt;code&gt;fetch()&lt;/code&gt; returned, so the &lt;code&gt;try/catch&lt;/code&gt; around &lt;code&gt;await fetch()&lt;/code&gt; never sees it. It becomes an uncaught exception, and an uncaught exception ends the process.&lt;/p&gt;

&lt;p&gt;So the crash needs three things at once: Node 24, macOS, and a peer that resets at the wrong moment. The test stops the primary a moment after the secondary comes up, close to the secondary's first check, which is about as close to the wrong moment as a test gets. In real use the race is rarer. But a secondary that dies exactly when it should take over is the failure the whole handoff exists to prevent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixed upstream, just not in Node 24
&lt;/h2&gt;

&lt;p&gt;undici fixed it in &lt;a href="https://github.com/nodejs/undici/pull/5547" rel="noopener noreferrer"&gt;nodejs/undici#5547&lt;/a&gt;, "fix(h1): ignore type of service errors", released in undici 8.8.0. It skips the call when nobody asked for a type of service, and it ignores the error when the call fails. Node 24 didn't get that change.&lt;/p&gt;

&lt;p&gt;I measured the difference by counting calls to &lt;code&gt;setTypeOfService&lt;/code&gt; during one plain &lt;code&gt;fetch()&lt;/code&gt; to a local server:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Node&lt;/th&gt;
&lt;th&gt;calls per fetch&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;24.21.0 (newest 24.x today)&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;26.10.0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I'm not the only one who hit this. &lt;a href="https://github.com/get-bb/bb/pull/4627" rel="noopener noreferrer"&gt;get-bb/bb&lt;/a&gt;, &lt;a href="https://github.com/lzwwaylon/telomi/pull/30" rel="noopener noreferrer"&gt;telomi&lt;/a&gt; and &lt;a href="https://github.com/pingdotgg/t3code/pull/8389" rel="noopener noreferrer"&gt;t3code&lt;/a&gt; each have a pull request for the same uncaught &lt;code&gt;EINVAL&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  You can't schedule a race, so fail at the boundary instead
&lt;/h2&gt;

&lt;p&gt;My first instinct was to reproduce the real thing: a keep-alive server in a child process, one request to pool the socket, then a reset or a kill, then a second request onto the dead socket. Two harnesses and six timing variants on Node 24.21.0 produced zero &lt;code&gt;EINVAL&lt;/code&gt;s. The kernel race simply didn't happen on my machine, which is also why the test passed nearly every time in CI.&lt;/p&gt;

&lt;p&gt;What worked was failing at the exact boundary instead of waiting for the race. Replace the native method with one that throws what macOS throws, then let undici's own write path call it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Socket&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node:net&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;http&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node:http&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;Socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prototype&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;setTypeOfService&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;setTypeOfService EINVAL&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;EINVAL&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;server&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createServer&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ok&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;127.0.0.1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;listening&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`http://127.0.0.1:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;address&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;port&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;resolved:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rejected:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&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;On Node 24.21.0 that script prints neither line. It dies on the uncaught &lt;code&gt;EINVAL&lt;/code&gt; with exit code 1, &lt;code&gt;try/catch&lt;/code&gt; and all. On Node 26.10.0 it prints &lt;code&gt;resolved: ok&lt;/code&gt;, because undici there never makes the call.&lt;/p&gt;

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

&lt;p&gt;Upgrading Node is the real fix, but I don't pick my users' Node version, and the package supports 20 and up. So the server wraps the method once at startup and ignores &lt;code&gt;EINVAL&lt;/code&gt; only:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ignoreTypeOfServiceEinval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;proto&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;original&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;proto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;setTypeOfService&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;original&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;function&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;original&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ignoresEinval&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;wrapped&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;original&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;apply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;EINVAL&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;err&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;span class="nx"&gt;wrapped&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ignoresEinval&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;proto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;setTypeOfService&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;wrapped&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;With the error swallowed, undici carries on writing to a socket that's gone, the write fails the normal way, and &lt;code&gt;fetch()&lt;/code&gt; rejects into the catch that was there all along. Any other error from the method still throws. On Node 20 and 22 the method doesn't exist, so the wrapper does nothing.&lt;/p&gt;

&lt;p&gt;The test suite now runs the script above in a child process with the wrapper applied and asserts that the fetch resolves. I checked that the test can fail: with the wrapper replaced by a no-op, the child exits 1 on Node 24.21.0 and the test goes red. With the wrapper it passes, on my machine and on Node 24.20.0 in CI. The fix shipped in &lt;a href="https://github.com/achiya-automation/safari-mcp/releases/tag/v2.22.10" rel="noopener noreferrer"&gt;v2.22.10&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I got wrong
&lt;/h2&gt;

&lt;p&gt;The label. A test that fails because the process under test died of an uncaught exception isn't flaky in any sense that matters. The code crashed, and the test just couldn't make it crash often. "Flaky" told me to wait and see, and waiting meant the second report came from main instead of from me.&lt;/p&gt;

&lt;p&gt;If your project runs on Node 24 on macOS and calls &lt;code&gt;fetch()&lt;/code&gt; against something that can go away under you (a local daemon, a sidecar, a dev server you restart), search your crash logs for &lt;code&gt;setTypeOfService EINVAL&lt;/code&gt;. It won't show up as a rejected request. It shows up as a process that isn't running anymore.&lt;/p&gt;

</description>
      <category>node</category>
      <category>javascript</category>
      <category>testing</category>
      <category>debugging</category>
    </item>
    <item>
      <title>An LLM Heard Women in 71% of Our 'Male' AI Voice Replies</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Wed, 30 Sep 2026 09:02:02 +0000</pubDate>
      <link>https://dev.to/achiya-automation/an-llm-heard-women-in-71-of-our-male-ai-voice-replies-23e5</link>
      <guid>https://dev.to/achiya-automation/an-llm-heard-women-in-71-of-our-male-ai-voice-replies-23e5</guid>
      <description>&lt;p&gt;On 27 September a real caller on my business line spoke to our "male" AI voice using &lt;em&gt;at&lt;/em&gt;, the feminine Hebrew "you". The voice sits at roughly 135 Hz. We had chosen it with a pitch check, and the pitch check was the mistake.&lt;/p&gt;

&lt;p&gt;In English that would be a matter of taste. In Hebrew it's a correctness bug, because present-tense verbs carry gender. Our assistant ends its greeting with "recording and passing it on", which is מקליט ומעביר for a man and מקליטה ומעבירה for a woman. The system prompt fixes the grammatical gender, so the audio has to match it for the whole call. If it doesn't, the sentence contradicts itself.&lt;/p&gt;

&lt;p&gt;So we measured how often each voice actually sounds like the gender it's supposed to be.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The speaker was &lt;code&gt;gemini-3.8-live&lt;/code&gt; in &lt;code&gt;he-IL&lt;/code&gt;, one prebuilt voice per run, thinking budget 0.&lt;/li&gt;
&lt;li&gt;Google's Chirp 3 HD catalogue labels 16 of its voice names male. We ran 13. The other three had been recorded with feminine grammar in an earlier run, which would have confounded the test.&lt;/li&gt;
&lt;li&gt;The script was four caller turns in Hebrew, sent as text through the realtime input (that's how our speech-to-text feeds the model in production), with the production prompt built for masculine grammar. Three separate calls per voice.&lt;/li&gt;
&lt;li&gt;Every reply of 1.5 seconds or more was converted to what a phone line delivers, then sent to &lt;code&gt;gemini-3.5-flash&lt;/code&gt; at temperature 0 with a single one-word question.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The conversion and the judge call, trimmed from the harness:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;audioop&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="n"&gt;wave&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;numpy&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;soxr&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;google.genai&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;types&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;phone_wav&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pcm24&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;24 kHz model output -&amp;gt; 8 kHz mu-law round trip, i.e. what the caller hears.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;frombuffer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pcm24&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dtype&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;int16&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;astype&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;float32&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;x8&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;soxr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resample&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;24000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;quality&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HQ&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;x8&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x8&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;32768&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;32767&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;astype&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;int16&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;x8&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;audioop&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ulaw2lin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;audioop&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lin2ulaw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x8&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tobytes&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;buf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;BytesIO&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;wave&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wb&lt;/span&gt;&lt;span class="sh"&gt;"&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;w&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setnchannels&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;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setsampwidth&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setframerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeframes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getvalue&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;Q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Listen to the speaker in this phone recording. Does the voice sound like &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
     &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;a man or a woman? Answer with exactly one word: man, woman, or unclear.&lt;/span&gt;&lt;span class="sh"&gt;"&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;def&lt;/span&gt; &lt;span class="nf"&gt;judge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pcm24&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&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;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;aio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generate_content&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;models/gemini-3.5-flash&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;contents&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;types&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Part&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_bytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;phone_wav&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pcm24&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;mime_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;audio/wav&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;Q&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;types&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;GenerateContentConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;temperature&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;woman&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;woman&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="nf"&gt;else &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;man&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;man&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unclear&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check for "woman" before "man". The second string is inside the first.&lt;/p&gt;

&lt;h2&gt;
  
  
  The results
&lt;/h2&gt;

&lt;p&gt;One judging pass, run on 30 September over the recordings made on 27 September:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Voice&lt;/th&gt;
&lt;th&gt;man&lt;/th&gt;
&lt;th&gt;woman&lt;/th&gt;
&lt;th&gt;unclear&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Zubenelgenubi&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Algieba&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sadaltager&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Algenib&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Alnilam&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Charon&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Schedar&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Umbriel&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Enceladus&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sadachbia&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Iapetus&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Puck&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fenrir&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That's 146 replies: 103 judged female (71%), 38 male (26%), 5 unclear. Only one voice was heard as a man more often than as a woman. None of the 39 calls got through without at least one reply judged female.&lt;/p&gt;

&lt;p&gt;These voices were speaking masculine Hebrew the whole time. If the judge were leaning on grammar, that would have pushed it toward "man", so the grammar can't explain the result.&lt;/p&gt;

&lt;h2&gt;
  
  
  The controls
&lt;/h2&gt;

&lt;p&gt;A judge that says "woman" to everything would produce the same table, so we checked it against clips where the answer is known:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Female voices from the same model, speaking feminine grammar: 34 of 34 replies judged female.&lt;/li&gt;
&lt;li&gt;Synthetic male callers from our test set, already at 8 kHz: judged male in 10 of 12 passes. Synthetic female callers: female in 6 of 6.&lt;/li&gt;
&lt;li&gt;The 135 Hz voice from the opening is called Pulcherrima, and Google's catalogue labels it female. We had it speak masculine grammar anyway. It was judged male in 0 of 11 replies.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The judge can recognise a man on a phone line. It just doesn't hear one in most of these voices.&lt;/p&gt;

&lt;h2&gt;
  
  
  Temperature 0 didn't make the judge deterministic
&lt;/h2&gt;

&lt;p&gt;This was the part I didn't expect. The same 11 Algieba clips were judged male 73% of the time on 27 September, then 45% and 55% on two passes on 30 September. One of the male control clips, an angry caller, came back man, woman, woman across three passes.&lt;/p&gt;

&lt;p&gt;What that means in practice:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;One judging pass is an anecdote. Run at least three and report the spread.&lt;/li&gt;
&lt;li&gt;Keep known-answer controls in every run, not only in the first one.&lt;/li&gt;
&lt;li&gt;Look at direction before magnitude. In both full passes, at least 12 of the 13 voices came out at or below 50% male. The exact percentages kept moving.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Telling the model to sound male made it worse
&lt;/h2&gt;

&lt;p&gt;The obvious fix was a line in the system prompt. Translated from the Hebrew, it said: "Your voice: a man's voice, low and steady. The whole call at exactly the same pitch, even in a short sentence, a thank-you or a goodbye. Never rise to a woman's pitch."&lt;/p&gt;

&lt;p&gt;Across the same 13 voices, the male share went from 26% without the line to 22% with it. Algieba, the only voice that had been winning, dropped to 11%. My guess, which I haven't tested, is that naming "a woman" in the instruction nudges the output toward one. We had shipped the line that afternoon and removed it the same evening.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitch isn't the variable
&lt;/h2&gt;

&lt;p&gt;This is old news in speech science. Hillenbrand and Clark (&lt;em&gt;Attention, Perception &amp;amp; Psychophysics&lt;/em&gt;, 2009) resynthesised sentences to flip the speaker's apparent sex. Shifting pitch alone, or formants alone, usually failed. Shifting both worked about 82% of the time. An F0 threshold checks one of two cues, and it's the cue our 135 Hz voice already passed while still sounding like a woman.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we shipped
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Customers now choose from 11 voices, all female. The male voices are quarantined and get re-tested whenever the model changes.&lt;/li&gt;
&lt;li&gt;My own line keeps the one male voice, because I can live with the risk there.&lt;/li&gt;
&lt;li&gt;Since 28 September, the greeting on customer lines is spoken by the live model itself. Before that, a separate text-to-speech model rendered it under the same voice name, and you could hear the switch between the two engines.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're building voice agents in a language with grammatical gender, measure the voice the way the caller will hear it, at 8 kHz rather than from the vendor's sample. Pick voices with a judge and controls, not with an F0 cutoff, and don't expect adjectives in the prompt to fix it.&lt;/p&gt;

&lt;p&gt;I build these systems at &lt;a href="https://achiya-automation.com/services/voice-bot/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=llm-judge-voice-gender" rel="noopener noreferrer"&gt;Achiya Automation&lt;/a&gt;, and the service in question is &lt;a href="https://onimli.com/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=llm-judge-voice-gender" rel="noopener noreferrer"&gt;Onimli&lt;/a&gt;, a Hebrew AI answering service for missed calls.&lt;/p&gt;

&lt;p&gt;If you've used an LLM as a judge for audio: how many passes do you run before you believe a number, and have you seen temperature 0 flip on identical clips like this?&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>testing</category>
      <category>discuss</category>
    </item>
    <item>
      <title>My 24-Month Audit Archive Was Keeping a Third of Each Day</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Mon, 28 Sep 2026 08:07:46 +0000</pubDate>
      <link>https://dev.to/achiya-automation/my-24-month-audit-archive-was-keeping-a-third-of-each-day-3h27</link>
      <guid>https://dev.to/achiya-automation/my-24-month-audit-archive-was-keeping-a-third-of-each-day-3h27</guid>
      <description>&lt;p&gt;On September 6 a disk alert fired on the server that runs my voice bots: 75% full. The culprit was &lt;code&gt;/var/log/auth.log&lt;/code&gt;, 763 MB after five days, on a box where it normally grows about 0.7 MB a week. Nearly every line started with &lt;code&gt;audispd:&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I knew why. Six days earlier I had switched on auditd's syslog plugin across the fleet so a central collector could receive the audit trail. I compressed the file, wrote the cause down, and moved on.&lt;/p&gt;

&lt;p&gt;This morning I measured it on all eight servers. The growth turned out to be the smaller finding. The archive I built to keep audit logs for 24 months had been saving about a third of each day on my busiest server, from the week I set it up.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;Eight Ubuntu 24.04 servers running auditd 3.1.2, with 22 to 25 rules each. Most rules are file watches, &lt;code&gt;-w &amp;lt;path&amp;gt; -p wa&lt;/code&gt;, on config files and on each app's directory. Israel's data security regulations require keeping access logs for 24 months. auditd knows nothing about months. Out of the box it keeps a ring:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/audit/auditd.conf
&lt;/span&gt;&lt;span class="py"&gt;max_log_file&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;8          # MB&lt;/span&gt;
&lt;span class="py"&gt;num_logs&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;5&lt;/span&gt;
&lt;span class="py"&gt;max_log_file_action&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;ROTATE&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five files of 8 MB each. When the ring is full, the oldest file is deleted. So on August 25 I added a cron job at 02:17 that forces a rotation, gzips every rotated file into &lt;code&gt;/var/log/audit-archive/&lt;/code&gt;, and deletes archives older than 730 days. Each archive's filename carries a hash of its content, so a file that moves from &lt;code&gt;audit.log.1&lt;/code&gt; to &lt;code&gt;audit.log.2&lt;/code&gt; isn't archived twice.&lt;/p&gt;

&lt;p&gt;On August 31 I enabled the syslog plugin:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/audit/plugins.d/syslog.conf
&lt;/span&gt;&lt;span class="py"&gt;active&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;yes&lt;/span&gt;
&lt;span class="py"&gt;args&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;LOG_INFO LOG_AUTHPRIV&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;LOG_AUTHPRIV&lt;/code&gt; is why the copies land in &lt;code&gt;auth.log&lt;/code&gt;. Ubuntu's rsyslog routes &lt;code&gt;auth,authpriv.*&lt;/code&gt; there, and logrotate keeps that file for 104 weeks.&lt;/p&gt;

&lt;h2&gt;
  
  
  What auth.log looks like now
&lt;/h2&gt;

&lt;p&gt;The same seven days of the week, before and after August 31, on six of the eight servers. The other two are dedicated to a client, so their numbers stay out of this post.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Server&lt;/th&gt;
&lt;th&gt;Aug 24–30&lt;/th&gt;
&lt;th&gt;Sep 21–27&lt;/th&gt;
&lt;th&gt;Growth&lt;/th&gt;
&lt;th&gt;audispd share&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;database&lt;/td&gt;
&lt;td&gt;2.3 MB&lt;/td&gt;
&lt;td&gt;734 MB&lt;/td&gt;
&lt;td&gt;×324&lt;/td&gt;
&lt;td&gt;99.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;website&lt;/td&gt;
&lt;td&gt;1.4 MB&lt;/td&gt;
&lt;td&gt;729 MB&lt;/td&gt;
&lt;td&gt;×524&lt;/td&gt;
&lt;td&gt;99.6%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;voice&lt;/td&gt;
&lt;td&gt;0.6 MB&lt;/td&gt;
&lt;td&gt;275 MB&lt;/td&gt;
&lt;td&gt;×441&lt;/td&gt;
&lt;td&gt;99.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WhatsApp gateway&lt;/td&gt;
&lt;td&gt;1.0 MB&lt;/td&gt;
&lt;td&gt;96 MB&lt;/td&gt;
&lt;td&gt;×101&lt;/td&gt;
&lt;td&gt;98.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;n8n&lt;/td&gt;
&lt;td&gt;1.3 MB&lt;/td&gt;
&lt;td&gt;88 MB&lt;/td&gt;
&lt;td&gt;×70&lt;/td&gt;
&lt;td&gt;95.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;chatwoot&lt;/td&gt;
&lt;td&gt;1.4 MB&lt;/td&gt;
&lt;td&gt;1.5 MB&lt;/td&gt;
&lt;td&gt;×1&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The last row is a control group: the plugin has been off on that server since September 8.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who is writing all of this?
&lt;/h2&gt;

&lt;p&gt;Not people. &lt;code&gt;aureport&lt;/code&gt; shows which rule and which executable produced the events in the local ring:&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="nb"&gt;sudo &lt;/span&gt;aureport &lt;span class="nt"&gt;--input-logs&lt;/span&gt; &lt;span class="nt"&gt;-k&lt;/span&gt; &lt;span class="nt"&gt;--summary&lt;/span&gt;   &lt;span class="c"&gt;# events per rule key&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;aureport &lt;span class="nt"&gt;--input-logs&lt;/span&gt; &lt;span class="nt"&gt;-x&lt;/span&gt; &lt;span class="nt"&gt;--summary&lt;/span&gt;   &lt;span class="c"&gt;# events per executable&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep &lt;code&gt;--input-logs&lt;/code&gt;. When stdin isn't a terminal, aureport reads stdin instead of the log files and prints &lt;code&gt;&amp;lt;no events of interest were found&amp;gt;&lt;/code&gt;. That cost me ten confused minutes this morning.&lt;/p&gt;

&lt;p&gt;On the &lt;strong&gt;database server&lt;/strong&gt;, 35,247 of the 35,659 events that matched a rule came from a single watch, &lt;code&gt;-w /opt/supabase -p wa&lt;/code&gt;, and the executable behind 35,082 of them was &lt;code&gt;postgres&lt;/code&gt;. The app directory contains the database's data volume, so every file Postgres opens for writing (paths like &lt;code&gt;base/5/2601&lt;/code&gt;) becomes an audit event.&lt;/p&gt;

&lt;p&gt;On the &lt;strong&gt;website&lt;/strong&gt;, 19,120 events hit the site's watch, and &lt;code&gt;rsync&lt;/code&gt; (14,988) and &lt;code&gt;rm&lt;/code&gt; (4,064) account for nearly all of them. That's the deploy. One deploy last night, between 23:07 and 23:12, produced 13,151 events made of 70,516 records.&lt;/p&gt;

&lt;p&gt;On the &lt;strong&gt;voice server&lt;/strong&gt; it's the same picture: 24,687 events on the app's watch, almost all from &lt;code&gt;rsync&lt;/code&gt;. The deploy there rewrites about 30,000 files on every run, changed or not.&lt;/p&gt;

&lt;p&gt;One write to a watched path isn't one line. It's a &lt;code&gt;SYSCALL&lt;/code&gt; record plus &lt;code&gt;CWD&lt;/code&gt;, one or two &lt;code&gt;PATH&lt;/code&gt; records and &lt;code&gt;PROCTITLE&lt;/code&gt;, 5.4 records per event on average in that deploy. The syslog plugin also forwards the &lt;code&gt;EOE&lt;/code&gt; (end of event) record, which auditd doesn't keep in its own log, so auth.log gets one more line per event than audit.log does.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ring runs out before the archive runs
&lt;/h2&gt;

&lt;p&gt;A 40 MB ring is fine as long as 40 MB lasts longer than the gap between archive runs. Here's how long it lasts, from &lt;code&gt;aureport --input-logs -t&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;database:&lt;/strong&gt; the five files cover 13.5 hours&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;website:&lt;/strong&gt; around last night's deploy, three 8 MB files filled in 3 min 48 s, then 58 s, then 17 s&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;chatwoot:&lt;/strong&gt; the five files cover almost three days&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The archive ran once a night. Anything that left the ring before 02:17 was deleted before anyone saved it.&lt;/p&gt;

&lt;p&gt;So I measured the archive itself: for every archived file, the first record's timestamp and the file's mtime, merged into intervals and compared with each calendar day. Twenty-nine days, August 30 to September 27:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Server&lt;/th&gt;
&lt;th&gt;Mean coverage&lt;/th&gt;
&lt;th&gt;Worst day&lt;/th&gt;
&lt;th&gt;Complete days&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;database&lt;/td&gt;
&lt;td&gt;33.6%&lt;/td&gt;
&lt;td&gt;15.0%&lt;/td&gt;
&lt;td&gt;0 of 29&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;website&lt;/td&gt;
&lt;td&gt;52.6%&lt;/td&gt;
&lt;td&gt;9.4%&lt;/td&gt;
&lt;td&gt;9 of 29&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;voice&lt;/td&gt;
&lt;td&gt;75.0%&lt;/td&gt;
&lt;td&gt;22.0%&lt;/td&gt;
&lt;td&gt;16 of 29&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;chatwoot&lt;/td&gt;
&lt;td&gt;94.0%&lt;/td&gt;
&lt;td&gt;16.4%&lt;/td&gt;
&lt;td&gt;26 of 29&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On the database server, the "24-month archive" holds 234 of the last 696 hours. On the website, deploy days drop to about 10%, because a deploy late in the evening pushes everything before it out of the ring. chatwoot's three incomplete days were maintenance days: a hardening pass, a Docker update and a fleet-wide reboot.&lt;/p&gt;

&lt;p&gt;The plugin didn't cause this. On August 30, the day before I enabled it, the database server's archive already held 28.4% of the day. Postgres had been filling the ring since the archive's first week.&lt;/p&gt;

&lt;h2&gt;
  
  
  The noise was the backup
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;audispd&lt;/code&gt; lines in auth.log, the ones I spent September 6 compressing, are kept for 104 weeks. Since August 31 they have been the most complete local copy of the audit trail on these servers.&lt;/p&gt;

&lt;p&gt;They're not a perfect copy. I counted &lt;code&gt;SYSCALL&lt;/code&gt; records, one per event, by the timestamp inside each record, in both logs, for 23:04 to 02:00 last night:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;audit.log&lt;/code&gt;: 19,177 events&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;auth.log&lt;/code&gt;: 18,912 events (98.6%)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The missing 1.4% left a trail too. The plugin writes to &lt;code&gt;/dev/log&lt;/code&gt;, which on Ubuntu 24.04 is journald's socket, and journald forwards to rsyslog. During that window journald logged four &lt;code&gt;Forwarding to syslog missed N messages&lt;/code&gt; lines, 2,789 messages in total. The journal's own copy fared worse: between 23:00 and 23:20 its rate limiter logged &lt;code&gt;Suppressed … messages from auditd.service&lt;/code&gt; six times, 101,004 messages in all.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I changed today
&lt;/h2&gt;

&lt;p&gt;The smallest change that stops the loss is to archive more often without forcing a rotation every time. The script got a flag and a lock:&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="nb"&gt;exec &lt;/span&gt;9&amp;gt;/run/audit-archive.lock
flock &lt;span class="nt"&gt;-w&lt;/span&gt; 120 9

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;1&lt;/span&gt;&lt;span class="k"&gt;:-}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s2"&gt;"--no-rotate"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
  /sbin/auditctl &lt;span class="nt"&gt;--signal&lt;/span&gt; rotate &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1 &lt;span class="o"&gt;||&lt;/span&gt; pkill &lt;span class="nt"&gt;-USR1&lt;/span&gt; auditd 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
  sleep &lt;/span&gt;2
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cron file got a second line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;17 2 * * * root /usr/local/bin/audit-archive.sh &amp;gt;/dev/null 2&amp;gt;&amp;amp;1
*/15 * * * * root /usr/local/bin/audit-archive.sh --no-rotate &amp;gt;/dev/null 2&amp;gt;&amp;amp;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The nightly run still forces a rotation, so quiet servers get a daily file. The quarter-hourly run only picks up files auditd already rotated by size, which is exactly the part that used to fall off the end. The lock keeps the 02:15 and 02:17 runs from writing the same temporary file. I rolled it out to all eight servers with the old files backed up. The first run on the database server archived three files that would have been gone before tonight's 02:17.&lt;/p&gt;

&lt;p&gt;It doesn't close every hole. A burst bigger than about 32 MB inside fifteen minutes, like two website deploys back to back, can still push a file out before the archive sees it. The durable fixes all mean writing less, and I haven't made them yet:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stop watching the database's data directory.&lt;/strong&gt; On August 25 I narrowed a &lt;code&gt;/var/lib/docker&lt;/code&gt; watch to &lt;code&gt;/var/lib/docker/containers&lt;/code&gt; for the same reason: Postgres block writes say nothing about who accessed what. The &lt;code&gt;/opt/supabase&lt;/code&gt; watch needs the same change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make the deploys skip unchanged files.&lt;/strong&gt; Thirty thousand rewritten files per deploy is an rsync problem, and auditd is only reporting it faithfully.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Give the syslog plugin its own facility,&lt;/strong&gt; &lt;code&gt;LOG_LOCAL6&lt;/code&gt; with its own file, so auth.log goes back to being about logins.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Check your own servers
&lt;/h2&gt;

&lt;p&gt;Three commands:&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;# how big is the ring?&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'^(max_log_file|num_logs|max_log_file_action) '&lt;/span&gt; /etc/audit/auditd.conf

&lt;span class="c"&gt;# how many hours does it hold right now?&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;aureport &lt;span class="nt"&gt;--input-logs&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt;

&lt;span class="c"&gt;# who is filling it?&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;aureport &lt;span class="nt"&gt;--input-logs&lt;/span&gt; &lt;span class="nt"&gt;-x&lt;/span&gt; &lt;span class="nt"&gt;--summary&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the second command shows fewer hours than the gap between your archive runs, your retention policy exists only in a document. I build and run automation infrastructure for small businesses at &lt;a href="https://achiya-automation.com/services/voice-bot/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=auditd-syslog-duplication" rel="noopener noreferrer"&gt;Achiya Automation&lt;/a&gt;, including the voice server in this post. This is the second time in five weeks that a retention number I had written down turned out to be one I had never measured.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;A question for anyone who archives auditd logs for compliance:&lt;/strong&gt; does your archive job run on rotation or on the clock, and how many hours does the ring actually hold on your busiest server? I'd especially like to hear from anyone running &lt;code&gt;max_log_file_action = keep_logs&lt;/code&gt; with a separate mover script, and whether that held up the first time the disk got tight.&lt;/p&gt;

</description>
      <category>linux</category>
      <category>security</category>
      <category>devops</category>
      <category>discuss</category>
    </item>
    <item>
      <title>My sudo Logs Hit Nearly 10 GB. Session Count Told Me Nothing.</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Thu, 24 Sep 2026 07:41:26 +0000</pubDate>
      <link>https://dev.to/achiya-automation/my-sudo-logs-hit-10-gb-session-count-told-me-nothing-1bn5</link>
      <guid>https://dev.to/achiya-automation/my-sudo-logs-hit-10-gb-session-count-told-me-nothing-1bn5</guid>
      <description>&lt;p&gt;A disk-usage alert sent me looking at &lt;code&gt;/var/log/sudo-io&lt;/code&gt; on a Chatwoot production box. It held 5.9 GB and was adding roughly 654 MB every week. Nothing on that server had changed. No new services, no new admins, no new cron jobs.&lt;/p&gt;

&lt;p&gt;My first instinct was the wrong one: I assumed the server was simply busier than the others. It isn't. It runs the &lt;em&gt;fewest&lt;/em&gt; sudo sessions of any box in the fleet that logs them.&lt;/p&gt;

&lt;p&gt;Here is what the numbers look like, measured live across the whole fleet this morning.&lt;/p&gt;

&lt;h2&gt;
  
  
  The measurement
&lt;/h2&gt;

&lt;p&gt;Two commands. The first counts sessions, the second measures bytes:&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;# how many sudo sessions were recorded&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;find /var/log/sudo-io &lt;span class="nt"&gt;-name&lt;/span&gt; log &lt;span class="nt"&gt;-type&lt;/span&gt; f | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;

&lt;span class="c"&gt;# how much disk those sessions occupy&lt;/span&gt;
&lt;span class="nb"&gt;sudo du&lt;/span&gt; &lt;span class="nt"&gt;-sk&lt;/span&gt; /var/log/sudo-io
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ten hosts in my SSH config. Only four of them have &lt;code&gt;/var/log/sudo-io&lt;/code&gt; at all, and one of those four is dedicated to a client, so its numbers stay out of this post. The other five never had &lt;code&gt;log_output&lt;/code&gt; enabled, and one was unreachable during the run. That inconsistency is its own finding, and I'll come back to it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Host&lt;/th&gt;
&lt;th&gt;Log volume&lt;/th&gt;
&lt;th&gt;Sessions&lt;/th&gt;
&lt;th&gt;KB per session&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;chatwoot&lt;/td&gt;
&lt;td&gt;5.48 GB&lt;/td&gt;
&lt;td&gt;16,168&lt;/td&gt;
&lt;td&gt;356&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;supabase&lt;/td&gt;
&lt;td&gt;2.33 GB&lt;/td&gt;
&lt;td&gt;56,794&lt;/td&gt;
&lt;td&gt;43&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;website&lt;/td&gt;
&lt;td&gt;1.71 GB&lt;/td&gt;
&lt;td&gt;14,289&lt;/td&gt;
&lt;td&gt;126&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;9.52 GB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;87,251&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;114&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read the last two columns together. &lt;code&gt;supabase&lt;/code&gt; recorded &lt;strong&gt;3.5x more sudo sessions&lt;/strong&gt; than &lt;code&gt;chatwoot&lt;/code&gt; and produced &lt;strong&gt;less than half&lt;/strong&gt; the volume. Per session, &lt;code&gt;chatwoot&lt;/code&gt; is 8.3x heavier.&lt;/p&gt;

&lt;p&gt;Session count and log size are not measuring the same thing, and I had been reading the wrong one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why bytes and sessions diverge
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;log_output&lt;/code&gt; in sudoers does not record &lt;em&gt;which command ran&lt;/em&gt;. It records everything the command writes to its pty. A short admin command that prints two lines costs a few hundred bytes. A command that streams a file costs the size of that file.&lt;/p&gt;

&lt;p&gt;So this:&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="nb"&gt;sudo &lt;/span&gt;systemctl restart nginx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;costs almost nothing, while this:&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="nb"&gt;sudo cat&lt;/span&gt; /opt/chatwoot-backups/dump-2026-09-14.sql
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;costs the full size of the dump, every night. The dump gets written to disk a second time, into the audit log, carrying no information the original didn't already have.&lt;/p&gt;

&lt;p&gt;That was the leak. The offsite backup job pulls a database dump every night by reading it through sudo. &lt;code&gt;supabase&lt;/code&gt; and &lt;code&gt;website&lt;/code&gt; already had those reads exempted in &lt;code&gt;/etc/sudoers.d/iolog-exemptions&lt;/code&gt;. &lt;code&gt;chatwoot&lt;/code&gt; did not. The fourth host had no exemptions file at all. Same fleet, same backup design, three different configurations — because the exemption file was written by hand on each host, one host at a time, and nothing ever checked that they matched.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix is an exemption, not a shutdown
&lt;/h2&gt;

&lt;p&gt;The tempting fix is to turn &lt;code&gt;log_output&lt;/code&gt; off. Don't. The recording is the point; you want to know what a human typed at 2am on a box holding customer conversations. What you don't want is a byte-for-byte copy of files you already have.&lt;/p&gt;

&lt;p&gt;sudoers lets you carve out specific commands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Cmnd_Alias IOLOG_EXEMPT = /usr/bin/cat /opt/chatwoot-backups/*, \
                          /usr/bin/stat /opt/chatwoot-backups/*, \
                          /usr/bin/zcat /var/log/caddy/*, \
                          /usr/bin/tail /var/log/caddy/*

Defaults!IOLOG_EXEMPT !log_output
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The session still gets logged: who ran what, when. Only the byte stream is dropped.&lt;/p&gt;

&lt;p&gt;Two things worth saying out loud about this file:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Validate before you apply it.&lt;/strong&gt; &lt;code&gt;visudo -cf /etc/sudoers.d/iolog-exemptions&lt;/code&gt; parses the file without installing it. A syntax error in &lt;code&gt;sudoers.d&lt;/code&gt; can lock you out of sudo on a machine you may only reach through sudo. Keep a timestamped backup next to it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The paths matter more than the commands.&lt;/strong&gt; Exempting &lt;code&gt;/usr/bin/cat&lt;/code&gt; outright would let anyone pipe any file past the audit log. Exempting &lt;code&gt;cat&lt;/code&gt; &lt;em&gt;restricted to the backup directory&lt;/em&gt; keeps the hole the size of the actual problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  What ten days later looks like
&lt;/h2&gt;

&lt;p&gt;I applied the exemptions across all four hosts on September 14 and measured again today, September 24.&lt;/p&gt;

&lt;p&gt;At 654 MB/week, &lt;code&gt;chatwoot&lt;/code&gt; should have been near 6.8 GB by now. It reads 5.48 GB. The curve turned over. Two things are doing that work together. New writes stopped, and the 60-day retention cron keeps deleting old sessions, so I'd expect the number to keep falling into mid-October as the pre-fix backlog ages out. I'm not claiming the exemption alone recovered 1.3 GB. I'm claiming the growth stopped, and that's the part I was chasing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I'd actually change
&lt;/h2&gt;

&lt;p&gt;The exemption fixed one symptom. The real defect is that five of ten hosts don't record sudo I/O, two of the four that do had drifted apart, and I only noticed because a disk filled up. A config that exists on some machines and not others isn't a policy, it's a habit that happened to repeat.&lt;/p&gt;

&lt;p&gt;So the check I added isn't "is the log too big." It's this, run across the fleet:&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;# does this host record sudo I/O at all, and is the exemption file present?&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rl&lt;/span&gt; log_output /etc/sudoers /etc/sudoers.d/ 2&amp;gt;/dev/null | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;
&lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /etc/sudoers.d/iolog-exemptions &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"exemptions: yes"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"exemptions: MISSING"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two lines, and it answers the question the disk alert was too late to ask. This is the same reasoning behind most of the fleet automation I build at &lt;a href="https://achiya-automation.com/services/integrations/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=sudo-io-log-growth" rel="noopener noreferrer"&gt;achiya-automation.com&lt;/a&gt; — the alert that fires when a threshold breaks is always a worse version of the check that confirms a configuration is still what you think it is.&lt;/p&gt;

&lt;h2&gt;
  
  
  The metric to steal
&lt;/h2&gt;

&lt;p&gt;If you have &lt;code&gt;log_output&lt;/code&gt; enabled anywhere, don't look at total size and don't look at session count. Look at bytes per session:&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="nb"&gt;sudo &lt;/span&gt;bash &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'kb=$(du -sk /var/log/sudo-io | cut -f1); \
  n=$(find /var/log/sudo-io -name log -type f | wc -l); \
  echo "$((kb / (n&amp;gt;0?n:1))) KB per session across $n sessions"'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Across my fleet the number is 114 KB. Anything in that range is ordinary admin work. A host sitting at 350+ has a command streaming a file through sudo, and you can find it in one pass. A host at 43 is doing plenty of work and costing you nothing.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;A question for anyone running &lt;code&gt;log_output&lt;/code&gt; in production:&lt;/strong&gt; what's your bytes-per-session number, and did the outlier turn out to be a backup job like mine, or something you'd never have guessed? I'm especially curious whether anyone exempts by &lt;em&gt;path&lt;/em&gt; the way I did here, or whether you found a cleaner pattern than hand-maintained &lt;code&gt;Cmnd_Alias&lt;/code&gt; blocks that drift between hosts.&lt;/p&gt;

</description>
      <category>linux</category>
      <category>devops</category>
      <category>sysadmin</category>
      <category>discuss</category>
    </item>
  </channel>
</rss>
