<?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: Vicente G. Reyes</title>
    <description>The latest articles on DEV Community by Vicente G. Reyes (@highcenburg).</description>
    <link>https://dev.to/highcenburg</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%2F126345%2F09607ce3-52e6-4252-be98-7a84d754bc60.png</url>
      <title>DEV Community: Vicente G. Reyes</title>
      <link>https://dev.to/highcenburg</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/highcenburg"/>
    <language>en</language>
    <item>
      <title>The CI/CD Pipeline That Was Lying to Us: A Deploy Debugging Story</title>
      <dc:creator>Vicente G. Reyes</dc:creator>
      <pubDate>Tue, 01 Sep 2026 13:10:03 +0000</pubDate>
      <link>https://dev.to/highcenburg/-the-cicd-pipeline-that-was-lying-to-us-a-deploy-debugging-story-9fi</link>
      <guid>https://dev.to/highcenburg/-the-cicd-pipeline-that-was-lying-to-us-a-deploy-debugging-story-9fi</guid>
      <description>&lt;p&gt;I had a GitHub Actions workflow called &lt;code&gt;Deploy to DigitalOcean&lt;/code&gt;. It had been sitting in the repo for weeks, fully wired up — SSH action, secrets references, the works. And yet every single time I shipped a backend change, I still had to SSH into the droplet and run &lt;code&gt;git pull&lt;/code&gt; by hand.&lt;/p&gt;

&lt;p&gt;I hadn't stopped to ask &lt;em&gt;why&lt;/em&gt; until I actually went looking. It turned out the pipeline wasn't broken in any one obvious way — it was broken in six small, unrelated ways, stacked on top of each other, each one hiding the next. This is the story of digging through all of them, in the order I actually found them.&lt;/p&gt;




&lt;h2&gt;
  
  
  Act 1: The Deploy Job That Never Ran
&lt;/h2&gt;

&lt;p&gt;The setup looked correct on paper. &lt;code&gt;deploy.yml&lt;/code&gt; was gated like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;workflow_run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;workflows&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;CI'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;main'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;types&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;completed&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.event.workflow_run.conclusion == 'success' }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reasonable: only deploy if CI passed. So the first thing worth checking wasn't the deploy job at all — it was whether CI had &lt;em&gt;ever&lt;/em&gt; passed on &lt;code&gt;main&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gh run list &lt;span class="nt"&gt;--workflow&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ci.yml &lt;span class="nt"&gt;--limit&lt;/span&gt; 5
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every single row: &lt;code&gt;failure&lt;/code&gt;. Every deploy run: &lt;code&gt;skipped&lt;/code&gt;. The deploy pipeline wasn't broken — it was doing exactly what it was told, gating correctly on a CI workflow that had never once gone green on &lt;code&gt;main&lt;/code&gt;. I hadn't connected those dots because the failures and the missing deploys lived in two different tabs of the Actions UI.&lt;/p&gt;

&lt;h2&gt;
  
  
  Act 2: Two Bugs Wearing One Trenchcoat
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;gh run view &amp;lt;run-id&amp;gt;&lt;/code&gt; showed two jobs failing for completely unrelated reasons.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The &lt;code&gt;linter&lt;/code&gt; job&lt;/strong&gt; died immediately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The specified python version file at: .python-version doesn't exist.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;.python-version&lt;/code&gt; was sitting right there in the repo root. Except it wasn't — it was in &lt;code&gt;.gitignore&lt;/code&gt;, left over from a &lt;code&gt;pyenv&lt;/code&gt; boilerplate line nobody had questioned. &lt;code&gt;actions/setup-python&lt;/code&gt;'s &lt;code&gt;python-version-file&lt;/code&gt; input needs the file to actually exist in the checkout, and a gitignored file never reaches CI no matter how real it looks on disk locally.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The &lt;code&gt;frontend&lt;/code&gt; job&lt;/strong&gt; died differently:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: [vitest-pool]: Failed to start forks worker for test files ...
Caused by: TypeError: webidl.util.markAsUncloneable is not a function
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That error has nothing to do with my test code. &lt;code&gt;ci.yml&lt;/code&gt; pinned &lt;code&gt;node-version: '20'&lt;/code&gt;, but &lt;code&gt;jsdom@30&lt;/code&gt; (a Vitest dependency) requires:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"engines"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"node"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"^22.22.2 || ^24.15.0 || &amp;gt;=26.0.0"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Node 20 doesn't have &lt;code&gt;markAsUncloneable&lt;/code&gt; — jsdom needs it. Every frontend test run was crashing before a single test file even executed. The actual backend test suite (&lt;code&gt;pytest&lt;/code&gt;) had been passing this entire time; it was just outvoted by two infrastructure bugs with nothing to do with the code being tested.&lt;/p&gt;

&lt;p&gt;Fix: un-ignore &lt;code&gt;.python-version&lt;/code&gt;, commit it. Bump &lt;code&gt;node-version&lt;/code&gt; to &lt;code&gt;'22'&lt;/code&gt;. Two-line diff, two root causes eliminated.&lt;/p&gt;

&lt;h2&gt;
  
  
  Act 3: The Debt That Was Always There
&lt;/h2&gt;

&lt;p&gt;Pushed the fix, watched CI run again. &lt;code&gt;pytest&lt;/code&gt; and &lt;code&gt;frontend&lt;/code&gt; went green. &lt;code&gt;linter&lt;/code&gt; failed &lt;em&gt;again&lt;/em&gt; — different reason this time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;black....................................................................Failed
- hook id: black
reformatted job_board/jobs/services.py

isort....................................................................Failed
- hook id: isort
Fixing job_board/jobs/services.py

djLint formatting for Django.............................................Failed
- hook id: djlint-reformat-django
1 file was updated.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Formatting drift had been quietly accumulating in three files — a multi-line import that black wanted split, a &lt;code&gt;.envs&lt;/code&gt; file missing a trailing newline, an &lt;code&gt;index.html&lt;/code&gt; &lt;code&gt;&amp;lt;meta&amp;gt;&lt;/code&gt; tag that djLint wanted wrapped. None of it mattered locally because nobody had &lt;code&gt;pre-commit&lt;/code&gt; wired into their git hooks. It only mattered in CI, which is exactly where it had been failing, unnoticed, this whole time.&lt;/p&gt;

&lt;p&gt;The fix here was almost mechanical: CI's own hook output &lt;em&gt;is&lt;/em&gt; the diff. Applied it verbatim rather than re-deriving it — no ambiguity about what "correctly formatted" means when the formatter already told you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Act 4: The Flaky Cache and the Duplicate Runs
&lt;/h2&gt;

&lt;p&gt;Two more small papercuts surfaced once real CI runs were flowing:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A cache backend race.&lt;/strong&gt; PR-triggered builds started intermittently failing with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: cannot parse bake definitions: ERROR: failed to solve:
failed to load cache key: repository does not contain ref refs/pull/166/merge
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a known &lt;code&gt;docker/bake-action&lt;/code&gt; + GitHub Actions cache quirk: a PR's merge ref gets recomputed (or invalidated) if the PR updates or merges while the cache backend is still resolving it. It never affected the push-triggered run on &lt;code&gt;main&lt;/code&gt; — the one that actually gates deploy — but it left a false red X on every promotion PR. Buildx's GHA cache backend has a purpose-built escape hatch for exactly this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="s"&gt;django.cache-from=type=gha,scope=django-cached-tests,ignore-error=true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ignore-error=true&lt;/code&gt; degrades a cache-restore failure to a cache miss instead of failing the whole job.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Duplicate CI runs.&lt;/strong&gt; With both &lt;code&gt;pull_request: branches: [main]&lt;/code&gt; and &lt;code&gt;push: branches: [main]&lt;/code&gt; as triggers, every promotion ran the exact same commit through CI twice — once when the PR opened, again the instant it merged. Since deploy only ever cares about the post-merge state of &lt;code&gt;main&lt;/code&gt;, the fix was to drop the &lt;code&gt;pull_request&lt;/code&gt; trigger entirely and let &lt;code&gt;push&lt;/code&gt; do the only job that mattered.&lt;/p&gt;

&lt;p&gt;While in there, I also pulled the &lt;code&gt;frontend&lt;/code&gt; lint/test job out of backend CI altogether — the frontend deploys through Vercel, which already runs its own build/lint pipeline as a separate PR check. There was no reason a frontend typo should ever gate a DigitalOcean backend deploy, so &lt;code&gt;frontend/**&lt;/code&gt; also got added to &lt;code&gt;paths-ignore&lt;/code&gt; — a frontend-only commit no longer triggers a backend rebuild at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Act 5: CI Was Finally Green. Deploy Still Failed.
&lt;/h2&gt;

&lt;p&gt;For the first time, &lt;code&gt;CI&lt;/code&gt; showed &lt;code&gt;success&lt;/code&gt; on a push to &lt;code&gt;main&lt;/code&gt;. And for the first time, &lt;code&gt;Deploy to DigitalOcean&lt;/code&gt; actually &lt;em&gt;ran&lt;/em&gt; instead of showing &lt;code&gt;skipped&lt;/code&gt;. It failed in 12 seconds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ssh"&gt;&lt;code&gt;&lt;span class="k"&gt;ssh&lt;/span&gt;.ParsePrivateKey: ssh: &lt;span class="no"&gt;no&lt;/span&gt; key found
&lt;span class="k"&gt;ssh&lt;/span&gt;: handshake failed: ssh: unable to authenticate, attempted methods [none], &lt;span class="no"&gt;no&lt;/span&gt; supported methods remain
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gh secret list &lt;span class="nt"&gt;--repo&lt;/span&gt; &amp;lt;org&amp;gt;/&amp;lt;repo&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero rows. &lt;code&gt;DO_HOST&lt;/code&gt;, &lt;code&gt;DO_USERNAME&lt;/code&gt;, &lt;code&gt;DO_SSH_KEY&lt;/code&gt;, &lt;code&gt;DO_PORT&lt;/code&gt; — none of them existed. The workflow had been referencing four secrets that had simply never been created. CI passing had done its job; it just exposed the next layer down.&lt;/p&gt;

&lt;h2&gt;
  
  
  Act 6: Two SSH Gotchas
&lt;/h2&gt;

&lt;p&gt;Setting these up surfaced two mistakes worth writing down because they're both easy to make and easy to misdiagnose.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gotcha 1 — &lt;code&gt;ssh-copy-id&lt;/code&gt; from inside the box.&lt;/strong&gt; Trying to install a public key onto the droplet while &lt;em&gt;already SSH'd into that droplet&lt;/em&gt; doesn't work: &lt;code&gt;ssh-copy-id&lt;/code&gt; opens a &lt;em&gt;new&lt;/em&gt; outbound connection back to the same host to install the key, but that connection has no valid key to authenticate with yet. Classic chicken-and-egg. Once you're already sitting in a shell on the target machine, skip &lt;code&gt;ssh-copy-id&lt;/code&gt; and just append directly:&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;cat&lt;/span&gt; ~/.ssh/id_ed25519.pub &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; ~/.ssh/authorized_keys
&lt;span class="nb"&gt;chmod &lt;/span&gt;700 ~/.ssh
&lt;span class="nb"&gt;chmod &lt;/span&gt;600 ~/.ssh/authorized_keys
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Gotcha 2 — mangled secret content.&lt;/strong&gt; Even after the public key was trusted server-side, the deploy still failed with the exact same &lt;code&gt;ssh: no key found&lt;/code&gt; error. The private key had been set as a GitHub secret via a string interpolation (&lt;code&gt;--body "..."&lt;/code&gt;) rather than a direct file redirect, which had silently collapsed the multi-line PEM block. The fix is to never let anything touch the formatting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gh secret &lt;span class="nb"&gt;set &lt;/span&gt;DO_SSH_KEY &lt;span class="nt"&gt;--repo&lt;/span&gt; &amp;lt;org&amp;gt;/&amp;lt;repo&amp;gt; &amp;lt; ~/.ssh/id_ed25519
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Redirecting the raw file guarantees byte-for-byte fidelity, including the &lt;code&gt;-----BEGIN/END-----&lt;/code&gt; lines and every internal newline that a shell string would otherwise eat.&lt;/p&gt;

&lt;h2&gt;
  
  
  Act 7: Right Key, Wrong Directory
&lt;/h2&gt;

&lt;p&gt;With auth finally working, the deploy script actually reached the droplet and ran — into a new error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;err: bash: line 1: cd: /home/***/apps/gigglegigs: No such file or directory
err: fatal: not a git repository (or any of the parent directories): .git
err: open /home/***/production.yml: no such file or directory
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;deploy.yml&lt;/code&gt; assumed the checkout lived at &lt;code&gt;~/apps/gigglegigs&lt;/code&gt;. It didn't — a quick &lt;code&gt;ls ~&lt;/code&gt; on the droplet showed the actual clone sitting at &lt;code&gt;~/GiggleGigs&lt;/code&gt;. That path had apparently been copy-pasted from a different project's deploy config and never actually validated, because the workflow had never gotten far enough to hit it before. One-line fix, and the deploy script finally ran start to finish: build, collectstatic, migrate, &lt;code&gt;up -d&lt;/code&gt;, all green.&lt;/p&gt;

&lt;h2&gt;
  
  
  Act 8: The First Real Deploy Broke the Site
&lt;/h2&gt;

&lt;p&gt;Two minutes after that first fully green deploy, the admin's user list started returning &lt;code&gt;502 Bad Gateway&lt;/code&gt;. The instinct is to panic here — first automated deploy, and the site's down? But the logs told a much less dramatic story:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;django-1  | PostgreSQL is available
django-1  | 186 static files copied to '/app/staticfiles', 538 post-processed.
django-1  | [2026-09-01 12:48:17 +0000] [1] [INFO] Starting gunicorn 21.2.0
django-1  | [2026-09-01 12:48:17 +0000] [1] [INFO] Listening at: http://0.0.0.0:5000
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Gunicorn didn't finish booting — Postgres wait, &lt;code&gt;collectstatic&lt;/code&gt;, worker fork — until &lt;code&gt;12:48:17&lt;/code&gt;. The 502 was timestamped &lt;code&gt;12:48:16&lt;/code&gt;. One second earlier. Nothing crashed; I'd just refreshed the admin during the exact window where &lt;code&gt;docker compose up -d&lt;/code&gt; had already killed the old &lt;code&gt;django&lt;/code&gt; container and the new one wasn't listening yet.&lt;/p&gt;

&lt;p&gt;That's not really a bug in the app — it's a gap in the deploy setup. Traefik here uses the static &lt;code&gt;file&lt;/code&gt; provider, routing unconditionally to &lt;code&gt;http://django:5000&lt;/code&gt; with zero awareness of container readiness:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;django&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;loadBalancer&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;servers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://django:5000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every deploy, without exception, would keep hitting this same window. The right fix is an active health check so Traefik stops sending traffic to a container until it's actually answering:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;django&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;loadBalancer&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;servers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://django:5000&lt;/span&gt;
      &lt;span class="na"&gt;healthCheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/healthz/&lt;/span&gt;
        &lt;span class="na"&gt;hostname&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api.example.com&lt;/span&gt;
        &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;5s'&lt;/span&gt;
        &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;3s'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things worth calling out about that config. First, &lt;code&gt;/healthz/&lt;/code&gt; didn't exist — it needed to be a genuinely trivial, unauthenticated view (no DB touch, no auth, just &lt;code&gt;200 OK&lt;/code&gt;), because coupling infra health to a real business endpoint means a permissions change or a slow query silently takes your health check down with it. Second, &lt;code&gt;hostname&lt;/code&gt; isn't optional: without it, Traefik's health check request sends &lt;code&gt;Host: django&lt;/code&gt; (the internal service name), and &lt;code&gt;ALLOWED_HOSTS&lt;/code&gt; — quite correctly — rejects that as a &lt;code&gt;DisallowedHost&lt;/code&gt;, returning &lt;code&gt;400&lt;/code&gt; and making Traefik think the container is permanently unhealthy. Setting &lt;code&gt;hostname&lt;/code&gt; to the real public domain makes the health check's request indistinguishable from real traffic, from Django's point of view.&lt;/p&gt;

&lt;p&gt;While already in &lt;code&gt;production.yml&lt;/code&gt;, I added &lt;code&gt;restart: unless-stopped&lt;/code&gt; to every long-running service (everything except the one-off backup container). Without it, a droplet reboot — a DigitalOcean maintenance window, a crash, anything — leaves every container down until someone notices and SSHes in to bring them back up manually. &lt;code&gt;unless-stopped&lt;/code&gt; means Docker brings them back on its own the moment the daemon starts.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Actually Was Wrong, All Together
&lt;/h2&gt;

&lt;p&gt;Laid out in one place, the full list is almost funny:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A gitignored file broke Python setup in CI&lt;/li&gt;
&lt;li&gt;A pinned Node version was two majors behind what a dependency required&lt;/li&gt;
&lt;li&gt;Formatting drift had been silently failing lint for who knows how long&lt;/li&gt;
&lt;li&gt;A GHA cache backend race gave false negatives on PR checks&lt;/li&gt;
&lt;li&gt;CI ran twice per promotion for no reason&lt;/li&gt;
&lt;li&gt;Zero deploy secrets existed&lt;/li&gt;
&lt;li&gt;A private key got mangled by shell interpolation&lt;/li&gt;
&lt;li&gt;The deploy script's target directory was simply wrong&lt;/li&gt;
&lt;li&gt;Traefik had no way to know when a container wasn't ready yet&lt;/li&gt;
&lt;li&gt;Nothing would restart itself after a reboot&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No single one of these was hard to fix once found. What made this a slog was that &lt;strong&gt;each bug hid the next one&lt;/strong&gt; — CI had to go green before deploy secrets could even matter; deploy secrets had to work before the wrong directory path could surface; the deploy had to actually &lt;em&gt;succeed&lt;/em&gt; before the Traefik timing gap became visible at all. A pipeline with ten small breakages in series doesn't look like ten breakages. It looks like one big wall, and the only way through it is to fix the outermost failure, rerun, and see what the next layer reveals.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;If your "automated" deploy still requires someone to manually pull and rebuild, don't assume the deploy job itself is broken — check whether the thing gating it has &lt;em&gt;ever&lt;/em&gt; actually succeeded. A pipeline that's silently and permanently gated behind a red CI run looks, from a distance, exactly like a pipeline that doesn't exist. The fix isn't usually one big rewrite. It's peeling one failing layer at a time until the thing underneath finally gets a chance to run — and then trusting the next error message, however unrelated it looks, to point at the next real problem.&lt;/p&gt;

</description>
      <category>githubactions</category>
      <category>docker</category>
      <category>django</category>
      <category>devops</category>
    </item>
    <item>
      <title>Side project deployed https://www.gigglegigs.com/ tomorrow is devops and SEO day!</title>
      <dc:creator>Vicente G. Reyes</dc:creator>
      <pubDate>Mon, 31 Aug 2026 13:26:16 +0000</pubDate>
      <link>https://dev.to/highcenburg/side-project-deployed-httpswwwgigglegigscom-tomorrow-is-devops-and-seo-day-boe</link>
      <guid>https://dev.to/highcenburg/side-project-deployed-httpswwwgigglegigscom-tomorrow-is-devops-and-seo-day-boe</guid>
      <description>&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
      &lt;div class="c-embed__body flex items-center justify-between"&gt;
        &lt;a href="https://www.gigglegigs.com/" rel="noopener noreferrer" class="c-link fw-bold flex items-center"&gt;
          &lt;span class="mr-2"&gt;gigglegigs.com&lt;/span&gt;
          

        &lt;/a&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;


</description>
      <category>devops</category>
      <category>seo</category>
      <category>sideprojects</category>
      <category>webdev</category>
    </item>
    <item>
      <title>What do you think is better, a free period or a freemium?</title>
      <dc:creator>Vicente G. Reyes</dc:creator>
      <pubDate>Sun, 30 Aug 2026 05:47:21 +0000</pubDate>
      <link>https://dev.to/highcenburg/what-do-you-think-is-better-a-free-period-or-a-freemium-5026</link>
      <guid>https://dev.to/highcenburg/what-do-you-think-is-better-a-free-period-or-a-freemium-5026</guid>
      <description></description>
      <category>product</category>
      <category>saas</category>
      <category>startup</category>
    </item>
    <item>
      <title>Your App Center List Is Not a Security Audit</title>
      <dc:creator>Vicente G. Reyes</dc:creator>
      <pubDate>Fri, 28 Aug 2026 05:10:02 +0000</pubDate>
      <link>https://dev.to/highcenburg/your-app-center-list-is-not-a-security-audit-196g</link>
      <guid>https://dev.to/highcenburg/your-app-center-list-is-not-a-security-audit-196g</guid>
      <description>&lt;p&gt;I opened Ubuntu's App Center, scrolled through ~90 installed packages, and asked the obvious question: is any of this malware?&lt;/p&gt;

&lt;p&gt;The list looked suspicious in all the wrong ways. A font package last updated nine years ago. A dozen unlabeled GStreamer plugins with generic gray icons. Something called BRLTTY I had no memory of installing. Plenty of surface area for paranoia.&lt;/p&gt;

&lt;p&gt;None of it turned out to matter, and the reason is worth writing down: &lt;strong&gt;App Center is a package browser, not an inventory.&lt;/strong&gt; It shows you the two sources it manages — the Ubuntu archive and the Snap Store — and nothing else. Every path an attacker would actually use to get code onto a developer workstation is invisible to it.&lt;/p&gt;

&lt;p&gt;This is the audit I ran instead, and what each command is actually good for.&lt;/p&gt;




&lt;h2&gt;
  
  
  Reading the GUI correctly first
&lt;/h2&gt;

&lt;p&gt;Two things I had wrong at a glance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Updated 9 years ago" is not neglect.&lt;/strong&gt; That's the package version date. &lt;code&gt;(URW)++ Core Font Set 20200910-8&lt;/code&gt;, TeXInfo, BRLTTY — these are frozen-by-design dependencies pulled in by other packages. A font metric file doesn't need a 2026 release to be safe.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gray placeholder icons are not unsigned packages.&lt;/strong&gt; They mean no AppStream metadata or &lt;code&gt;.desktop&lt;/code&gt; file. Most are libraries that were never meant to appear in a launcher. A few, as I found later, are a real (and benign) finding.&lt;/p&gt;

&lt;p&gt;Neither observation tells you anything about integrity. For that you need the actual tooling.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. Snap publishers
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;snap list

&lt;span class="k"&gt;for &lt;/span&gt;s &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;snap list | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'NR&amp;gt;1{print $1}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;snap info &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$s&lt;/span&gt;&lt;span class="s2"&gt;"&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;'^(name|publisher)'&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The publisher column is the entire point. Snap uses two markers:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Marker&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;✓&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Verified publisher — identity confirmed by Canonical&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;✪&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Starred — a trusted community maintainer&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On my machine: 21 snaps, all marked. Canonical for the base and GNOME runtimes, &lt;code&gt;mozilla✓&lt;/code&gt; for Firefox, &lt;code&gt;bitwarden✓&lt;/code&gt; (8bit Solutions LLC), &lt;code&gt;spotify✓&lt;/code&gt;. The three starred ones were Discord and &lt;code&gt;ffmpeg-2404&lt;/code&gt; from &lt;strong&gt;Snapcrafters&lt;/strong&gt;, and Pinta from &lt;strong&gt;James Carroll&lt;/strong&gt; — the legitimate upstream maintainers in both cases.&lt;/p&gt;

&lt;p&gt;An unmarked publisher on a snap claiming to be a well-known app is the thing to look for. There weren't any.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Third-party APT repositories
&lt;/h2&gt;

&lt;p&gt;This is the real risk vector, and it's the one App Center will never show you.&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;grep&lt;/span&gt; &lt;span class="nt"&gt;-rhE&lt;/span&gt; &lt;span class="s1"&gt;'^deb '&lt;/span&gt; /etc/apt/sources.list /etc/apt/sources.list.d/ 2&amp;gt;/dev/null
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Seven results, all using &lt;code&gt;signed-by=&lt;/code&gt; with a dedicated keyring — which is the modern, correct form. The deprecated &lt;code&gt;apt-key&lt;/code&gt; approach trusts a key for &lt;em&gt;every&lt;/em&gt; repo on the system; &lt;code&gt;signed-by&lt;/code&gt; scopes it to one. If you see repos without it, that's worth fixing regardless of whether anything is compromised.&lt;/p&gt;

&lt;p&gt;Six were immediately recognizable: Docker, GitHub CLI, Heroku, Cloudflare WARP, mise, and the Ulauncher PPA. The seventh was truncated in my terminal and read, in part, &lt;code&gt;antigravity-repo-key.gpg ... us-central1-apt.pkg.dev/projects/antigra...&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Unfamiliar name, Google Cloud infrastructure hostname. Exactly the shape of thing that deserves thirty seconds of verification rather than a shrug.&lt;/p&gt;

&lt;p&gt;It's Google's Antigravity IDE. Google's official Linux download page publishes that repo verbatim — same keyring path, same Artifact Registry host, same project. Confirmed, but confirming the URL is only half the check.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Verifying the signing key
&lt;/h2&gt;

&lt;p&gt;A repo URL you recognize means nothing if the key trusting it was swapped.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gpg &lt;span class="nt"&gt;--show-keys&lt;/span&gt; &lt;span class="nt"&gt;--fingerprint&lt;/span&gt; /etc/apt/keyrings/antigravity-repo-key.gpg
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pub   rsa2048 2021-05-04 [SC]
      35BA A0B3 3E9E B396 F59C  A838 C0BA 5CE6 DC63 15A3
uid   Artifact Registry Repository Signer &amp;lt;artifact-registry-repository-signer@google.com&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Full-length match against the published fingerprint, and a single &lt;code&gt;pub&lt;/code&gt; key with no extra subkeys smuggled into the file. Compare all 40 characters — matching the last eight is a known-weak check that collision attacks have historically defeated.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One nuance that matters.&lt;/strong&gt; This is Google's &lt;em&gt;shared&lt;/em&gt; Artifact Registry signing key. It signs every public AR apt repo, not just Antigravity's. So the fingerprint proves "Google Artifact Registry published this" — it does not prove "the Antigravity team published this." The trust boundary is the repository path. Verify both halves or you've verified neither.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Binary integrity
&lt;/h2&gt;

&lt;p&gt;This is the command that actually answers the malware question.&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;apt &lt;span class="nb"&gt;install &lt;/span&gt;debsums
&lt;span class="nb"&gt;sudo &lt;/span&gt;debsums &lt;span class="nt"&gt;-s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;debsums&lt;/code&gt; recomputes the checksum of every file installed from a &lt;code&gt;.deb&lt;/code&gt; and compares it against what the package shipped. &lt;code&gt;-s&lt;/code&gt; prints only problems. If something had patched a system binary, this is where it surfaces.&lt;/p&gt;

&lt;p&gt;My output — three lines, all of the same kind:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;debsums: missing file /usr/share/applications/btop.desktop (from btop package)
debsums: missing file /usr/share/applications/org.flameshot.Flameshot.desktop
debsums: missing file /usr/share/applications/org.gnome.SystemMonitor.desktop
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Missing is not modified, and the distinction is the whole finding.&lt;/strong&gt; Zero checksum mismatches means not one binary or library on the system differs from what Ubuntu published. What's missing is three launcher entries — which is also why those three showed gray placeholder icons back in the GUI. Almost certainly an app-grid cleanup I did and forgot. Malware adds &lt;code&gt;.desktop&lt;/code&gt; files for persistence; it has no reason to delete them from unrelated packages.&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;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--reinstall&lt;/span&gt; btop flameshot gnome-system-monitor
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One caveat: not every package ships checksums, so &lt;code&gt;debsums&lt;/code&gt; coverage is good but not total. Add &lt;code&gt;sudo debsums -ca&lt;/code&gt; to list modified config files, which is a separate and noisier category.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Running services and persistence
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl list-units &lt;span class="nt"&gt;--type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;service &lt;span class="nt"&gt;--state&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;running
&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-la&lt;/span&gt; ~/.config/autostart/ /etc/systemd/system/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Thirty-four running services, every one accounted for: Docker and containerd, &lt;code&gt;warp-svc&lt;/code&gt; matching the Cloudflare repo, ClamAV daemon and freshclam, and the usual GNOME and systemd stack. Autostart held two entries, both mine.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;/etc/systemd/system/&lt;/code&gt; listing had a detail I liked seeing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;avahi-daemon.service -&amp;gt; /dev/null
cups.service -&amp;gt; /dev/null
libvirtd.service -&amp;gt; /dev/null
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Symlinking a unit to &lt;code&gt;/dev/null&lt;/code&gt; is masking — a stronger form of disable that can't be overridden by another unit's dependencies. Past-me hardening the box and leaving a legible trace of it.&lt;/p&gt;




&lt;h2&gt;
  
  
  What this audit does not cover
&lt;/h2&gt;

&lt;p&gt;Every check above is scoped to the package layer. That layer came back clean, and it's also not where compromise on a developer machine tends to originate.&lt;/p&gt;

&lt;p&gt;The paths that stay invisible to all of it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Language package managers&lt;/strong&gt; — &lt;code&gt;npm -g&lt;/code&gt;, &lt;code&gt;pip&lt;/code&gt;, &lt;code&gt;cargo install&lt;/code&gt;, &lt;code&gt;go install&lt;/code&gt;. Typosquats and compromised maintainer accounts are a live and recurring problem here, and postinstall scripts run with your privileges.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;curl | bash&lt;/code&gt; installers&lt;/strong&gt; — no signature, no manifest, no uninstall path, nothing for &lt;code&gt;debsums&lt;/code&gt; to compare against.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Editor and IDE extensions&lt;/strong&gt; — VS Code marketplace extensions run with full user permissions and are minimally reviewed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Agentic coding tools&lt;/strong&gt; — anything with filesystem write access and an autonomous execution loop. This is the newest item on the list and the one with the least established review culture around it.
That is the sensible next audit. The system package layer is the part with actual cryptographic guarantees behind it — signed repos, verified publishers, reproducible checksums. Everything above it is running on trust.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The runbook
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Snap publishers — every entry should be ✓ or ✪&lt;/span&gt;
snap list
&lt;span class="k"&gt;for &lt;/span&gt;s &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;snap list | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'NR&amp;gt;1{print $1}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;snap info &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$s&lt;/span&gt;&lt;span class="s2"&gt;"&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;'^(name|publisher)'&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;

&lt;span class="c"&gt;# Third-party APT repos — all should use signed-by=&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rhE&lt;/span&gt; &lt;span class="s1"&gt;'^deb '&lt;/span&gt; /etc/apt/sources.list /etc/apt/sources.list.d/ 2&amp;gt;/dev/null

&lt;span class="c"&gt;# Verify any unfamiliar repo key against its published fingerprint (all 40 chars)&lt;/span&gt;
gpg &lt;span class="nt"&gt;--show-keys&lt;/span&gt; &lt;span class="nt"&gt;--fingerprint&lt;/span&gt; /etc/apt/keyrings/&amp;lt;name&amp;gt;.gpg

&lt;span class="c"&gt;# Binary integrity — the check that matters&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;debsums &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;debsums &lt;span class="nt"&gt;-s&lt;/span&gt;

&lt;span class="c"&gt;# Persistence and running services&lt;/span&gt;
systemctl list-units &lt;span class="nt"&gt;--type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;service &lt;span class="nt"&gt;--state&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;running
&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-la&lt;/span&gt; ~/.config/autostart/ /etc/systemd/system/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five minutes end to end. Worth running on any machine you build client work on, if only to replace a vague sense of unease with an actual answer.&lt;/p&gt;

</description>
      <category>linux</category>
      <category>security</category>
      <category>ubuntu</category>
      <category>devops</category>
    </item>
    <item>
      <title>Why I Couldn't Access My Own Site (But My Phone Could): A Cloudways Auto-Ban Story</title>
      <dc:creator>Vicente G. Reyes</dc:creator>
      <pubDate>Mon, 24 Aug 2026 13:12:24 +0000</pubDate>
      <link>https://dev.to/highcenburg/why-i-couldnt-access-my-own-site-but-my-phone-could-a-cloudways-auto-ban-story-1f2l</link>
      <guid>https://dev.to/highcenburg/why-i-couldnt-access-my-own-site-but-my-phone-could-a-cloudways-auto-ban-story-1f2l</guid>
      <description>&lt;p&gt;I sat down to work on Rev6 and couldn't load the site. Not a slow load, not an SSL warning — nothing. The browser just spun until it gave up. My first assumption was the usual suspect: the server was down.&lt;/p&gt;

&lt;p&gt;Then I checked my phone, on the same Wi-Fi. Rev6 loaded instantly.&lt;/p&gt;

&lt;p&gt;That one detail sent me down a much longer rabbit hole than I expected — and the actual cause had nothing to do with my laptop, my router, or even my ISP.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Setup
&lt;/h2&gt;

&lt;p&gt;Rev6 is hosted on Cloudways, which provisions on top of DigitalOcean. DNS resolved fine — the domain pointed to the correct droplet IP. So this wasn't a broken domain or expired DNS record. The problem was specifically about &lt;em&gt;reaching&lt;/em&gt; that IP.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Failed Attempts
&lt;/h2&gt;

&lt;p&gt;I went through the usual network debugging checklist, and every single one came back clean:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Ping and curl to the server&lt;/strong&gt; — both hung and eventually timed out on ports 80, 443, and even 22 (SSH). Not "connection refused," just total silence, as if the packets vanished.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;General internet sanity check&lt;/strong&gt; — Google, GitHub, Cloudflare all loaded instantly. So this wasn't a broken connection, just this one destination.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Local firewall (&lt;code&gt;ufw&lt;/code&gt;/&lt;code&gt;iptables&lt;/code&gt;/&lt;code&gt;nftables&lt;/code&gt;)&lt;/strong&gt; — checked the full ruleset, &lt;code&gt;OUTPUT&lt;/code&gt; policy was &lt;code&gt;ACCEPT&lt;/code&gt;, no rule matched the server's IP or any DigitalOcean range.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;VPN or proxy&lt;/strong&gt; — none active, no &lt;code&gt;http_proxy&lt;/code&gt;/&lt;code&gt;https_proxy&lt;/code&gt; env vars, no tunnel interfaces.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MAC-based router filtering&lt;/strong&gt; — randomized my Wi-Fi adapter's MAC address entirely. Still blocked.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hostname-based filtering&lt;/strong&gt; — some routers key parental-control rules off the DHCP hostname instead of the MAC. Disabled hostname broadcasting, renewed the lease. Still blocked.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The router itself&lt;/strong&gt; — logged into the ONT admin panel and checked every filtering section: IP Filter, MAC Filter, WLAN MAC Filter, Parental Control, Device Access Control. All disabled, all empty.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;At this point every layer between my laptop and my ISP was confirmed clean. Whatever was blocking the connection wasn't local.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Clue That Cracked It
&lt;/h2&gt;

&lt;p&gt;The "works on my phone" comparison turned out to be misleading. When I checked my phone's actual public IP (via &lt;code&gt;whatismyip.com&lt;/code&gt;), it came back as a completely different address in a different range than my laptop's real ISP-assigned IP — a sign that Brave's VPN feature was tunneling all my phone's traffic elsewhere. My phone was never a fair comparison; it simply wasn't taking the same path to the internet.&lt;/p&gt;

&lt;p&gt;The real breakthrough came from testing the connection from a network entirely outside my ISP. From there, the request to the server didn't time out — it came back as an &lt;strong&gt;active refusal&lt;/strong&gt;. That's a meaningfully different signal:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;From my home connection:&lt;/strong&gt; total silence — no response at all&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;From an outside network:&lt;/strong&gt; an explicit "connection refused"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Silence usually means something in the path is &lt;em&gt;dropping&lt;/em&gt; packets. An active refusal means the packets &lt;em&gt;arrived&lt;/em&gt;, and something on the other end responded. That distinction pointed the investigation away from my network entirely and toward the server side.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Real Issue: Cloudways Had Auto-Banned My IP
&lt;/h2&gt;

&lt;p&gt;Cloudways includes a &lt;strong&gt;Bot Protection&lt;/strong&gt; feature — AI-driven heuristics and CAPTCHA challenges meant to keep malicious bots off the server. It's a genuinely useful feature. It's also fully automated, which means it can ban a legitimate visitor without any human ever reviewing the decision.&lt;/p&gt;

&lt;p&gt;Under &lt;strong&gt;Server → Security → Firewall&lt;/strong&gt;, there's a blacklist table showing banned IPs, the country they're associated with, and the reason. Searching my own IP directly returned nothing — but filtering by country (Philippines) surfaced it immediately:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;IP&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;th&gt;TTL&lt;/th&gt;
&lt;th&gt;Country&lt;/th&gt;
&lt;th&gt;Event&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;em&gt;(my IP)&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;black&lt;/td&gt;
&lt;td&gt;1 week from now&lt;/td&gt;
&lt;td&gt;Philippines&lt;/td&gt;
&lt;td&gt;Blacklisted for CAPTCHA failure&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;My own home IP had been silently banned for a full week after failing a CAPTCHA challenge — almost certainly an automated bot check I never even saw, triggered while browsing my own site.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix
&lt;/h2&gt;

&lt;p&gt;From the same Firewall screen:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Search for the banned IP (or filter by country if the exact IP search comes up empty)&lt;/li&gt;
&lt;li&gt;Open the &lt;strong&gt;⋮&lt;/strong&gt; actions menu on that row&lt;/li&gt;
&lt;li&gt;Remove/delete the blacklist entry&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Access was restored immediately — no server restart, no DNS changes, nothing else required.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;When a site is unreachable but DNS resolves and the rest of the internet works fine, don't assume it's your network just because another device on the same Wi-Fi can get through — verify that device is actually taking the same path out to the internet first. A background VPN can make an "it works on my phone" comparison completely meaningless.&lt;/p&gt;

&lt;p&gt;And when the destination is a managed host like Cloudways, remember that automated bot/security protections can ban &lt;em&gt;you&lt;/em&gt; just as easily as an actual attacker. If everything on your end checks out clean, check the host's own security dashboard before assuming there's a deeper network problem — the block might already be sitting there in plain sight, just filed under "security."&lt;/p&gt;

</description>
      <category>devops</category>
      <category>networking</category>
      <category>security</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The Bug That Threw No Errors: How a Repeat-Trial Guard Silently Killed Our Free Trial Funnel</title>
      <dc:creator>Vicente G. Reyes</dc:creator>
      <pubDate>Fri, 14 Aug 2026 12:06:09 +0000</pubDate>
      <link>https://dev.to/highcenburg/the-bug-that-threw-no-errors-how-a-repeat-trial-guard-silently-killed-our-free-trial-funnel-3l2h</link>
      <guid>https://dev.to/highcenburg/the-bug-that-threw-no-errors-how-a-repeat-trial-guard-silently-killed-our-free-trial-funnel-3l2h</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/bugsmash"&gt;DEV's Summer Bug Smash: Smash Stories&lt;/a&gt; powered by &lt;a href="https://sentry.io/" rel="noopener noreferrer"&gt;Sentry&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;I'm the lead developer on &lt;strong&gt;Rev6.fit&lt;/strong&gt;, a fitness membership platform built on WordPress + WooCommerce Subscriptions + LearnDash. It runs 49 plugins, five membership families, and a stack of custom code snippets — the kind of production environment where every change has a blast radius.&lt;/p&gt;

&lt;p&gt;Rev6's entire top-of-funnel is a &lt;strong&gt;7-day free trial&lt;/strong&gt;. Every consumer membership — All-Access, On-Demand, Vitality — leads with "$0 today, first charge in 7 days." If the trial disappears, new signups don't slow down. They stop.&lt;/p&gt;

&lt;p&gt;And one day, it disappeared. For every prospect. Silently.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom
&lt;/h2&gt;

&lt;p&gt;Logged-out visitors — which is to say, &lt;strong&gt;every single potential new customer&lt;/strong&gt; — landed on membership product pages and saw... a full-price subscription. No "with a 7-day free trial" label. No "$0 today." Just the sticker price.&lt;/p&gt;

&lt;p&gt;Existing members saw everything correctly. The client saw everything correctly (logged in, obviously). The site threw zero errors. Sentry — which we run on both the PHP and JS sides — was green. PageSpeed was fine. Checkout worked.&lt;/p&gt;

&lt;p&gt;The funnel was just quietly bleeding out.&lt;/p&gt;

&lt;p&gt;The catch didn't come from a dashboard. It came from a human: &lt;strong&gt;Avie&lt;/strong&gt;, my primary client contact, noticed the trial messaging was gone from the membership pages and flagged it. That detail matters to the story — with all our monitoring green, the alerting system that actually fired was someone who knows the site well enough to feel when something's off. Every production stack has one of those. They're underrated.&lt;/p&gt;

&lt;h2&gt;
  
  
  The root cause
&lt;/h2&gt;

&lt;p&gt;Here's the part I love, because it's such a classic shape: &lt;strong&gt;the bug was inside a guard written for a different problem.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The site carried a custom snippet whose job was to enforce &lt;strong&gt;one free trial per customer&lt;/strong&gt; — if you'd already consumed your trial, the trial offer was stripped from the subscription for you. Reasonable policy for any trial-led membership business.&lt;/p&gt;

&lt;p&gt;The right logic is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Has &lt;em&gt;this user&lt;/em&gt; already had a trial? If yes, remove the trial."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;But the snippet carried an &lt;strong&gt;extra condition&lt;/strong&gt; that effectively turned it into:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Can I &lt;em&gt;confirm&lt;/em&gt; this user deserves a trial? If not, remove the trial."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And for a logged-out visitor, there's no user to check. No account → no trial history → can't confirm eligibility → &lt;strong&gt;strip the trial&lt;/strong&gt;. The guard failed &lt;em&gt;closed&lt;/em&gt; against the exact people it should have failed &lt;em&gt;open&lt;/em&gt; for: first-time visitors who haven't created an account yet.&lt;/p&gt;

&lt;p&gt;A simplified reconstruction of the shape (the real snippet stays private, but this is the logic faithfully):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The repeat-trial guard — intended behavior&lt;/span&gt;
&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'woocommerce_subscriptions_product_trial_length'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$trial_length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$product&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_current_user_id&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// ❌ The extra condition: guests have no $user_id,&lt;/span&gt;
    &lt;span class="c1"&gt;// so they fell into the "no trial" branch too&lt;/span&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="nv"&gt;$user_id&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nf"&gt;rev6_user_has_used_trial&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$user_id&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="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// no trial for you&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$trial_length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;10&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The subtle trap: on Rev6, &lt;strong&gt;guest checkout is disabled&lt;/strong&gt; — accounts are auto-created at signup. So a guest seeing the trial offer is completely safe; by the time money and trial entitlement are involved, they &lt;em&gt;have&lt;/em&gt; an account and the repeat-trial check can do its job. The guest branch in the guard wasn't just wrong — it was protecting against a scenario the platform's own architecture already made impossible.&lt;/p&gt;

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

&lt;p&gt;The fix was almost anticlimactic — remove the guest condition, keep the repeat-trial protection:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&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="nv"&gt;$user_id&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$trial_length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// ✅ guests see the trial — accounts are created at signup anyway&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="nf"&gt;rev6_user_has_used_trial&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$user_id&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="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// repeat-trial protection stays intact&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$trial_length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One condition. That's the whole diff. But the verification checklist was the real work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;✅ Logged-out visitor sees "with a 7-day free trial" on every membership product&lt;/li&gt;
&lt;li&gt;✅ Checkout shows &lt;strong&gt;$0 today / first charge in 7 days&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;✅ Account auto-creation at signup still works&lt;/li&gt;
&lt;li&gt;✅ A user who already consumed a trial still gets &lt;strong&gt;no&lt;/strong&gt; second trial&lt;/li&gt;
&lt;li&gt;✅ Verified on staging first, then live
The last item on that list matters most: it would have been very easy to "fix" this by deleting the guard entirely — and quietly reopen the repeat-trial loophole the snippet existed to close. Smashing a bug shouldn't resurrect its predecessor.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What Sentry taught me by &lt;em&gt;not&lt;/em&gt; catching this
&lt;/h2&gt;

&lt;p&gt;Rev6 runs Sentry on both PHP and JS, and it's caught real production issues for us — an orphaned marketing-automation job erroring every 60 seconds, broken script dependencies on product pages, a Stripe Express Checkout misconfiguration. It's earned its keep.&lt;/p&gt;

&lt;p&gt;But this bug was invisible to it, and that's the lesson I'd hand any dev working on revenue-critical flows:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Error monitoring catches code that fails. It can't catch code that succeeds at doing the wrong thing.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The trial guard executed perfectly. No exception, no warning, no log line. It returned &lt;code&gt;0&lt;/code&gt; exactly as written — the code was correct; the &lt;em&gt;condition&lt;/em&gt; was wrong. That's a whole class of bug that lives below the monitoring waterline: pricing logic, discount eligibility, visibility rules, permission gates. The stuff that decides whether money moves.&lt;/p&gt;

&lt;p&gt;My takeaways for that class of bug:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Test your money paths logged out.&lt;/strong&gt; Your default browsing state as a developer (logged in, admin bar on, caches primed) is the state your prospects will never be in. Incognito is a QA tool.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Guards should fail open or closed &lt;em&gt;deliberately&lt;/em&gt;.&lt;/strong&gt; Every early-return in an eligibility check is a policy decision. Write the comment. "Guests: allow, because accounts are created at signup" would have made this bug impossible to write.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Behavioral monitoring complements error monitoring.&lt;/strong&gt; An alert on "trial signups per day dropped to zero" would have caught this in hours — instead of waiting for a sharp-eyed human to happen across the page.
## The win&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The trial is back, the repeat-trial abuse protection still holds, and every first-time visitor to Rev6 now sees the offer that the entire funnel was designed around. No new plugins, no rewrite — one condition removed, with a verification list long enough to trust the change on a live revenue path.&lt;/p&gt;

&lt;p&gt;The bugs that throw exceptions are the easy ones. The legendary ones return &lt;code&gt;0&lt;/code&gt; and walk away clean.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm Ice — freelance full-stack dev (Django/React + WordPress/WooCommerce + Shopify). More at &lt;a href="https://vicentereyes.org" rel="noopener noreferrer"&gt;vicentereyes.org&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>bugsmash</category>
      <category>php</category>
      <category>wordpress</category>
    </item>
    <item>
      <title>The WebP Migration That Wasn't About WebP</title>
      <dc:creator>Vicente G. Reyes</dc:creator>
      <pubDate>Fri, 31 Jul 2026 10:58:28 +0000</pubDate>
      <link>https://dev.to/highcenburg/the-webp-migration-that-wasnt-about-webp-3n90</link>
      <guid>https://dev.to/highcenburg/the-webp-migration-that-wasnt-about-webp-3n90</guid>
      <description>&lt;p&gt;Nobody asked me to do this.&lt;/p&gt;

&lt;p&gt;I maintain a WooCommerce store bolted to LearnDash courses on a Divi theme — eight years of content, a few thousand images. Page weight had been nagging at me for a while, so I put "convert the images to WebP" on my own list. No ticket, no client request, just a maintenance item I thought would take an afternoon.&lt;/p&gt;

&lt;p&gt;I almost installed ShortPixel and called it a day.&lt;/p&gt;

&lt;p&gt;Instead I spent twenty minutes auditing first, and found that WebP was maybe the fourth most important thing wrong with how that site served images. This is what the audit turned up, and why "just install an optimizer plugin" would have made things worse.&lt;/p&gt;

&lt;h2&gt;
  
  
  Always inventory before you optimize
&lt;/h2&gt;

&lt;p&gt;The site runs on a 4GB VPS that had already OOM-killed MySQL twice that quarter. Bulk image transcoding is CPU and memory heavy. So before touching anything:&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;cd &lt;/span&gt;wp-content/uploads
&lt;span class="nb"&gt;du&lt;/span&gt; &lt;span class="nt"&gt;-sh&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"JPEG:  &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;find &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;-type&lt;/span&gt; f &lt;span class="se"&gt;\(&lt;/span&gt; &lt;span class="nt"&gt;-iname&lt;/span&gt; &lt;span class="s1"&gt;'*.jpg'&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nt"&gt;-iname&lt;/span&gt; &lt;span class="s1"&gt;'*.jpeg'&lt;/span&gt; &lt;span class="se"&gt;\)&lt;/span&gt; | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"PNG:   &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;find &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;-type&lt;/span&gt; f &lt;span class="nt"&gt;-iname&lt;/span&gt; &lt;span class="s1"&gt;'*.png'&lt;/span&gt; | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"WebP:  &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;find &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;-type&lt;/span&gt; f &lt;span class="nt"&gt;-iname&lt;/span&gt; &lt;span class="s1"&gt;'*.webp'&lt;/span&gt; | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output stopped me cold:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2.1G    .
JPEG:  3245
PNG:   1443
WebP:  12301
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Twelve thousand WebP files on a site that supposedly had no WebP.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 1: 241MB of files nothing was serving
&lt;/h2&gt;

&lt;p&gt;The naming told the story — &lt;code&gt;image.jpg.webp&lt;/code&gt;, sidecar files in the same directory as the original. That's EWWW Image Optimizer's convention.&lt;/p&gt;

&lt;p&gt;EWWW was not installed. Somebody had run it, then removed the plugin.&lt;/p&gt;

&lt;p&gt;When EWWW generates sidecars, delivery depends on either an &lt;code&gt;.htaccess&lt;/code&gt; rewrite doing &lt;code&gt;Accept&lt;/code&gt;-header negotiation, or a &lt;code&gt;&amp;lt;picture&amp;gt;&lt;/code&gt; element filter. Both live in the plugin. Remove the plugin and the files become inert — 241MB of dead weight, still being scanned by the server's malware scanner on every pass, still counted in every backup.&lt;/p&gt;

&lt;p&gt;Verified they were genuinely unreferenced before deleting:&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;# no content references&lt;/span&gt;
wp db query &lt;span class="s2"&gt;"SELECT COUNT(*) FROM wp_posts WHERE post_content LIKE '%.jpg.webp%';"&lt;/span&gt;
&lt;span class="c"&gt;# no attachment metadata references&lt;/span&gt;
wp db query &lt;span class="s2"&gt;"SELECT COUNT(*) FROM wp_postmeta WHERE meta_value LIKE '%.jpg.webp%';"&lt;/span&gt;
&lt;span class="c"&gt;# no rewrite rule anywhere&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-in&lt;/span&gt; webp .htaccess ~/conf/&lt;span class="k"&gt;*&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three zeros. Deleted, 241MB back.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; image optimizer plugins leave their output behind when uninstalled. If you inherit a site, grep the uploads directory for orphaned formats before you add another optimizer to the pile.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 2: the size ladder had been demolished
&lt;/h2&gt;

&lt;p&gt;This is the one that mattered.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp &lt;span class="nb"&gt;eval&lt;/span&gt; &lt;span class="s1"&gt;'print_r( get_intermediate_image_sizes() );'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;Array&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;large&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;gform&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;choice&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;sm&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;17&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;gform&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;choice&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;md&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;18&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;gform&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;choice&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;lg&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four registered sizes. No &lt;code&gt;thumbnail&lt;/code&gt;, no &lt;code&gt;medium&lt;/code&gt;, no &lt;code&gt;medium_large&lt;/code&gt;. And critically — no &lt;code&gt;woocommerce_thumbnail&lt;/code&gt;, no &lt;code&gt;woocommerce_gallery_thumbnail&lt;/code&gt;, none of Divi's.&lt;/p&gt;

&lt;p&gt;Look at the array keys. They're non-sequential: 3, 16, 17, 18. The array originally held 0 through 18 and something had &lt;code&gt;unset()&lt;/code&gt; entries without reindexing. That's a filter stripping sizes at runtime, not sizes that were never registered.&lt;/p&gt;

&lt;p&gt;The culprit was a plugin called &lt;code&gt;disable-thumbnails-and-threshold&lt;/code&gt;, hooking &lt;code&gt;intermediate_image_sizes&lt;/code&gt; at priority 100, with eighteen sizes checked off in its settings. A previous dev had installed it to fight disk pressure. Understandable instinct, catastrophic execution.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why this is worse than it sounds
&lt;/h3&gt;

&lt;p&gt;WooCommerce still &lt;em&gt;believed&lt;/em&gt; it had a 300×300 thumbnail size:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp &lt;span class="nb"&gt;eval&lt;/span&gt; &lt;span class="s1"&gt;'print_r( wc_get_image_size("woocommerce_thumbnail") );'&lt;/span&gt;
&lt;span class="c"&gt;# =&amp;gt; width 300, height 300, crop 1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The size was registered in WooCommerce's config, but the files were never generated. WooCommerce ships &lt;code&gt;WC_Regenerate_Images&lt;/code&gt;, which hooks &lt;code&gt;wp_get_attachment_image_src&lt;/code&gt; and resizes on the fly when a requested size is missing.&lt;/p&gt;

&lt;p&gt;I counted actual coverage:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;find wp-content/uploads &lt;span class="nt"&gt;-type&lt;/span&gt; f &lt;span class="se"&gt;\(&lt;/span&gt; &lt;span class="nt"&gt;-iname&lt;/span&gt; &lt;span class="s1"&gt;'*-300x300.jpg'&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nt"&gt;-iname&lt;/span&gt; &lt;span class="s1"&gt;'*-300x300.png'&lt;/span&gt; &lt;span class="se"&gt;\)&lt;/span&gt; | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;
&lt;span class="c"&gt;# =&amp;gt; 141&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;141 files out of 2,216 attachments. Six percent coverage.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Meaning: for roughly 94% of product image requests, WooCommerce was doing an on-the-fly Imagick resize. On a box the host had already flagged for CPU saturation.&lt;/p&gt;

&lt;p&gt;And the &lt;code&gt;srcset&lt;/code&gt; consequence: with only &lt;code&gt;large&lt;/code&gt; (1024px) registered, every responsive image had exactly one candidate. A phone loading a product grid pulled 1024px files into 300px slots. WebP shaves ~30% off a file. Serving the right dimensions shaves 90%.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The format was never the problem. The missing size ladder was.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 3: the images weren't the disk hog
&lt;/h2&gt;

&lt;p&gt;Adding up the raster formats: 705MB PNG + 242MB WebP + 192MB JPEG = 1,139MB. Total uploads: 2.1GB.&lt;/p&gt;

&lt;p&gt;Where was the other gigabyte?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;find &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;-type&lt;/span&gt; f &lt;span class="nt"&gt;-printf&lt;/span&gt; &lt;span class="s1"&gt;'%f\n'&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="nt"&gt;-F&lt;/span&gt;&lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'NF&amp;gt;1{print tolower($NF)}'&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; | &lt;span class="nb"&gt;uniq&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;122 PDFs, 12 MP4s, 2 Keynote files. Course manuals and lesson videos — two videos alone were 170MB, and one training manual existed in four copies across two editions for 185MB.&lt;/p&gt;

&lt;p&gt;If I'd gone in with "let's reclaim disk by optimizing images," I'd have spent a day of CPU to reclaim less than the duplicate PDFs were wasting.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: three filters, no new plugins
&lt;/h2&gt;

&lt;p&gt;I considered ShortPixel and Imagify. Both are good. Both were wrong here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;They'd add ~17,000 API credits of cost for 2,216 attachments across 8 sizes&lt;/li&gt;
&lt;li&gt;They write sidecar files and need a delivery mechanism — &lt;strong&gt;the exact pattern that left 241MB of orphans when EWWW was removed&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;They'd be plugin #50 on a site already carrying too many
WordPress core has done this natively since 6.x. Three snippets, no dependencies.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  1. Restore the size ladder
&lt;/h3&gt;

&lt;p&gt;Rather than deactivating the disable-thumbnails plugin — which would swing from "no sizes" to "all nineteen sizes" and regenerate every unused Divi variant — I unchecked exactly five and left thirteen stripped:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$o&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'dtat_disablethumbnails_option_name'&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="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="s1"&gt;'thumbnail'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'medium'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'medium_large'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'woocommerce_thumbnail'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'woocommerce_gallery_thumbnail'&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$k&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;unset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$o&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$k&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="nf"&gt;update_option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'dtat_disablethumbnails_option_name'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$o&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nine registered sizes instead of four or nineteen. The Divi &lt;code&gt;et-pb-*&lt;/code&gt; sizes stay stripped — the theme's own bloat wasn't worth reintroducing.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. WebP output for generated subsizes
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'image_editor_output_format'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$formats&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$filename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$mime_type&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="nb"&gt;in_array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$mime_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="s1"&gt;'image/jpeg'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'image/png'&lt;/span&gt; &lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="kc"&gt;true&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="nv"&gt;$formats&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$mime_type&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'image/webp'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$formats&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'wp_editor_set_quality'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$quality&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$mime_type&lt;/span&gt; &lt;span class="p"&gt;)&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="s1"&gt;'image/webp'&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$mime_type&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;82&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$quality&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;10&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The critical property here: WordPress preserves the source file as &lt;code&gt;original_image&lt;/code&gt; in attachment metadata. Every future regeneration encodes from the lossless master, not from the WebP. Verify it before you commit to a bulk run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp &lt;span class="nb"&gt;eval&lt;/span&gt; &lt;span class="s1"&gt;'$m = wp_get_attachment_metadata( 123 ); var_dump( $m["original_image"] ?? "ABSENT" );'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that returns &lt;code&gt;ABSENT&lt;/code&gt;, stop. You'd be setting up cumulative quality loss — every regeneration re-encoding lossy from lossy, degrading invisibly over months until it's baked into everything.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Rewrite hardcoded URLs at render time
&lt;/h3&gt;

&lt;p&gt;This is the gotcha that nearly shipped broken.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;image_editor_output_format&lt;/code&gt; governs what WordPress &lt;em&gt;generates&lt;/em&gt;. It does nothing about URLs already written into &lt;code&gt;post_content&lt;/code&gt;. A Gutenberg &lt;code&gt;wp-block-image&lt;/code&gt; figure has the path baked into the block markup. So does a Divi shortcode. Regeneration doesn't touch stored markup.&lt;/p&gt;

&lt;p&gt;I measured the exposure:&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;post_type&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="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;wp_posts&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;post_status&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'publish'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'draft'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'inherit'&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;post_content&lt;/span&gt; &lt;span class="n"&gt;REGEXP&lt;/span&gt; &lt;span class="s1"&gt;'wp-content/uploads/[^"]+[.](png|jpe?g)'&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;post_type&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;n&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;190 non-revision posts — including 25 Divi Library items and 3 Theme Builder layouts, which inject into many pages each. Left alone, most of the site's content images would have kept serving PNG while their WebP siblings sat unused on disk.&lt;/p&gt;

&lt;p&gt;Fixed at render time rather than with a database &lt;code&gt;search-replace&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'wp_content_img_tag'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$html&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$attachment_id&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="kc"&gt;false&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nb"&gt;strpos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$html&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'/wp-content/uploads/'&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="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$html&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nv"&gt;$dir&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;wp_get_upload_dir&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// Original filename -&amp;gt; current primary file, for -scaled attachments.&lt;/span&gt;
    &lt;span class="nv"&gt;$orig_map&lt;/span&gt; &lt;span class="o"&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="nv"&gt;$attachment_id&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$meta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;wp_get_attachment_metadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$attachment_id&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="o"&gt;!&lt;/span&gt; &lt;span class="k"&gt;empty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$meta&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'original_image'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="k"&gt;empty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$meta&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'file'&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="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$orig_map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$meta&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'original_image'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;wp_basename&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$meta&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'file'&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="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="nv"&gt;$cache&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;preg_replace_callback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s1"&gt;'#(/wp-content/uploads/[^\s"\']+?)\.(png|jpe?g)#i'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$m&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$orig_map&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nv"&gt;$cache&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$m&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="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="k"&gt;isset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$cache&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$key&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="p"&gt;{&lt;/span&gt;
                &lt;span class="nv"&gt;$rel&lt;/span&gt;           &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;substr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$m&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="nb"&gt;strlen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'/wp-content/uploads'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
                &lt;span class="nv"&gt;$cache&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$key&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;file_exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$dir&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'basedir'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$rel&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'.webp'&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="nv"&gt;$cache&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$key&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="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$m&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="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'.webp'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;

            &lt;span class="nv"&gt;$base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;wp_basename&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$m&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="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;isset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$orig_map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$base&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="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;dirname&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'/'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$orig_map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$base&lt;/span&gt; &lt;span class="p"&gt;];&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;

            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$m&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="p"&gt;},&lt;/span&gt;
        &lt;span class="nv"&gt;$html&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The &lt;code&gt;-scaled&lt;/code&gt; trap
&lt;/h3&gt;

&lt;p&gt;My first version of that filter was ten lines shorter — just the extension swap and the&lt;br&gt;
&lt;code&gt;file_exists()&lt;/code&gt; guard. It tested clean and shipped, and then I found this in a Divi module:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;".../DrEdythe_7.2024.png"&lt;/span&gt;
     &lt;span class="na"&gt;srcset=&lt;/span&gt;&lt;span class="s"&gt;".../DrEdythe_7.2024-scaled.webp 1831w,
             .../DrEdythe_7.2024-215x300.webp 215w, ..."&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every &lt;code&gt;srcset&lt;/code&gt; candidate WebP. The &lt;code&gt;src&lt;/code&gt; still a 9.9MB PNG — the largest file on the site.&lt;/p&gt;

&lt;p&gt;WordPress scales any upload over &lt;code&gt;big_image_size_threshold&lt;/code&gt; (2560px by default) and makes the&lt;br&gt;
scaled copy the primary file, keeping the upload as &lt;code&gt;original_image&lt;/code&gt;. With WebP output enabled&lt;br&gt;
you get &lt;code&gt;foo-scaled.webp&lt;/code&gt; as primary and &lt;code&gt;foo.png&lt;/code&gt; as the preserved master. &lt;strong&gt;There is no&lt;br&gt;
&lt;code&gt;foo.webp&lt;/code&gt;.&lt;/strong&gt; So the naive swap looks for a file that will never exist, finds nothing, and&lt;br&gt;
leaves the PNG in place — silently, on exactly the biggest images you most wanted to fix.&lt;/p&gt;

&lt;p&gt;The fix is to stop guessing filenames. &lt;code&gt;wp_content_img_tag&lt;/code&gt; passes the attachment ID, so&lt;br&gt;
metadata can tell you the real current filename. Just remember &lt;code&gt;, 20, 3&lt;/code&gt; on the &lt;code&gt;add_filter&lt;/code&gt;&lt;br&gt;
call — without the argument count the ID never arrives and the branch is dead code.&lt;/p&gt;

&lt;p&gt;Why render-time over &lt;code&gt;wp search-replace&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No database mutation.&lt;/strong&gt; Rollback is deactivating a snippet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's conditional.&lt;/strong&gt; It only rewrites when a real target exists — a &lt;code&gt;.webp&lt;/code&gt; sibling on disk, or a primary file named in attachment metadata. A blind &lt;code&gt;.png&lt;/code&gt; → &lt;code&gt;.webp&lt;/code&gt; search-replace would have broken every reference to the ~54 icons that were correctly never converted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It covers everything.&lt;/strong&gt; Gutenberg blocks, Divi modules, classic editor content — all pass through &lt;code&gt;the_content&lt;/code&gt;.
The per-request static cache keeps &lt;code&gt;file_exists()&lt;/code&gt; negligible, and page caching absorbs the rest.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Running the regeneration
&lt;/h2&gt;

&lt;p&gt;The site had Regenerate Thumbnails installed with a perfectly good UI. I used WP-CLI anyway, for two reasons.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Disk visibility.&lt;/strong&gt; The plugin runs until done or until the volume fills. The box was at 84%. Batching from CLI let me check headroom between chunks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp post list &lt;span class="nt"&gt;--post_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;attachment &lt;span class="nt"&gt;--post_mime_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;image/jpeg,image/png &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ids &lt;span class="nt"&gt;--posts_per_page&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nt"&gt;-1&lt;/span&gt; | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="s1"&gt;' '&lt;/span&gt; &lt;span class="s1"&gt;'\n'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /tmp/ids.txt
&lt;span class="nb"&gt;split&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; 200 /tmp/ids.txt /tmp/idb_

&lt;span class="k"&gt;for &lt;/span&gt;b &lt;span class="k"&gt;in&lt;/span&gt; /tmp/idb_&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;nice&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 19 wp media regenerate &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="s1"&gt;'\n'&lt;/span&gt; &lt;span class="s1"&gt;' '&lt;/span&gt; &amp;lt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$b&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="nt"&gt;--only-missing&lt;/span&gt; &lt;span class="nt"&gt;--yes&lt;/span&gt;
  &lt;span class="nb"&gt;du&lt;/span&gt; &lt;span class="nt"&gt;-sm&lt;/span&gt; wp-content/uploads
  free &lt;span class="nt"&gt;-m&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-2&lt;/span&gt;
  &lt;span class="nb"&gt;sleep &lt;/span&gt;30
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Action Scheduler.&lt;/strong&gt; The site had a chronic Action Scheduler backlog the host had already flagged as a CPU source. Admin-driven bulk tools queue work there. WP-CLI is synchronous and bypasses it entirely.&lt;/p&gt;

&lt;p&gt;I also blocked WooCommerce's own background regeneration for the duration, since re-registering &lt;code&gt;woocommerce_thumbnail&lt;/code&gt; can trigger it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'woocommerce_background_image_regeneration'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'__return_false'&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;nice -n 19&lt;/code&gt; matters more than it looks. It means the kernel gives live traffic priority over the migration on every single scheduling decision. CPU sat around 69% during the run and the site stayed responsive, because the 69% was work nobody else wanted.&lt;/p&gt;

&lt;p&gt;Measured throughput: &lt;strong&gt;66 seconds per 200 attachments.&lt;/strong&gt; The full run took under twenty minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rolling it out to production
&lt;/h2&gt;

&lt;p&gt;Staging and production were separate applications on the same server. I wrote the whole thing up as a runbook and replayed it, and it went nearly identically — which is the point of rehearsing.&lt;/p&gt;

&lt;p&gt;Nearly. One thing bit me.&lt;/p&gt;

&lt;p&gt;The host gives each application a &lt;strong&gt;private &lt;code&gt;/tmp&lt;/code&gt; namespace&lt;/strong&gt;. My batch files, ID list, and log had all been written to &lt;code&gt;/tmp&lt;/code&gt; during the rehearsal, owned by the staging user. On production, every &lt;code&gt;/tmp&lt;/code&gt; write failed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;bash: /tmp/ids.txt: Permission denied
rm: cannot remove '/tmp/idb_aa': Operation not permitted
split: /tmp/idb_aa: Permission denied
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Except the reads didn't fail. &lt;code&gt;wc -l /tmp/ids.txt&lt;/code&gt; cheerfully returned 2216 — &lt;strong&gt;staging's file&lt;/strong&gt;. My first production batch ran against the rehearsal environment's ID list. It happened to work, because the two databases share attachment IDs, but that was luck. The background script never started at all, and I only noticed because the log line count sat at zero.&lt;/p&gt;

&lt;p&gt;Moved everything to a private directory outside the web root and it ran clean.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The lesson isn't "check permissions."&lt;/strong&gt; It's that a partially-failed setup is more dangerous than a fully-failed one. If &lt;code&gt;/tmp&lt;/code&gt; had been readable &lt;em&gt;and&lt;/em&gt; unwritable in a consistent way I'd have caught it instantly. Instead the writes failed loudly and the reads succeeded silently, and the silent success is what nearly shipped a run against the wrong data.&lt;/p&gt;

&lt;p&gt;Final numbers matched the rehearsal exactly: 2,097 of 2,216 converted, 55 sub-threshold icons untouched, 64 attachments whose files were already missing, and every oversized &lt;code&gt;-scaled&lt;/code&gt; image passing the URL-rewrite check.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;WooCommerce thumbnail coverage&lt;/td&gt;
&lt;td&gt;141 / 2,216 (6%)&lt;/td&gt;
&lt;td&gt;complete&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;srcset&lt;/code&gt; candidates per image&lt;/td&gt;
&lt;td&gt;1 (1024px)&lt;/td&gt;
&lt;td&gt;4 (100/150/300/768)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Attachments with WebP primary&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;2,097&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Uploads directory&lt;/td&gt;
&lt;td&gt;~2,100MB&lt;/td&gt;
&lt;td&gt;~1,900MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sample: 1.33MB PNG hero&lt;/td&gt;
&lt;td&gt;1,332,273 B&lt;/td&gt;
&lt;td&gt;27,634 B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sample: 322KB JPEG headshot&lt;/td&gt;
&lt;td&gt;322,419 B&lt;/td&gt;
&lt;td&gt;37,214 B&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;The uploads directory got smaller.&lt;/strong&gt; Adding roughly 11,000 WebP files while deleting 12,283 orphaned sidecars nets out negative. Disk was never the constraint I'd braced for.&lt;/p&gt;

&lt;p&gt;The biggest win isn't in that table. It's that WooCommerce stopped resizing images on demand for 94% of product requests. That was real CPU on every uncached page load, and it's simply gone now.&lt;/p&gt;

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

&lt;p&gt;I'd have called this done, except the first Divi page I checked afterward served this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;".../DrEdythe_7.2024-scaled.webp"&lt;/span&gt;
     &lt;span class="na"&gt;srcset=&lt;/span&gt;&lt;span class="s"&gt;".../DrEdythe_7.2024-scaled.webp 1831w,
             .../DrEdythe_7.2024-732x1024.webp 732w, ..."&lt;/span&gt;
     &lt;span class="na"&gt;sizes=&lt;/span&gt;&lt;span class="s"&gt;"(max-width: 1831px) 100vw, 1831px"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Correct format, correct &lt;code&gt;src&lt;/code&gt;, four &lt;code&gt;srcset&lt;/code&gt; candidates. And still wrong.&lt;/p&gt;

&lt;p&gt;Read the &lt;code&gt;sizes&lt;/code&gt; attribute. It tells the browser the image occupies the full viewport width up to 1831px. The module actually renders in a column a third of that. So the browser dutifully picks the 1831w candidate — 574KB — when the 732w file at 99KB would have covered the slot.&lt;/p&gt;

&lt;p&gt;Which is the same failure as the thumbnail ladder, wearing a different hat. I spent a session making sure the right files existed and the right URLs pointed at them, and the browser is still downloading 6× more than it needs because one attribute lies about the layout.&lt;/p&gt;

&lt;p&gt;That one's fixable through &lt;code&gt;wp_calculate_image_sizes&lt;/code&gt;, but it needs measuring against real module widths rather than guessed — and it's a bigger remaining win than the format change was.&lt;/p&gt;

&lt;p&gt;There's also a cleanup I deliberately didn't do. Superseded originals are still on disk: &lt;code&gt;DrEdythe_7.2024-732x1024.png&lt;/code&gt; at 1.36MB sitting inert next to its 99KB WebP, and hundreds more like it. Tempting to sweep. But while the render-time filter is doing delivery, those files &lt;em&gt;are&lt;/em&gt; the rollback path — deactivating the snippet reverts URLs to &lt;code&gt;.png&lt;/code&gt;, which only works if the &lt;code&gt;.png&lt;/code&gt; is still there. That deletion waits until WebP has been boring in production for a month.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell past-me
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Audit before you optimize.&lt;/strong&gt; The task I'd written down for myself was "convert images to WebP." The actual problem was a stripped size ladder causing on-the-fly resizing and 3× mobile payloads. Twenty minutes of &lt;code&gt;find&lt;/code&gt; and &lt;code&gt;wp eval&lt;/code&gt; reordered the entire job — and turned a cosmetic improvement into a real one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Non-sequential array keys are a fingerprint.&lt;/strong&gt; &lt;code&gt;[3], [16], [17], [18]&lt;/code&gt; told me a filter was unsetting entries at runtime, not that sizes were never registered. That one detail pointed straight at the plugin responsible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check what the previous optimizer left behind.&lt;/strong&gt; Uninstalling an image plugin doesn't remove its output. Grep for orphaned formats before adding another.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;original_image&lt;/code&gt; is the check that prevents slow-motion disaster.&lt;/strong&gt; Without it you get lossy-from-lossy regeneration compounding over years, and nobody notices until every image on the site is soft.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check the biggest images last, not first.&lt;/strong&gt; My URL-rewrite filter passed every test I wrote&lt;br&gt;
and still left the single largest file on the site unconverted, because &lt;code&gt;-scaled&lt;/code&gt; originals&lt;br&gt;
don't have the filename the naive swap looks for. Verify against your worst-case asset, not a&lt;br&gt;
representative one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A verification query can measure itself.&lt;/strong&gt; Those 64 missing files bothered me, so I wrote a&lt;br&gt;
check to find out whether anything referenced them. It reported 64 out of 64 referenced —&lt;br&gt;
alarming, and wrong. It was counting each attachment's own &lt;code&gt;_wp_attached_file&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;_wp_attachment_metadata&lt;/code&gt; rows as evidence that something else pointed at it, and matching a&lt;br&gt;
filename stem like &lt;code&gt;back&lt;/code&gt; against every post containing the word "back." Every row came back&lt;br&gt;
"used" because the query couldn't tell a reference from a reflection. Walking the actual pages&lt;br&gt;
in a browser had already given the right answer; I just trusted the SQL more because it had a&lt;br&gt;
number in it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Generating the right sizes beats changing the format — and telling the browser the truth beats both.&lt;/strong&gt; WebP is maybe 30% off a file. Serving a 300px image instead of a 1024px one is 90%. And an honest &lt;code&gt;sizes&lt;/code&gt; attribute is what makes the browser actually choose the 300px one. Format, dimensions, declaration — in ascending order of how much they matter and descending order of how much attention they get.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Notes: written up from a real migration on a WooCommerce + LearnDash + Divi site I maintain. Proactive maintenance work, not a client request. Site details anonymized — swap in specifics if you have sign-off. Table prefix shown as &lt;code&gt;wp_&lt;/code&gt; throughout; use &lt;code&gt;$(wp db prefix)&lt;/code&gt; on real sites.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>woocommerce</category>
      <category>php</category>
    </item>
    <item>
      <title>How fast do you finish your usage on Claude Code?</title>
      <dc:creator>Vicente G. Reyes</dc:creator>
      <pubDate>Wed, 29 Jul 2026 04:24:30 +0000</pubDate>
      <link>https://dev.to/highcenburg/how-fast-do-you-finish-your-usage-on-claude-code-4n76</link>
      <guid>https://dev.to/highcenburg/how-fast-do-you-finish-your-usage-on-claude-code-4n76</guid>
      <description>&lt;p&gt;On heavy coding work, I finish Claude's usage in more or less 2 hours. Is that fine or not? I'm on Claude Pro. How about you?&lt;/p&gt;

</description>
      <category>discuss</category>
      <category>code</category>
    </item>
    <item>
      <title>The Case of the Disappearing Free Trial</title>
      <dc:creator>Vicente G. Reyes</dc:creator>
      <pubDate>Fri, 24 Jul 2026 14:18:17 +0000</pubDate>
      <link>https://dev.to/highcenburg/the-case-of-the-disappearing-free-trial-26m2</link>
      <guid>https://dev.to/highcenburg/the-case-of-the-disappearing-free-trial-26m2</guid>
      <description>&lt;p&gt;&lt;em&gt;A debugging story about WooCommerce Subscriptions, caching red herrings, and one deliberate line of code that quietly broke a conversion funnel.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Symptom
&lt;/h2&gt;

&lt;p&gt;A client's fitness membership site offers a 7-day free trial on its subscription products. Simple enough: sign up, browse the workout library free for a week, get charged automatically after — standard WooCommerce Subscriptions behavior.&lt;/p&gt;

&lt;p&gt;Except guests visiting the site couldn't see the trial at all. The product page just showed the regular monthly price. No trial messaging, no "$0 due today." And when a guest actually went through checkout, they were charged the full price immediately — no 7-day grace period in sight.&lt;/p&gt;

&lt;p&gt;Logged-in users saw the trial just fine. Every time.&lt;/p&gt;

&lt;p&gt;That single fact — &lt;strong&gt;logged in: works, logged out: doesn't&lt;/strong&gt; — turned out to be both the biggest clue and the biggest trap of the entire investigation.&lt;/p&gt;

&lt;h2&gt;
  
  
  First Theories (All Wrong)
&lt;/h2&gt;

&lt;p&gt;The obvious first guess: maybe this particular product's trial configuration was broken. A quick look at the product's variations showed the trial length was set correctly — 7 days, right there in the admin. So that wasn't it.&lt;/p&gt;

&lt;p&gt;Next theory: maybe it was a caching problem. The site ran multiple cache layers — a page cache, a reverse proxy cache, and a persistent object cache. Guest requests are cached far more aggressively than logged-in ones, so a stale cached copy of the page (generated before some earlier fix) could easily explain "guests see the old broken version, logged-in users always get a fresh page."&lt;/p&gt;

&lt;p&gt;This felt promising enough to chase hard:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Purged the page cache. No change.&lt;/li&gt;
&lt;li&gt;Purged the reverse-proxy cache. No change.&lt;/li&gt;
&lt;li&gt;Flushed the persistent object cache directly via WP-CLI. No change.&lt;/li&gt;
&lt;li&gt;Tested with cache-busting query strings and &lt;code&gt;Cache-Control: no-cache&lt;/code&gt; headers, confirmed via response headers that the request was a genuine cache &lt;strong&gt;MISS&lt;/strong&gt;, generated fresh, at that exact moment. &lt;strong&gt;Still no trial for the guest request.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last test was important. It proved, with certainty, that this wasn't caching at all. PHP was executing fully, fresh, for an anonymous visitor — and still choosing not to show the trial. Whatever was happening, it was happening in application logic, not in a cache layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ruling Out an Entire Stack, One Piece at a Time
&lt;/h2&gt;

&lt;p&gt;With caching off the table, the search moved into the code itself. Over the following days:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Product-level theories&lt;/strong&gt; (duplicate variations, a "Virtual" checkbox left unchecked, a product-type mismatch between simple vs. variable subscriptions) were tested and eliminated — the same behavior showed up across &lt;em&gt;every&lt;/em&gt; subscription product on the site, not just one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom code&lt;/strong&gt; — theme files, must-use plugins, active Code Snippets — was searched exhaustively via &lt;code&gt;grep&lt;/code&gt; for anything referencing trial length, price display, or login state. All clean.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WooCommerce Dynamic Pricing&lt;/strong&gt;, a plugin capable of role-based price rules, was a strong suspect for a while. Ruled out by testing as a plain logged-in Customer account (no special role) — the trial showed correctly, meaning it wasn't about &lt;em&gt;which&lt;/em&gt; role you had, only &lt;em&gt;whether&lt;/em&gt; you were logged in at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A subscription "Enhancer" plugin&lt;/strong&gt; with its own trial-limiting logic looked like the smoking gun for a moment. Tracing its actual function line-by-line showed it explicitly left guests untouched — it only affected logged-in users checking for repeat trials. Not the cause.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WooCommerce Subscriptions' own core code&lt;/strong&gt; was traced end-to-end: the function that reads a product's trial length, the function that turns that into the "with a 7-day free trial" string, the method that assembles the final price HTML. No login check anywhere in any of it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;By this point, every obvious suspect — and several non-obvious ones — had been checked and cleared. The trial data was correct. The display logic was correct. And yet, for a guest, the number was still coming out as zero.&lt;/p&gt;

&lt;h2&gt;
  
  
  Following the Data, Not the Guesses
&lt;/h2&gt;

&lt;p&gt;The turning point was switching from "search the files" to "ask WordPress directly what's actually happening at runtime."&lt;/p&gt;

&lt;p&gt;WP-CLI made this possible:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp &lt;span class="nb"&gt;eval&lt;/span&gt; &lt;span class="s1"&gt;'echo WC_Subscriptions_Product::get_trial_length(11343);'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run as an anonymous request (user ID 0, matching a real guest), this returned &lt;code&gt;0&lt;/code&gt;. Run with an authenticated user context, it correctly returned &lt;code&gt;7&lt;/code&gt;. Confirmed, cleanly: the discrepancy was real, reproducible, and happening inside this exact function call.&lt;/p&gt;

&lt;p&gt;That function's actual implementation was almost embarrassingly simple:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;get_trial_length&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$product&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;apply_filters&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'woocommerce_subscriptions_product_trial_length'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;get_meta_data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mf"&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;It reads the correct value from the database (&lt;code&gt;7&lt;/code&gt;, confirmed directly via &lt;code&gt;wp post meta get&lt;/code&gt;) — then passes it through a WordPress filter before returning it. Somewhere, something was hooked onto that filter and changing the number.&lt;/p&gt;

&lt;p&gt;A plain-text search for that filter name across the plugin and theme files found only two matches: the definition itself, and one plugin's filter (already cleared above). Nothing else — which didn't make sense, because &lt;em&gt;something&lt;/em&gt; was clearly modifying the value.&lt;/p&gt;

&lt;p&gt;The missing piece was that &lt;strong&gt;custom code snippets aren't stored as files at all&lt;/strong&gt; — they live in the database, injected into the request at runtime. No filesystem search will ever find them.&lt;/p&gt;

&lt;p&gt;The real breakthrough came from asking WordPress's own hook registry directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp &lt;span class="nb"&gt;eval&lt;/span&gt; &lt;span class="s1"&gt;'global $wp_filter; 
foreach ($wp_filter["woocommerce_subscriptions_product_trial_length"]-&amp;gt;callbacks as $priority =&amp;gt; $callbacks) { 
    foreach ($callbacks as $cb) { 
        echo "Priority $priority: " . (is_array($cb["function"]) 
            ? get_class($cb["function"][0]) . "::" . $cb["function"][1] 
            : "Closure") . "\n"; 
    } 
}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This listed every single callback actually hooked onto that filter, in execution order — including a &lt;strong&gt;Closure&lt;/strong&gt;, running before everything else, that no static search had caught. Using PHP's reflection API to ask that closure where it was defined pointed straight at a database-stored code snippet.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Actual Bug
&lt;/h2&gt;

&lt;p&gt;The snippet in question, named &lt;em&gt;"Blocks Multiple Free Trials,"&lt;/em&gt; was a perfectly reasonable piece of anti-abuse logic: don't let a customer claim a second free trial on a product they've already subscribed to before. Good instinct, sound feature.&lt;/p&gt;

&lt;p&gt;But nested inside it was this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&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="nf"&gt;is_user_logged_in&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="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Stronger enforcement: require login for any trial&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not a bug in the traditional sense — a deliberate design decision, with a comment explaining exactly what it did. Someone had decided that requiring a login before offering any trial was a form of "stronger enforcement." It technically worked as written. It just wasn't what the business actually wanted: real customers were landing on the site from social media, seeing a product that advertised a 7-day trial, and getting charged in full the moment they checked out as a guest.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix
&lt;/h2&gt;

&lt;p&gt;The fix didn't touch the legitimate repeat-trial protection at all — only the guest-blocking branch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&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="nf"&gt;is_user_logged_in&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="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$trial_length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Guests get the trial; repeat-check only applies to logged-in users&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Guests now see the trial correctly, first-time visitors get $0-today checkout with the first charge seven days later, and returning customers still can't double-dip on a second trial. One line changed; the intended behavior preserved.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Made This One Hard
&lt;/h2&gt;

&lt;p&gt;A few things stack up to make a bug like this genuinely difficult, worth naming for anyone hitting something similar:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The correlation was real but the causation was wrong.&lt;/strong&gt; "Logged in vs. logged out" was a completely accurate description of the symptom the entire time — it just pointed everyone toward login-related plugin settings and eligibility logic, when the actual cause was a single conditional buried three layers deep in unrelated anti-abuse code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every individual system was innocent.&lt;/strong&gt; WooCommerce core, the theme, the caching layers, the pricing plugin — each one, checked in isolation, was completely correct. The bug lived in custom glue code that no one else's system could see or account for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code Snippets don't show up in a filesystem search.&lt;/strong&gt; Anything stored and evaluated from the database is invisible to &lt;code&gt;grep&lt;/code&gt;, and easy to forget existed at all if it isn't the snippet you're actively thinking about.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A deliberate decision looks identical to a bug from the outside.&lt;/strong&gt; The code wasn't broken; it was doing exactly what it was told. The mismatch was between what the code said and what the business actually wanted — which no amount of debugging tools can catch unless you're willing to trace all the way down to the literal returned value and ask "why."&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;When behavior differs by user state, don't assume it's a permissions/eligibility &lt;em&gt;setting&lt;/em&gt; — check for &lt;strong&gt;custom filters&lt;/strong&gt; hooked into the same pipeline, especially in code you didn't write yourself.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;grep&lt;/code&gt; is powerful, but it can't see what's stored in a database. If a site has any kind of snippet manager, check its actual contents directly — not just "how many snippets are active" in an admin list.&lt;/li&gt;
&lt;li&gt;When static analysis stalls, ask the running application directly. &lt;code&gt;wp eval&lt;/code&gt; and inspecting &lt;code&gt;$wp_filter&lt;/code&gt; at runtime found in minutes what days of file searching couldn't.&lt;/li&gt;
&lt;li&gt;Not every "bug" is broken code. Sometimes it's a correct implementation of an incorrect assumption — and the fix is a conversation about intent, not just a patch.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>wordpress</category>
      <category>php</category>
      <category>webdev</category>
      <category>debugging</category>
    </item>
    <item>
      <title>The Button That Sent People to a Blank Form</title>
      <dc:creator>Vicente G. Reyes</dc:creator>
      <pubDate>Wed, 22 Jul 2026 11:03:55 +0000</pubDate>
      <link>https://dev.to/highcenburg/the-button-that-sent-people-to-a-blank-form-42hj</link>
      <guid>https://dev.to/highcenburg/the-button-that-sent-people-to-a-blank-form-42hj</guid>
      <description>&lt;p&gt;Every service page on my portfolio ends with the same call to action: a big &lt;strong&gt;Start Project&lt;/strong&gt; button. Click it on the Automation page, and you land on the contact form ready to talk about automation. That was the idea, anyway.&lt;/p&gt;

&lt;p&gt;What actually happened: you'd click &lt;em&gt;Start Project&lt;/em&gt; on &lt;code&gt;/services/automation-solutions&lt;/code&gt;, get redirected to &lt;code&gt;/contact&lt;/code&gt;, and the "What do you need?" picker would be sitting there completely unselected. The one piece of context the user had just handed me — &lt;em&gt;I want automation&lt;/em&gt; — was thrown away the moment they navigated. They'd have to re-tell me what they came to tell me.&lt;/p&gt;

&lt;p&gt;This is a small bug. It's also exactly the kind of friction that quietly costs conversions. Here's the fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Setup
&lt;/h2&gt;

&lt;p&gt;There are actually two contact forms in this codebase, which is where the confusion started.&lt;/p&gt;

&lt;p&gt;The service pages (&lt;code&gt;ServicePage.tsx&lt;/code&gt;) each render their own inline contact form near the bottom, and that one &lt;em&gt;already&lt;/em&gt; pre-selected the right service — it derives the default from the URL slug:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SLUG_TO_SERVICE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;software-development&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;software&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;shopify-development&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;shopify&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;automation-solutions&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;automation&lt;/span&gt;&lt;span class="dl"&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;defaultService&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;SLUG_TO_SERVICE&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;slug&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="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;as&lt;/span&gt; &lt;span class="nx"&gt;ServiceValue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But the &lt;strong&gt;Start Project&lt;/strong&gt; button in the hero didn't scroll to that inline form. It navigated to the standalone contact page, a completely separate component (&lt;code&gt;ContactPage.tsx&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;navigate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/contact&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  Start Project &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ArrowRight&lt;/span&gt; &lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And &lt;code&gt;ContactPage&lt;/code&gt; always initialized its form with an empty service:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setForm&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;FormData&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;name&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="na"&gt;email&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="na"&gt;service&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="na"&gt;budget&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="na"&gt;timeline&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="na"&gt;website&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="na"&gt;message&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="na"&gt;honeypot&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the button crossed a boundary — from a page that knew what the user wanted to a page that had no idea — and nothing carried the context across.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix: Carry the Intent in the URL
&lt;/h2&gt;

&lt;p&gt;The cleanest way to pass a small piece of state across a navigation is the URL itself. A query param is shareable, bookmarkable, survives a refresh, and doesn't need any global store. So the button now appends the service it already knows about:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt;
  &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;navigate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;defaultService&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;`/contact?service=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;defaultService&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/contact&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  Start Project &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ArrowRight&lt;/span&gt; &lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the guard: if the slug maps to nothing (&lt;code&gt;defaultService&lt;/code&gt; is &lt;code&gt;''&lt;/code&gt;), it falls back to a plain &lt;code&gt;/contact&lt;/code&gt; instead of producing an ugly &lt;code&gt;/contact?service=&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;On the receiving end, &lt;code&gt;ContactPage&lt;/code&gt; reads the param on mount and validates it before trusting it. That validation matters — a query param is user-editable, and I don't want &lt;code&gt;?service=drop-table&lt;/code&gt; (or just a typo) to put the form into a weird state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&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;useSearchParams&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="s1"&gt;react-router-dom&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;VALID_SERVICES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;SERVICES&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;s&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;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&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;readonly&lt;/span&gt; &lt;span class="kr"&gt;string&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;normalizeService&lt;/span&gt; &lt;span class="o"&gt;=&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="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;ServiceValue&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;VALID_SERVICES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&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="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;ServiceValue&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;p&gt;Then I use a &lt;strong&gt;lazy initializer&lt;/strong&gt; for the form state so the param is applied exactly once, when the component first mounts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useSearchParams&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setForm&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;FormData&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;name&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="na"&gt;email&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="na"&gt;service&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;normalizeService&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;searchParams&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;service&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="na"&gt;budget&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="na"&gt;timeline&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="na"&gt;website&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="na"&gt;message&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="na"&gt;honeypot&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole change — two files, about eleven lines. The service picker now highlights the right option the moment the page loads, and because the budget selector only appears &lt;em&gt;after&lt;/em&gt; a service is chosen, arriving from a service page also reveals the correct price ranges immediately.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Subtlety About When It Runs
&lt;/h2&gt;

&lt;p&gt;The lazy initializer only runs once, on mount. That's deliberate, and it's worth understanding why it's correct here.&lt;/p&gt;

&lt;p&gt;When a user clicks &lt;em&gt;Start Project&lt;/em&gt;, they navigate from the &lt;code&gt;/services/...&lt;/code&gt; route to the &lt;code&gt;/contact&lt;/code&gt; route. React Router unmounts one page component and mounts the other — a fresh mount, so the initializer runs and reads the param. &lt;/p&gt;

&lt;p&gt;But if you were &lt;em&gt;already&lt;/em&gt; on &lt;code&gt;/contact&lt;/code&gt; and only the query string changed, React Router would keep the same component mounted and the initializer would &lt;strong&gt;not&lt;/strong&gt; re-run. For a second I wondered if I needed a &lt;code&gt;useEffect&lt;/code&gt; to sync on param changes. I decided against it: there's no UI path where the query param changes while you're sitting on the contact page. Adding an effect to handle a situation that can't happen is just a new bug waiting to be misunderstood later. If that ever changes, the effect is easy to add.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing It for Real
&lt;/h2&gt;

&lt;p&gt;I didn't want to eyeball this one. A pre-select bug is exactly the kind of thing that "looks fine" and silently regresses, so I drove a real browser against the dev server and checked the actual DOM state instead of trusting a screenshot.&lt;/p&gt;

&lt;p&gt;The selected service button carries a &lt;code&gt;-translate-y-1&lt;/code&gt; class (the little neubrutalist "lift"), so I could assert on it directly:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;URL after action&lt;/th&gt;
&lt;th&gt;Selected option&lt;/th&gt;
&lt;th&gt;Budget section&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Click &lt;em&gt;Start Project&lt;/em&gt; on Automation&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/contact?service=automation&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Automation&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Visible, automation ranges&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Land on &lt;code&gt;/contact&lt;/code&gt; directly&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/contact&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;em&gt;(none)&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;Hidden&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tampered &lt;code&gt;?service=bogus&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/contact?service=bogus&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;em&gt;(none)&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;Hidden&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The middle and bottom rows are the ones I actually cared about — they prove the feature doesn't leak into the no-param case and that &lt;code&gt;normalizeService&lt;/code&gt; throws away garbage instead of choking on it.&lt;/p&gt;

&lt;p&gt;One gotcha while testing: my first attempt navigated between cases with &lt;code&gt;history.pushState&lt;/code&gt; inside the running app, and every case came back showing "Automation" still selected. That wasn't a bug in the fix — it was the &lt;em&gt;same&lt;/em&gt; behavior I described above. &lt;code&gt;pushState&lt;/code&gt; doesn't remount &lt;code&gt;ContactPage&lt;/code&gt;, so the initializer never re-ran and the old state persisted. Switching to full page loads for each case gave honest results. A nice reminder that your test harness has to respect the component lifecycle you're actually shipping.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Takeaway
&lt;/h2&gt;

&lt;p&gt;The bug wasn't a broken function. Everything worked in isolation — the button navigated, the form rendered, the picker picked. The bug lived in the &lt;em&gt;gap between two components&lt;/em&gt;, where a piece of user intent quietly fell on the floor.&lt;/p&gt;

&lt;p&gt;The URL is a great place to catch things falling through those gaps. It's the one piece of state both sides of a navigation can always agree on. Put the intent in the query string, validate it on the way in, and the two pages don't need to know anything about each other.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>typescript</category>
      <category>react</category>
      <category>testing</category>
    </item>
    <item>
      <title>I Added Sentry to a WooCommerce Membership Site. It Caught a Real Bug Within the Hour.</title>
      <dc:creator>Vicente G. Reyes</dc:creator>
      <pubDate>Mon, 20 Jul 2026 13:16:19 +0000</pubDate>
      <link>https://dev.to/highcenburg/i-added-sentry-to-a-woocommerce-membership-site-it-caught-a-real-bug-within-the-hour-5d4e</link>
      <guid>https://dev.to/highcenburg/i-added-sentry-to-a-woocommerce-membership-site-it-caught-a-real-bug-within-the-hour-5d4e</guid>
      <description>&lt;p&gt;I'm the lead developer on a WooCommerce/Divi/LearnDash membership platform. Like most mature WordPress sites, it runs a lot of plugins — page builder, LMS, membership gating, marketing automation, forms, SMTP — and when something breaks, the failure usually surfaces as a vague member complaint days later, not as an actionable error with a stack trace.&lt;/p&gt;

&lt;p&gt;I finally fixed that by wiring up &lt;a href="https://sentry.io" rel="noopener noreferrer"&gt;Sentry&lt;/a&gt; with the &lt;a href="https://wordpress.org/plugins/wp-sentry-integration/" rel="noopener noreferrer"&gt;WordPress Sentry plugin&lt;/a&gt; (&lt;code&gt;wp-sentry-integration&lt;/code&gt;). Setup took about twenty minutes. The first real bug showed up in the feed within the hour — an error loop that had been silently firing on every hit to a REST endpoint, and that nobody would ever have reported, because it never broke anything a user could see.&lt;/p&gt;

&lt;p&gt;This post covers the setup, the gotchas, and the first catch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the wp-sentry plugin and not the raw SDK snippets
&lt;/h2&gt;

&lt;p&gt;When you create a Sentry project, the onboarding page hands you a &lt;code&gt;\Sentry\init()&lt;/code&gt; snippet for PHP and a loader &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tag for the browser. You don't want either of them on a WordPress site.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;wp-sentry-integration&lt;/code&gt; plugin bundles both official SDKs and does the integration work you'd otherwise hand-roll:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Initializes the PHP SDK early enough in the WordPress bootstrap to catch fatals from other plugins&lt;/li&gt;
&lt;li&gt;Hooks PHP errors, exceptions, and fatal errors&lt;/li&gt;
&lt;li&gt;Enqueues the browser SDK on the frontend and wires up tracing and Session Replay&lt;/li&gt;
&lt;li&gt;Attaches WordPress context to every event: the logged-in user, environment, release, and plugin/theme metadata&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The part that confused me at first: the plugin's settings page has checkboxes, and none of them are clickable. That's by design. &lt;strong&gt;The plugin has zero UI configuration.&lt;/strong&gt; Everything is driven by constants in &lt;code&gt;wp-config.php&lt;/code&gt;, and the settings page is a read-only dashboard that reports which constants it detected. Checkbox ticked means the constant exists and is valid. You can click them all day and nothing happens.&lt;/p&gt;

&lt;p&gt;I've come around to this being the right call. Your error-tracking config lives in code, survives plugin updates, can't be broken from wp-admin, and a compromised admin account can't quietly redirect your error stream to someone else's DSN. Same philosophy as &lt;code&gt;DISABLE_WP_CRON&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Two Sentry projects, not one
&lt;/h3&gt;

&lt;p&gt;Create separate projects for PHP and browser JavaScript. On a plugin-heavy WooCommerce site, browser noise — extension errors, ancient browsers, third-party scripts — will drown out real server-side errors if they share a project. Splitting them keeps triage sane and lets you set different alert rules and quotas per side.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Constants in wp-config.php
&lt;/h3&gt;

&lt;p&gt;Everything goes above the &lt;code&gt;/* That's all, stop editing! */&lt;/code&gt; line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// --- Sentry ---&lt;/span&gt;
&lt;span class="nb"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'WP_SENTRY_PHP_DSN'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'https://xxxx@oXXXX.ingest.us.sentry.io/PHP_PROJECT_ID'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nb"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'WP_SENTRY_BROWSER_DSN'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'https://xxxx@oXXXX.ingest.us.sentry.io/JS_PROJECT_ID'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nb"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'WP_SENTRY_ENV'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'production'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Attach the logged-in WP user (id/username/email) to events&lt;/span&gt;
&lt;span class="nb"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'WP_SENTRY_SEND_DEFAULT_PII'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Keep tracing cheap — it eats quota fast&lt;/span&gt;
&lt;span class="nb"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'WP_SENTRY_TRACES_SAMPLE_RATE'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nb"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'WP_SENTRY_BROWSER_TRACES_SAMPLE_RATE'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Session Replay: no ambient recording, full replay on error&lt;/span&gt;
&lt;span class="nb"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'WP_SENTRY_BROWSER_REPLAYS_SESSION_SAMPLE_RATE'&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="nb"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'WP_SENTRY_BROWSER_REPLAYS_ON_ERROR_SAMPLE_RATE'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One trap: the browser value must be the &lt;strong&gt;DSN&lt;/strong&gt; from Settings → Client Keys, not the CDN loader script URL that Sentry's JS onboarding page shows you. They look superficially similar. The DSN has the &lt;code&gt;https://key@org.ingest...sentry.io/project_id&lt;/code&gt; shape.&lt;/p&gt;

&lt;p&gt;A few decisions worth explaining:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;SEND_DEFAULT_PII&lt;/code&gt; on a membership site&lt;/strong&gt; is genuinely useful — you can tie a checkout error directly to the member who reported it instead of playing email tag. The tradeoff is that member emails now live in Sentry, so lock down team access and account for it in your privacy policy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replay sample rates of 0 / 1.0&lt;/strong&gt; means no routine session recording, but when a JS error fires you get the full replay of what the user did leading up to it. On checkout flows this is gold. Sentry masks all text and inputs by default; verify that before trusting it near payment forms.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Skip profiling.&lt;/strong&gt; It requires the &lt;code&gt;excimer&lt;/code&gt; PHP extension, which most managed WordPress hosts don't ship. Tracing plus replay covers what you actually need.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  3. Verify
&lt;/h3&gt;

&lt;p&gt;Reload the plugin's tools page — the checkboxes should now be ticked and the test buttons active. Fire the PHP test event and the browser test error, and confirm each lands in the correct project. The browser test on my setup also captured a session replay, which confirmed the whole replay pipeline in one shot.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Turn on inbound filters immediately
&lt;/h3&gt;

&lt;p&gt;In the JS project: Settings → Inbound Filters → enable the browser-extension, legacy-browser, and web-crawler filters. Do this on day one, not after your quota is gone.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first catch
&lt;/h2&gt;

&lt;p&gt;Within the hour, a real issue appeared in the PHP project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Exception: Scheduled action for bwfcrm_broadcast_run_queue will not be
executed as no callbacks are registered.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Firing from &lt;code&gt;GET /wp-json/woofunnels/v1/worker&lt;/code&gt; — FunnelKit's background worker endpoint.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reading the trace
&lt;/h3&gt;

&lt;p&gt;The exception came from Action Scheduler's safety check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&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="nf"&gt;has_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$hook&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="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'Scheduled action ... will not be executed
        as no callbacks are registered.'&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;Action Scheduler refuses to run a scheduled job that has no listeners — it treats a dangling hook as a broken state and throws.&lt;/p&gt;

&lt;p&gt;The hook, &lt;code&gt;bwfcrm_broadcast_run_queue&lt;/code&gt;, belongs to FunnelKit's CRM/broadcast module. The site doesn't use email broadcasts (I confirmed with the client — a one-line message, thirty seconds), so nothing registers that hook anymore. But the &lt;em&gt;recurring action&lt;/em&gt; for it still lived in FunnelKit's custom action store, scheduled to run every 60 seconds.&lt;/p&gt;

&lt;p&gt;Sentry's SQL breadcrumbs made the loop visible: the worker fetches the action, the execution throws, and the fetch path re-schedules the next occurrence. An orphaned job, erroring and rebooking itself forever. It had presumably been doing this since whenever the CRM module was deactivated — invisible, because the exception is handled and nothing user-facing ever broke.&lt;/p&gt;

&lt;p&gt;This is exactly the class of bug error monitoring exists for. No member would ever report it. No admin screen showed it. It was just background waste: a query cycle and a thrown exception on every worker run, on a site that already had a history of Action Scheduler bloat.&lt;/p&gt;

&lt;h3&gt;
  
  
  The fix: delete the work, keep a guard
&lt;/h3&gt;

&lt;p&gt;Two parts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Delete the orphaned recurring action.&lt;/strong&gt; The table and hook name come straight from the event's own breadcrumbs, so the query is exact — but look before you delete:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp db query &lt;span class="s2"&gt;"SELECT id, hook, status, recurring_interval, group_slug
  FROM wp_bwf_actions
  WHERE hook = 'bwfcrm_broadcast_run_queue';"&lt;/span&gt;

wp db query &lt;span class="s2"&gt;"DELETE FROM wp_bwf_actions
  WHERE hook = 'bwfcrm_broadcast_run_queue';"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(Adjust the table prefix for your install.) This isn't as scary as deleting from an unknown table sounds: the plugin's own queue runner deletes rows from this table constantly as part of normal processing — the last breadcrumb in the Sentry event was literally a &lt;code&gt;DELETE&lt;/code&gt; on the same row. Worst case, re-enabling the CRM module re-registers the hook and re-seeds the schedule from scratch. There's no unrecoverable state in a row that says "run this hook every 60 seconds."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Register a no-op guard.&lt;/strong&gt; Some plugins have a habit of re-seeding their recurring actions on init or after updates. If that happens here, I don't want the error loop back — so I registered a listener that does nothing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Guard: orphaned FunnelKit CRM broadcast hook.&lt;/span&gt;
&lt;span class="c1"&gt;// The bwfcrm_broadcast_run_queue recurring action has no registered&lt;/span&gt;
&lt;span class="c1"&gt;// callback (CRM/broadcasts unused — confirmed with the client).&lt;/span&gt;
&lt;span class="c1"&gt;// This no-op prevents Action Scheduler from throwing if the action&lt;/span&gt;
&lt;span class="c1"&gt;// gets re-seeded. Safe to remove if FunnelKit CRM is ever activated.&lt;/span&gt;
&lt;span class="nf"&gt;add_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'bwfcrm_broadcast_run_queue'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'__return_null'&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;__return_null&lt;/code&gt; is a WordPress core utility whose entire body is &lt;code&gt;return null;&lt;/code&gt;. Registering it means &lt;code&gt;has_action()&lt;/code&gt; returns true, the safety check passes, and a re-seeded action "runs" harmlessly instead of restarting the error stream.&lt;/p&gt;

&lt;p&gt;I put this in Code Snippets rather than the theme's &lt;code&gt;functions.php&lt;/code&gt; — it's an infrastructure fix, not presentation code, and it should survive theme switches, stay individually toggleable, and be findable by name by the next developer (or by me in six months). The comment block matters as much as the code: a bare &lt;code&gt;add_action( 'something', '__return_null' )&lt;/code&gt; with no explanation is a trap for whoever finds it later.&lt;/p&gt;

&lt;p&gt;Then: resolve the issue in Sentry. If the action ever re-seeds &lt;em&gt;and&lt;/em&gt; the guard is somehow gone, Sentry reopens the issue and I know immediately — that's regression detection for free.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;On WordPress, configure Sentry through the wp-sentry plugin's constants, not the onboarding snippets.&lt;/strong&gt; The plugin is the engine; &lt;code&gt;wp-config.php&lt;/code&gt; is the ignition key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Split PHP and browser into separate projects&lt;/strong&gt; and enable inbound filters on the JS side before the noise starts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Handled exceptions are still signal.&lt;/strong&gt; The bug I found threw a &lt;em&gt;caught&lt;/em&gt; exception — nothing crashed, no user saw anything. Without monitoring it would have run forever.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sentry breadcrumbs can hand you the fix.&lt;/strong&gt; The SQL breadcrumbs contained the exact table, the exact row, and proof that deleting the row was an operation the plugin itself performs routinely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fix orphaned scheduled actions in two layers:&lt;/strong&gt; delete the orphan (stop the waste), and register a no-op guard (stay fixed if the plugin re-seeds it). Then resolve the issue in Sentry and let it be your regression alarm.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Total time from "install the plugin" to "real production bug found, diagnosed from the stack trace and breadcrumbs, fixed with a two-layer patch": about an hour. That's the fastest an observability tool has ever paid for itself on one of my projects.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>php</category>
      <category>webdev</category>
      <category>sentry</category>
    </item>
    <item>
      <title>Closing the Loop: Emailing Myself When My Resume Gets Downloaded</title>
      <dc:creator>Vicente G. Reyes</dc:creator>
      <pubDate>Fri, 17 Jul 2026 12:06:56 +0000</pubDate>
      <link>https://dev.to/highcenburg/closing-the-loop-emailing-myself-when-my-resume-gets-downloaded-h8j</link>
      <guid>https://dev.to/highcenburg/closing-the-loop-emailing-myself-when-my-resume-gets-downloaded-h8j</guid>
      <description>&lt;p&gt;I already had a &lt;a href="https://www.vicentereyes.org/blog/building-a-resume-download-gate-email-collection-signed-tokens-and-an-s3-lesson-550fb050" rel="noopener noreferrer"&gt;resume download gate&lt;/a&gt; — email required, signed link, 15-minute expiry, presigned S3 URL good for two minutes. What it didn't have was a way for &lt;em&gt;me&lt;/em&gt; to know it happened. Someone could download my resume and I'd never find out unless I went digging through &lt;code&gt;ResumeDownloadRequest&lt;/code&gt; rows in the admin.&lt;/p&gt;

&lt;p&gt;This is the short follow-up: one notification email, sent to me, the moment someone actually opens the PDF.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where to Hook It
&lt;/h2&gt;

&lt;p&gt;The gate has two views:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ResumeRequestDownloadView.post()&lt;/code&gt; — fires when someone submits their email and gets sent a link&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ResumeDownloadView.get()&lt;/code&gt; — fires when someone clicks that link and the signed token is verified&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Submitting an email isn't the same as downloading. Plenty of people fill in a form and never check their inbox. I wanted the notification tied to a confirmed download, so it belongs in &lt;code&gt;ResumeDownloadView.get()&lt;/code&gt; — right after the token is verified and the resume is confirmed to exist, just before the redirect to the presigned S3 URL.&lt;/p&gt;




&lt;h2&gt;
  
  
  Finding the Right Recipient
&lt;/h2&gt;

&lt;p&gt;My first instinct was to reach for &lt;code&gt;settings.ADMINS&lt;/code&gt;, Django's built-in list for &lt;code&gt;mail_admins()&lt;/code&gt;. But &lt;code&gt;ADMINS&lt;/code&gt; in &lt;code&gt;config/settings/base.py&lt;/code&gt; looked like a placeholder:&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;ADMINS&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;Vicente G. Reyes&lt;/span&gt;&lt;span class="sh"&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;me@example.com&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;me@example.com&lt;/code&gt; — that &lt;code&gt;x&lt;/code&gt; reads exactly like scaffolding nobody replaced. I almost went and fixed it to a real inbox before sending anything.&lt;/p&gt;

&lt;p&gt;Then I noticed something else: &lt;code&gt;.envs/.production/.django&lt;/code&gt; also had &lt;code&gt;DJANGO_SERVER_EMAIL=x&lt;/code&gt;. Two placeholder-looking values with the same address wasn't a coincidence — it turned out &lt;code&gt;me@example.com&lt;/code&gt; is a real catch-all alias on the domain, already wired to forward to an inbox that gets checked. Not a placeholder at all.&lt;/p&gt;

&lt;p&gt;That distinction mattered for a second reason: &lt;code&gt;SERVER_EMAIL&lt;/code&gt; and &lt;code&gt;ADMINS&lt;/code&gt; do different jobs in Django. &lt;code&gt;SERVER_EMAIL&lt;/code&gt; is the &lt;strong&gt;From&lt;/strong&gt; address Django uses when it sends admin error emails via &lt;code&gt;mail_admins()&lt;/code&gt;. &lt;code&gt;ADMINS&lt;/code&gt; is the &lt;strong&gt;recipient&lt;/strong&gt; list for those same emails. Confusing the two would have had me trying to "fix" a sender address as if it were a destination.&lt;/p&gt;

&lt;p&gt;Given &lt;code&gt;me@example.com&lt;/code&gt; was already correct and already sitting in &lt;code&gt;ADMINS&lt;/code&gt;, there was nothing to add. I just needed to read from the setting that already existed.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Change
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;project/core/views.py&lt;/code&gt; — inside &lt;code&gt;ResumeDownloadView&lt;/code&gt;:&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;class&lt;/span&gt; &lt;span class="nc"&gt;ResumeDownloadView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;APIView&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;authentication_classes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="n"&gt;permission_classes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;

    &lt;span class="k"&gt;def&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;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;query_params&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;token&lt;/span&gt;&lt;span class="sh"&gt;"&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="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&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;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;message&lt;/span&gt;&lt;span class="sh"&gt;"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;errors&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&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;token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&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;This field is required.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]},&lt;/span&gt;
                &lt;span class="p"&gt;},&lt;/span&gt;
                &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HTTP_400_BAD_REQUEST&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;signer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;signing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;TimestampSigner&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="bp"&gt;None&lt;/span&gt;
        &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;contextlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;suppress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;signing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SignatureExpired&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;signing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BadSignature&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="n"&gt;signer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unsign_object&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_age&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;RESUME_TOKEN_MAX_AGE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&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="n"&gt;signer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unsign_object&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&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;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;message&lt;/span&gt;&lt;span class="sh"&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;Download link expired. Please request a new one.&lt;/span&gt;&lt;span class="sh"&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;errors&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="p"&gt;},&lt;/span&gt;
                    &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HTTP_410_GONE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;signing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BadSignature&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&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;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;message&lt;/span&gt;&lt;span class="sh"&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;Invalid download token.&lt;/span&gt;&lt;span class="sh"&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;errors&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="p"&gt;},&lt;/span&gt;
                    &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HTTP_400_BAD_REQUEST&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;resume&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Resume&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pk&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;data&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;pk&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;first&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;resume&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;resume&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&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;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;message&lt;/span&gt;&lt;span class="sh"&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;Resume not found.&lt;/span&gt;&lt;span class="sh"&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;errors&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
                &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HTTP_404_NOT_FOUND&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_notify_owner&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&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;email&lt;/span&gt;&lt;span class="sh"&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;unknown&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;HttpResponseRedirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resume&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pdf&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;def&lt;/span&gt; &lt;span class="nf"&gt;_notify_owner&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;downloader_email&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="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;recipients&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;admin_email&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;admin_email&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ADMINS&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;recipients&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;body&lt;/span&gt; &lt;span class="o"&gt;=&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;Someone just downloaded your resume.&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="sh"&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;Email: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;downloader_email&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&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;Time: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;strftime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;%Y-%m-%d %H&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="n"&gt;M&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="n"&gt;S&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="n"&gt;Z&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&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;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;contextlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;suppress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="nf"&gt;send_mail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Resume downloaded&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;from_email&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DEFAULT_FROM_EMAIL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;recipient_list&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;recipients&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;fail_silently&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&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;Same pattern as the existing &lt;code&gt;_send_download_email&lt;/code&gt; right above it in the file: plain &lt;code&gt;send_mail&lt;/code&gt;, wrapped in &lt;code&gt;contextlib.suppress(Exception)&lt;/code&gt;, &lt;code&gt;fail_silently=True&lt;/code&gt;. If Mailgun hiccups, the downloader still gets their redirect — a failed notification email should never block the actual download.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;recipients&lt;/code&gt; iterates all of &lt;code&gt;ADMINS&lt;/code&gt; rather than hardcoding &lt;code&gt;settings.ADMINS[0][1]&lt;/code&gt;, so adding a second admin later needs zero changes here.&lt;/p&gt;

&lt;p&gt;One import needed adding — &lt;code&gt;django.utils.timezone&lt;/code&gt;, for the timestamp in the notification body.&lt;/p&gt;




&lt;h2&gt;
  
  
  Tests
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;project/core/tests.py&lt;/code&gt; had zero coverage for the resume download flow before this — the gate shipped without tests originally. Added a fixture for a &lt;code&gt;Resume&lt;/code&gt; with a real uploaded file, a fixture for a signed token, and a test class covering the redirect, the notification, and the existing error paths:&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;datetime&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pytest&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.core&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;signing&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.core.files.uploadedfile&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;SimpleUploadedFile&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.urls&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;reverse&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;rest_framework.test&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;APIClient&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;project.blogs.models&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BlogPost&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;project.projects.models&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Project&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;project.services.models&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Service&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;.models&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;FAQ&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;.models&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Resume&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;.models&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Skill&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;.models&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Stat&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;.models&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Testimonial&lt;/span&gt;


&lt;span class="c1"&gt;# ---------------------------------------------------------------------------
# Resume download
# ---------------------------------------------------------------------------
&lt;/span&gt;

&lt;span class="nd"&gt;@pytest.fixture&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;resume&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&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;Resume&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;pdf&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;SimpleUploadedFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;resume.pdf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;%PDF-1.4 fake&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;content_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;application/pdf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="nd"&gt;@pytest.fixture&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;resume_token&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resume&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;signer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;signing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;TimestampSigner&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;signer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sign_object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pk&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;resume&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;email&lt;/span&gt;&lt;span class="sh"&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;downloader@example.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;


&lt;span class="nd"&gt;@pytest.mark.django_db&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TestResumeDownloadView&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_valid_token_redirects_to_pdf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&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;resume&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resume_token&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&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="nf"&gt;reverse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;api:resume-download&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;?token=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;resume_token&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;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;302&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_valid_token_notifies_owner&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&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;resume&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resume_token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mailoutbox&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="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;reverse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;api:resume-download&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;?token=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;resume_token&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;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mailoutbox&lt;/span&gt;&lt;span class="p"&gt;)&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;assert&lt;/span&gt; &lt;span class="n"&gt;mailoutbox&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="n"&gt;subject&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Resume downloaded&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;mailoutbox&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="n"&gt;to&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;me@example.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;downloader@example.com&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;mailoutbox&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="n"&gt;body&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_missing_token_returns_400&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&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;db&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&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="nf"&gt;reverse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;api:resume-download&lt;/span&gt;&lt;span class="sh"&gt;"&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;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_invalid_token_returns_400&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&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;db&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&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="nf"&gt;reverse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;api:resume-download&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;?token=garbage&lt;/span&gt;&lt;span class="sh"&gt;"&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;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_unknown_resume_pk_returns_404&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&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;db&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;signer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;signing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;TimestampSigner&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;signer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sign_object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pk&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;999&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;email&lt;/span&gt;&lt;span class="sh"&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;downloader@example.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&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="nf"&gt;reverse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;api:resume-download&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;?token=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;token&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;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;mailoutbox&lt;/code&gt; is a &lt;code&gt;pytest-django&lt;/code&gt; fixture — it's Django's test &lt;code&gt;EmailBackend&lt;/code&gt; capturing every send in a list, no mocking required. Asserting on &lt;code&gt;mailoutbox[0].to&lt;/code&gt; doubles as a regression check: if &lt;code&gt;ADMINS&lt;/code&gt; ever gets edited back to a placeholder, this test fails immediately instead of silently emailing nobody.&lt;/p&gt;

&lt;p&gt;Ran inside the project's Docker dev container, matching how every other management command in this repo gets run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; docker-compose.local.yml run &lt;span class="nt"&gt;--rm&lt;/span&gt; django pytest project/core/tests.py &lt;span class="nt"&gt;-k&lt;/span&gt; Resume &lt;span class="nt"&gt;-v&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;project/core/tests.py::TestResumeDownloadView::test_valid_token_redirects_to_pdf PASSED
project/core/tests.py::TestResumeDownloadView::test_valid_token_notifies_owner PASSED
project/core/tests.py::TestResumeDownloadView::test_missing_token_returns_400 PASSED
project/core/tests.py::TestResumeDownloadView::test_invalid_token_returns_400 PASSED
project/core/tests.py::TestResumeDownloadView::test_unknown_resume_pk_returns_404 PASSED

5 passed in 5.31s
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Batch the notification if downloads spike.&lt;/strong&gt; Right now every single download sends a separate email. Fine at current traffic; if this ever got linked from somewhere with real volume, I'd want a short debounce window rather than an inbox flood.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Move the send off the request path.&lt;/strong&gt; Same tradeoff as the original gate's &lt;code&gt;send_mail&lt;/code&gt; call — it's synchronous, so the redirect waits on the SMTP round trip to Mailgun. No Celery in this project yet, so for now it's a few hundred milliseconds tacked onto every download. Worth revisiting if Celery ever gets added for other reasons.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Double-check &lt;code&gt;ADMINS&lt;/code&gt; before trusting it elsewhere.&lt;/strong&gt; This whole feature nearly took a wrong turn because a real address happened to look exactly like unfinished scaffolding. Worth a settings comment noting &lt;code&gt;me@example.com&lt;/code&gt; is a live catch-all, not a TODO.&lt;/p&gt;

</description>
      <category>django</category>
      <category>python</category>
      <category>testing</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
