<?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: Tdude</title>
    <description>The latest articles on DEV Community by Tdude (@tdude).</description>
    <link>https://dev.to/tdude</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%2F4206206%2F3bda2a84-3507-497f-91b1-1c3252535469.jpg</url>
      <title>DEV Community: Tdude</title>
      <link>https://dev.to/tdude</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/tdude"/>
    <language>en</language>
    <item>
      <title>Most of my robots were me</title>
      <dc:creator>Tdude</dc:creator>
      <pubDate>Sun, 01 Nov 2026 23:00:00 +0000</pubDate>
      <link>https://dev.to/tdude/most-of-my-robots-were-me-fen</link>
      <guid>https://dev.to/tdude/most-of-my-robots-were-me-fen</guid>
      <description>&lt;p&gt;&lt;strong&gt;What this is about.&lt;/strong&gt; I added a counter to my own analytics to find out how&lt;br&gt;
many robots visit my sites, and whether any of them would say who they were.&lt;br&gt;
Nearly every number it gave me was measuring something other than what I&lt;br&gt;
thought.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why.&lt;/strong&gt; Because a number you have not grouped by anything is not yet&lt;br&gt;
information. There is a difference between belief and knowing.&lt;/p&gt;

&lt;h2&gt;
  
  
  5 reads, 62 bots
&lt;/h2&gt;

&lt;p&gt;The counter went live, I posted a new piece on LinkedIn and Facebook, and came&lt;br&gt;
back to "5 reads, 62 bots" a few minutes later. Sixty-two robots on one post&lt;br&gt;
felt like arrival.&lt;/p&gt;

&lt;p&gt;It was not. The single biggest source was Facebook, fetching the page over and&lt;br&gt;
over from different addresses to build one preview card. LinkedIn did it twice&lt;br&gt;
more. &lt;strong&gt;Sharing a link looks almost exactly like being crawled&lt;/strong&gt;, and nothing on&lt;br&gt;
my dashboard said so.&lt;/p&gt;

&lt;p&gt;The actual AI crawlers had sent one request each. GPTBot, ClaudeBot,&lt;br&gt;
PerplexityBot and Common Crawl had each knocked once and left. That is&lt;br&gt;
discovery, not reading.&lt;/p&gt;

&lt;p&gt;So the headline split in two: AI crawlers in one box, link previews in another.&lt;br&gt;
By the end of the day Facebook had fetched that single page from &lt;strong&gt;130 different&lt;br&gt;
addresses&lt;/strong&gt;, every one of them provably Facebook. I had assumed a preview card&lt;br&gt;
was one request.&lt;/p&gt;

&lt;h2&gt;
  
  
  21,791 visits, and 20,610 of them were me
&lt;/h2&gt;

&lt;p&gt;The next morning the counter said 21,791.&lt;/p&gt;

&lt;p&gt;I was briefly delighted. Then I grouped it, and found 20,709 visits from a&lt;br&gt;
client calling itself &lt;code&gt;curl&lt;/code&gt;, hitting the homepage, from three addresses, none&lt;br&gt;
of which had an address at all.&lt;/p&gt;

&lt;p&gt;Every one of my app containers has a health check. It runs &lt;code&gt;curl&lt;/code&gt; against its&lt;br&gt;
own homepage every fifteen seconds to decide whether the container is still&lt;br&gt;
alive. Seven containers, four probes a minute each, is about forty thousand&lt;br&gt;
requests a day. My bot filter is deliberately broad, it matches the word&lt;br&gt;
&lt;code&gt;curl&lt;/code&gt;, and so every single heartbeat was filed as an automated visitor.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ninety-five percent of my bot traffic was my own server taking its own&lt;br&gt;
pulse.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The fix is one line. Working out which signal to trust took the rest of the&lt;br&gt;
morning. Not the user-agent, because anything can claim to be anything. Not the&lt;br&gt;
path, because real crawlers ask for the homepage too. The health check is&lt;br&gt;
distinguishable because it talks to the app directly and never passes through&lt;br&gt;
the web server in front, so it arrives with &lt;strong&gt;no client address at all&lt;/strong&gt;. No&lt;br&gt;
address means it came from inside the house.&lt;/p&gt;

&lt;p&gt;This can break, and I would rather it broke this way round. If the proxy ever&lt;br&gt;
stopped handing me the real client address, I would quietly undercount real&lt;br&gt;
robots instead of confidently counting imaginary ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deleting them mattered more than I expected
&lt;/h2&gt;

&lt;p&gt;Fixing the code only stops the bleeding, so I deleted the 20,787 heartbeats&lt;br&gt;
already recorded.&lt;/p&gt;

&lt;p&gt;That sounds like tidying. It was not. The counts are also rolled up into a small&lt;br&gt;
permanent table, so that this October stays comparable with next October: the&lt;br&gt;
raw records are deliberately short-lived, the daily totals are not. Cleaning&lt;br&gt;
only the raw table would have left today's summary looking healthy while the&lt;br&gt;
permanent history went on insisting I had twenty thousand robot visitors on the&lt;br&gt;
day I shipped the counter. Wrong numbers that expire are a nuisance. Wrong&lt;br&gt;
numbers kept forever are a liability.&lt;/p&gt;

&lt;p&gt;I nearly made it worse. My first instinct was to delete everything with no&lt;br&gt;
client address, which is the obvious signature. Checking the query before&lt;br&gt;
running it showed that would also have deleted perfectly good records for&lt;br&gt;
Googlebot, Bingbot and ClaudeBot, recorded before I started keeping origins at&lt;br&gt;
all. The predicate had to be narrower.&lt;/p&gt;

&lt;p&gt;Pruned on both sides, the real figure for that first day is &lt;strong&gt;914 visits from 26&lt;br&gt;
bot families&lt;/strong&gt;: far less impressive, and the first one I believe.&lt;/p&gt;

&lt;h2&gt;
  
  
  I was wrong about asking them who they are
&lt;/h2&gt;

&lt;p&gt;The thing I actually wanted was an email address. If a robot is reading&lt;br&gt;
everything I write, I would quite like to know who to talk to.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; I do NOT track human users like this. Please read on!&lt;/p&gt;

&lt;p&gt;I assumed this was hopeless. There is a header in the HTTP spec, &lt;code&gt;From:&lt;/code&gt;, meant&lt;br&gt;
to carry the email address of whoever is responsible for an automated client,&lt;br&gt;
and my firm expectation was that nobody had used it since about 1996. I said so&lt;br&gt;
out loud, more than once, and logged it mostly as a joke.&lt;/p&gt;

&lt;p&gt;Once the heartbeats were out of the way, 66 visits had filled it in.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;googlebot(at)googlebot.com&lt;br&gt;
bingbot(at)microsoft.com&lt;br&gt;
gptbot(at)openai.com&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Google, Microsoft and OpenAI all announce themselves, unprompted, in a header I&lt;br&gt;
had written off. Two smaller crawlers I did not recognise went further and gave&lt;br&gt;
real abuse-contact addresses. The obfuscated "(at)" is their own, presumably so&lt;br&gt;
the addresses survive being scraped out of server logs by somebody else's robot,&lt;br&gt;
which is a pleasing piece of symmetry.&lt;/p&gt;

&lt;p&gt;So the answer to "can I ask a robot who runs it" turns out to be: mostly you do&lt;br&gt;
not have to ask. The well-behaved ones have been telling me all along and I was&lt;br&gt;
not reading it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happened, and what else I could have done
&lt;/h2&gt;

&lt;p&gt;Three numbers were wrong in three different ways. The first counted link&lt;br&gt;
previews as readers. The second counted my own health checks as visitors. The&lt;br&gt;
third, the one I never even displayed, was sitting there full of answers because&lt;br&gt;
I had assumed it would be empty.&lt;/p&gt;

&lt;p&gt;The fix for the big one was a single condition, and the alternatives were all&lt;br&gt;
worse:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Instead of "no client address"&lt;/th&gt;
&lt;th&gt;I would gain&lt;/th&gt;
&lt;th&gt;It would cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Matching the user-agent&lt;/td&gt;
&lt;td&gt;Obvious and readable&lt;/td&gt;
&lt;td&gt;Anything can claim to be &lt;code&gt;curl&lt;/code&gt;, and real tools legitimately are&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Matching the homepage path&lt;/td&gt;
&lt;td&gt;Also obvious&lt;/td&gt;
&lt;td&gt;Real crawlers request the homepage constantly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Excluding the container's own network range&lt;/td&gt;
&lt;td&gt;Precise today&lt;/td&gt;
&lt;td&gt;Breaks the first time the network layout changes, silently&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Not counting bots at all&lt;/td&gt;
&lt;td&gt;No wrong numbers&lt;/td&gt;
&lt;td&gt;No numbers&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I went hunting for robots and found a mirror. Before you trust a number, make it&lt;br&gt;
sit still and tell you what it is made of. Mine was 95 percent heartbeat.&lt;/p&gt;

&lt;p&gt;Now comes the hard question: are you sure your (third party) tracker is made up&lt;br&gt;
of the same suspicios minds? Are you sure they filter out the noise or do they&lt;br&gt;
sell you snake oil? &lt;a href="https://idunworks.com/forms/give-us-a-shout" rel="noopener noreferrer"&gt;Give us a shout&lt;/a&gt; and we can check&lt;br&gt;
for you!&lt;/p&gt;

&lt;p&gt;Next in this series: the most requested page on my server, which is a page that&lt;br&gt;
does not exist, and what I did to the robots asking for it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://idunworks.com/blog/most-of-my-robots-were-me" rel="noopener noreferrer"&gt;idunworks.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>analytics</category>
      <category>bots</category>
    </item>
    <item>
      <title>Three holes in a green CI, and a lock for the free plan</title>
      <dc:creator>Tdude</dc:creator>
      <pubDate>Fri, 30 Oct 2026 09:55:37 +0000</pubDate>
      <link>https://dev.to/tdude/three-holes-in-a-green-ci-and-a-lock-for-the-free-plan-4ak0</link>
      <guid>https://dev.to/tdude/three-holes-in-a-green-ci-and-a-lock-for-the-free-plan-4ak0</guid>
      <description>&lt;p&gt;&lt;strong&gt;What this is about.&lt;/strong&gt; On a site we worked on recently, the test pipeline went green across the board, and we looked into what green actually covered. Three things turned out not to be covered at all. This is how we found and closed each one, on a free GitHub plan.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why.&lt;/strong&gt; A green CI tells you that the tests it ran passed. It doesn't tell you which tests it didn't run, or whether anything waits for it before you deploy.&lt;/p&gt;

&lt;h2&gt;
  
  
  The question
&lt;/h2&gt;

&lt;p&gt;Once CI was green, we wrote the next step down as one line:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;go on with test, CI and safety planning. Output a shortlist in order of priority&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That gave us ten items, and the top two were about the pipeline itself, not about the code. So we started at the top.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hole 1: tests that CI never ran
&lt;/h2&gt;

&lt;p&gt;The backend is a handful of small services, and CI runs each one's tests in its own job. A couple of services had plenty of tests and no job at all. Nobody had removed them; they had just never been added to the list.&lt;/p&gt;

&lt;p&gt;Adding them was one word each in the workflow file. Then the first run showed what they had been hiding:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A pile of tests errored instead of running.&lt;/strong&gt; Their database tests looked for a Postgres address in a dedicated variable. One test file skipped politely when it wasn't set; the files that borrowed its fixtures didn't, and crashed on an empty URL.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The async tests failed&lt;/strong&gt; because &lt;code&gt;pytest-asyncio&lt;/code&gt; wasn't installed in CI. They were never broken; they were never run.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fix was a small &lt;code&gt;conftest.py&lt;/code&gt; with one rule: &lt;strong&gt;without a test database, skip every test that needs one. In CI, fail instead.&lt;/strong&gt; The second half matters most. A broken database in CI should turn the run red, not quietly turn the database tests into skips.&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;pytest_configure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;global&lt;/span&gt; &lt;span class="n"&gt;_db_ready&lt;/span&gt;
    &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TEST_DB_ENV&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;_db_ready&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;_database_reachable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;_db_ready&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CI&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="n"&gt;pytest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;UsageError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CI: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;TEST_DB_ENV&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; unreachable at &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;_redact&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&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;&lt;code&gt;_redact&lt;/code&gt; drops the password from the URL before it reaches a log, because CI logs are read by more people than your secrets file is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hole 2: tests that built a different schema than production
&lt;/h2&gt;

&lt;p&gt;In production, the service builds its tables by running its migrations when the container starts. Its tests built them straight from the models with &lt;code&gt;create_all&lt;/code&gt;. Two roads to the same place, as long as nobody forgets a migration. Forget one, and the tests pass while production queries fail.&lt;/p&gt;

&lt;p&gt;So we added one test: run the real migrations on an empty database, then ask Alembic to compare the result with the models.&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="n"&gt;diff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;compare_metadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Base&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;diff&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;models and migrations differ:&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&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="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;diff&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It found drift on its first run. Harmless drift, and the database itself needed no change. Still, a test that checks for drift and finds some on day one has earned its place.&lt;/p&gt;

&lt;p&gt;Another service had the opposite problem. Its background jobs opened their own database connection from the real connection string, not the test one. In CI that pointed at an empty database, so the jobs failed, logged an error and moved on, and the tests passed. A few tests had in fact been passing &lt;em&gt;because&lt;/em&gt; a background job failed silently. Now the jobs use the test database, or stay off unless a test asks for them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hole 3: nothing waited for green
&lt;/h2&gt;

&lt;p&gt;The repo is private and on a free plan, and GitHub's branch protection, the thing that stops a red commit from merging, needs a paid plan there. So a commit could go red in CI and still be deployed a minute later.&lt;/p&gt;

&lt;p&gt;Every deploy goes through one command, so the lock went there. Before anything is synced or deployed, a small script:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;checks that the commit being deployed is the one on the main branch,&lt;/li&gt;
&lt;li&gt;finds the CI run for that commit, and waits for it if it's still going,&lt;/li&gt;
&lt;li&gt;refuses unless it ended in success.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Docs-only commits don't trigger CI at all, so those get special handling: if every file changed since the last tested commit is documentation, that earlier run counts. Anything else without a run of its own is refused.&lt;/p&gt;

&lt;p&gt;There's an escape hatch, &lt;code&gt;ALLOW_UNTESTED=1&lt;/code&gt;, which prints a warning and goes ahead. It's for emergencies, not for when CI is slow.&lt;/p&gt;

&lt;p&gt;We tested the script against a fake GitHub CLI in a throwaway repo, one case at a time: green, red, cancelled, still running, docs-only, untested code, not pushed, and the override. Then against the real thing, right after a push:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;… waiting for Test Suite run on &amp;lt;commit&amp;gt;
✓ CI green on &amp;lt;commit&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same change fixed the order of pushes in the deploy command, too. It pushed the main repo before the submodule it points to, which is exactly the wrong way round.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happened, how we fixed it, and how else it could be done
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Services missing from CI:&lt;/strong&gt; added to the job list, plus the one test dependency they needed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Database tests that errored or skipped:&lt;/strong&gt; a shared rule that skips without a database and fails in CI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Schema built two ways:&lt;/strong&gt; one test that runs the real migrations and compares them with the models.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Background jobs leaking out of the test database:&lt;/strong&gt; pointed at the test database, or switched off by default.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Red commits deployable:&lt;/strong&gt; a CI check inside the deploy command, with a loud override.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Other ways to make deploys wait for CI:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Better at&lt;/th&gt;
&lt;th&gt;Costs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;A check in the deploy command&lt;/strong&gt; (what we did)&lt;/td&gt;
&lt;td&gt;Free, works on any plan, covers the only way you deploy&lt;/td&gt;
&lt;td&gt;Only protects that one command; a manual deploy can skip it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Branch protection&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Enforced by GitHub for everyone, before merge&lt;/td&gt;
&lt;td&gt;Needs a paid plan for private repos on personal accounts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Deploy from CI&lt;/strong&gt; (CI deploys only after tests pass)&lt;/td&gt;
&lt;td&gt;No way around it, and no laptop involved&lt;/td&gt;
&lt;td&gt;Server credentials live in CI, and you need to trust and maintain that path&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;A pre-push hook&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Catches it before it even leaves your machine&lt;/td&gt;
&lt;td&gt;Runs the tests locally on every push, and is easy to skip&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Before you trust a green CI
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;List your services and compare that with the list CI runs. They drift apart.&lt;/li&gt;
&lt;li&gt;Make "no database" a skip on your laptop and a failure in CI.&lt;/li&gt;
&lt;li&gt;If production migrates and tests use &lt;code&gt;create_all&lt;/code&gt;, add one test that compares the two.&lt;/li&gt;
&lt;li&gt;Grep for anything that opens its own database connection; tests should own all of them.&lt;/li&gt;
&lt;li&gt;Make your deploy ask CI before it does anything, and make skipping that loud.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Green means a bit more there now than it did this morning. More from the workshop is on the &lt;a href="https://idunworks.com/blog" rel="noopener noreferrer"&gt;Ship Log&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>git</category>
      <category>ci</category>
      <category>ai</category>
    </item>
    <item>
      <title>My most popular page does not exist</title>
      <dc:creator>Tdude</dc:creator>
      <pubDate>Thu, 29 Oct 2026 23:00:00 +0000</pubDate>
      <link>https://dev.to/tdude/my-most-popular-page-does-not-exist-3e8p</link>
      <guid>https://dev.to/tdude/my-most-popular-page-does-not-exist-3e8p</guid>
      <description>&lt;p&gt;&lt;strong&gt;What this is about.&lt;/strong&gt; While working out why my new robot counter had such a&lt;br&gt;
flattering opinion of my readership, I read the raw server log and found that&lt;br&gt;
the most requested page on the whole machine was one that has never existed.&lt;br&gt;
This is what I did about it, and the mistake that nearly took all my sites&lt;br&gt;
offline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why.&lt;/strong&gt; Building the trap took an afternoon. Working out what must never be&lt;br&gt;
trapped took longer, and that is the part worth writing down.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fifteen thousand requests for nothing
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;/wordpress&lt;/code&gt; was the most requested path on the server for my newly deployed&lt;br&gt;
site. More than the homepage. Fifteen thousand requests in a single log file.&lt;/p&gt;

&lt;p&gt;I have never run WordPress there (!). I once had a React front end that read&lt;br&gt;
content from WordPress on a different machine entirely, which is not the same&lt;br&gt;
thing and would not leave this trail.&lt;/p&gt;

&lt;p&gt;Every one of those requests forged a browser user-agent, rotating through&lt;br&gt;
Chrome, Edge, Safari, Firefox and Opera in almost exactly equal numbers. That is&lt;br&gt;
one scanner with a costume box, not many visitors. It is also the kind of sneaky&lt;br&gt;
little robot my shiny new counter cannot see, because it is pretending to be a&lt;br&gt;
person. Once identified, never doubted. No amount of counting fixes that.&lt;/p&gt;

&lt;p&gt;What it can see is that the requests came from a couple of dozen addresses, two&lt;br&gt;
of which accounted for 87 percent of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Twenty bytes a second
&lt;/h2&gt;

&lt;p&gt;A &lt;strong&gt;&lt;em&gt;tarpit&lt;/em&gt;&lt;/strong&gt; is an old idea (a pit and a load of tar): answer, but answer so&lt;br&gt;
irritatingly slow that the other side wastes real time. Ha! So I gave them&lt;br&gt;
something to download. A file called &lt;code&gt;wordpress_backup.sql&lt;/code&gt;, served at &lt;em&gt;twenty&lt;br&gt;
bytes&lt;/em&gt; per second. The whole megabyte takes just over fourteen hours.&lt;/p&gt;

&lt;p&gt;The sketch I started from bolted on a Python service whose entire job was to&lt;br&gt;
dribble ones and zeros forever. The web server does it alone, sulkily, for&lt;br&gt;
nothing: a finite file served slowly enough is indistinguishable from an&lt;br&gt;
infinite one, so there is no extra process, no extra port and nothing extra to&lt;br&gt;
keep patched. I also dropped a (for me) clever header containing a JavaScript&lt;br&gt;
infinite loop, on the grounds that response headers are not executed by&lt;br&gt;
anything.&lt;/p&gt;

&lt;p&gt;Then the web server and I fell out over what "slow" means.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The rate limit throttles the headers too.&lt;/strong&gt; A client spends eighteen seconds&lt;br&gt;
receiving about 360 bytes of response headers, so it is stuck before it even&lt;br&gt;
learns the filename it is being offered. I spent a while convinced my header&lt;br&gt;
configuration was broken, when in fact it was still in flight. Stained by tar up&lt;br&gt;
to my knees already...&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The defaults make a worse trap than small buffers do.&lt;/strong&gt; Out of the box the&lt;br&gt;
server hands over one four-kilobyte chunk immediately and then goes quiet for&lt;br&gt;
about three and a half minutes, which any scanner with an inactivity timeout&lt;br&gt;
treats as a dead connection and abandons. Shrinking the buffers turns that into&lt;br&gt;
a steady dribble, so the connection stays interesting enough to sit in. Smaller&lt;br&gt;
buffers, stickier trap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Up to my elbows in tar
&lt;/h2&gt;

&lt;p&gt;Then I widened it to the other things robots ask for. &lt;code&gt;/.env&lt;/code&gt;, &lt;code&gt;/.git/config&lt;/code&gt;,&lt;br&gt;
the files that hand over credentials when they exist. All of those begin with a&lt;br&gt;
dot, so a rule for dot paths looked obvious.&lt;/p&gt;

&lt;p&gt;So does &lt;code&gt;/.well-known/acme-challenge/&lt;/code&gt;, which is how my TLS certificates prove&lt;br&gt;
they are mine when they renew.&lt;/p&gt;

&lt;p&gt;A rule that trapped every dot path would have tarpitted my own certificate&lt;br&gt;
renewals. Nothing would have broken that day, or that week. It would have&lt;br&gt;
surfaced a month or two later as expired certificates on every site at once, and&lt;br&gt;
I would never have connected it to the "clever" thing I did in October.&lt;/p&gt;

&lt;p&gt;There is a second exclusion of the same shape. Search engines speculatively ask&lt;br&gt;
every site for &lt;code&gt;/wp-json/&lt;/code&gt; and &lt;code&gt;/wp-sitemap.xml&lt;/code&gt;, just in case WordPress is&lt;br&gt;
hiding there. A blanket rule for anything starting with &lt;code&gt;wp&lt;/code&gt; would have put&lt;br&gt;
Googlebot in the tarpit for fourteen hours at a time. I first assumed those&lt;br&gt;
requests were leftovers from some old setup and would fade. They are not. They&lt;br&gt;
are standard crawler behaviour and they will keep arriving forever (or until&lt;br&gt;
Mullenweg goes crazy again), so the exclusion is permanent. The note in the&lt;br&gt;
config now says so. The version where it looks temporary is the version somebody&lt;br&gt;
deletes at the Spring cleanout.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it has caught
&lt;/h2&gt;

&lt;p&gt;So far the trap has held 91 requests for a combined &lt;strong&gt;25.6 hours&lt;/strong&gt;. The average&lt;br&gt;
stay is seventeen minutes. Twenty lasted more than ten. The longest single&lt;br&gt;
connection sat there for &lt;strong&gt;eight hours and ten minutes&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The funniest part is that it is barely catching WordPress scanners at all. The&lt;br&gt;
top catches are &lt;code&gt;/.env&lt;/code&gt; and &lt;code&gt;/.git/config&lt;/code&gt;. I built a little black sticky goo&lt;br&gt;
for one kind of robot and it filled up with another. Never thought slow&lt;br&gt;
connections could bring me so much joy :)&lt;/p&gt;

&lt;h2&gt;
  
  
  What happened, and what else I could have done
&lt;/h2&gt;

&lt;p&gt;A page that does not exist was my most popular page. It now answers, very&lt;br&gt;
slowly, to everyone except the three kinds of visitor that must never be slowed&lt;br&gt;
down: real people, search engines doing their normal speculative sniffing, and&lt;br&gt;
my own certificates renewing themselves.&lt;/p&gt;

&lt;p&gt;A tarpit is not the only answer, and it is not obviously the best one:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Instead of a tarpit&lt;/th&gt;
&lt;th&gt;I would gain&lt;/th&gt;
&lt;th&gt;It would cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Returning "not found"&lt;/td&gt;
&lt;td&gt;Free, no risk at all&lt;/td&gt;
&lt;td&gt;The scanner leaves in two milliseconds and is back tomorrow&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Blocking the worst networks at the firewall&lt;/td&gt;
&lt;td&gt;The cheapest real relief; two addresses were most of the traffic&lt;/td&gt;
&lt;td&gt;Those addresses rotate, so it is maintenance forever&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auto-banning after a few probes&lt;/td&gt;
&lt;td&gt;Adapts without a hand-maintained list&lt;/td&gt;
&lt;td&gt;One more moving part, and a false positive blocks a person&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Serving a deliberately huge file at full speed&lt;/td&gt;
&lt;td&gt;Costs them bandwidth immediately&lt;/td&gt;
&lt;td&gt;Costs me the same bandwidth, which is the wrong trade&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The honest caveat is that a tarpit costs me something too. I hold a connection&lt;br&gt;
so that they hold one, and if the scanning came from thousands of addresses&lt;br&gt;
instead of a couple of dozen, that trade would stop favouring me. Hence a hard&lt;br&gt;
ceiling on how many of these connections can exist at once, and a refused one&lt;br&gt;
being closed rather than answered.&lt;/p&gt;

&lt;p&gt;And a small confession about measurement, which is becoming a theme. My first&lt;br&gt;
instinct was to keep these requests out of the logs entirely, since fifteen&lt;br&gt;
thousand lines a day is noise. I did exactly that, and then realised I had built&lt;br&gt;
a trap with no way of telling whether it caught anything. They now go to their&lt;br&gt;
own log file, which is how I know about the eight hours.&lt;/p&gt;

&lt;p&gt;Hit me in the comments if you think my reasoning is flawed, or if you think bots&lt;br&gt;
should be treated as equals.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://idunworks.com/blog/my-most-popular-page-does-not-exist" rel="noopener noreferrer"&gt;idunworks.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>bots</category>
    </item>
    <item>
      <title>I locked the door my deploy knocked on</title>
      <dc:creator>Tdude</dc:creator>
      <pubDate>Wed, 28 Oct 2026 10:04:34 +0000</pubDate>
      <link>https://dev.to/tdude/i-locked-the-door-my-deploy-knocked-on-1jm3</link>
      <guid>https://dev.to/tdude/i-locked-the-door-my-deploy-knocked-on-1jm3</guid>
      <description>&lt;p&gt;&lt;strong&gt;What this is about.&lt;/strong&gt; I asked my AI for a full maintenance audit of my CMS, and we spent an afternoon closing what it found. Then the first deploy failed, and one of my own fixes had caused it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why.&lt;/strong&gt; Every fix changes something somebody depends on, and sometimes that somebody is your own tooling. It's better to find out in a rollback than in an outage.&lt;/p&gt;

&lt;h2&gt;
  
  
  The audit
&lt;/h2&gt;

&lt;p&gt;My brief was short:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;i want a full audit with a report at each step.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It came back in parts: UX, marketing, product, and security. I said "let's concentrate on the sec. items first", and we did them in a few rounds. The ones worth telling:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The quiz answered itself.&lt;/strong&gt; My course pages fetched each quiz with the right answer included, unscrambled, and the page just didn't show it. Anyone opening the browser's network tab with a beautiful or curious mind could get a perfect score. Students now get the &lt;em&gt;questions&lt;/em&gt;, and only course editors get the &lt;em&gt;answers&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The browser named its own price.&lt;/strong&gt; When you bought a course, the checkout trusted the amount the browser sent. Now the server looks up the price itself, and when the payment comes back it checks that what was paid matches what was owed. If it doesn't, nothing gets unlocked and I get a loud log line. In a webshop this is one of the building blocks. I started the courses out of a need for a small course platform, no payment. Easy to forget the nuances.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A door between my services had a wall, but no lock.&lt;/strong&gt; The route that grants course access after a payment can only be reached from inside my own network. It still trusted anything that reached it. It now wants a shared secret, and it refuses when the secret isn't set, instead of letting everyone in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Logging out didn't log you out.&lt;/strong&gt; The button cleared your cookies, but the session behind them stayed valid until it expired. Logout now ends the session on the server too. A deactivated account, or a changed password, ends every session that account has. Easy to miss when you're up to your elbows in code-grease creating a course platform.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each fix came with a test, and the test suites ran green: about 620 tests across three services. I've seen worse.&lt;/p&gt;

&lt;h2&gt;
  
  
  The deploy
&lt;/h2&gt;

&lt;p&gt;Another fix from the same evening hid the API's documentation pages in production. FastAPI publishes them at &lt;code&gt;/docs&lt;/code&gt; by default, a guided tour of every endpoint you have. Strangers don't need that tour.&lt;/p&gt;

&lt;p&gt;The first site went out, and the deploy stopped with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Health checks timed out after 120s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My deploys are of the blue-green kind: The new version starts next to the old one, a script checks that it's healthy, and only then does traffic move over. The script's idea of "healthy" was asking for &lt;code&gt;/docs&lt;/code&gt; and getting a page back. I had just made sure it would never get one again.&lt;/p&gt;

&lt;p&gt;So for two minutes the new version stood there, perfectly fine, knocking on a door I had locked an hour earlier. Then the script gave up, the old version kept serving, and nobody visiting the site noticed a thing.&lt;/p&gt;

&lt;p&gt;The fix was one word in three scripts: ask &lt;code&gt;/health&lt;/code&gt; instead. That route exists precisely to say "I'm alive". Nobody will ever hide it, because hiding it would be pointless. Go there and it will kick your ass to 404.&lt;/p&gt;

&lt;p&gt;Then the evening ran long. Halfway through the fleet I said "hold off the rest for tomorrow", and the AI started stopping a deploy that was already halfway out. Yeah, what does "hold off" mean really? I corrected it:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;i meant to not start a new deploy. Finish what is started as agreed&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Fair. A half-finished deploy is worse than either a finished one or one that never started. The last site went out the next morning, and every site passed the same checks: no docs, logout only by POST, internal routes closed to the public, and a quiz with no answers in it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happened, how we fixed it, and how else it could be done
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Quiz answers in the response:&lt;/strong&gt; a separate public schema without the answer field.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Client-set prices:&lt;/strong&gt; the server prices the order and checks the paid amount before it unlocks anything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unlocked internal route:&lt;/strong&gt; a shared secret, with the route closed if the secret is missing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Logout that didn't:&lt;/strong&gt; sessions are revoked on the server at logout, deactivation and password change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The failed deploy:&lt;/strong&gt; health checks ask a dedicated &lt;code&gt;/health&lt;/code&gt; route, not the docs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Other ways to handle the docs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Better at&lt;/th&gt;
&lt;th&gt;Costs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Turn them off in production&lt;/strong&gt; (what we did)&lt;/td&gt;
&lt;td&gt;Nothing to find, nothing to maintain&lt;/td&gt;
&lt;td&gt;You lose the docs in production, so you read them locally&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Keep them behind an admin login&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Docs are still there when you need them live&lt;/td&gt;
&lt;td&gt;Another protected route to get right&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Block them at the web server&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;One rule covers every service&lt;/td&gt;
&lt;td&gt;The app still serves them to anything inside the network&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Leave them on&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Easy for integrators&lt;/td&gt;
&lt;td&gt;A free map of your API for anyone who's curious&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Before you lock something
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Search your deploy, monitoring and health scripts for the URL you're closing.&lt;/li&gt;
&lt;li&gt;Give every service a &lt;code&gt;/health&lt;/code&gt; route, and point every probe at it.&lt;/li&gt;
&lt;li&gt;Make sure a failed health check rolls back, before you ever need it to.&lt;/li&gt;
&lt;li&gt;When someone says "stop", ask if "don't start" or "abort" is what they mean. &lt;a href="https://www.youtube.com/watch?v=7lVXT5NZsSM" rel="noopener noreferrer"&gt;Or right&lt;/a&gt;?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The rollback did exactly what I built it for, on the first night it was needed. More from the workshop is on the &lt;a href="https://idunworks.com/blog" rel="noopener noreferrer"&gt;Ship Log&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://idunworks.com/blog/i-locked-the-door-my-deploy-knocked-on" rel="noopener noreferrer"&gt;idunworks.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>devops</category>
      <category>ai</category>
      <category>programming</category>
    </item>
    <item>
      <title>My translator thought Monday.com was a weekday</title>
      <dc:creator>Tdude</dc:creator>
      <pubDate>Mon, 26 Oct 2026 07:38:52 +0000</pubDate>
      <link>https://dev.to/tdude/my-translator-thought-mondaycom-was-a-weekday-40pn</link>
      <guid>https://dev.to/tdude/my-translator-thought-mondaycom-was-a-weekday-40pn</guid>
      <description>&lt;p&gt;&lt;strong&gt;What this is about.&lt;/strong&gt; This spring a machine translated my blog into Swedish. This autumn I read the result. Yeah I know, kinda lazy. So much time and so little to do... It translated a product name into a day of the week, and my install commands into Swedish.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why.&lt;/strong&gt; Generic AI is good at generic questions. The moment it has to work inside your business, in your voice, with your products and your rules, it falls over, and it looks like an AI problem. It's a context problem. That's good news, because context is something you own. You can has fix!&lt;/p&gt;

&lt;h2&gt;
  
  
  The Swedish was crap
&lt;/h2&gt;

&lt;p&gt;Most of my older posts have Swedish versions, made in March by the translation service I run next to my CMS. In October I asked my AI to review the old posts and redo the Swedish, because, in my words:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The one I came over is crap.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That turned out to be generous. Some highlights:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Monday.com&lt;/strong&gt; became &lt;em&gt;Måndag.com&lt;/em&gt;. &lt;strong&gt;Lovable&lt;/strong&gt;, a Swedish company, became &lt;em&gt;Lövbar&lt;/em&gt;, which means "leafable".&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pip install -r requirements.txt&lt;/code&gt; became &lt;code&gt;pip installera -r krav.txt&lt;/code&gt;. Poetry, the Python tool, became &lt;em&gt;Poesi&lt;/em&gt;. Go, the language, became &lt;em&gt;Gå&lt;/em&gt;, the verb.&lt;/li&gt;
&lt;li&gt;"Execution", as in running a task, became &lt;em&gt;avrättning&lt;/em&gt;. That's the kind with a blindfold and an axe.&lt;/li&gt;
&lt;li&gt;"Data residency laws" became laws about residence permits.&lt;/li&gt;
&lt;li&gt;"$5.55B" became &lt;em&gt;5,55 dollar&lt;/em&gt;. A rounding error of about five billion.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The German was no better. "Digest" became &lt;em&gt;Verdauungstrakt&lt;/em&gt;, the digestive tract. Hilarious!&lt;/p&gt;

&lt;p&gt;None of this is the model being stupid. Every one of those words was translated correctly, as a word. What the machine didn't know was that Monday.com is a product, that a line in a code block is something you type, and that my post was about servers, not prisons. Language it had plenty of. What it was missing was the context.&lt;/p&gt;

&lt;h2&gt;
  
  
  Context is more than documents
&lt;/h2&gt;

&lt;p&gt;When people say "give the AI your data" they usually mean documents: the manual, the product catalogue, the FAQ. That's the part everyone has heard of. Look it up before you answer is the whole idea behind what's called RAG: an AI that reads your manual before it opens its mouth. It's a good idea, and I wrote about building one in my &lt;a href="https://idunworks.com/blog/the-bigger-model-lost" rel="noopener noreferrer"&gt;Local AI series&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The translations taught me there are other kinds, and they're easier to miss.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What your words mean.&lt;/strong&gt; "Make" is a verb, unless it's the automation tool. "Execution" is a word, unless you're a developer. A style guide or a glossary of your products, your tools and your no-translate words is context too, and it's usually the cheapest kind to write down.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which version is current.&lt;/strong&gt; Several of my Swedish posts were translated from an older English version. The English had moved on and the Swedish was still quoting lines I'd deleted. The AI had read the manual but it was last year's manual! Today, an hour after the new Swedish went in, I edited two English posts in the CMS. The check we'd built refused to write the next Swedish update because the page structure no longer matched. That refusal was the most useful thing it did all day.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Whose corrections count?&lt;/strong&gt; My translation service keeps the corrections I make by hand, as examples to learn from. A good idea, with two traps in it. First, the obvious way for my AI's script to save its new Swedish was the same door my corrections go through, and everything through that door is filed as "a human fixed this". That would have taught the machine to imitate a machine. The script uses a door that doesn't record anything. Second, some of my own "corrections" from March weren't corrections at all: I'd added lines in Swedish that never existed in English. As training data, that teaches a model to invent.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same wall in a shop or a course
&lt;/h2&gt;

&lt;p&gt;My translations are a low-stakes place to hit this wall. Here's where it costs money.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A webshop.&lt;/strong&gt; Ask a generic AI to write a product description and it writes a nice one, for a different brand. It doesn't know that your T-shirts are printed on demand, that you never say "premium", or that returns take fourteen days, not thirty. A support bot without your shipping policy gives a confident, wrong delivery date, and your customer remembers the date, not the bot. Give the same model your catalogue, your policies and ten descriptions you're proud of, and the copy sounds like you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A course.&lt;/strong&gt; A student asks a question about lesson four. A generic model answers from the internet, which is fine until it contradicts your lesson. One that reads the course material first answers from what you taught, and can point to the page. The good version also knows which edition of the course the student is on, and that's the stale-version problem again.&lt;/p&gt;

&lt;p&gt;In both cases the model is the cheap part. Context is the work: deciding what goes in, keeping it current, and knowing which corrections came from a person.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happened, and how else it could be done
&lt;/h2&gt;

&lt;p&gt;The machine translations went wrong because the machine had my words and none of my context. The fix was the boring kind:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;My AI translated from the live English page, not from a copy.&lt;/li&gt;
&lt;li&gt;Code, links and product names were left alone.&lt;/li&gt;
&lt;li&gt;A script compares the translation with the live page and refuses it when the structure differs.&lt;/li&gt;
&lt;li&gt;The old versions are kept, so I can roll any post back.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Better at&lt;/th&gt;
&lt;th&gt;Costs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Better prompt only&lt;/strong&gt; (tell it the tone)&lt;/td&gt;
&lt;td&gt;Free, quick&lt;/td&gt;
&lt;td&gt;Doesn't know your products; forgets everything next time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Glossary and style guide&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Fixes names and tone everywhere at once&lt;/td&gt;
&lt;td&gt;Someone has to write and keep it up to date&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Look up your documents first&lt;/strong&gt; (RAG)&lt;/td&gt;
&lt;td&gt;Answers from your own material&lt;/td&gt;
&lt;td&gt;Only as current as the documents you feed it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Fine-tune on your corrections&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Learns your voice for good&lt;/td&gt;
&lt;td&gt;Needs clean data: real human fixes, nothing invented&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Run it on your own server&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Your catalogue and policies never leave the building&lt;/td&gt;
&lt;td&gt;You keep it running, and you pay for the hardware up front&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Running it yourself matters more than it sounds. Once the AI reads your price lists, your policies and your customers' questions, that context is the most valuable thing in the system. I'd rather keep it next to the &lt;a href="https://idunworks.com/blog/the-roi-of-a-private-ai" rel="noopener noreferrer"&gt;rest of my stack&lt;/a&gt; than mail it to someone else's model every time I ask a question.&lt;/p&gt;

&lt;p&gt;Before you blame the model, check these things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Does it know your names, products and no-translate words?&lt;/li&gt;
&lt;li&gt;Is it reading the current version of your material?&lt;/li&gt;
&lt;li&gt;Can you tell which corrections came from a person?&lt;/li&gt;
&lt;li&gt;Is anything checking its output against the source?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The Swedish is fixed. Lovable is Lovable again, and no one gets executed in my Docker posts any more. The old Swedish is still saved, in case I ever want a laugh.&lt;/p&gt;

&lt;p&gt;Good luck,//T&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://idunworks.com/blog/my-translator-thought-monday-was-a-weekday" rel="noopener noreferrer"&gt;idunworks.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>cms</category>
    </item>
  </channel>
</rss>
