<?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: MarcoBlch</title>
    <description>The latest articles on DEV Community by MarcoBlch (@marcoblch).</description>
    <link>https://dev.to/marcoblch</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%2F4112464%2F71d50e82-606f-4c4a-98bc-5443c44d14ac.jpg</url>
      <title>DEV Community: MarcoBlch</title>
      <link>https://dev.to/marcoblch</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/marcoblch"/>
    <language>en</language>
    <item>
      <title>My Stripe Webhooks Were Failing 96% of the Time</title>
      <dc:creator>MarcoBlch</dc:creator>
      <pubDate>Tue, 22 Sep 2026 19:05:54 +0000</pubDate>
      <link>https://dev.to/marcoblch/my-stripe-webhooks-were-failing-96-of-the-time-2pdo</link>
      <guid>https://dev.to/marcoblch/my-stripe-webhooks-were-failing-96-of-the-time-2pdo</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published on &lt;a href="https://meridianbuild.dev/blog/my-stripe-webhooks-were-failing-96-percent/" rel="noopener noreferrer"&gt;meridianbuild.dev&lt;/a&gt;, my engineering blog where I write up the real bugs and decisions behind the products I build.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;FastPass only works if money moves correctly. A sender pays, the money sits in escrow, and a Stripe webhook is the thing that tells my backend the payment actually happened. So when I finally opened the webhook logs one evening, my stomach dropped. Around 96 percent of them were failing. Money was landing in Stripe and my app had no idea.&lt;/p&gt;

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

&lt;p&gt;&lt;a href="https://fastpass.email" rel="noopener noreferrer"&gt;FastPass&lt;/a&gt; is a pay to reach platform. Someone pays to guarantee a reply from a busy person, the money is held in escrow, and it gets released or refunded based on what happens next. Stripe and Stripe Connect handle the money and the 75/25 split between the recipient and the platform. Every state change, a payment captured, a transfer made, a refund issued, arrives as a Stripe webhook. If the webhooks do not land, the whole machine freezes.&lt;/p&gt;

&lt;p&gt;The webhook lives in a Supabase Edge Function, which runs on Deno, not Node. Hold that thought.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug
&lt;/h2&gt;

&lt;p&gt;Every webhook starts by verifying Stripe's signature, so I know the call is really from Stripe and not a forged request. The textbook line looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webhooks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;constructEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;webhookSecret&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That line was fine on my laptop. In production it threw on every real event. Verification failed, so I rejected the event, so the payment update never ran. The dashboard showed green deploys, Stripe showed successful charges, and my database quietly fell behind reality.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;constructEvent&lt;/code&gt; verifies the signature with an HMAC. In Node, that HMAC is computed synchronously. But Deno, and every edge runtime (Vercel Edge, Cloudflare Workers, Supabase Functions), only gives you the Web Crypto API, and Web Crypto is asynchronous. The synchronous helper has no synchronous crypto to call, so it simply cannot do the work. It does not warn you. It throws, and you are left blaming your webhook secret.&lt;/p&gt;

&lt;p&gt;So "96 percent of my webhooks are invalid" was never a Stripe problem and never a security problem. It was me calling a synchronous function in a place where crypto only exists in async form.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix is one word
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webhooks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;constructEventAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;webhookSecret&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;constructEventAsync&lt;/code&gt; reaches for the Web Crypto API, awaits it, and returns the verified event. Same inputs, same security, one &lt;code&gt;await&lt;/code&gt;. The moment it shipped, signature verification went green and the backlog of stuck payments started clearing.&lt;/p&gt;

&lt;p&gt;Two things I added while I was in there:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Idempotency.&lt;/strong&gt; Stripe retries webhooks, and now that they were actually being accepted, I did not want the same event processed twice. A double payout is a very direct way to lose money. Before doing anything, I check a &lt;code&gt;webhook_events&lt;/code&gt; table for the event id and skip if I have seen it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;seen&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;webhook_events&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;event_id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;single&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seen&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;ok&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;skipped&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="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;already_processed&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;&lt;strong&gt;Return 200 even on a rejected signature.&lt;/strong&gt; Stripe retries anything that is not a 2xx, aggressively. During the incident that meant retry storms piling onto a broken endpoint. Now a bad signature is logged and answered with 200, so Stripe stops hammering while I investigate. It is one of the rare cases where a 200 on a failure is correct, because the alternative is a self inflicted denial of service.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;If you move a webhook, or any signature check, into an edge or Deno runtime and it starts failing for no obvious reason, suspect the crypto before you suspect your secret. Edge runtimes expose Web Crypto, which is async only. SDKs that grew up on Node ship synchronous helpers that quietly do not work there. The fix is almost always the same shape: find the &lt;code&gt;...Async&lt;/code&gt; variant and await it. Stripe has &lt;code&gt;constructEventAsync&lt;/code&gt;, and most libraries have an equivalent once you go looking.&lt;/p&gt;

&lt;p&gt;The scariest bugs are the ones where nothing crashes. My app was up, Stripe was happy, money was moving, and the only symptom was a number in a log I had not thought to read. I hit the mirror image of this on the same project, &lt;a href="https://meridianbuild.dev/blog/sitemap-advertised-11-pages-site-had-56/" rel="noopener noreferrer"&gt;a sitemap that quietly advertised the wrong pages&lt;/a&gt;, where everything returned 200 and was still wrong.&lt;/p&gt;

&lt;p&gt;If you want to see what it powers: FastPass lets you &lt;a href="https://fastpass.email/get-paid-to-answer-messages" rel="noopener noreferrer"&gt;get paid to answer messages&lt;/a&gt; instead of drowning in a free inbox.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>testing</category>
    </item>
    <item>
      <title>Why Every Funnel Event in My Analytics Read Zero</title>
      <dc:creator>MarcoBlch</dc:creator>
      <pubDate>Tue, 22 Sep 2026 12:35:00 +0000</pubDate>
      <link>https://dev.to/marcoblch/why-every-funnel-event-in-my-analytics-read-zero-1ed0</link>
      <guid>https://dev.to/marcoblch/why-every-funnel-event-in-my-analytics-read-zero-1ed0</guid>
      <description>&lt;p&gt;📖 &lt;em&gt;Originally published on &lt;a href="https://meridianbuild.dev/blog/why-every-funnel-event-read-zero/" rel="noopener noreferrer"&gt;meridianbuild.dev&lt;/a&gt; my engineering blog documenting the real bugs and decisions behind &lt;a href="https://outfitmaker.ai" rel="noopener noreferrer"&gt;OutfitMaker&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The worst kind of broken instrumentation is the kind that reports a number. A blank dashboard makes you check the wiring. A dashboard full of zeros makes you think your product is just quiet.&lt;/p&gt;

&lt;p&gt;For as long as I'd had funnel tracking in &lt;a href="https://outfitmaker.ai" rel="noopener noreferrer"&gt;OutfitMaker&lt;/a&gt;, every custom event I cared about read zero. Signup: 0. Activation: 0. First Item Uploaded: 0. The onboarding-choice events: 0. Meanwhile pageviews ticked up normally and real people were clearly signing up, uploading clothes, and getting outfit suggestions. I'd half-convinced myself the funnel was just thin. It wasn't thin. It was lying.&lt;/p&gt;

&lt;p&gt;Here's the teardown. It took two PRs and turned out to be four separate bugs stacked on top of each other.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the events were supposed to fire
&lt;/h2&gt;

&lt;p&gt;OutfitMaker runs &lt;a href="https://outfitmaker.ai" rel="noopener noreferrer"&gt;Kaunta&lt;/a&gt;, a self-hosted, privacy-friendly analytics script, loaded once per page. Automatic pageviews work out of the box. For funnel milestones I used a server-driven pattern: a controller sets &lt;code&gt;flash[:analytics_event] = "Signup"&lt;/code&gt;, and the layout turns that flash into a &lt;code&gt;kaunta.track()&lt;/code&gt; call on the next page.&lt;/p&gt;

&lt;p&gt;The layout snippet looked like this and it had been copy-pasted into three layouts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight erb"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;kaunta_script_url&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;
  &lt;span class="na"&gt;data-website-id=&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;kaunta_website_id&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;
  &lt;span class="na"&gt;async&lt;/span&gt; &lt;span class="na"&gt;defer&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;

&lt;span class="cp"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;flash&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:analytics_event&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;present?&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;script&amp;gt;&lt;/span&gt;
  &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;DOMContentLoaded&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kaunta&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kaunta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;track&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;flash&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:analytics_event&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="no"&gt;I18n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;locale&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;span class="cp"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="k"&gt;end&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It reads fine. It is also wrong in three independent ways, each one enough on its own to zero out the event.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 1: Turbo never re-fires DOMContentLoaded
&lt;/h2&gt;

&lt;p&gt;Every one of these milestones arrives on a page reached by a &lt;code&gt;redirect_to&lt;/code&gt; after a form POST. You submit the signup form, the server creates the user, sets the flash, and redirects you to the next page. In a &lt;a href="https://meridianbuild.dev/blog/hotwire-over-react-for-a-solo-dev/" rel="noopener noreferrer"&gt;Hotwire app&lt;/a&gt; that redirect is a &lt;strong&gt;Turbo visit&lt;/strong&gt;, not a full document load.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;DOMContentLoaded&lt;/code&gt; fires exactly once, on a cold load. Turbo swaps the &lt;code&gt;&amp;lt;body&amp;gt;&lt;/code&gt; and does &lt;strong&gt;not&lt;/strong&gt; re-evaluate &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; scripts on navigation. So on the page that actually carried the flash, the listener was being registered after the event it was waiting for had already passed — and then never ran again. The &lt;code&gt;track()&lt;/code&gt; call was real, reachable code that simply never executed.&lt;/p&gt;

&lt;p&gt;This is the same family of silent Turbo failure I &lt;a href="https://meridianbuild.dev/blog/duplicate-turbo-frame-bug-broke-three-features/" rel="noopener noreferrer"&gt;wrote about with duplicate frame ids&lt;/a&gt;: nothing throws, the HTTP response is a clean 200, and the gap is a layer of intent the framework doesn't share with you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 2: the async race
&lt;/h2&gt;

&lt;p&gt;Suppose the page &lt;em&gt;did&lt;/em&gt; cold-load a direct visit, say. The Kaunta loader is &lt;code&gt;async defer&lt;/code&gt;, so &lt;code&gt;window.kaunta&lt;/code&gt; is usually still undefined when &lt;code&gt;DOMContentLoaded&lt;/code&gt; runs. The guard:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kaunta&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* track */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;is a coin flip you lose most of the time. When the script wasn't ready, the event was dropped on the floor with no retry. No error, no queue, nothing. So even the rare cold-load path was unreliable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 3: the layout the funnel actually lands on
&lt;/h2&gt;

&lt;p&gt;The snippet lived in the &lt;code&gt;application&lt;/code&gt;, &lt;code&gt;landing&lt;/code&gt;, and &lt;code&gt;blog&lt;/code&gt; layouts. But a successful signup redirects to &lt;code&gt;onboarding_path&lt;/code&gt;, which renders under the &lt;strong&gt;&lt;code&gt;onboarding&lt;/code&gt; layout&lt;/strong&gt; and that layout had no Kaunta at all. Not the loader, not the trigger.&lt;/p&gt;

&lt;p&gt;So for the single most important event in the whole funnel, &lt;code&gt;Signup&lt;/code&gt;, all three bugs were academic. The page it landed on couldn't have tracked anything even if the script and the timing had been perfect. &lt;code&gt;Signup&lt;/code&gt; wasn't unreliable. It was structurally impossible.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: fire from Stimulus, share one partial
&lt;/h2&gt;

&lt;p&gt;The repair (&lt;code&gt;9dec828&lt;/code&gt;, PR #85) moves the trigger out of the &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; and into a Stimulus controller that runs from the &lt;code&gt;&amp;lt;body&amp;gt;&lt;/code&gt;. Stimulus &lt;code&gt;connect()&lt;/code&gt; runs on &lt;strong&gt;every&lt;/strong&gt; Turbo navigation, which kills Bug 1. And instead of a one-shot guard, it polls for &lt;code&gt;window.kaunta&lt;/code&gt;, which kills Bug 2:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Controller&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@hotwired/stimulus&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;extends&lt;/span&gt; &lt;span class="nx"&gt;Controller&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="nx"&gt;values&lt;/span&gt; &lt;span class="o"&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="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;props&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;default&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="nf"&gt;connect&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="o"&gt;!&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nameValue&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="c1"&gt;// Skip Turbo's cached *preview* render so a snapshot that still&lt;/span&gt;
    &lt;span class="c1"&gt;// contains this element doesn't double-fire on the preview pass.&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;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;documentElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hasAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;data-turbo-preview&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fired&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForKaunta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// ~5s max (25 × 200ms)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nf"&gt;waitForKaunta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;attemptsLeft&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;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fired&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kaunta&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kaunta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;track&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;function&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fired&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
      &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kaunta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;track&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nameValue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;propsValue&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;attemptsLeft&lt;/span&gt; &lt;span class="o"&gt;&amp;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;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pollTimer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForKaunta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;attemptsLeft&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;200&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;data-turbo-preview&lt;/code&gt; check is the kind of detail you only learn by getting it wrong: Turbo renders a cached preview of a page before the fresh version arrives, and if your tracking element is in that snapshot it'll fire on the preview too. Bail on the preview pass; fire on the real render.&lt;/p&gt;

&lt;p&gt;The flash is surfaced by a tiny partial that just mounts the controller:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight erb"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;flash&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:analytics_event&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;present?&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;hidden&lt;/span&gt;
       &lt;span class="na"&gt;data-controller=&lt;/span&gt;&lt;span class="s"&gt;"kaunta-event"&lt;/span&gt;
       &lt;span class="na"&gt;data-kaunta-event-name-value=&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;flash&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:analytics_event&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;
       &lt;span class="na"&gt;data-kaunta-event-props-value=&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;locale: &lt;/span&gt;&lt;span class="no"&gt;I18n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;locale&lt;/span&gt; &lt;span class="p"&gt;}.&lt;/span&gt;&lt;span class="nf"&gt;to_json&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;span class="cp"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="k"&gt;end&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bug 3 gets fixed by construction: the loader and the trigger become two shared partials (&lt;code&gt;shared/_kaunta_head&lt;/code&gt; and &lt;code&gt;shared/_kaunta_event&lt;/code&gt;), rendered in &lt;strong&gt;all four&lt;/strong&gt; user-facing layouts &lt;code&gt;application&lt;/code&gt;, &lt;code&gt;landing&lt;/code&gt;, &lt;code&gt;blog&lt;/code&gt;, and the previously-naked &lt;code&gt;onboarding&lt;/code&gt;. That deletes the three-way copy-paste and closes the gap in the same move. Three layouts each lost ~30 lines.&lt;/p&gt;

&lt;p&gt;I can't run the suite on the box I write these from (no Ruby, no Node it &lt;a href="https://meridianbuild.dev/blog/rails-8-migration-on-a-live-product/" rel="noopener noreferrer"&gt;validates on deploy&lt;/a&gt;), so I locked the server side of the contract with an integration test instead of trusting the browser behaviour to a comment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="s2"&gt;"first item upload emits the kaunta-event trigger with the event name"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;post&lt;/span&gt; &lt;span class="n"&gt;wardrobe_items_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;params: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;wardrobe_item: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;image: &lt;/span&gt;&lt;span class="n"&gt;image&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="n"&gt;follow_redirect!&lt;/span&gt;

  &lt;span class="n"&gt;assert_select&lt;/span&gt; &lt;span class="s2"&gt;"[data-controller='kaunta-event']"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;count: &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
  &lt;span class="n"&gt;assert_select&lt;/span&gt; &lt;span class="s2"&gt;"[data-kaunta-event-name-value='First Item Uploaded']"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;count: &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;

  &lt;span class="c1"&gt;# Regression guard: the old, broken inline tracker must be gone.&lt;/span&gt;
  &lt;span class="n"&gt;assert_no_match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/addEventListener\(['"]DOMContentLoaded['"]/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;"flash analytics event must not be tracked via a DOMContentLoaded script"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;assert_no_match&lt;/code&gt; is the part I care about most. It's not testing what the page does; it's testing that the broken pattern can never come back through a careless copy-paste.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 4: Signup was still zero
&lt;/h2&gt;

&lt;p&gt;I deployed, watched &lt;code&gt;First Item Uploaded&lt;/code&gt; and the onboarding-choice events start landing events that had &lt;strong&gt;never&lt;/strong&gt; been anything but zero and felt good about it. Then I looked again the next day. &lt;code&gt;Signup&lt;/code&gt; was still zero.&lt;/p&gt;

&lt;p&gt;The first three bugs were dead. This was a fourth one wearing their clothes.&lt;/p&gt;

&lt;p&gt;The cause: &lt;strong&gt;100% of OutfitMaker's signups come through Google OAuth.&lt;/strong&gt; Nobody uses the email/password form. And the &lt;code&gt;Signup&lt;/code&gt; event was only ever set in &lt;code&gt;RegistrationsController#create&lt;/code&gt; the email/password path. The OAuth path, &lt;code&gt;OmniauthCallbacksController&lt;/code&gt;, set no event and, it turned out, sent no admin notification either. Which also explained a separate mystery: I never got an email when someone signed up. I'd been blind to my own new users because I'd only instrumented the door nobody walks through.&lt;/p&gt;

&lt;p&gt;The fix (&lt;code&gt;dca116a&lt;/code&gt;, PR #88) pulls the side effects into a shared concern both controllers include:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;SignupSideEffects&lt;/span&gt;
  &lt;span class="kp"&gt;extend&lt;/span&gt; &lt;span class="no"&gt;ActiveSupport&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Concern&lt;/span&gt;

  &lt;span class="kp"&gt;private&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;handle_new_signup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:locale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;I18n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;flash&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:analytics_event&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Signup"&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="no"&gt;Rails&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test?&lt;/span&gt;

    &lt;span class="no"&gt;UserMailer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;welcome_email&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;deliver_later&lt;/span&gt;
    &lt;span class="no"&gt;AdminMailer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new_user_signup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;deliver_later&lt;/span&gt; &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;admin?&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two subtleties in the OAuth side made this more than a one-liner.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Telling a new signup from a returning login.&lt;/strong&gt; &lt;code&gt;from_omniauth&lt;/code&gt; both creates new users and logs in existing ones, and it'll also link OAuth to an existing email account. Only a genuinely new record should fire &lt;code&gt;Signup&lt;/code&gt;. Rails 7's &lt;code&gt;previously_new_record?&lt;/code&gt; is exactly that signal true only on the request that created the row:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="vi"&gt;@user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;previously_new_record?&lt;/span&gt;
  &lt;span class="n"&gt;handle_new_signup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="vi"&gt;@user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;sign_in&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="vi"&gt;@user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;event: :authentication&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;redirect_to&lt;/span&gt; &lt;span class="n"&gt;onboarding_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;step: &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;
  &lt;span class="n"&gt;flash&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:notice&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"flash.omniauth.success"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;provider: &lt;/span&gt;&lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;sign_in_and_redirect&lt;/span&gt; &lt;span class="vi"&gt;@user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;event: :authentication&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Flash survives exactly one redirect.&lt;/strong&gt; Devise's &lt;code&gt;sign_in_and_redirect&lt;/code&gt; would send a new user to root, which then redirects to onboarding two hops. &lt;code&gt;flash[:analytics_event]&lt;/code&gt; only survives one. The second redirect would silently eat it, and I'd be right back to a zeroed &lt;code&gt;Signup&lt;/code&gt; for a subtly different reason. So the new-signup branch redirects &lt;strong&gt;straight&lt;/strong&gt; to onboarding in a single hop, and the flash lives long enough for the Stimulus trigger to fire.&lt;/p&gt;

&lt;p&gt;I covered the new-vs-returning logic at the model level too, because it's the load-bearing assumption a brand-new user, a returning user, and an OAuth-linked existing account each get their own test asserting whether &lt;code&gt;previously_new_record?&lt;/code&gt; is set.&lt;/p&gt;

&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;After the second deploy, Kaunta started showing real signups where there had only ever been a flat line and the rest of the chain (Activation, the post-wow upgrade prompts) lit up with it. OutfitMaker is small enough that these are modest counts, but the point isn't the magnitude. It's that the dashboard finally describes the product instead of contradicting it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;Instrumentation that silently reports zero is worse than none, because you act on it. I spent real time wondering why my "funnel was thin" when the funnel was fine and the funnel &lt;em&gt;meter&lt;/em&gt; was unplugged. Two rules I'd hand my past self:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;In a Turbo app, never fire analytics from a &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; &lt;code&gt;DOMContentLoaded&lt;/code&gt; script.&lt;/strong&gt; Your most important events arrive on Turbo visits, where that listener is already too late. Fire from a Stimulus &lt;code&gt;connect()&lt;/code&gt; and wait for your script to be ready instead of guarding once and giving up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A funnel's top must cover every path into it.&lt;/strong&gt; I instrumented the signup flow I wrote first and assumed it was &lt;em&gt;the&lt;/em&gt; signup flow. The one my users actually used was untouched. Count the doors before you trust the turnstile.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The quiet bugs are the expensive ones. A crash gets fixed the same day. A zero that should be a number can sit there for as long as you're willing to believe your product is just quiet.&lt;/p&gt;

</description>
      <category>rails</category>
      <category>frontend</category>
      <category>bugs</category>
      <category>webdev</category>
    </item>
    <item>
      <title>My AI Was Blind in Production for Weeks and Nothing Crashed</title>
      <dc:creator>MarcoBlch</dc:creator>
      <pubDate>Fri, 11 Sep 2026 08:25:35 +0000</pubDate>
      <link>https://dev.to/marcoblch/my-ai-was-blind-in-production-for-weeks-and-nothing-crashed-1oi1</link>
      <guid>https://dev.to/marcoblch/my-ai-was-blind-in-production-for-weeks-and-nothing-crashed-1oi1</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;📖 &lt;em&gt;Originally published on &lt;a href="https://meridianbuild.dev/blog/my-ai-was-blind-in-production/" rel="noopener noreferrer"&gt;meridianbuild.dev&lt;/a&gt; my engineering blog documenting the real bugs and decisions behind &lt;a href="https://outfitmaker.ai" rel="noopener noreferrer"&gt;OutfitMaker&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The whole pitch of &lt;a href="https://outfitmaker.ai" rel="noopener noreferrer"&gt;OutfitMaker&lt;/a&gt;'s outfit suggestions is that the AI &lt;em&gt;sees&lt;/em&gt; your wardrobe. Not a list of tags like "blue oxford shirt, brown chinos"  the actual photos of your actual garments, so Gemini can reason about color, texture, cut and how two specific pieces look together. That's the difference between a suggestion engine and a search filter, and it's the &lt;a href="https://meridianbuild.dev/blog/multimodal-ai-for-outfit-suggestions/" rel="noopener noreferrer"&gt;multimodal path I built the feature around&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;For a few weeks in production, it wasn't seeing anything at all.&lt;/p&gt;

&lt;p&gt;The feature still worked. Users opened the app, asked for an outfit, and got outfits back. Nothing errored. Nothing 500'd. Sentry was quiet. The only thing wrong was that every suggestion was being generated by a model that had received exactly zero images it was captioning off the text labels and nothing else. My "multimodal AI" was, in the most literal sense, blind, and the app was too polite to tell me.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one log line that gave it away
&lt;/h2&gt;

&lt;p&gt;I wasn't hunting for this. I was reading production logs for something unrelated when the same line kept scrolling past:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Multimodal suggestion: 0/25 items with images
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Twenty-five items in the user's wardrobe, zero of them reaching Gemini with a photo attached. Every request. Right above it, the reason:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;Failed to encode image &lt;span class="k"&gt;for &lt;/span&gt;item 4867:
&lt;span class="sb"&gt;`&lt;/span&gt;identify &lt;span class="nt"&gt;-format&lt;/span&gt; %m %w %h %b ...&lt;span class="sb"&gt;`&lt;/span&gt; failed with status: 127
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;status: 127&lt;/code&gt; is the shell's way of saying &lt;em&gt;command not found&lt;/em&gt;. Whatever was trying to run &lt;code&gt;identify&lt;/code&gt; couldn't find &lt;code&gt;identify&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;identify&lt;/code&gt; is an ImageMagick binary. And that's the moment the whole thing clicked into place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two libraries that both do "images"
&lt;/h2&gt;

&lt;p&gt;OutfitMaker leans on two completely separate image toolchains, and I had stopped thinking of them as separate.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;libvips&lt;/strong&gt; (via &lt;code&gt;ruby-vips&lt;/code&gt;) powers Active Storage variants the thumbnails and resized wardrobe images the app serves all day.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ImageMagick&lt;/strong&gt; (via &lt;strong&gt;MiniMagick&lt;/strong&gt;) is what two specific services shell out to: &lt;code&gt;OutfitSuggestionService#encode_item_image&lt;/code&gt;, which measures and encodes a garment photo before handing it to Gemini, and &lt;code&gt;WatermarkService&lt;/code&gt;, which stamps shared Look Previews.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;MiniMagick doesn't bundle ImageMagick. It's a thin Ruby wrapper that shells out to the &lt;code&gt;identify&lt;/code&gt; and &lt;code&gt;convert&lt;/code&gt; binaries and expects them to be on the &lt;code&gt;PATH&lt;/code&gt;. If they're not there, it doesn't raise a nice &lt;code&gt;LoadError&lt;/code&gt; at boot it fails at call time, per image, with a shell exit code.&lt;/p&gt;

&lt;p&gt;Here's the part that stung: this is invisible to your &lt;code&gt;Gemfile&lt;/code&gt;. &lt;code&gt;bundle install&lt;/code&gt; is perfectly happy. &lt;code&gt;MiniMagick&lt;/code&gt; the gem is installed. The dependency that's actually missing lives one layer down, in the operating system, and nothing in Ruby's dependency graph knows or cares.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Dockerfile was telling the truth; I just hadn't read it
&lt;/h2&gt;

&lt;p&gt;The runtime stage of the multi-stage Dockerfile installed exactly what Active Storage needed and nothing more:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;RUN &lt;/span&gt;apt-get update &lt;span class="nt"&gt;-qq&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;    apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--no-install-recommends&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; curl libvips postgresql-client python3 libgomp1 &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; /var/lib/apt/lists /var/cache/apt/archives
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;libvips&lt;/code&gt; is there. &lt;code&gt;imagemagick&lt;/code&gt; is not. So in the production container, &lt;code&gt;identify&lt;/code&gt; genuinely did not exist.&lt;/p&gt;

&lt;p&gt;Why didn't I catch it in development? Because my laptop has ImageMagick installed the way most dev machines accumulate it — from some Homebrew formula years ago, pulled in as a transitive dependency of something else. Locally, &lt;code&gt;identify&lt;/code&gt; was on the &lt;code&gt;PATH&lt;/code&gt;, MiniMagick was happy, images encoded, everything looked fine. The bug only existed in the one environment I couldn't see into by running &lt;code&gt;identify&lt;/code&gt; in a terminal: the slim production image.&lt;/p&gt;

&lt;p&gt;This is the recurring lesson of shipping Rails in Docker. Your production runtime is a &lt;em&gt;separate contract&lt;/em&gt; from your Gemfile, and the slimmer you make the image, the more of that contract you're signing implicitly. Every &lt;code&gt;--no-install-recommends&lt;/code&gt; is a small bet that you listed everything you actually need.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix is one word
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt; RUN apt-get update -qq &amp;amp;&amp;amp; \
&lt;span class="gd"&gt;-    apt-get install --no-install-recommends -y curl libvips postgresql-client python3 libgomp1 &amp;amp;&amp;amp; \
&lt;/span&gt;&lt;span class="gi"&gt;+    apt-get install --no-install-recommends -y curl imagemagick libvips postgresql-client python3 libgomp1 &amp;amp;&amp;amp; \
&lt;/span&gt;     rm -rf /var/lib/apt/lists /var/cache/apt/archives
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the entire change in PR #81 (&lt;code&gt;b57fb72&lt;/code&gt;). I checked on &lt;code&gt;ruby:3.3.5-slim&lt;/code&gt; first that &lt;code&gt;apt-get install imagemagick&lt;/code&gt; actually provides &lt;code&gt;identify&lt;/code&gt; (ImageMagick 6.9.11, about 30MB with dependencies), deployed, and watched the log line flip from &lt;code&gt;0/25&lt;/code&gt; to items actually carrying image data into the model. Watermarking, which was silently broken the same way, came back at the same time.&lt;/p&gt;

&lt;p&gt;One word in a Dockerfile. Weeks of a flagship feature quietly running at a fraction of its quality.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually took away
&lt;/h2&gt;

&lt;p&gt;The one-line fix is not the interesting part. Three things are.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Silent degradation is worse than a crash.&lt;/strong&gt; A crash gets a Sentry alert, a red graph, a bug report. This got none of that, because from the outside the feature "worked" it returned outfits. The failure was in &lt;em&gt;quality&lt;/em&gt;, which no exception tracker measures. If I'd had a single assertion that treated &lt;code&gt;0/25 items with images&lt;/code&gt; as an error condition instead of an info log, I'd have known in minutes, not weeks. Degraded-but-successful is the hardest failure mode to see, and it's exactly the one AI features love to hit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Shell-out dependencies need a home in your health checks.&lt;/strong&gt; A gem that wraps a system binary is a dependency your language tooling can't verify. The honest fix isn't just "add the package" it's making the runtime prove it has what it needs. A boot-time check that &lt;code&gt;identify&lt;/code&gt; exists would have turned a silent production degradation into a failed deploy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The real cleanup is to not need ImageMagick at all.&lt;/strong&gt; &lt;code&gt;libvips&lt;/code&gt; was already in the image for Active Storage, and it can do everything those two MiniMagick call sites need. The follow-up isn't to keep patching the runtime it's to migrate &lt;code&gt;encode_item_image&lt;/code&gt; and &lt;code&gt;WatermarkService&lt;/code&gt; off MiniMagick and onto &lt;code&gt;ruby-vips&lt;/code&gt;, so the dependency I forgot to install becomes a dependency I no longer have. The best way to stop forgetting a thing is to delete the thing.&lt;/p&gt;

&lt;p&gt;If you run a multimodal feature in production, go check your logs for the equivalent of &lt;code&gt;0/25&lt;/code&gt;. The model will never complain that you forgot to send it the picture. It'll just quietly do its best with half the input and hand you back something plausible which is the most dangerous thing an AI can do.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;More OutfitMaker war stories in the same vein: &lt;a href="https://meridianbuild.dev/blog/fashn-to-gemini-vertex-ai-cant-return-images/" rel="noopener noreferrer"&gt;the Gemini swap where Vertex refused to return images&lt;/a&gt;, and &lt;a href="https://meridianbuild.dev/blog/toctou-in-my-gemini-rate-limiter/" rel="noopener noreferrer"&gt;the TOCTOU race in my Gemini rate limiter&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>rails</category>
      <category>ruby</category>
    </item>
    <item>
      <title>My Sidekiq Worker OOM'd Four Times. I Fought It Four Ways Before Buying RAM</title>
      <dc:creator>MarcoBlch</dc:creator>
      <pubDate>Wed, 09 Sep 2026 07:40:19 +0000</pubDate>
      <link>https://dev.to/marcoblch/my-sidekiq-worker-oomd-four-times-i-fought-it-four-ways-before-buying-ram-35nh</link>
      <guid>https://dev.to/marcoblch/my-sidekiq-worker-oomd-four-times-i-fought-it-four-ways-before-buying-ram-35nh</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;📖 &lt;em&gt;Originally published on &lt;a href="https://meridianbuild.dev/blog/four-memory-defenses-against-a-sidekiq-oom/" rel="noopener noreferrer"&gt;meridianbuild.dev&lt;/a&gt; my engineering blog documenting the real bugs and decisions behind &lt;a href="https://outfitmaker.ai" rel="noopener noreferrer"&gt;OutfitMaker&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The feature that kept killing my background worker was the feature working exactly as designed.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://outfitmaker.ai" rel="noopener noreferrer"&gt;OutfitMaker&lt;/a&gt;'s strongest activation loop is what I call the closet dump: a new user photographs their whole wardrobe in one sitting and uploads it in a burst. When it fires, it's great — the app is now clearing something like 500 items a day. But each uploaded garment kicks off an &lt;code&gt;ImageAnalysisJob&lt;/code&gt;, and each of those does something quietly expensive: it shells out to &lt;a href="https://github.com/danielgatis/rembg" rel="noopener noreferrer"&gt;&lt;code&gt;rembg&lt;/code&gt;&lt;/a&gt; to strip the background, which spawns a Python subprocess that loads a ~170MB U2Net model into memory.&lt;/p&gt;

&lt;p&gt;One model load is fine. Five at once, on a small box, is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  OOM #1 the burst that killed the worker
&lt;/h2&gt;

&lt;p&gt;On August 20 the Sidekiq worker OOM-crashed mid-burst. A 70-item upload fanned out into &lt;code&gt;ImageAnalysisJob&lt;/code&gt;s, Sidekiq ran them at concurrency 5, and five simultaneous U2Net loads was roughly a gigabyte of model weights materializing at the same instant. The Linux OOM killer did what it does, the worker died, and every job in flight died with it — 610 items stranded in &lt;code&gt;pending&lt;/code&gt;, which I had to re-enqueue by hand in prod.&lt;/p&gt;

&lt;p&gt;The tempting fix is "lower the concurrency." I didn't reach for that first, because it would tax &lt;em&gt;every&lt;/em&gt; job to contain a problem that only &lt;em&gt;one&lt;/em&gt; job type causes. The other jobs in the queue are network-bound Gemini calls — they sit around waiting on I/O and use almost no memory. Throttling them to protect rembg is collateral damage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Defense 1: a Redis semaphore that caps rembg at one concurrent run.&lt;/strong&gt; A &lt;code&gt;SET NX EX&lt;/code&gt; lock (&lt;code&gt;rembg:exclusive&lt;/code&gt;) means only one background removal executes at a time; the other worker threads keep churning through the Gemini-bound jobs, so throughput barely moves while peak memory drops about 5×. The details that made it safe in production:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If a job waits past a 90-second budget for the lock, it &lt;strong&gt;skips&lt;/strong&gt; background removal. That's fine rembg has always been best-effort; callers already handle a &lt;code&gt;nil&lt;/code&gt; result, and &lt;code&gt;retry_analysis&lt;/code&gt; can add the cutout later.&lt;/li&gt;
&lt;li&gt;If Redis itself is unreachable, the job proceeds &lt;em&gt;unguarded&lt;/em&gt; rather than blocking analysis on a dead dependency.&lt;/li&gt;
&lt;li&gt;The lock carries a TTL, so a worker that dies holding it doesn't wedge the whole pipeline the lock just expires.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Twenty-one green test runs, shipped. Peak memory way down. Done, I thought.&lt;/p&gt;

&lt;h2&gt;
  
  
  OOM #2 the single run that was too big by itself
&lt;/h2&gt;

&lt;p&gt;Two days later, August 22, a &lt;em&gt;different&lt;/em&gt; OOM signature. The semaphore was working the logs showed lock-busy skips exactly as designed but now single, serialized rembg runs were being OOM-killed by the container's cgroup. The tell was a &lt;code&gt;Background Removal Failed:&lt;/code&gt; line with an &lt;strong&gt;empty stderr&lt;/strong&gt;, repeating every 30 seconds or so. Empty stderr means the process didn't error; it got killed.&lt;/p&gt;

&lt;p&gt;rembg's memory scales with input pixels. A modern phone shoots 12-megapixel photos, and pushing 12MP through U2Net inference spikes to multiple gigabytes &lt;em&gt;even one at a time&lt;/em&gt;. Serializing hadn't helped because the problem wasn't concurrency anymore; it was the size of a single input.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Defense 2: downscale the image to ≤1600px on the long side before handing it to rembg.&lt;/strong&gt; U2Net operates on roughly 320px internally feeding it a 12MP original buys you literally nothing in output quality. Downscaling first caps the memory spike, and as a bonus makes each run faster (which means fewer of those 90-second lock-busy skips). Small images pass through untouched; if the resize fails, it falls back to the original. Best-effort all the way down.&lt;/p&gt;

&lt;p&gt;This is the one I'd call a &lt;em&gt;real&lt;/em&gt; fix, not a workaround and at the time it felt like the end of it. My own commit note said it out loud: &lt;em&gt;"the memory-plan upgrade question: NOT needed — 8GB is ample once inputs are sane."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I was about to be proven half wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Defense 3 and OOM #4 the software ladder runs out
&lt;/h2&gt;

&lt;p&gt;There was a quieter third defense in between: setting &lt;code&gt;MALLOC_ARENA_MAX=2&lt;/code&gt;. Ruby under glibc will happily spin up a memory arena per thread, and the fragmentation adds up on a multi-threaded Sidekiq process. Pinning the arena count is a well-worn Rails-in-production knob that trims baseline bloat for free.&lt;/p&gt;

&lt;p&gt;And then, on August 23 at 19:48, OOM #4 with all three prior defenses live.&lt;/p&gt;

&lt;p&gt;The culprit this time was the one structural thing I'd been avoiding. Sidekiq queue &lt;em&gt;weights&lt;/em&gt; don't cap per-queue concurrency. During an upload burst, nothing stops all the worker threads from running &lt;code&gt;ImageAnalysisJob&lt;/code&gt; simultaneously, each holding a full-resolution image buffer in Ruby memory (phones are shooting 24MP now) &lt;em&gt;alongside&lt;/em&gt; the serialized rembg. The downscale protected the Python subprocess; it didn't protect the Ruby heap holding the originals.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Defense 4: drop Sidekiq concurrency from 5 to 3.&lt;/strong&gt; Three threads still clear ~500 items a day comfortably, because the jobs are Gemini-bound they spend most of their time waiting on the network, not computing. Bursts drain slower, but the worker survives them. I wrote the honest line into the config comment: &lt;em&gt;if OOM #5 happens past this, the software ladder is exhausted the Railway Pro upgrade becomes the correct spend.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That's the sentence that mattered. Four defenses in, I'd stopped pretending the box was big enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part nobody likes to write: I bought RAM
&lt;/h2&gt;

&lt;p&gt;Here's the thing the concurrency cut exposed. Dropping from 5 to 3 threads slowed burst draining &lt;em&gt;exactly during activation&lt;/em&gt; the closet-dump moment, which is the single most important thing a new user does. My memory defenses were now actively degrading the business outcome they existed to protect. A user dumps their whole closet, and my worker politely trickles through it three at a time because I was scared of an OOM.&lt;/p&gt;

&lt;p&gt;So on August 25 I moved the service to Railway Pro and verified a 32GB limit on both containers. Then I went back and &lt;strong&gt;undid the survival taxes&lt;/strong&gt; but only those:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Sidekiq concurrency went &lt;strong&gt;back to 5&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;The rembg lock became a &lt;strong&gt;2-slot&lt;/strong&gt; semaphore instead of one (a per-thread token with a compare-and-delete release via &lt;code&gt;EVAL&lt;/code&gt;, because &lt;code&gt;redis-client&lt;/code&gt; has no &lt;code&gt;eval&lt;/code&gt; sugar).&lt;/li&gt;
&lt;li&gt;The downscale-to-1600px and &lt;code&gt;MALLOC_ARENA_MAX=2&lt;/code&gt; &lt;strong&gt;stayed&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last line is the whole lesson. Speed restored, risk not re-introduced. 423 tests, zero failures.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd actually take from five days of this
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Separate the correctness fixes from the survival taxes.&lt;/strong&gt; Downscaling a 12MP image before a model that works at 320px is just &lt;em&gt;correct&lt;/em&gt; it should have been there from day one, and it stays forever. Capping concurrency to survive a too-small box is a &lt;em&gt;tax&lt;/em&gt;: it works, but it costs you exactly where you can least afford it, and you should undo it the moment you have headroom. When I got more RAM, I kept the first kind and reversed the second. If I'd treated them all as "the fix," I'd have paid the activation tax indefinitely for no reason.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A ladder of cheap fixes is the right instinct right up until it isn't.&lt;/strong&gt; Each defense was individually correct and individually shippable, and I'd make the same first three calls again. But there's a failure mode where you keep climbing the software ladder past the rung where the honest answer is "the machine is too small." I almost did. The tell was defense #4 starting to hurt the product. Buying RAM isn't an admission of failure; it's a line item, and sometimes it's cheaper than the fifth clever workaround plus the activation you're leaking while you build it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ML subprocesses are memory bombs, and your Ruby heap is a second one.&lt;/strong&gt; The rembg model got all my attention because it was the loud failure. But OOM #4 was Ruby holding full-resolution image buffers across five threads a cost that had nothing to do with Python. If you fan out image work in Sidekiq, both sides of the shell-out are spending memory, and only measuring one of them is how you get a fourth OOM after three fixes.&lt;/p&gt;

&lt;p&gt;If you want more of these, I've written about &lt;a href="https://meridianbuild.dev/blog/circuit-breakers-in-production-rails-patching-the-gem/" rel="noopener noreferrer"&gt;wrapping those same external calls in circuit breakers&lt;/a&gt; and &lt;a href="https://meridianbuild.dev/blog/toctou-in-my-gemini-rate-limiter/" rel="noopener noreferrer"&gt;the concurrency race hiding in my Gemini rate limiter&lt;/a&gt; the same worker, different ways to make it hurt.&lt;/p&gt;

</description>
      <category>rails</category>
      <category>bugs</category>
      <category>architecture</category>
    </item>
    <item>
      <title>I Added Circuit Breakers to a Rails App and Had to Patch the Gem Twice</title>
      <dc:creator>MarcoBlch</dc:creator>
      <pubDate>Sun, 06 Sep 2026 15:53:24 +0000</pubDate>
      <link>https://dev.to/marcoblch/i-added-circuit-breakers-to-a-rails-app-and-had-to-patch-the-gem-twice-4fja</link>
      <guid>https://dev.to/marcoblch/i-added-circuit-breakers-to-a-rails-app-and-had-to-patch-the-gem-twice-4fja</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;📖 &lt;em&gt;Originally published on &lt;a href="https://meridianbuild.dev/blog/circuit-breakers-in-production-rails-patching-the-gem/" rel="noopener noreferrer"&gt;meridianbuild.dev&lt;/a&gt; my engineering blog documenting the real bugs and decisions behind &lt;a href="https://outfitmaker.ai" rel="noopener noreferrer"&gt;OutfitMaker&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://outfitmaker.ai" rel="noopener noreferrer"&gt;OutfitMaker&lt;/a&gt; leans on a lot of external services. Outfit suggestions, wardrobe image analysis, missing-item detection and trip planning all call Gemini through Vertex AI. The "Look Preview" feature calls a &lt;em&gt;second&lt;/em&gt; Google API for image generation (a whole story on its own &lt;a href="https://meridianbuild.dev/blog/fashn-to-gemini-vertex-ai-cant-return-images/" rel="noopener noreferrer"&gt;I wrote about that swap here&lt;/a&gt;). Product images come from Replicate. Affiliate suggestions come from Amazon via RapidAPI.&lt;/p&gt;

&lt;p&gt;Every one of those is a thing that can go down, get slow, or start rate-limiting me without warning. And when one does, the failure mode in a Rails app is ugly: a Sidekiq job retries, hammers the dead service, ties up a worker, the retry queue backs up, and the failure spreads to features that have nothing to do with the broken provider.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;circuit breaker&lt;/strong&gt; is the standard fix. It's a small state machine that sits in front of an external call. After a set number of failures inside a time window, it "opens" and while it's open, calls fail instantly instead of waiting on a dead service. After a cooldown it goes "half-open," lets one call through to test the water, and either closes (recovered) or opens again. The point isn't to make failures disappear. It's to make them &lt;em&gt;cheap and contained&lt;/em&gt; instead of expensive and contagious.&lt;/p&gt;

&lt;p&gt;I shipped circuit breakers across all six integrations in two phases in early May 2026 — Vertex services first (&lt;code&gt;b7f9fd3&lt;/code&gt;, PR #63), then Look Preview, Replicate and Amazon (&lt;code&gt;b637695&lt;/code&gt;, PR #67). I reached for the &lt;a href="https://rubygems.org/gems/breaker_machines" rel="noopener noreferrer"&gt;&lt;code&gt;breaker_machines&lt;/code&gt;&lt;/a&gt; gem, pinned to &lt;code&gt;0.10.3&lt;/code&gt;. The DSL is clean and the wiring took an afternoon.&lt;/p&gt;

&lt;p&gt;Then I tried to actually make a circuit trip, and the afternoon turned into a week.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision one: separate circuits, not one big Vertex circuit
&lt;/h2&gt;

&lt;p&gt;Four of my services hit Gemini Vertex AI on the same &lt;code&gt;gemini-2.5-flash&lt;/code&gt; model. The tempting design is a single &lt;code&gt;:gemini_vertex&lt;/code&gt; circuit they all share, because they genuinely share fate one Vertex outage breaks all four.&lt;/p&gt;

&lt;p&gt;I gave each service its own circuit instead. The reasoning is in a design note in the shared mixin:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# DESIGN NOTE separate Vertex circuits despite shared model&lt;/span&gt;
&lt;span class="c1"&gt;#   1. Per-feature blast radius. The April 23 incident came from&lt;/span&gt;
&lt;span class="c1"&gt;#      ImageAnalysisJob under load. A shared circuit would have opened&lt;/span&gt;
&lt;span class="c1"&gt;#      and silently degraded outfit suggestions and trip planning by&lt;/span&gt;
&lt;span class="c1"&gt;#      contagion — features paid users depend on.&lt;/span&gt;
&lt;span class="c1"&gt;#   2. Per-feature observability. Sentry breadcrumbs and metrics tag by&lt;/span&gt;
&lt;span class="c1"&gt;#      circuit name. Separate circuits = a glance tells you which feature&lt;/span&gt;
&lt;span class="c1"&gt;#      tripped, no log mining.&lt;/span&gt;
&lt;span class="c1"&gt;#   3. Per-feature cost of false positives. A false-positive open on&lt;/span&gt;
&lt;span class="c1"&gt;#      MissingItemDetector returns [] (graceful). A false-positive open on&lt;/span&gt;
&lt;span class="c1"&gt;#      OutfitSuggestionService blocks a paywalled flow.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A shared circuit is one fewer thing to configure, but it couples the blast radius of every feature to the noisiest one. The whole reason I was adding breakers was to &lt;em&gt;stop&lt;/em&gt; one feature's failure from spreading. A shared circuit would have quietly re-introduced exactly that.&lt;/p&gt;

&lt;p&gt;To keep four near-identical declarations DRY without coupling them, there's a class-method helper:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;gemini_vertex_circuit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;circuit_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                          &lt;span class="n"&gt;service_exception&lt;/span&gt;&lt;span class="p"&gt;:,&lt;/span&gt;
                          &lt;span class="n"&gt;network_errors&lt;/span&gt;&lt;span class="p"&gt;:,&lt;/span&gt;
                          &lt;span class="ss"&gt;failures: &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                          &lt;span class="ss"&gt;within: &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;minute&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                          &lt;span class="ss"&gt;reset_after_seconds: &lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                          &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;circuit&lt;/span&gt; &lt;span class="n"&gt;circuit_name&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;threshold&lt;/span&gt; &lt;span class="ss"&gt;failures: &lt;/span&gt;&lt;span class="n"&gt;failures&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;within: &lt;/span&gt;&lt;span class="n"&gt;within&lt;/span&gt;
    &lt;span class="n"&gt;reset_after&lt;/span&gt; &lt;span class="n"&gt;reset_after_seconds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;jitter: &lt;/span&gt;&lt;span class="mf"&gt;0.25&lt;/span&gt;
    &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;network_errors&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;service_exception&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;instance_exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;block&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;block&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replicate gets a different template &lt;code&gt;failures: 5&lt;/code&gt; within &lt;code&gt;1.hour&lt;/code&gt; instead of &lt;code&gt;3&lt;/code&gt; within &lt;code&gt;1.minute&lt;/code&gt; because its traffic is roughly one invocation a day right now, so a one-minute window is statistically unreachable, and its published rate limit makes short failure clusters more likely than a real outage. The thresholds describe the service, not a global default.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision two: where circuit state lives
&lt;/h2&gt;

&lt;p&gt;Circuit state has to be shared across processes my web dynos and Sidekiq workers all need to agree that a circuit is open. So it goes in Redis. The non-obvious part is &lt;em&gt;which&lt;/em&gt; Redis.&lt;/p&gt;

&lt;p&gt;I gave it a dedicated database (&lt;code&gt;db 1&lt;/code&gt;), separate from &lt;code&gt;Rails.cache&lt;/code&gt; (&lt;code&gt;db 0&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Why a separate Redis DB instead of reusing Rails.cache?&lt;/span&gt;
&lt;span class="c1"&gt;#   1. Rails.cache.clear (and Rack::Attack key churn) wipe everything in db 0.&lt;/span&gt;
&lt;span class="c1"&gt;#      Circuit breaker state must survive cache flushes losing it during an&lt;/span&gt;
&lt;span class="c1"&gt;#      outage would re-arm the breaker mid-incident and let traffic stampede&lt;/span&gt;
&lt;span class="c1"&gt;#      a service that's already down.&lt;/span&gt;
&lt;span class="c1"&gt;#   2. Namespace ("bm") is a defense in depth, not a substitute for db&lt;/span&gt;
&lt;span class="c1"&gt;#      isolation: ActiveSupport's :redis_cache_store only namespaces keys, it&lt;/span&gt;
&lt;span class="c1"&gt;#      does not isolate the Redis DB.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The TTL is 24 hours long enough to outlast a real multi-hour provider outage, because a short TTL would silently drop circuit state at 3am during a low-traffic night, right before morning traffic resumes. There's also a build-time branch: when assets compile with &lt;code&gt;SECRET_KEY_BASE_DUMMY&lt;/code&gt; set and no Redis, the store falls back to a &lt;code&gt;NullStore&lt;/code&gt; so the breaker becomes a no-op. No traffic at build time means no state to track.&lt;/p&gt;

&lt;p&gt;All of that is design. None of it is what cost me the week. The week went to discovering that with this exact setup, &lt;strong&gt;the circuit never actually tripped.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug one: the breaker counted to zero forever
&lt;/h2&gt;

&lt;p&gt;I wrote a test that fired enough failures to cross the threshold and asserted the circuit opened. It didn't. The failure count stayed at zero no matter how many exceptions I threw.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;breaker_machines&lt;/code&gt;' cache adapter counts failures by calling &lt;code&gt;increment&lt;/code&gt; on the cache store and then reading the value back. With &lt;code&gt;RedisCacheStore&lt;/code&gt;, &lt;code&gt;increment&lt;/code&gt; issues a raw Redis &lt;code&gt;INCR&lt;/code&gt;, which stores a plain string &lt;code&gt;"3"&lt;/code&gt;. But the read comes back through ActiveSupport's default deserialization path, which tries to un-marshal that string, fails, and returns &lt;code&gt;nil&lt;/code&gt;. &lt;code&gt;nil.to_i&lt;/code&gt; is &lt;code&gt;0&lt;/code&gt;. The counter is structurally incapable of going up.&lt;/p&gt;

&lt;p&gt;The fix is four lines read the counter raw and coerce it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;BreakerMachinesCacheRedisFix&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_window_count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;window_seconds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="vi"&gt;@cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;respond_to?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:increment&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="vi"&gt;@cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;raw: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to_i&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;
      &lt;span class="k"&gt;super&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="no"&gt;BreakerMachines&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Storage&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prepend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;BreakerMachinesCacheRedisFix&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Finding the four lines took the better part of two days. The patch itself is guarded so it can't rot silently:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="k"&gt;defined?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;BreakerMachines&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;VERSION&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="no"&gt;BreakerMachines&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;VERSION&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"0.10.3"&lt;/span&gt;
  &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="s2"&gt;"breaker_machines_cache_patch is pinned to 0.10.3, currently loaded: ..."&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="no"&gt;BreakerMachines&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Storage&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;instance_method&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:get_window_count&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;arity&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
  &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="s2"&gt;"BreakerMachines::Storage::Cache#get_window_count signature changed; ..."&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If I ever bump the gem, the app refuses to boot until I've re-checked whether the bug still exists. A monkey patch you forget about is worse than the bug it fixed. (This is the same instinct as the atomic rewrite in my &lt;a href="https://meridianbuild.dev/blog/toctou-in-my-gemini-rate-limiter/" rel="noopener noreferrer"&gt;Gemini rate-limiter post&lt;/a&gt;: when a read-then-write straddles a process boundary, the boundary is where the bug hides.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug two: a fresh worker forgot the circuit was open
&lt;/h2&gt;

&lt;p&gt;With the counter fixed, circuits tripped correctly. Then I tested the scenario that actually matters in production: a worker boots into a world where the circuit is &lt;em&gt;already&lt;/em&gt; open, set by some other process. It should refuse calls immediately. Instead, its first call sailed straight through to the dead service.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;breaker_machines&lt;/code&gt; does try to handle this. Its &lt;code&gt;Circuit#initialize&lt;/code&gt; reads the stored status from Redis and assigns &lt;code&gt;self.status = "open"&lt;/code&gt;. The problem is &lt;em&gt;ordering&lt;/em&gt;. Right after &lt;code&gt;initialize&lt;/code&gt; returns, the underlying &lt;code&gt;state_machines&lt;/code&gt; gem runs its own &lt;code&gt;initialize_states&lt;/code&gt; lifecycle hook, which resets &lt;code&gt;@status&lt;/code&gt; back to the initial value &lt;code&gt;:closed&lt;/code&gt; because the manual assignment never tripped the flag that tells &lt;code&gt;state_machines&lt;/code&gt; "this attribute is already set." The restore happens, then gets quietly overwritten.&lt;/p&gt;

&lt;p&gt;The trace, captured in the patch's own documentation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[restore] stored=#&amp;lt;Status status=:open, opened_at=...&amp;gt;
[restore] after assign: status=open
[trace status= called with "closed"] caller=[
  "state_machines/machine/state_methods.rb:88:in `write'",
  "state_machines/machine/state_methods.rb:35:in `initialize_state'",
  "state_machines/machine_collection.rb:36:in `block in initialize_states'"
]
FINAL: closed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix is to restore &lt;em&gt;again&lt;/em&gt;, after the lifecycle is done:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;BreakerMachinesStateRestoreFix&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt;
    &lt;span class="k"&gt;super&lt;/span&gt;
    &lt;span class="n"&gt;restore_status_from_storage&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="vi"&gt;@storage&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="no"&gt;BreakerMachines&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Circuit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prepend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;BreakerMachinesStateRestoreFix&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;super&lt;/code&gt; runs the full chain — including the &lt;code&gt;state_machines&lt;/code&gt; reset. Then I re-apply the stored status, this time with nothing left to clobber it. The restore is just a read-and-assign, so calling it twice is harmless.&lt;/p&gt;

&lt;p&gt;Why does this matter enough to patch a gem? Because of how the failure scales. Every fresh worker that boots during an outage and Sidekiq autoscaling spins up &lt;em&gt;more&lt;/em&gt; workers exactly when things are failing pays one wasted call to the dead service before its in-memory state catches up. Redeploying to ship a fix during an outage produces a whole fleet of forgetful workers. The cost is small per worker and real in aggregate, and it's worst at the exact moment you most need the breaker to hold. At one worker it's a rounding error; at five workers plus frequent deploys it's the pattern the breakers existed to remove.&lt;/p&gt;

&lt;p&gt;Because it's a &lt;code&gt;Module#prepend&lt;/code&gt; on the base &lt;code&gt;Circuit&lt;/code&gt; class, the fix applied to all six circuits at once with zero per-service changes — one Railway restart.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "open" looks like to a user
&lt;/h2&gt;

&lt;p&gt;A tripped circuit shouldn't show a stack trace. The fallback on the outfit-suggestion circuit distinguishes the two cases the gem lumps together:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;fallback&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
  &lt;span class="c1"&gt;# The breaker_machines fallback fires on EVERY whitelisted error, not&lt;/span&gt;
  &lt;span class="c1"&gt;# just open-state calls. Two distinct cases:&lt;/span&gt;
  &lt;span class="c1"&gt;#   1. Circuit OPEN — error is BreakerMachines::CircuitOpenError. Replace&lt;/span&gt;
  &lt;span class="c1"&gt;#      with a localized "temporarily unavailable" message, tag Sentry with&lt;/span&gt;
  &lt;span class="c1"&gt;#      the circuit name.&lt;/span&gt;
  &lt;span class="c1"&gt;#   2. Circuit CLOSED but raised an in-whitelist exception re-raise&lt;/span&gt;
  &lt;span class="c1"&gt;#      unchanged so the controller's existing rescue handles it like before.&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_a?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;BreakerMachines&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;CircuitOpenError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# ... localized message + Sentry tag ...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open circuit means "we already know this is down, here's a calm message." A closed circuit that raised means the real failure should flow through untouched. Collapsing those two into one generic error would have either hidden real bugs or shown scary copy for a known, handled state.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers I can and can't give you
&lt;/h2&gt;

&lt;p&gt;What I can stand behind, because it's in the repo: the gem is pinned to &lt;code&gt;0.10.3&lt;/code&gt;, both patches carry version guards that fail the boot on an unverified bump, and the two patch test files are 87 and 163 lines the state-restore one simulates two processes to prove a fresh circuit sees the stored &lt;code&gt;open&lt;/code&gt; state. The two bugs are reproducible on &lt;code&gt;0.10.3&lt;/code&gt;, and I confirmed the counter bug is still present in the &lt;code&gt;0.10.8&lt;/code&gt; source.&lt;/p&gt;

&lt;p&gt;What I can't give you is a clean "incidents prevented" graph. OutfitMaker isn't at the scale where provider outages hit daily, and I'm not going to dress up the breakers' value with numbers I don't have. The honest framing: this was insurance bought before the fire, and most of the work was discovering the policy didn't pay out until I patched it twice.&lt;/p&gt;

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

&lt;p&gt;A circuit breaker is a small state machine that fails fast and contains the blast radius when an external service goes down and for an AI-heavy app riding on three different Google and third-party APIs, it's not premature optimization, it's table stakes. But "add the gem" is the 20% of the work. The 80% is the boring, specific reality underneath: your cache store serializes counters in a way the adapter didn't expect, and your circuit state evaporates the instant a worker restarts unless the restore runs &lt;em&gt;after&lt;/em&gt; the state-machine lifecycle, not during it.&lt;/p&gt;

&lt;p&gt;If you're wiring breakers into a multi-process Rails deploy, write the two tests that actually matter before you trust the library: one that proves a circuit &lt;em&gt;trips&lt;/em&gt; under your real cache store, and one that proves a freshly booted process &lt;em&gt;sees&lt;/em&gt; a circuit another process already opened. Mine both failed against a popular, well-written gem. The breakers in &lt;a href="https://outfitmaker.ai" rel="noopener noreferrer"&gt;OutfitMaker&lt;/a&gt; only do their job because those two tests forced the patches that made them true.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>rails</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
