<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://www.fastruby.io/blog/rss.xml" rel="self" type="application/atom+xml" /><link href="https://www.fastruby.io/blog/" rel="alternate" type="text/html" /><updated>2026-08-20T15:25:17-04:00</updated><id>https://www.fastruby.io/blog/rss.xml</id><title type="html">The Rails Tech Debt Blog</title><subtitle>| FastRuby.io</subtitle><author><name>OmbuLabs</name></author><entry><title type="html">SimpleCov is now version 1!</title><link href="https://www.fastruby.io/blog/simplecov-1-0-release.html" rel="alternate" type="text/html" title="SimpleCov is now version 1!" /><published>2026-08-20T15:05:36-04:00</published><updated>2026-08-20T15:05:36-04:00</updated><id>https://www.fastruby.io/blog/simplecov-1-0-release</id><content type="html" xml:base="https://www.fastruby.io/blog/simplecov-1-0-release.html"><![CDATA[<p>Here at FastRuby.io we work with test suites and test coverage reports every single day, given our specialty. In the Ruby world that means
we interact with SimpleCov every day. Not only that, we explicitely rely on SimpleCov reports in two of the open source projects we maintain,
<a href="https://github.com/whitesmith/rubycritic">RubyCritic</a> and <a href="https://github.com/fastruby/skunk">Skunk</a>.</p>

<p>Which is why, after more than a decade at 0.x, we were super excited to see <a href="https://github.com/simplecov-ruby/simplecov">SimpleCov</a> ship its first stable release on July 12, 2026.
In this article we’d like to share the details on the breaking changes, the deprecations and how to address them, if you’re impacted by them.</p>

<!--more-->

<p>Everything below refers only to the <a href="https://github.com/simplecov-ruby/simplecov/blob/main/CHANGELOG.md#100-2026-07-12">1.0.0 entry in the changelog</a>.</p>

<p>Also, it’s worth noting that the minimum required Ruby version for this release is 3.2.</p>

<h2 id="breaking-changes">Breaking changes</h2>

<h3 id="how-filters-match-paths">How filters match paths</h3>

<p>Two related changes can quietly alter which files end up in your report.</p>

<p>The first is that <code class="language-plaintext highlighter-rouge">SourceFile#project_filename</code> now returns a relative path, <code class="language-plaintext highlighter-rouge">lib/foo.rb</code> instead of <code class="language-plaintext highlighter-rouge">/lib/foo.rb</code>, which affects any anchored <code class="language-plaintext highlighter-rouge">RegexFilter</code> that relied on the leading slash. Because this breaks without a warning, you need to grep your codebase for patterns relying on the leading slash:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">grep</span> <span class="nt">-rn</span> <span class="s2">"add_filter</span><span class="se">\|</span><span class="s2">remove_filter</span><span class="se">\|</span><span class="s2">add_group"</span> .simplecov spec/spec_helper.rb <span class="nb">test</span>/test_helper.rb
</code></pre></div></div>

<p>Any pattern that anchors on a slash needs rewriting:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Before</span>
<span class="n">add_filter</span> <span class="sr">%r{^/lib/}</span>

<span class="c1"># After</span>
<span class="n">add_filter</span> <span class="sr">%r{</span><span class="se">\A</span><span class="sr">lib/}</span>
</code></pre></div></div>

<p>The second change is that <code class="language-plaintext highlighter-rouge">StringFilter</code> now matches at path-segment boundaries, so <code class="language-plaintext highlighter-rouge">"lib"</code> matches <code class="language-plaintext highlighter-rouge">/lib/</code> but no longer matches <code class="language-plaintext highlighter-rouge">/library/</code>. If you were deliberately relying on substring behavior, like saying <code class="language-plaintext highlighter-rouge">add_filter "_spec"</code> to catch <code class="language-plaintext highlighter-rouge">user_spec.rb</code>, switch that filter to a <code class="language-plaintext highlighter-rouge">Regexp</code>:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Before: matched anything containing the substring</span>
<span class="n">add_filter</span> <span class="s2">"generated"</span>

<span class="c1"># After: explicit substring matching</span>
<span class="n">add_filter</span><span class="p">(</span><span class="sr">/generated/</span><span class="p">)</span>
</code></pre></div></div>

<p>This change has one very important observation to note, however: An argument containing a dot is still treated as a filename pattern and still matches as a substring, so <code class="language-plaintext highlighter-rouge">add_filter "test.rb"</code> continues to catch <code class="language-plaintext highlighter-rouge">faked_test.rb</code>. A trailing slash likewise still means directory-only matching. Only dot-free arguments like <code class="language-plaintext highlighter-rouge">"lib"</code> or <code class="language-plaintext highlighter-rouge">"_spec"</code> became segment-anchored.</p>

<h3 id="the-status-line-moved-to-stderr">The status line moved to stderr</h3>

<p>The “Coverage report generated for X to Y” line, and the per-criterion totals under it, now go to stderr instead of stdout. If a CI script of yours parsed that line from stdout, it will now see nothing. You can suppress the message entirely on the formatter:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="no">SimpleCov</span><span class="p">.</span><span class="nf">formatter</span> <span class="o">=</span> <span class="no">SimpleCov</span><span class="o">::</span><span class="no">Formatter</span><span class="o">::</span><span class="no">HTMLFormatter</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="ss">silent: </span><span class="kp">true</span><span class="p">)</span>
</code></pre></div></div>

<p>Or you can restore the old behavior at the call site:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bundle <span class="nb">exec </span>rspec 2&gt;&amp;1
</code></pre></div></div>

<h3 id="htmlformatter-now-outputs-coveragejson">HTMLFormatter now outputs coverage.json</h3>

<p>SimpleCov used to automatically activate <code class="language-plaintext highlighter-rouge">JSONFormatter</code> when the <code class="language-plaintext highlighter-rouge">CC_TEST_REPORTER_ID</code> environment variable was set, and that special case is gone. It is no longer needed, because the default <code class="language-plaintext highlighter-rouge">HTMLFormatter</code> now writes <code class="language-plaintext highlighter-rouge">coverage.json</code> alongside the HTML report, serializing the same payload <code class="language-plaintext highlighter-rouge">JSONFormatter</code> would. If you relied on the old auto-activation and you <em>weren’t</em> using the HTML formatter, you’ll need to add either the <code class="language-plaintext highlighter-rouge">JSONFormatter</code> or the <code class="language-plaintext highlighter-rouge">HTMLFormatter</code> explicitly. Note that only one of them is needed
and using <code class="language-plaintext highlighter-rouge">HTMLFormatter</code> gives you the <code class="language-plaintext highlighter-rouge">JSONFormatter</code> output plus the HTML report.</p>

<h3 id="coveragejson-schema-change"><code class="language-plaintext highlighter-rouge">coverage.json</code> schema change</h3>

<p><code class="language-plaintext highlighter-rouge">coverage.json</code> changed shape, from <code class="language-plaintext highlighter-rouge">{ "covered_percent": 80.0 }</code> to the full <code class="language-plaintext highlighter-rouge">{ "covered": 8, "missed": 2, "total": 10, "percent": 80.0, "strength": 0.0 }</code>, with <code class="language-plaintext highlighter-rouge">covered_percent</code> renamed to <code class="language-plaintext highlighter-rouge">percent</code>. On a related note, both <code class="language-plaintext highlighter-rouge">simplecov_json_formatter</code> and <code class="language-plaintext highlighter-rouge">simplecov-html</code> have been merged into the main gem. The old requires still work through a shim, so you can drop the separate gems from your <code class="language-plaintext highlighter-rouge">Gemfile</code> whenever it is convenient.</p>

<h3 id="simplecovstart-now-loads-the-test_frameworks-profile-by-default">SimpleCov.start now loads the test_frameworks profile by default</h3>

<p>Calling <code class="language-plaintext highlighter-rouge">SimpleCov.start</code> with no arguments now loads the <code class="language-plaintext highlighter-rouge">test_frameworks</code> profile by default, which filters out paths under <code class="language-plaintext highlighter-rouge">test/</code>, <code class="language-plaintext highlighter-rouge">spec/</code>, <code class="language-plaintext highlighter-rouge">features/</code>, and
<code class="language-plaintext highlighter-rouge">autotest/</code>. Rails applications usually call <code class="language-plaintext highlighter-rouge">SimpleCov.start "rails"</code> instead, and the <code class="language-plaintext highlighter-rouge">rails</code> profile has loaded <code class="language-plaintext highlighter-rouge">test_frameworks</code> for as long as the profile has
existed, so those projects need no change.</p>

<p>If you do call <code class="language-plaintext highlighter-rouge">start</code> with no arguments, your report will drop its overall coverage percentage, and how much depends on the ratio of test code to application
code. Should you also set the <code class="language-plaintext highlighter-rouge">minimum_coverage</code> attribute and the new number fall below that threshold, you’ll need to either establish a new acceptable
threshold or remove the filters:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Option A: accept the more honest number and re-baseline the threshold</span>
<span class="no">SimpleCov</span><span class="p">.</span><span class="nf">minimum_coverage</span> <span class="mi">80</span>

<span class="c1"># Option B: keep the old behavior by dropping the new filter</span>
<span class="no">SimpleCov</span><span class="p">.</span><span class="nf">start</span> <span class="k">do</span>
  <span class="n">remove_filter</span> <span class="sr">%r{</span><span class="se">\A</span><span class="sr">(test|features|spec|autotest)/}</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Option B is genuinely useful if you want to surface dead test helpers that nothing calls anymore. For everything else, re-baselining is the better move. If the
number you land on is lower than you are comfortable with, we put together <a href="https://www.fastruby.io/blog/10-strategies-for-upgrading-ruby-or-rails-applications-with-low-test-coverage">10 strategies for upgrading apps with low test coverage</a>
that should help.</p>

<h3 id="parallel-waits-and-two-removals">Parallel waits and two removals</h3>

<p>Under <code class="language-plaintext highlighter-rouge">parallel_tests</code>, SimpleCov now waits in the first process rather than the last, using <code class="language-plaintext highlighter-rouge">ParallelTests.first_process?</code>, which matches what the <code class="language-plaintext highlighter-rouge">parallel_tests</code> README recommends. For most people this fixes a deadlock, and the old <code class="language-plaintext highlighter-rouge">PARALLEL_TEST_GROUPS=1</code> workaround is no longer needed. The rare project that wired up its own <code class="language-plaintext highlighter-rouge">wait_for_other_processes_to_finish</code> in an <code class="language-plaintext highlighter-rouge">after(:suite)</code> hook keyed on <code class="language-plaintext highlighter-rouge">last_process?</code> now hits the symmetric deadlock and has to switch to <code class="language-plaintext highlighter-rouge">first_process?</code>.</p>

<p>Two removals round out this section. <code class="language-plaintext highlighter-rouge">SimpleCov.coverage_criterion</code> is gone and <code class="language-plaintext highlighter-rouge">primary_coverage</code> (or <code class="language-plaintext highlighter-rouge">coverage :branch, primary: true</code>) replaces it and the <code class="language-plaintext highlighter-rouge">docile</code> dependency is gone too, with <code class="language-plaintext highlighter-rouge">SimpleCov.configure</code> blocks now evaluated via <code class="language-plaintext highlighter-rouge">instance_exec</code> and instance variable proxying, which needs no action on your side unless you were doing something exotic inside a configure block.</p>

<h2 id="deprecations">Deprecations</h2>

<p>Below we give a small description of the deprecations that were created in this version. While these don’t break now, we recommend effecting these changes
so that future upgrades go smoothly.</p>

<h3 id="the-configuration-api-was-redesigned">The configuration API was redesigned</h3>

<p>The config API has been reorganized around a smaller, more consistent set of verbs:</p>

<table>
  <thead>
    <tr>
      <th>Legacy</th>
      <th>Replacement</th>
      <th>Notes</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">add_filter</code></td>
      <td><code class="language-plaintext highlighter-rouge">skip</code></td>
      <td>Identical matcher grammar, no behavior change</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">add_group</code></td>
      <td><code class="language-plaintext highlighter-rouge">group</code></td>
      <td>Identical matcher grammar, no behavior change</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">track_files</code></td>
      <td><code class="language-plaintext highlighter-rouge">cover</code></td>
      <td>Behavior differs, see below</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">use_merging</code></td>
      <td><code class="language-plaintext highlighter-rouge">merging</code></td>
      <td>No behavior change</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">enable_for_subprocesses</code></td>
      <td><code class="language-plaintext highlighter-rouge">merge_subprocesses</code></td>
      <td>No behavior change</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">enable_coverage_for_eval</code></td>
      <td><code class="language-plaintext highlighter-rouge">enable_coverage :eval</code></td>
      <td>Folds into the same call as <code class="language-plaintext highlighter-rouge">:line</code> / <code class="language-plaintext highlighter-rouge">:branch</code> / <code class="language-plaintext highlighter-rouge">:method</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">print_error_status</code> (reader)</td>
      <td><code class="language-plaintext highlighter-rouge">print_errors</code></td>
      <td>The <code class="language-plaintext highlighter-rouge">print_error_status=</code> writer is unaffected for now</td>
    </tr>
  </tbody>
</table>

<p>Most of these are a straight rename:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Before</span>
<span class="no">SimpleCov</span><span class="p">.</span><span class="nf">start</span> <span class="k">do</span>
  <span class="n">add_filter</span> <span class="s2">"/vendor/"</span>
  <span class="n">add_group</span> <span class="s2">"Services"</span><span class="p">,</span> <span class="s2">"app/services"</span>
  <span class="n">use_merging</span> <span class="kp">true</span>
<span class="k">end</span>

<span class="c1"># After</span>
<span class="no">SimpleCov</span><span class="p">.</span><span class="nf">start</span> <span class="k">do</span>
  <span class="n">skip</span> <span class="s2">"/vendor/"</span>
  <span class="n">group</span> <span class="s2">"Services"</span><span class="p">,</span> <span class="s2">"app/services"</span>
  <span class="n">merging</span> <span class="kp">true</span>
<span class="k">end</span>
</code></pre></div></div>

<p>The one to pay attention to is <code class="language-plaintext highlighter-rouge">cover</code>. It includes unloaded files the way <code class="language-plaintext highlighter-rouge">track_files</code> did, but it also restricts the report to the matching set given to it, so a single <code class="language-plaintext highlighter-rouge">cover "lib/**/*.rb"</code> will drop <code class="language-plaintext highlighter-rouge">app/</code> from your report entirely. To keep the old behavior, pass every directory you want reported:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Before</span>
<span class="n">track_files</span> <span class="s2">"lib/**/*.rb"</span>

<span class="c1"># After: list everything, not just the previously-tracked glob</span>
<span class="n">cover</span> <span class="s2">"lib/**/*.rb"</span><span class="p">,</span> <span class="s2">"app/**/*.rb"</span>
</code></pre></div></div>

<h3 id="simplecovstart-cannot-be-called-from-simplecov">SimpleCov.start cannot be called from .simplecov</h3>

<p>Calling <code class="language-plaintext highlighter-rouge">SimpleCov.start</code> from <code class="language-plaintext highlighter-rouge">.simplecov</code> is deprecated. Tracking still begins for backward compatibility, but you get a one-time warning, and a future release will require the explicit call to live in <code class="language-plaintext highlighter-rouge">spec_helper.rb</code> or <code class="language-plaintext highlighter-rouge">test_helper.rb</code>. Treat <code class="language-plaintext highlighter-rouge">.simplecov</code> as configuration only:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># .simplecov</span>
<span class="no">SimpleCov</span><span class="p">.</span><span class="nf">configure</span> <span class="k">do</span>
  <span class="n">skip</span> <span class="s2">"/spec/"</span>
  <span class="n">minimum_coverage</span> <span class="mi">90</span>
<span class="k">end</span>

<span class="c1"># spec/spec_helper.rb, at the very top, before anything else is required</span>
<span class="nb">require</span> <span class="s2">"simplecov"</span>
<span class="no">SimpleCov</span><span class="p">.</span><span class="nf">start</span> <span class="s2">"rails"</span>
</code></pre></div></div>

<h3 id="nocov-comments-and-associated-configurations-are-deprecated">:nocov: comments and associated configurations are deprecated</h3>

<p>This means specifically the <code class="language-plaintext highlighter-rouge">SimpleCov.nocov_token</code> and <code class="language-plaintext highlighter-rouge">SimpleCov.skip_token</code> configurations. Every file still using <code class="language-plaintext highlighter-rouge"># :nocov:</code> emits a one-time warning to stderr at load time. Instances of <code class="language-plaintext highlighter-rouge"># :nocov:</code> must be replaced with the new directive comments, <code class="language-plaintext highlighter-rouge"># simplecov:enable</code> and <code class="language-plaintext highlighter-rouge"># simplecov:disable</code>:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Before</span>
<span class="c1"># :nocov:</span>
<span class="k">def</span> <span class="nf">hard_to_test</span>
  <span class="n">legacy_thing</span>
<span class="k">end</span>
<span class="c1"># :nocov:</span>

<span class="c1"># After</span>
<span class="c1"># simplecov:disable</span>
<span class="k">def</span> <span class="nf">hard_to_test</span>
  <span class="n">legacy_thing</span>
<span class="k">end</span>
<span class="c1"># simplecov:enable</span>
</code></pre></div></div>

<p>It’s worth mentioning that the directive comments also allow you to specify if you want to disable only line, branch or method coverage, like so: <code class="language-plaintext highlighter-rouge"># simplecov:disable line</code>. By default, all 3 are enabled.</p>

<h3 id="branches_coverage_percent-and-methods_coverage_percent-are-deprecated"><code class="language-plaintext highlighter-rouge">branches_coverage_percent</code> and <code class="language-plaintext highlighter-rouge">methods_coverage_percent</code> are deprecated</h3>

<p>This will likely affect you only if you have custom formatters. <code class="language-plaintext highlighter-rouge">SimpleCov::SourceFile#branches_coverage_percent</code> and <code class="language-plaintext highlighter-rouge">#methods_coverage_percent</code> are now replaced by <code class="language-plaintext highlighter-rouge">covered_percent</code> which take a criterion argument that defaults to <code class="language-plaintext highlighter-rouge">:line</code>:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Before</span>
<span class="n">file</span><span class="p">.</span><span class="nf">branches_coverage_percent</span>
<span class="n">file</span><span class="p">.</span><span class="nf">methods_coverage_percent</span>

<span class="c1"># After</span>
<span class="n">file</span><span class="p">.</span><span class="nf">covered_percent</span><span class="p">(</span><span class="ss">:branch</span><span class="p">)</span>
<span class="n">file</span><span class="p">.</span><span class="nf">covered_percent</span><span class="p">(</span><span class="ss">:method</span><span class="p">)</span>
</code></pre></div></div>

<h3 id="minimum_coverage_by_-setters-deprecated"><code class="language-plaintext highlighter-rouge">minimum_coverage_by_*</code> setters deprecated</h3>

<p>Finally, the <code class="language-plaintext highlighter-rouge">minimum_coverage_by_file</code> and <code class="language-plaintext highlighter-rouge">minimum_coverage_by_group</code> setters give way to the new <code class="language-plaintext highlighter-rouge">coverage</code> method’s <code class="language-plaintext highlighter-rouge">minimum_per_file</code> and <code class="language-plaintext highlighter-rouge">minimum_per_group</code> verbs:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Before</span>
<span class="no">SimpleCov</span><span class="p">.</span><span class="nf">minimum_coverage_by_file</span> <span class="ss">line: </span><span class="mi">70</span><span class="p">,</span> <span class="s2">"app/x.rb"</span> <span class="o">=&gt;</span> <span class="mi">100</span>

<span class="c1"># After</span>
<span class="no">SimpleCov</span><span class="p">.</span><span class="nf">coverage</span><span class="p">(</span><span class="ss">:line</span><span class="p">)</span> <span class="k">do</span>
  <span class="n">minimum_per_file</span> <span class="mi">70</span>
  <span class="n">minimum_per_file</span> <span class="mi">100</span><span class="p">,</span> <span class="ss">only: </span><span class="s2">"app/x.rb"</span>
<span class="k">end</span>
</code></pre></div></div>

<p>The no-arg getters are unchanged, and only the setter forms warn.</p>

<h2 id="new-features-worth-your-time">New features worth your time</h2>

<p>Once you are migrated, there is a real payoff here.</p>

<h3 id="configuring-criteria-and-scoping-the-report">Configuring criteria and scoping the report</h3>

<p>The new criterion-first <code class="language-plaintext highlighter-rouge">coverage</code> method configures each criterion (<code class="language-plaintext highlighter-rouge">:line</code>, <code class="language-plaintext highlighter-rouge">:branch</code>, <code class="language-plaintext highlighter-rouge">:method</code>) in one place, with identical syntax regardless of which one you are configuring:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="no">SimpleCov</span><span class="p">.</span><span class="nf">start</span> <span class="k">do</span>
  <span class="n">coverage</span> <span class="ss">:line</span> <span class="k">do</span>
    <span class="n">minimum</span> <span class="mi">90</span>
    <span class="n">minimum_per_file</span> <span class="mi">80</span>
    <span class="n">maximum_drop</span> <span class="mi">5</span>
  <span class="k">end</span>

  <span class="n">coverage</span> <span class="ss">:branch</span><span class="p">,</span> <span class="ss">minimum: </span><span class="mi">80</span><span class="p">,</span> <span class="ss">primary: </span><span class="kp">true</span>
<span class="k">end</span>
</code></pre></div></div>

<p>The options are <code class="language-plaintext highlighter-rouge">minimum</code>, <code class="language-plaintext highlighter-rouge">maximum</code>, <code class="language-plaintext highlighter-rouge">exact</code>, <code class="language-plaintext highlighter-rouge">maximum_drop</code>, <code class="language-plaintext highlighter-rouge">minimum_per_file</code> (with <code class="language-plaintext highlighter-rouge">only:</code> overrides), and <code class="language-plaintext highlighter-rouge">minimum_per_group</code>.</p>

<p>Alongside it, <code class="language-plaintext highlighter-rouge">SimpleCov.cover</code> finally provides an allowlist. Where <code class="language-plaintext highlighter-rouge">add_filter</code> could only ever subtract, <code class="language-plaintext highlighter-rouge">cover</code> is the positive counterpart:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="no">SimpleCov</span><span class="p">.</span><span class="nf">start</span> <span class="k">do</span>
  <span class="n">cover</span> <span class="s2">"app/**/*.rb"</span><span class="p">,</span> <span class="s2">"lib/**/*.rb"</span>
<span class="k">end</span>
</code></pre></div></div>

<p>It accepts string globs, Regexps, blocks, or arrays of those, and multiple calls union together.</p>

<p>Finally, if you want to start from a blank slate, <code class="language-plaintext highlighter-rouge">SimpleCov.no_default_skips</code> opts out of the filters <code class="language-plaintext highlighter-rouge">SimpleCov.start</code> installs.</p>

<h3 id="directive-comments-and-synthetic-branches">Directive comments and synthetic branches</h3>

<p>On the branch side, <code class="language-plaintext highlighter-rouge">SimpleCov.ignore_branches</code> lets you opt out of the synthetic <code class="language-plaintext highlighter-rouge">:else</code> branches that Ruby’s <code class="language-plaintext highlighter-rouge">Coverage</code> library reports for constructs with no literal <code class="language-plaintext highlighter-rouge">else</code> keyword, which includes exhaustive <code class="language-plaintext highlighter-rouge">case/in</code> matches, <code class="language-plaintext highlighter-rouge">case/when</code> without <code class="language-plaintext highlighter-rouge">else</code>, <code class="language-plaintext highlighter-rouge">||=</code>, <code class="language-plaintext highlighter-rouge">&amp;&amp;=</code>, and <code class="language-plaintext highlighter-rouge">if</code> or <code class="language-plaintext highlighter-rouge">unless</code> without <code class="language-plaintext highlighter-rouge">else</code>:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="no">SimpleCov</span><span class="p">.</span><span class="nf">start</span> <span class="k">do</span>
  <span class="n">enable_coverage</span> <span class="ss">:branch</span>
  <span class="n">ignore_branches</span> <span class="ss">:implicit_else</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Rails applications get something else useful here: <code class="language-plaintext highlighter-rouge">ignore_branches :eval_generated</code> and the new <code class="language-plaintext highlighter-rouge">ignore_methods :eval_generated</code> drop the phantom branch and method entries that macros like <code class="language-plaintext highlighter-rouge">delegate</code> inject.</p>

<h3 id="parallel-runners">Parallel runners</h3>

<p>SimpleCov’s coordination with parallel runners now goes through a pluggable <code class="language-plaintext highlighter-rouge">SimpleCov::ParallelAdapters</code> chain instead of hard-coding the <code class="language-plaintext highlighter-rouge">parallel_tests</code> gem’s API. Two adapters ship, one wrapping the <code class="language-plaintext highlighter-rouge">parallel_tests</code> gem and a generic one for any runner following the <code class="language-plaintext highlighter-rouge">TEST_ENV_NUMBER</code> and <code class="language-plaintext highlighter-rouge">PARALLEL_TEST_GROUPS</code> convention. Custom runners can register their own adapter by subclassing <code class="language-plaintext highlighter-rouge">SimpleCov::ParallelAdapters::Base</code>. There is also a new <code class="language-plaintext highlighter-rouge">SimpleCov.parallel_wait_timeout</code> (default 60 seconds) for the case where one worker runs much heavier files and routinely finishes well after the others, so raise it if you want that worker’s coverage in the merge rather than having the threshold checks run against a partial total.</p>

<h2 id="conclusion">Conclusion</h2>

<p>There are other features we decided not to mention for brevity’s sake. They can all be found in the changelog, at any rate. Given what has changed, most migrations to SimpleCov 1.0 should be straightforward and we recommend to go through with it because the new features add many quality of life improvements for gathering good coverage reports especially in CI, which is essential for dealing with tech debt and guaranteeing your app is functioning as expected.</p>

<p>Here at FastRuby.io we use SimpleCov heavily and it’s one of the staple gems we always end up recommending to clients if they aren’t using anything.</p>

<p>If you want to also know what we can do for you and your team to make you move faster and not get dragged down by endless bugs, performance issues and legacy problems no one understands anymore, <a href="https://www.fastruby.io/#contactus">send us a message and let’s talk!</a>.</p>

<h2 id="resources-and-further-reading">Resources and Further Reading</h2>

<ul>
  <li><a href="https://github.com/simplecov-ruby/simplecov/blob/main/CHANGELOG.md#100-2026-07-12">SimpleCov 1.0.0 changelog</a></li>
  <li><a href="https://github.com/simplecov-ruby/simplecov#readme">SimpleCov README</a></li>
  <li><a href="https://docs.ruby-lang.org/en/master/Coverage.html">Ruby’s Coverage library</a></li>
</ul>]]></content><author><name>mateuspereira</name></author><category term="upgrades" /><summary type="html"><![CDATA[SimpleCov released its first stable 1.0 version. Here is a quick guide to the breaking changes and deprecations, how to tell if they affect you, and how to fix each one.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.fastruby.io/blog/simplecov-is-now-version-1.jpg" /><media:content medium="image" url="https://www.fastruby.io/blog/simplecov-is-now-version-1.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">How to Distribute Private Gems</title><link href="https://www.fastruby.io/blog/how-to-distribute-private-gems.html" rel="alternate" type="text/html" title="How to Distribute Private Gems" /><published>2026-08-18T11:54:56-04:00</published><updated>2026-08-18T11:54:56-04:00</updated><id>https://www.fastruby.io/blog/how-to-distribute-private-gems</id><content type="html" xml:base="https://www.fastruby.io/blog/how-to-distribute-private-gems.html"><![CDATA[<p>When I first started thinking about private gem distribution, I approached the problem the way I always do, backward from <code class="language-plaintext highlighter-rouge">bundle install</code>. What does a developer need to make that command succeed for a private library? Three things: a source (URL or repo), credentials that work in CI and locally, and a reliable way to receive updates without breaking deployments.</p>

<p>Not all code belongs on <a href="https://www.rubygems.org">RubyGems.org</a>, and that’s okay. Sometimes the goal is internal reuse- sharing an SDK or auth client across services. Sometimes it’s a business model: you ship a private gem behind a license. Either way, the constraints are the same: authentication, delivery, and trust.</p>

<p>In this blog post, we’ll give an overview of the options for distributing private gems.</p>

<!--more-->

<h2 id="how-private-gems-are-born">How Private Gems Are Born</h2>

<p>Organizations use private gems for practical reasons. Gems that are built for internal usage can be seen as a convenient way to share libraries such as SDKs, service clients, and shared models across many services without coupling through a monorepo. Organizations can also have advanced functionality as closed source gems and sell access to it.</p>

<p>These use cases share common operational challenges: you must authenticate and authorize installs, deliver updates and prevent unauthorized redistribution. Addressing those concerns influences the distribution method you pick and the infrastructure you operate.</p>

<h2 id="how-rubygems-and-bundler-handle-sources">How RubyGems and Bundler Handle Sources</h2>

<p>By default <code class="language-plaintext highlighter-rouge">gem push</code> targets RubyGems.org and Bundler resolves dependencies from the sources declared in your <code class="language-plaintext highlighter-rouge">Gemfile</code>. For example:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">source</span> <span class="s2">"https://rubygems.org"</span>

<span class="n">gem</span> <span class="s2">"rails"</span><span class="p">,</span> <span class="s2">"~&gt; 7.1"</span>
</code></pre></div></div>

<p>However, both behaviors are configurable. <code class="language-plaintext highlighter-rouge">gem push</code> accepts a <code class="language-plaintext highlighter-rouge">--host</code> flag so you can upload to another gem server, and the RubyGems client will also read credentials from <code class="language-plaintext highlighter-rouge">~/.gem/credentials</code> or the <code class="language-plaintext highlighter-rouge">GEM_HOST_API_KEY</code> environment variable. Meanwhile, Bundler reads sources from the <code class="language-plaintext highlighter-rouge">Gemfile</code>, but you can store credentials with <code class="language-plaintext highlighter-rouge">bundle config</code> (which writes to <code class="language-plaintext highlighter-rouge">.bundle/config</code>), configure mirrors, or point specific hosts to private registries.</p>

<p>Behind the scenes <code class="language-plaintext highlighter-rouge">bundle install</code> downloads metadata from the configured registry, verifies checksums, and installs gems locally. Because RubyGems.org lacks private access controls and payment hooks, organizations that aim to distribute their private gems usually use a private registry or use private Git repos as an alternative.</p>

<h2 id="private-gem-distribution">Private Gem Distribution</h2>
<h3 id="git-based-distribution">Git-Based Distribution</h3>

<p>It’s the simplest option to distribute a private gem. Publish the gem source to a private repository and reference it in your Gemfile. This leverages existing Git authentication (SSH keys or OAuth) and avoids running a registry. For small teams or internal tooling this is often the path.</p>

<p>Referencing a Git repo moves work to the client. Bundler cannot use a pre-built index, so installs can be slower and metadata caching is limited. Because Bundler doesn’t have an index for Git sources, automating version access or licensing controls (for example, restricting which versions a particular customer can install) becomes harder to manage at scale.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>gem "my_private_gem", git: "git@github.com:org/my_private_gem.git"
# or with specific branch/tag
gem "my_private_gem", git: "https://github.com/org/my_private_gem.git", branch: "main"
# or with specific ref
gem "my_private_gem", git: "git@github.com:org/my_private_gem.git", ref: "v1.2.3"
</code></pre></div></div>

<h3 id="private-gem-servers">Private Gem Servers</h3>

<p>Private gem servers, whether managed SaaS or self-hosted, address the shortcomings of Git-based installs by providing an indexable registry with access control, logging, and lifecycle management. Example commercial providers include <a href="https://gemfury.com/">Gemfury</a> and <a href="https://cloudsmith.com/">Cloudsmith</a>; larger organizations often choose <a href="https://jfrog.com/artifactory/">Artifactory</a>.</p>

<p>If you prefer an open-source server, two lightweight and commonly used options are <a href="https://www.gemstash.org/">Gemstash</a> and <a href="https://github.com/geminabox/geminabox">Geminabox</a>.</p>

<h4 id="gemstash-lightweight-rubygems-friendly">Gemstash (lightweight, RubyGems-friendly)</h4>

<p>Gemstash is a small server that can host private gems, proxy and cache RubyGems.org, and provide simple authentication. It’s maintained by contributors in the RubyGems ecosystem and is opinionated for standard Bundler/Rubygems workflows.</p>

<p>Here’s a quick start to run a local server:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># install and run a local server</span>
gem <span class="nb">install </span>gemstash
gemstash start   <span class="c"># serves at http://localhost:9292 by default</span>

<span class="c"># build and push a gem</span>
gem build mygem
gem push <span class="nt">--host</span> http://localhost:9292 pkg/mygem-0.1.0.gem

<span class="c"># point your Gemfile at the server</span>
<span class="nb">source</span> <span class="s1">'http://localhost:9292'</span>
</code></pre></div></div>

<p>It’s also possible to set up authentication and tokens in this Gemstash local server. Check <a href="https://github.com/rubygems/gemstash">their documentation</a> for more information.</p>

<h4 id="geminabox-simple-small-footprint">Geminabox (simple, small footprint)</h4>

<p>Geminabox is Sinatra app that serves gems from a directory. It’s extremely lightweight and easy to deploy (single process), making it a good choice for small teams or quick internal registries.</p>

<p>Here’s a quick start to run a local server:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># install and run the server</span>
gem <span class="nb">install </span>geminabox
geminabox   <span class="c"># runs a server (defaults to http://localhost:9292)</span>

<span class="c"># build and push a gem (uses 'gem inabox' which is provided by the geminabox toolchain)</span>
gem build mygem
gem inabox pkg/mygem-0.1.0.gem <span class="nt">--host</span> http://localhost:9292

<span class="c"># point your Gemfile at the server</span>
<span class="nb">source</span> <span class="s1">'http://localhost:9292'</span>
</code></pre></div></div>

<p>For production use, Geminabox or Gemstash can be set up behind an authenticated reverse proxy strategy (Nginx with basic auth or an SSO gateway) or use TLS + token-based access. Both servers are widely used, well-documented, and simple to operate.</p>

<p>Bundler can be pointed at a private source using an authenticated URL, for example:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">source</span> <span class="s2">"https://USERNAME:TOKEN@gem.fury.io/myorg/"</span>
</code></pre></div></div>

<p>Tokens or API keys are typically injected through environment variables or stored in Bundler’s local config (<code class="language-plaintext highlighter-rouge">.bundle/config</code>). Running a registry gives you practical operational controls: you can revoke access, audit downloads, and integrate the registry with CI/CD for automated publishing.</p>

<h3 id="private-gem-registries">Private Gem Registries</h3>

<p>When productizing a gem, distribution and authentication become core product components. Projects like Sidekiq provide license keys that act as credentials against a private registry; others (for example, providers of long-term support) gate patches and releases behind subscription checks. The implementation is typically the same: a license service issues short-lived or revocable tokens, a registry enforces access, and CI/CD pipelines publish releases and reconcile entitlement after successful payments.</p>

<p>Fundamentally, a commercial private gem system typically rests on three pillars:</p>

<ul>
  <li>Entitlement Management: A service (like a license server) that knows who is allowed to access what.</li>
  <li>Enforcement Point: A private registry that checks these entitlements before serving gems.</li>
  <li>Automation Bridge: Pipelines that automatically grant or revoke access based on events like payment or subscription changes.</li>
</ul>

<p>It also should account for other security controls like: rotating tokens, scoped credentials, and signing artifacts to reduce risks.</p>

<h2 id="security-and-devops-considerations">Security and DevOps Considerations</h2>

<p>Shipping a private gem rests on a practical security responsibility: controlling who can fetch your packages.</p>

<p>Implement least-privilege tokens (separate tokens for publishing vs. install), integrate with SSO where possible, and have a token rotation and revocation process for offboarding or compromised keys.</p>

<h2 id="real-world-examples">Real-World Examples</h2>
<h3 id="sidekiq-pro"><a href="https://sidekiq.org/products/pro/">Sidekiq Pro</a></h3>

<ul>
  <li>Uses token-based authentication for Bundler installs.</li>
  <li>Paid customers receive personal tokens tied to their license.</li>
  <li>Gems are hosted on a private server with strict version control.</li>
</ul>

<h3 id="rails-lts"><a href="https://railslts.com/en">Rails LTS</a></h3>

<ul>
  <li>Customers manually configure credentials after purchasing.</li>
  <li>Patches are distributed as gem updates to specific Rails versions.</li>
  <li>Focuses on compliance and security rather than automation.</li>
</ul>

<h3 id="internal-gems-enterprise-use">Internal Gems (Enterprise Use)</h3>

<ul>
  <li>Common in microservice organizations.</li>
  <li>Teams use self-hosted Gemstash for internal SDKs.</li>
  <li>Easy integration with GitHub Actions or CircleCI.</li>
</ul>

<h2 id="conclusion">Conclusion</h2>

<p>Private gems effectively bridge the gap between open-source collaboration and commercial or internal sustainability. By choosing the right distribution method, from a simple Git repo to a fully-featured private registry, and adhering to sound security principles, you can share your gem safely. Whether you’re building a shared internal SDK or selling a pro-tier library, the goal remains the same: secure your pipeline, manage trust, and respect your users’ access.</p>

<p>Need help setting up secure private gem distribution for your team? <a href="https://www.fastruby.io/#contactus">We can help.</a></p>]]></content><author><name>hmdros</name></author><category term="rails" /><summary type="html"><![CDATA[This post is an overview of the best methods for distributing private Ruby gems, from simple Git repos to secure, token-based registries for commercial products.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.fastruby.io/blog/how_to_distribute_private_gems.png" /><media:content medium="image" url="https://www.fastruby.io/blog/how_to_distribute_private_gems.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Why To Use A Multi-Stage Dockerfile</title><link href="https://www.fastruby.io/blog/why-to-use-a-multi-stage-dockerfile.html" rel="alternate" type="text/html" title="Why To Use A Multi-Stage Dockerfile" /><published>2026-08-13T12:54:59-04:00</published><updated>2026-08-13T12:54:59-04:00</updated><id>https://www.fastruby.io/blog/why-to-use-a-multi-stage-dockerfile</id><content type="html" xml:base="https://www.fastruby.io/blog/why-to-use-a-multi-stage-dockerfile.html"><![CDATA[<p>Docker has made it easy to use the same environment everywhere, from development to production. But the most basic Dockerfile, where all your dependencies are lumped together in one image, has hidden costs. In this article, we’ll learn the advantages of multi-stage Dockerfiles both from a security and a performance standpoint, primarily for production images.</p>

<!--more-->

<h2 id="what-is-a-multi-stage-dockerfile">What is a Multi-Stage Dockerfile?</h2>

<p>You might have a Dockerfile in your application that looks something like this:</p>

<div class="language-dockerfile highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">FROM</span><span class="s"> ruby:3.2</span>

<span class="k">WORKDIR</span><span class="s"> /app</span>

<span class="c"># Install system dependencies needed to compile native gems</span>
<span class="k">RUN </span>apt-get update <span class="o">&amp;&amp;</span> apt-get <span class="nb">install</span> <span class="nt">-y</span> <span class="se">\
</span>    gcc <span class="se">\
</span>    make <span class="se">\
</span>    libpq-dev

<span class="c"># Install gems</span>
<span class="k">COPY</span><span class="s"> Gemfile Gemfile.lock ./</span>
<span class="k">RUN </span>bundle <span class="nb">install</span>

<span class="c"># Copy application code</span>
<span class="k">COPY</span><span class="s"> . .</span>

<span class="k">EXPOSE</span><span class="s"> 3000</span>
<span class="k">CMD</span><span class="s"> ["ruby", "app.rb"]</span>
</code></pre></div></div>

<p>This is a typical single-stage setup. There’s one base image, for example, <code class="language-plaintext highlighter-rouge">ruby:3.2</code>, and everything your application needs gets installed inside it: the full Bundler toolchain, build utilities like <code class="language-plaintext highlighter-rouge">gcc</code> and <code class="language-plaintext highlighter-rouge">make</code> for compiling native gem extensions, and dev dependencies like RSpec or Pry, all living side by side. The appeal is obvious. It’s straightforward to write, easy to reason about, and gets the job done.</p>

<p>But that simplicity comes with a trade-off. For example, every tool you used to build your application is still sitting inside the image you ship to production even though you no longer need it there.</p>

<p>In contrast, a multi-stage Dockerfile introduces the idea of stages, discrete phases of your build process, each defined by its own <code class="language-plaintext highlighter-rouge">FROM</code> statement. Here’s what that looks like in practice:</p>
<ul>
  <li>Multiple <code class="language-plaintext highlighter-rouge">FROM</code> statements: each one starts a new stage with its own base image and its own filesystem. You might have a builder stage that installs compilers and build tools, and a separate production stage that only contains what your application needs to run.</li>
  <li>Selective artifact copying stages can pull specific files from a previous stage using <code class="language-plaintext highlighter-rouge">COPY --from=&lt;stage&gt;</code>. This means your final image gets only the output of the build such as compiled code and static assets, and not the tools that produced it.</li>
</ul>

<p>Here’s an example of what a multi-stage Dockerfile looks like in practice. This multi-stage Dockerfile is from the most recent version of Rails:</p>

<div class="language-dockerfile highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># syntax=docker/dockerfile:1</span>
<span class="c"># check=error=true</span>

<span class="c"># This Dockerfile is designed for production, not development. Use with Kamal or build'n'run by hand:</span>
<span class="c"># docker build -t rails_demo .</span>
<span class="c"># docker run -d -p 80:80 -e RAILS_MASTER_KEY=&lt;value from config/master.key&gt; --name rails_demo rails_demo</span>

<span class="c"># For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html</span>

<span class="c"># Make sure RUBY_VERSION matches the Ruby version in .ruby-version</span>
<span class="k">ARG</span><span class="s"> RUBY_VERSION=4.0.4</span>
<span class="k">FROM</span><span class="w"> </span><span class="s">docker.io/library/ruby:$RUBY_VERSION-slim</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="s">base</span>

<span class="c"># Rails app lives here</span>
<span class="k">WORKDIR</span><span class="s"> /rails</span>

<span class="c"># Install base packages</span>
<span class="k">RUN </span>apt-get update <span class="nt">-qq</span> <span class="o">&amp;&amp;</span> <span class="se">\
</span>    apt-get <span class="nb">install</span> <span class="nt">--no-install-recommends</span> <span class="nt">-y</span> curl libjemalloc2 libvips sqlite3 <span class="o">&amp;&amp;</span> <span class="se">\
</span>    <span class="nb">ln</span> <span class="nt">-s</span> /usr/lib/<span class="si">$(</span><span class="nb">uname</span> <span class="nt">-m</span><span class="si">)</span><span class="nt">-linux-gnu</span>/libjemalloc.so.2 /usr/local/lib/libjemalloc.so <span class="o">&amp;&amp;</span> <span class="se">\
</span>    <span class="nb">rm</span> <span class="nt">-rf</span> /var/lib/apt/lists /var/cache/apt/archives

<span class="c"># Set production environment variables and enable jemalloc for reduced memory usage and latency.</span>
<span class="k">ENV</span><span class="s"> RAILS_ENV="production" \</span>
    BUNDLE_DEPLOYMENT="1" \
    BUNDLE_PATH="/usr/local/bundle" \
    BUNDLE_WITHOUT="development" \
    LD_PRELOAD="/usr/local/lib/libjemalloc.so"

# Throw-away build stage to reduce size of final image
<span class="k">FROM</span><span class="w"> </span><span class="s">base</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="s">build</span>

<span class="c"># Install packages needed to build gems</span>
<span class="k">RUN </span>apt-get update <span class="nt">-qq</span> <span class="o">&amp;&amp;</span> <span class="se">\
</span>    apt-get <span class="nb">install</span> <span class="nt">--no-install-recommends</span> <span class="nt">-y</span> build-essential git libvips libyaml-dev pkg-config <span class="o">&amp;&amp;</span> <span class="se">\
</span>    <span class="nb">rm</span> <span class="nt">-rf</span> /var/lib/apt/lists /var/cache/apt/archives

<span class="c"># Install application gems</span>
<span class="k">COPY</span><span class="s"> vendor/* ./vendor/</span>
<span class="k">COPY</span><span class="s"> Gemfile Gemfile.lock ./</span>

<span class="k">RUN </span>bundle <span class="nb">install</span> <span class="o">&amp;&amp;</span> <span class="se">\
</span>    <span class="nb">rm</span> <span class="nt">-rf</span> ~/.bundle/ <span class="s2">"</span><span class="k">${</span><span class="nv">BUNDLE_PATH</span><span class="k">}</span><span class="s2">"</span>/ruby/<span class="k">*</span>/cache <span class="s2">"</span><span class="k">${</span><span class="nv">BUNDLE_PATH</span><span class="k">}</span><span class="s2">"</span>/ruby/<span class="k">*</span>/bundler/gems/<span class="k">*</span>/.git <span class="o">&amp;&amp;</span> <span class="se">\
</span>    <span class="c"># -j 1 disable parallel compilation to avoid a QEMU bug: https://github.com/rails/bootsnap/issues/495</span>
    bundle exec bootsnap precompile -j 1 --gemfile

<span class="c"># Copy application code</span>
<span class="k">COPY</span><span class="s"> . .</span>

<span class="c"># Precompile bootsnap code for faster boot times.</span>
<span class="c"># -j 1 disable parallel compilation to avoid a QEMU bug: https://github.com/rails/bootsnap/issues/495</span>
<span class="k">RUN </span>bundle <span class="nb">exec </span>bootsnap precompile <span class="nt">-j</span> 1 app/ lib/

<span class="c"># Precompiling assets for production without requiring secret RAILS_MASTER_KEY</span>
<span class="k">RUN </span><span class="nv">SECRET_KEY_BASE_DUMMY</span><span class="o">=</span>1 ./bin/rails assets:precompile

<span class="c"># Final stage for app image</span>
<span class="k">FROM</span><span class="s"> base</span>

<span class="c"># Run and own only the runtime files as a non-root user for security</span>
<span class="k">RUN </span>groupadd <span class="nt">--system</span> <span class="nt">--gid</span> 1000 rails <span class="o">&amp;&amp;</span> <span class="se">\
</span>    useradd rails <span class="nt">--uid</span> 1000 <span class="nt">--gid</span> 1000 <span class="nt">--create-home</span> <span class="nt">--shell</span> /bin/bash
<span class="k">USER</span><span class="s"> 1000:1000</span>

<span class="c"># Copy built artifacts: gems, application</span>
<span class="k">COPY</span><span class="s"> --chown=rails:rails --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}"</span>
<span class="k">COPY</span><span class="s"> --chown=rails:rails --from=build /rails /rails</span>

<span class="c"># Entrypoint prepares the database.</span>
<span class="k">ENTRYPOINT</span><span class="s"> ["/rails/bin/docker-entrypoint"]</span>

<span class="c"># Start server via Thruster by default, this can be overwritten at runtime</span>
<span class="k">EXPOSE</span><span class="s"> 80</span>
<span class="k">CMD</span><span class="s"> ["./bin/thrust", "./bin/rails", "server"]</span>

</code></pre></div></div>

<p>Here’s what’s happening across the three stages:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">base</code>: this stage sets up the common foundation (slim Ruby image, shared env vars) that both other stages build on.</li>
  <li><code class="language-plaintext highlighter-rouge">build</code>: the throw-away stage. Installs <code class="language-plaintext highlighter-rouge">build-essential</code>, <code class="language-plaintext highlighter-rouge">git</code>, compiles gems, precompiles assets. None of these tools ship to production.</li>
  <li>the unnamed third <code class="language-plaintext highlighter-rouge">FROM base</code>: this copies only the built artifacts from <code class="language-plaintext highlighter-rouge">build</code> and runs as a non-root user.</li>
</ul>

<h2 id="why-use-a-multi-stage-dockerfile">Why Use a Multi-Stage Dockerfile?</h2>

<p>Now that we know what the differences are between a single-stage and multi-stage Dockerfile, let’s talk about why you might choose a multi-stage Dockerfile.</p>

<p>Two main advantages of multi-stage builds are performance and security:</p>

<h3 id="performance">Performance</h3>

<p>Multi-stage builds usually have a smaller final image size since the build tools and dev dependencies don’t make it into the final image. Multi-stage builds also play nicely with Docker’s layer caching. Because each stage is isolated, Docker can cache them independently. If your dependencies haven’t changed, the builder stage can be skipped entirely on the next run. You only rebuild the stages that actually changed, which adds up to significant time savings over hundreds of pipeline runs. Smaller images and cached stages mean faster pulls, faster deployments, and less time waiting around in your CI/CD pipeline.</p>

<h3 id="security">Security</h3>

<p>A multi-stage build is more secure than a single-stage build because the production image contains only what’s needed to run the application, nothing more.</p>

<p>Every package installed in your image is a potential vulnerability. Build tools like <code class="language-plaintext highlighter-rouge">gcc</code>, <code class="language-plaintext highlighter-rouge">make</code>, and <code class="language-plaintext highlighter-rouge">curl</code> are common in single-stage images and they’re also common exploit vectors. With a multi-stage build, those tools never make it into your production image. Fewer packages means fewer CVEs to worry about, and a much smaller surface for attackers to target.</p>

<p>You can reduce this even further by pairing your final stage with a slim base image. For example, <a href="https://hub.docker.com/layers/library/ruby/4.0.0-slim/images/sha256-581e1c0a771beb4260290349ce63b8de3e50c041ff3d509543a0b7f1ac11921f"><code class="language-plaintext highlighter-rouge">ruby:4.0-slim</code></a> strips out packages that aren’t needed for most Ruby apps, and <a href="https://hub.docker.com/layers/library/ruby/4.0-alpine/images/sha256:c20865a22bd9feec512ee4deb2ec526f3827c512a295b4b271f325eacc4ec501"><code class="language-plaintext highlighter-rouge">ruby:4.0-alpine</code></a> goes even further, using Alpine Linux as its base, a minimal OS that keeps only the bare essentials by default.</p>

<h2 id="tips-and-common-pitfalls">Tips and Common Pitfalls</h2>

<ul>
  <li>Skip multi-stage for development and test environments. The security and build-performance wins mostly matter for what you ship to production. In dev you’re rebuilding constantly and want to run commands like <code class="language-plaintext highlighter-rouge">bundle install</code>, <code class="language-plaintext highlighter-rouge">rails console</code>, or add a gem on the fly. A multi-stage setup adds friction for little payoff there, since your dev tools need to stay in the image anyway. Keep a single-stage Dockerfile (or a dedicated dev stage in the same file) for local development and testing, and reserve the multi-stage build for your production image.</li>
  <li>Name your stages. Using <code class="language-plaintext highlighter-rouge">AS builder</code> and <code class="language-plaintext highlighter-rouge">AS production</code> in your <code class="language-plaintext highlighter-rouge">FROM</code> statements makes your Dockerfile much easier to read and maintain, especially as the number of stages grows.</li>
  <li>Know what to copy. The most common stumbling block is figuring out exactly which files need to move between stages. For a Ruby app, that’s typically your installed gems directory and your application code. Take the time to map this out before you write the <code class="language-plaintext highlighter-rouge">COPY --from</code> lines.</li>
  <li>Run as a non-root user in your final stage, like the Rails example above does. Even if an attacker gets into the container, they won’t have root access to work with.</li>
  <li>Build cache ordering matters: Put the steps least likely to change at the top of each stage and most likely to change at the bottom. For example, copy your Gemfile and run <code class="language-plaintext highlighter-rouge">bundle install</code> before copying your application code. That way, if you change a Ruby file, Docker doesn’t re-run <code class="language-plaintext highlighter-rouge">bundle install</code> unnecessarily.</li>
  <li>Match your base images when going slim or Alpine. If your final stage switches to a musl-based image like <code class="language-plaintext highlighter-rouge">ruby:4.0-alpine</code>, but your builder stage compiled native gem extensions on a glibc-based image like <code class="language-plaintext highlighter-rouge">ruby:4.0</code> or <code class="language-plaintext highlighter-rouge">ruby:4.0-slim</code>, those extensions may fail at runtime. Either build and run on matching bases, or compile your gems inside an Alpine builder stage.</li>
  <li>Forgetting build dependencies at runtime. A very common mistake is copying your compiled gems into the final stage but forgetting to install the runtime system libraries they depend on. For example, the <code class="language-plaintext highlighter-rouge">pg</code> gem needs <code class="language-plaintext highlighter-rouge">libpq5</code> at runtime even though it only needs <code class="language-plaintext highlighter-rouge">libpq-dev</code> to compile. Your app will build fine but crash at runtime. This is probably the most common real-world gotcha with multi-stage builds.</li>
  <li>Secret leakage between stages. People sometimes assume that because secrets (API keys, credentials) used in the build stage don’t get copied to the final stage, they’re safe. But they can still be exposed in the image’s layer history. Use Docker BuildKit secrets instead:
    <div class="language-dockerfile highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">RUN </span><span class="nt">--mount</span><span class="o">=</span><span class="nb">type</span><span class="o">=</span>secret,id<span class="o">=</span>my_secret ...
</code></pre></div>    </div>
  </li>
  <li>If you want to go even further than slim images, look into distroless images. These images contain absolutely nothing except your app and its runtime- no shell, no package manager, nothing. They represent the ultimate reduction in attack surface. Just be aware that Ruby support for distroless is still limited compared to languages like Go or Java, so do your research before committing to it for a Ruby project.</li>
</ul>

<h2 id="conclusion">Conclusion</h2>

<p>Multi-stage Dockerfiles require slightly more work upfront. But in return, you get images that are dramatically smaller, meaningfully more secure, and faster to build. That’s a trade-off worth making for any production workload.</p>

<p>Want to convert your single-stage Dockerfiles to multi-stage? <a href="https://www.fastruby.io/#contactus">We can help!</a></p>]]></content><author><name>gelseyt</name></author><category term="devops" /><summary type="html"><![CDATA[Your Dockerfile might be shipping gcc, make, and RSpec into production without realizing it. See why multi-stage Dockerfiles improve performance and security for Ruby apps.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.fastruby.io/blog/why-to-use-a-multi-stage-dockerfile.jpg" /><media:content medium="image" url="https://www.fastruby.io/blog/why-to-use-a-multi-stage-dockerfile.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Measuring Code Coverage For Non-Ruby Runners</title><link href="https://www.fastruby.io/blog/ruby-coverage-for-non-ruby-runners.html" rel="alternate" type="text/html" title="Measuring Code Coverage For Non-Ruby Runners" /><published>2026-08-10T15:51:50-04:00</published><updated>2026-08-10T15:51:50-04:00</updated><id>https://www.fastruby.io/blog/ruby-coverage-for-non-ruby-runners</id><content type="html" xml:base="https://www.fastruby.io/blog/ruby-coverage-for-non-ruby-runners.html"><![CDATA[<p>When we think about Ruby code coverage, our go-to gem for this is <a href="https://github.com/simplecov-ruby/simplecov">SimpleCov</a>, which works great when the test suite uses <a href="https://rubygems.org/gems/minitest">Minitest</a>, <a href="https://rubygems.org/gems/rspec">RSpec</a>, <a href="https://rubygems.org/gems/cucumber">Cucumber</a>, <a href="https://rubygems.org/gems/capybara">Capybara</a>, and all these tools that are integrated with Ruby and Rails. But many applications also use other tools like <a href="https://playwright.dev">Playwright</a> or <a href="https://www.cypress.io">Cypress</a> to run e2e tests, and we can’t use SimpleCov the same way.</p>

<p>Most of the time, what we have seen is that the Ruby code executed when running these tools ends up left behind and not being counted for the total code coverage, even though we know the code is actually being tested.</p>

<!--more-->

<h2 id="sample-application">Sample Application</h2>

<p>To make it easier to try this, we created <a href="https://github.com/fastruby/coverage-with-cypress-sample-app">a sample application</a> that uses the <a href="https://rubygems.org/gems/cypress-on-rails"><code class="language-plaintext highlighter-rouge">cypress-on-rails</code> gem</a> along with Minitest, and includes the instructions and custom Rake tasks to get the final code coverage when running both test suites.</p>

<p>Note that this could be done with any other tool like Playwright or <a href="https://github.com/angular/protractor">Protractor</a> (deprecated) since the actual integration is independent of the tool as long as it runs the Rails server.</p>

<h2 id="approach-summary">Approach Summary</h2>

<p>The main problem that we are facing when using these external tools is that the Rails server is controlled by this runner and not started by a Ruby runner, so we can’t easily control the lifecycle of the server to measure and extract the coverage information.</p>

<p>We can see how the <code class="language-plaintext highlighter-rouge">cypress-on-rails</code> gem already overcomes some of these limitations with custom helpers to execute fixtures, factories, and db cleanup in <a href="https://github.com/shakacode/cypress-playwright-on-rails/blob/3ba1ba4fa9d8e5e4864c4022dea7084dcb51aa05/lib/generators/cypress_on_rails/templates/spec/cypress/e2e/rails_examples/using_fixtures.cy.js#L7">their generated sample tests</a>.</p>

<p>We have a few moving pieces here, so this is a quick summary of the process:</p>

<ul>
  <li>when we run <code class="language-plaintext highlighter-rouge">rails test</code> we get the code coverage of the Minitest test suite</li>
  <li>then we’ll run Coverband’s standalone server on the side, so it collects stats from the Rails servers started by Cypress</li>
  <li>once the e2e tests are done (running <code class="language-plaintext highlighter-rouge">rails cypress:run</code>), we can use a custom Rake task to extract the code coverage from Coverband into a JSON file</li>
  <li>finally, we can merge the code coverage of the 2 tools into a single final report</li>
</ul>

<h2 id="the-details">The Details</h2>

<h3 id="minitest-code-coverage">Minitest Code Coverage</h3>

<p>This step is the standard SimpleCov setup: we only need to add the SimpleCov gem in <a href="https://github.com/fastruby/coverage-with-cypress-sample-app/blob/main/Gemfile#L66">the <code class="language-plaintext highlighter-rouge">:test</code> group</a>, and enable SimpleCov at <a href="https://github.com/fastruby/coverage-with-cypress-sample-app/blob/main/test/test_helper.rb#L4">the beginning of our <code class="language-plaintext highlighter-rouge">test_helper.rb</code> file</a>.</p>

<p>After this we can do a quick test, run <code class="language-plaintext highlighter-rouge">rails test</code> and see the generated report:</p>

<p><img src="/blog/assets/images/cypress-coverage/minitest-list.png" alt="Minitest total coverage" /></p>

<p>We can see the total coverage is around 35%, and we can see the details of the <code class="language-plaintext highlighter-rouge">UsersController</code> coverage that we’ll use for this example too:</p>

<p><img src="/blog/assets/images/cypress-coverage/minitest-users-controller.png" alt="Minitest UsersController coverage" /></p>

<p>Note the total coverage of this file is around 39%, with many lines not covered (lines 15, 24, and 26). This is expected, as we are <a href="https://github.com/fastruby/coverage-with-cypress-sample-app/blob/main/test/controllers/users_controller_test.rb#L9">only testing the index action</a> in our Minitest suite.</p>

<p>But we know we have more tests for this file; the Cypress test suite is testing an <a href="https://github.com/fastruby/coverage-with-cypress-sample-app/blob/main/e2e/cypress/e2e/user_creation.cy.js#L6">invalid form submission of the new user form</a>.</p>

<h3 id="coverband-standalone-server">Coverband Standalone Server</h3>

<p>As mentioned above, we can’t simply add SimpleCov to the Cypress tests, so we are going to use <a href="https://rubygems.org/gems/coverband">Coverband</a> (which uses SimpleCov internally) to capture the code that is being executed by the tests, similar to what we would do if running Coverband in production.</p>

<p>Coverband provides a standalone server that we can use so we can control the lifecycle of the code coverage tracking independently of the lifecycle of the Cypress runner.</p>

<p>First we have to add the <code class="language-plaintext highlighter-rouge">coverband</code> gem (note that we are adding it only in the <code class="language-plaintext highlighter-rouge">test</code> group, we are not using it to track production coverage!). Then, we can execute the server with <code class="language-plaintext highlighter-rouge">RAILS_ENV=test rails coverband:coverage_server</code> and leave it running on the side.</p>

<blockquote>
  <p>Note that, now that Coverband is added, we get an error message at the end of the Minitest run. Ideally, we could use a different Rails environment for the e2e tests so the gem wouldn’t be loaded, but the <code class="language-plaintext highlighter-rouge">cypress-on-rails</code> gem hardcodes the <code class="language-plaintext highlighter-rouge">test</code> Rails environment.</p>
</blockquote>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>E, [2026-01-02T16:29:34.221521 #322239] ERROR -- : coverage failed to store
E, [2026-01-02T16:29:34.221579 #322239] ERROR -- : Coverband Error: #&lt;RuntimeError: coverage measurement is not enabled&gt; coverage measurement is not enabled
</code></pre></div></div>

<h3 id="running-cypress-tests">Running Cypress Tests</h3>

<p>Now it’s time to run the e2e tests. In this example we run <code class="language-plaintext highlighter-rouge">rails cypress:run</code>, but it would be the same for any other tool, as long as we set the same RAILS_ENV variable as the Coverband server (so the gem is required).</p>

<p>When the tests finish, we won’t see any information about the code coverage, but we can open the Coverband server in the browser to check how the percentage is being tracked in <code class="language-plaintext highlighter-rouge">http://localhost:9022</code>.</p>

<p>It’s important to note that Coverband will calculate all the coverage of any code that gets executed (even for Ruby files we don’t really care about when looking at the final code coverage value) and we get some information like coverage of config files or test files that will affect the coverage percentage. With SimpleCov, we use <a href="https://github.com/fastruby/coverage-with-cypress-sample-app/blob/main/test/test_helper.rb#L4">the <code class="language-plaintext highlighter-rouge">'rails'</code> profile</a> to ignore those extra files, but we don’t need to worry about it at this point for Coverband. We’ll clean that up at the end.</p>

<h3 id="extracting-the-code-coverage-from-coverband">Extracting the Code Coverage from Coverband</h3>

<p>Coverband provides a <code class="language-plaintext highlighter-rouge">coverband:coverage_html</code> task that uses SimpleCov to process the internal coverage data. There’s also a <code class="language-plaintext highlighter-rouge">coverband:coverage_json</code> but these tasks don’t generate the raw data we need to merge it with the Minitest resultset. To solve this, we created a custom Rake task that will extract the raw data and format the JSON file to have a structure that we can then merge.</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># https://github.com/fastruby/coverage-with-cypress-sample-app/blob/main/lib/tasks/json_coverage.rake#L2</span>

<span class="n">desc</span> <span class="s2">"JSON formatted report of Coverband code coverage"</span>
<span class="n">task</span> <span class="ss">:simplecov_json_report</span> <span class="k">do</span>
  <span class="nb">require</span> <span class="s2">"coverband"</span>
  <span class="nb">require</span> <span class="s2">"coverband/utils/result"</span>
  <span class="nb">require</span> <span class="s2">"coverband/utils/file_list"</span>
  <span class="nb">require</span> <span class="s2">"coverband/utils/source_file"</span>
  <span class="nb">require</span> <span class="s2">"coverband/utils/lines_classifier"</span>
  <span class="nb">require</span> <span class="s2">"coverband/utils/results"</span>

  <span class="nb">require</span> <span class="s2">"simplecov"</span>
  <span class="nb">require</span> <span class="s2">"simplecov_json_formatter"</span>

  <span class="sb">`mkdir -p </span><span class="si">#{</span><span class="no">SimpleCov</span><span class="p">.</span><span class="nf">coverage_path</span><span class="si">}</span><span class="sb">`</span>

  <span class="c1"># SimpleCov hardcodes this constant as `coverage.json`, we are renaming this here to make it more</span>
  <span class="c1"># clear, but this step is not necessary</span>
  <span class="no">SimpleCovJSONFormatter</span><span class="o">::</span><span class="no">ResultExporter</span><span class="p">.</span><span class="nf">send</span><span class="p">(</span><span class="ss">:remove_const</span><span class="p">,</span> <span class="s2">"FILENAME"</span><span class="p">)</span>
  <span class="no">SimpleCovJSONFormatter</span><span class="o">::</span><span class="no">ResultExporter</span><span class="o">::</span><span class="no">FILENAME</span> <span class="o">=</span> <span class="s2">"cypress_coverage.json"</span>

  <span class="n">coverband_reports</span> <span class="o">=</span> <span class="no">Coverband</span><span class="o">::</span><span class="no">Reporters</span><span class="o">::</span><span class="no">Base</span><span class="p">.</span><span class="nf">report</span><span class="p">(</span><span class="no">Coverband</span><span class="p">.</span><span class="nf">configuration</span><span class="p">.</span><span class="nf">store</span><span class="p">)</span>
  <span class="no">Coverband</span><span class="o">::</span><span class="no">Reporters</span><span class="o">::</span><span class="no">Base</span><span class="p">.</span><span class="nf">fix_reports</span><span class="p">(</span><span class="n">coverband_reports</span><span class="p">)</span>
  <span class="n">result</span> <span class="o">=</span> <span class="no">Coverband</span><span class="o">::</span><span class="no">Utils</span><span class="o">::</span><span class="no">Results</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="n">coverband_reports</span><span class="p">)</span>
  <span class="no">SimpleCov</span><span class="o">::</span><span class="no">Formatter</span><span class="o">::</span><span class="no">JSONFormatter</span><span class="p">.</span><span class="nf">new</span><span class="p">.</span><span class="nf">format</span><span class="p">(</span><span class="n">result</span><span class="p">)</span>

  <span class="c1"># fix json structure so it can be merged with coverage:merge</span>
  <span class="c1"># we want a `"Cypress"` key at the root of the JSON object, with the `"coverage"` inside it</span>
  <span class="n">generated_json_file</span> <span class="o">=</span> <span class="no">File</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="no">SimpleCov</span><span class="p">.</span><span class="nf">coverage_path</span><span class="p">,</span> <span class="no">SimpleCovJSONFormatter</span><span class="o">::</span><span class="no">ResultExporter</span><span class="o">::</span><span class="no">FILENAME</span><span class="p">)</span>
  <span class="n">content</span> <span class="o">=</span> <span class="p">{</span> <span class="s2">"Cypress"</span> <span class="o">=&gt;</span> <span class="no">JSON</span><span class="p">.</span><span class="nf">parse</span><span class="p">(</span><span class="no">File</span><span class="p">.</span><span class="nf">read</span><span class="p">(</span><span class="n">generated_json_file</span><span class="p">))</span> <span class="p">}</span>
  <span class="no">File</span><span class="p">.</span><span class="nf">write</span><span class="p">(</span><span class="n">generated_json_file</span><span class="p">,</span> <span class="n">content</span><span class="p">.</span><span class="nf">to_json</span><span class="p">)</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Now we can run <code class="language-plaintext highlighter-rouge">rails simplecov_json_report</code> to generate the <code class="language-plaintext highlighter-rouge">coverage/cypress_coverage.json</code> file.</p>

<blockquote>
  <p>Note that this step will show a really high code coverage percentage, over 75%, but this is not correct since it includes config, test, and other files for this calculation!</p>
</blockquote>

<h3 id="merging-results">Merging Results</h3>

<p>Now we get to the final step: we have the <code class="language-plaintext highlighter-rouge">coverage/.resultset.json</code> and <code class="language-plaintext highlighter-rouge">coverage/cypress_coverage.json</code> files that we need to merge into a single file.</p>

<p>For this, we have created another custom Rake task that uses SimpleCov’s <code class="language-plaintext highlighter-rouge">collate</code> method:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># https://github.com/fastruby/coverage-with-cypress-sample-app/blob/main/lib/tasks/json_coverage.rake#L34</span>

<span class="n">namespace</span> <span class="ss">:coverage</span> <span class="k">do</span>
  <span class="n">desc</span> <span class="s2">"Merge Minitest's and Cypress' code coverage json files into one"</span>
  <span class="n">task</span> <span class="ss">:merge</span> <span class="k">do</span>
    <span class="nb">require</span> <span class="s2">"simplecov"</span>

    <span class="c1"># change this if you use different json result names</span>
    <span class="n">coverage_files</span> <span class="o">=</span> <span class="no">Dir</span><span class="p">[</span><span class="s2">"</span><span class="si">#{</span><span class="no">SimpleCov</span><span class="p">.</span><span class="nf">coverage_path</span><span class="si">}</span><span class="s2">/.resultset.json"</span><span class="p">]</span> <span class="o">+</span> <span class="no">Dir</span><span class="p">[</span><span class="s2">"</span><span class="si">#{</span><span class="no">SimpleCov</span><span class="p">.</span><span class="nf">coverage_path</span><span class="si">}</span><span class="s2">/cypress_coverage.json"</span><span class="p">]</span>

    <span class="c1"># make sure to use the `rails` profile to not add noise with files we won't test</span>
    <span class="no">SimpleCov</span><span class="p">.</span><span class="nf">collate</span> <span class="n">coverage_files</span><span class="p">,</span> <span class="s2">"rails"</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>And we can run this task with <code class="language-plaintext highlighter-rouge">rails coverage:merge</code> and see the new generated code coverage report:</p>

<p><img src="/blog/assets/images/cypress-coverage/merged-list.png" alt="Merged total percentage" /></p>

<p>Here we can see the total percentage went up to almost 53%.</p>

<p><img src="/blog/assets/images/cypress-coverage/merged-users-controller.png" alt="Merged UsersController coverage" /></p>

<p>And here we can see the <code class="language-plaintext highlighter-rouge">UsersController</code> coverage is over 60% and we can see the lines inside the <code class="language-plaintext highlighter-rouge">new</code> and <code class="language-plaintext highlighter-rouge">create</code> actions are now green as we expected from the Cypress test.</p>

<h2 id="conclusion">Conclusion</h2>

<p>This is a basic example of the approach; in a real production application it may require some tweaks depending on the tool being used, along with the proper setup for CI and proper automation of the extraction and merging of the coverage data.</p>

<p>A similar problem happens when we want to measure the JavaScript/TypeScript code coverage and we are using a Ruby runner like Capybara along with runners like Jest or Cypress. You can read about capturing all the code coverage and merge results together in this article: <a href="/blog/rails/javascript/code-coverage/js-code-coverage-in-rails.html">JavaScript Test Code Coverage in Rails</a>.</p>

<p>Do you need help with your tests? <a href="/#contactus">Let’s talk!</a></p>]]></content><author><name>arieljuod</name></author><category term="rails" /><summary type="html"><![CDATA[Learn how to measure and merge Ruby code coverage when using non-Ruby e2e test runners like Cypress or Playwright, combining SimpleCov and Coverband into one report.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.fastruby.io/blog/ruby-code-coverage-for-non-ruby-runners.jpg" /><media:content medium="image" url="https://www.fastruby.io/blog/ruby-code-coverage-for-non-ruby-runners.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Opening a Repo Is Now an Execution Event</title><link href="https://www.fastruby.io/blog/opening-a-repo-is-now-an-execution-event.html" rel="alternate" type="text/html" title="Opening a Repo Is Now an Execution Event" /><published>2026-08-06T10:32:40-04:00</published><updated>2026-08-06T10:32:40-04:00</updated><id>https://www.fastruby.io/blog/opening-a-repo-is-now-an-execution-event</id><content type="html" xml:base="https://www.fastruby.io/blog/opening-a-repo-is-now-an-execution-event.html"><![CDATA[<p>If you work on Rails for a living, you clone repositories you didn’t write all week long: a gem you’re debugging, a client’s application you’re about to audit, a bug reproduction attached to an issue. For years, opening one of those in your editor was the safe part. You were reading someone else’s code, not running it, and the only real rule was to not run anything until you had looked.</p>

<p>That rule quietly stopped being enough. AI coding editors like Claude Code and Cursor read project-local configuration the moment you open a repository, and some of that configuration is executable. A repo you cloned five seconds ago can hand your editor a command to run before you have read a line of it, and this is not hypothetical: the npm worm that hit <code class="language-plaintext highlighter-rouge">keyv</code> this week (<a href="https://socket.dev/blog/popular-npm-packages-in-the-keyv-and-cacheable-namespaces-compromised-in-active-supply-chain">Socket</a>, <a href="https://www.aikido.dev/blog/keyv-and-friends-compromised-in-npm-supply-chain-attack">Aikido</a>, and <a href="https://www.microsoft.com/en-us/security/blog/2026/08/04/chaindrop-supply-chain-compromise-anatomy-self-propagating-worm/">Microsoft</a> all covered it) weaponized exactly this, writing itself into <code class="language-plaintext highlighter-rouge">.claude/settings.json</code> so it runs again for anyone who opens the project.</p>

<p>In this post, we’ll look at why opening a repository in an AI editor is now an execution event, how the keyv worm turned that into a live attack, and what to check in a cloned repo’s <code class="language-plaintext highlighter-rouge">.claude/</code> and <code class="language-plaintext highlighter-rouge">.vscode/</code> directories before you point your editor at it.</p>

<!--more-->

<h2 id="why-opening-a-repo-now-executes-code">Why opening a repo now executes code</h2>

<p>For years the mental model was simple: untrusted code runs when you tell it to. You install a package and its install scripts run, you run the app and its code runs, but opening a repository to read it was passive. Looking wasn’t doing.</p>

<p>AI editors break that model by design. Claude Code reads <code class="language-plaintext highlighter-rouge">.claude/settings.json</code> on every session start, and one of the things that file can configure is a <code class="language-plaintext highlighter-rouge">SessionStart</code> hook, a shell command that runs when the session begins, with no separate step asking you to approve it first. A <code class="language-plaintext highlighter-rouge">.claude/</code> directory you didn’t create can hand your session a command just as easily as one you built yourself.</p>

<p>Cursor and VS Code have the same shape with more friction: a task in <code class="language-plaintext highlighter-rouge">.vscode/tasks.json</code> can be set to run on folder open, though only in a workspace you have trusted and only once you have opted into automatic tasks. Either way, the config is meant to be read and acted on the moment the project opens, which is exactly what makes it useful to someone who has slipped it into a repo you are about to clone.</p>

<p>A configuration-injection bug in <a href="https://marimo.io">marimo</a>, a Python notebook tool (<a href="https://github.com/advisories/GHSA-v9v8-jp27-55gf">CVE-2026-67618</a>), surfaced the same week and leaked API keys the moment you opened a crafted notebook, a different mechanism with the same shape. Two unrelated tools hitting the same problem in the same week is a sign this is a shift in how tools treat project-local config, not an npm or a Rails quirk. The common thread is that the more a tool reads and acts on configuration shipped inside a project, the more of its behavior a repo you didn’t write controls the moment you open it.</p>

<h2 id="already-weaponized-the-keyv-worm">Already weaponized: the keyv worm</h2>

<p>The keyv worm shows this is not a theoretical risk. The npm package <code class="language-plaintext highlighter-rouge">keyv</code>, along with a few hundred others, was compromised on August 4th, 2026, in a self-propagating worm variously called ChainDrop or “Shai-Hulud: Here We Go Again”. The credential-stealing half of it is ordinary: an install script harvests tokens and keys from the filesystem and ships them out to the attacker. The half that matters here is how it sticks around. As Socket noted, listing it among techniques “not documented in earlier Shai-Hulud reporting,” the payload writes a <code class="language-plaintext highlighter-rouge">SessionStart</code> hook into <code class="language-plaintext highlighter-rouge">.claude/settings.json</code> and a <code class="language-plaintext highlighter-rouge">folderOpen</code> task into <code class="language-plaintext highlighter-rouge">.vscode/tasks.json</code>.</p>

<p>Those files are never touched by <code class="language-plaintext highlighter-rouge">npm install</code>. The next time you or a teammate opens the project, they are read by your editor, which means the infection outlives the dependency. You can pull the compromised package out of your lockfile and the hook waiting in <code class="language-plaintext highlighter-rouge">.claude/</code> is still there, ready to run the next time someone opens the repo in Claude Code. That is the difference between “we shipped a bad dependency” and “everyone who opens this repo runs the payload,” and it is why cleaning up after this worm is not just a lockfile edit.</p>

<h2 id="why-your-usual-instincts-miss-it">Why your usual instincts miss it</h2>

<p>None of the usual reflexes catch this. “Don’t run code you haven’t read” and “only install signed, attested packages” are both good habits, and both miss the point here, because the dangerous part isn’t something you run or install at all. The hook rides in the repository’s own files. Provenance, a signed record tying a package back to the exact source commit and CI build that produced it, tells you which pipeline published a dependency; it says nothing about a <code class="language-plaintext highlighter-rouge">.claude/settings.json</code> committed straight into a repo you cloned. Ruby has its own take on verifying the packages you install, the gem signing and <code class="language-plaintext highlighter-rouge">--trust-policy</code> we looked at in <a href="https://www.fastruby.io/blog/rubygems-security">The Forgotten Flag: How –trust-policy Works</a>, and it shares the same blind spot: signing covers the artifacts you install, not the config you open.</p>

<h2 id="why-this-lands-on-rails-teams">Why this lands on Rails teams</h2>

<p>This started as an npm story, but the exposure isn’t really about your dependencies. If your Rails app has a Node asset pipeline it is worth keeping an eye on your lockfile, and keeping <code class="language-plaintext highlighter-rouge">Gemfile.lock</code> honest matters too (we have written before about <a href="https://www.fastruby.io/blog/hidden-dangers-in-your-gemfile">supply-chain risk in your Gemfile</a> and about how <a href="https://www.fastruby.io/blog/why-patching-the-gem-didnt-fix-cve-2026-66066">patching a gem isn’t always the whole fix</a>). But a gem or npm dependency audit looks at what you install. The <code class="language-plaintext highlighter-rouge">.claude/</code> and <code class="language-plaintext highlighter-rouge">.vscode/</code> vector is about what you open, and opening repositories you didn’t write is something Rails teams do constantly.</p>

<p>Consultancies live this at the extreme: at FastRuby.io we open other people’s Rails codebases for a living, cloning client applications to audit and upgrade them, more often than not with an AI editor already running. Every one of those repositories is, by definition, one we did not write, and any of them could ship a <code class="language-plaintext highlighter-rouge">.claude/settings.json</code> that runs the moment the session starts. A dependency audit answers “did I install something bad?” but it has nothing to say about the question that actually applies here: whose repo did I just open in Claude Code?</p>

<h2 id="what-to-check-before-you-open-a-repo">What to check before you open a repo</h2>

<p>So before you open a freshly cloned repository in Claude Code, Cursor, or VS Code, especially one from outside your team, spend a minute on the config it ships with (the kind of check worth eventually baking into a <a href="https://www.fastruby.io/blog/tech-debt-audit-with-claude-code">Claude Code skill</a> so you are not relying on memory):</p>

<ul>
  <li>Open <code class="language-plaintext highlighter-rouge">.claude/settings.json</code> and look for a <code class="language-plaintext highlighter-rouge">hooks</code> key, particularly a <code class="language-plaintext highlighter-rouge">SessionStart</code> hook. A legitimate hook is one your own team added on purpose; anything you don’t recognize is worth reading in full, because it runs on session start whether or not you asked.</li>
  <li>Open <code class="language-plaintext highlighter-rouge">.vscode/tasks.json</code> and look for a task set to run on folder open, and read exactly what its command does.</li>
  <li>If the repo has a JavaScript pipeline, check whether your lockfile resolves <code class="language-plaintext highlighter-rouge">keyv</code>, <code class="language-plaintext highlighter-rouge">flat-cache</code>, <code class="language-plaintext highlighter-rouge">file-entry-cache</code>, or <code class="language-plaintext highlighter-rouge">cacheable</code> to one of the poisoned releases (<code class="language-plaintext highlighter-rouge">keyv@6.0.0</code>, <code class="language-plaintext highlighter-rouge">flat-cache@6.1.24</code>, <code class="language-plaintext highlighter-rouge">file-entry-cache@11.1.6</code>, <code class="language-plaintext highlighter-rouge">cacheable@2.5.1</code>) rather than to the older, clean lines. <code class="language-plaintext highlighter-rouge">yarn why keyv</code> or <code class="language-plaintext highlighter-rouge">npm ls keyv</code> shows what actually resolved.</li>
</ul>

<p>If any of that turns up something you didn’t put there, deleting the file isn’t the end of it. Assume the command already ran, and rotate every credential the machine or CI run could have reached since you opened the repo, from a separate, uncompromised machine. A hook you remove after the fact doesn’t recall whatever it already sent out.</p>

<h2 id="conclusion">Conclusion</h2>

<p>Opening a repository in your editor used to be the safe step, the thing you did precisely because you weren’t ready to run anything yet. AI editors changed that: Claude Code and Cursor read project-local config on open and can act on it, so cloning a repo you didn’t write and pointing an AI editor at it is an execution event in its own right. The keyv worm is the first widely seen case of someone weaponizing that, and it will not be the last.</p>

<p>For Rails teams, and especially for anyone who clones client code for a living, the habit worth building is a small one: before you open an unfamiliar repo in an AI editor, look at its <code class="language-plaintext highlighter-rouge">.claude/</code> and <code class="language-plaintext highlighter-rouge">.vscode/</code> directories the same way you would look at a shell script someone emailed you. A few minutes there is a lot cheaper than rotating every credential on the box afterward.</p>

<p>Wondering what is hiding in the <code class="language-plaintext highlighter-rouge">.claude/</code> or <code class="language-plaintext highlighter-rouge">.vscode/</code> config of the repos your team has been cloning, or want a second set of eyes on a codebase before you trust it? <a href="https://www.fastruby.io/#contactus">Send us a message!</a></p>]]></content><author><name>gelseyt</name></author><category term="security" /><summary type="html"><![CDATA[Opening a gem or a client's Rails app in Claude Code or Cursor now runs its config: what to check in .claude/ and .vscode/ before you trust a cloned repo.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.fastruby.io/blog/opening-repo-is-now-execution-event.jpg" /><media:content medium="image" url="https://www.fastruby.io/blog/opening-repo-is-now-execution-event.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Why Your Yarn App Suddenly Looks for Bun</title><link href="https://www.fastruby.io/blog/why-your-yarn-app-suddenly-looks-for-bun.html" rel="alternate" type="text/html" title="Why Your Yarn App Suddenly Looks for Bun" /><published>2026-08-06T09:38:52-04:00</published><updated>2026-08-06T09:38:52-04:00</updated><id>https://www.fastruby.io/blog/why-your-yarn-app-suddenly-looks-for-bun</id><content type="html" xml:base="https://www.fastruby.io/blog/why-your-yarn-app-suddenly-looks-for-bun.html"><![CDATA[<p>You run bundle update, kick off a build, and asset precompilation stops on this:”</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>cssbundling-rails: Command install failed, ensure bun is installed
Tasks: TOP =&gt; assets:precompile =&gt; css:build =&gt; css:install
</code></pre></div></div>

<p>Except your application uses Yarn. It has always used Yarn. Nothing in the project references Bun, and nobody on the team added it.</p>

<p>This is not an exotic edge case. It can happen to ordinary Yarn applications, and it lingers because the fix has been merged upstream but never released. In this post, we’ll walk through why <code class="language-plaintext highlighter-rouge">cssbundling-rails</code> misidentifies your package manager, and how to unblock your build.</p>

<!--more-->

<h2 id="the-short-version">The Short Version</h2>

<p><code class="language-plaintext highlighter-rouge">cssbundling-rails</code> 1.4.3 <a href="https://github.com/rails/cssbundling-rails/pull/165">added <code class="language-plaintext highlighter-rouge">yarn.lock</code> to the list of lockfiles</a> that mark a project as a Bun project. While preparing to install JavaScript dependencies, <code class="language-plaintext highlighter-rouge">cssbundling-rails</code> determines that Bun should be used when the Bun binary exists in the PATH. The issue explored in this post occurs when two things are true at once: the project contains a <code class="language-plaintext highlighter-rouge">yarn.lock</code> file and the build environment happens to have Bun installed.</p>

<p>When both are true, the gem runs <code class="language-plaintext highlighter-rouge">bun install</code> against a project that has never used Bun. If that command fails, the rake task reports <code class="language-plaintext highlighter-rouge">ensure bun is installed</code> even though Bun is installed, because the message is assembled from the name of the command it just tried to run.</p>

<p>The fix is <a href="https://github.com/rails/cssbundling-rails/pull/172">PR #172</a>, commit <code class="language-plaintext highlighter-rouge">466cd57</code>, merged to <code class="language-plaintext highlighter-rouge">main</code> in July 2025. As of August 2026 it is still not in a released version, and 1.4.3 remains the latest gem. So the workaround is to pin to the commit rather than to a version:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">gem</span> <span class="s2">"cssbundling-rails"</span><span class="p">,</span> <span class="ss">github: </span><span class="s2">"rails/cssbundling-rails"</span><span class="p">,</span> <span class="ss">ref: </span><span class="s2">"466cd57"</span>
</code></pre></div></div>

<p>You are likely to hit this if all of the following are true:</p>

<ul>
  <li>You use <code class="language-plaintext highlighter-rouge">cssbundling-rails</code> with Yarn, and <code class="language-plaintext highlighter-rouge">yarn.lock</code> is committed.</li>
  <li>Your resolved version is 1.4.3. It is currently the latest release, so <code class="language-plaintext highlighter-rouge">bundle update</code> lands you there by default.</li>
  <li>Bun is reachable on <code class="language-plaintext highlighter-rouge">PATH</code> wherever you run <code class="language-plaintext highlighter-rouge">assets:precompile</code>, whether that is a Docker build, CI, or a PaaS build step. This is easier to hit than it sounds: Render’s images ship Bun by default, plenty of custom base images install it alongside Node, and so does any developer who has ever tried Bun locally.</li>
</ul>

<p>Note that your project <strong>does not need</strong> to use Bun in any way, and you <strong>do not need</strong> both a <code class="language-plaintext highlighter-rouge">yarn.lock</code> and a <code class="language-plaintext highlighter-rouge">bun.lock</code>. An ordinary Yarn application is enough, as long as the Bun binary happens to be sitting in the environment that runs the build.</p>

<h2 id="how-we-ran-into-it">How We Ran Into It</h2>

<p>We hit this while moving a Rails application onto a newer Ruby base image. It had used Yarn for years. Bun had never entered the picture.</p>

<p>Everything built cleanly until <code class="language-plaintext highlighter-rouge">assets:precompile</code>, which stopped on <code class="language-plaintext highlighter-rouge">ensure bun is installed</code>. Our first assumption was that some dependency had quietly started pulling Bun in, so we went looking through <code class="language-plaintext highlighter-rouge">package.json</code> and the lockfile. Neither pointed to Bun. The gem had not failed to run a command; it had decided on its own to run the wrong one.</p>

<p>The answer was in a shared base image. A little above the Ruby setup, it installed Bun, so it would be available to other projects using it, and put it on the path:</p>

<div class="language-dockerfile highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">RUN </span>curl <span class="nt">-fsSL</span> https://bun.com/install | bash
<span class="k">ENV</span><span class="s"> PATH="/root/.bun/bin:${PATH}"</span>
</code></pre></div></div>

<p>Our project did not need Bun, but every build now ran with Bun in PATH. Those two lines (above) are what turned a quirk in a gem’s detection logic into a failed build.</p>

<p>That reframed the investigation. The question was not “why is Bun broken”, it was “why is a Yarn application attempting to use Bun?”</p>

<h2 id="whats-actually-happening">What’s Actually Happening</h2>

<p>The task graph in the error message is the first clue:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>assets:precompile =&gt; css:build =&gt; css:install
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">cssbundling-rails</code> hooks into <a href="https://www.fastruby.io/blog/speed-up-assets-precompile"><code class="language-plaintext highlighter-rouge">assets:precompile</code></a> and runs <code class="language-plaintext highlighter-rouge">css:build</code>, which depends on <code class="language-plaintext highlighter-rouge">css:install</code>, which shells out to a package manager to install your JavaScript dependencies. The gem picks that package manager automatically, based on which lockfiles it finds in the project.</p>

<p>For the precompile path, that decision lives in <code class="language-plaintext highlighter-rouge">Cssbundling::Tasks</code>, in <a href="https://github.com/rails/cssbundling-rails/blob/v1.4.3/lib/tasks/cssbundling/build.rake"><code class="language-plaintext highlighter-rouge">lib/tasks/cssbundling/build.rake</code></a>. The relevant parts of version 1.4.3 looks like this:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="no">LOCK_FILES</span> <span class="o">=</span> <span class="p">{</span>
  <span class="ss">bun: </span><span class="sx">%w[bun.lockb bun.lock yarn.lock]</span><span class="p">,</span>
  <span class="ss">yarn: </span><span class="sx">%w[yarn.lock]</span><span class="p">,</span>
  <span class="ss">pnpm: </span><span class="sx">%w[pnpm-lock.yaml]</span><span class="p">,</span>
  <span class="ss">npm: </span><span class="sx">%w[package-lock.json]</span>
<span class="p">}</span>

<span class="k">def</span> <span class="nf">install_command</span>
  <span class="k">case</span>
  <span class="k">when</span> <span class="n">using_tool?</span><span class="p">(</span><span class="ss">:bun</span><span class="p">)</span> <span class="k">then</span> <span class="s2">"bun install"</span>
  <span class="k">when</span> <span class="n">using_tool?</span><span class="p">(</span><span class="ss">:yarn</span><span class="p">)</span> <span class="k">then</span> <span class="s2">"yarn install"</span>
  <span class="k">when</span> <span class="n">using_tool?</span><span class="p">(</span><span class="ss">:pnpm</span><span class="p">)</span> <span class="k">then</span> <span class="s2">"pnpm install"</span>
  <span class="k">when</span> <span class="n">using_tool?</span><span class="p">(</span><span class="ss">:npm</span><span class="p">)</span> <span class="k">then</span> <span class="s2">"npm install"</span>
  <span class="k">else</span> <span class="k">raise</span> <span class="s2">"cssbundling-rails: No suitable tool found for installing JavaScript dependencies"</span>
  <span class="k">end</span>
<span class="k">end</span>

<span class="k">def</span> <span class="nf">tool_exists?</span><span class="p">(</span><span class="n">tool</span><span class="p">)</span>
  <span class="nb">system</span> <span class="s2">"command -v </span><span class="si">#{</span><span class="n">tool</span><span class="si">}</span><span class="s2"> &gt; /dev/null"</span>
<span class="k">end</span>

<span class="k">def</span> <span class="nf">using_tool?</span><span class="p">(</span><span class="n">tool</span><span class="p">)</span>
  <span class="n">tool_exists?</span><span class="p">(</span><span class="n">tool</span><span class="p">)</span> <span class="o">&amp;&amp;</span> <span class="no">LOCK_FILES</span><span class="p">[</span><span class="n">tool</span><span class="p">].</span><span class="nf">any?</span> <span class="p">{</span> <span class="o">|</span><span class="n">file</span><span class="o">|</span> <span class="no">File</span><span class="p">.</span><span class="nf">exist?</span><span class="p">(</span><span class="n">file</span><span class="p">)</span> <span class="p">}</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Two details: <code class="language-plaintext highlighter-rouge">yarn.lock</code> sits in the Bun list, so every Yarn project matches the Bun branch. And <code class="language-plaintext highlighter-rouge">using_tool?</code> joins both checks with <code class="language-plaintext highlighter-rouge">&amp;&amp;</code>, so the Bun match only wins when <code class="language-plaintext highlighter-rouge">tool_exists?</code> actually finds the <code class="language-plaintext highlighter-rouge">bun</code> binary. With Bun absent, the check falls through to Yarn and everything works, which is why this bug stays invisible on a plain <code class="language-plaintext highlighter-rouge">ruby:*</code> image. With Bun present, <code class="language-plaintext highlighter-rouge">install_command</code> returns <code class="language-plaintext highlighter-rouge">bun install</code> for a project that has never used Bun.</p>

<p>From there the misclassification lands in one of two ways. If <code class="language-plaintext highlighter-rouge">bun install</code> succeeds, it quietly writes a <code class="language-plaintext highlighter-rouge">bun.lock</code> into the repository alongside your <code class="language-plaintext highlighter-rouge">yarn.lock</code>, which is what most of the upstream reports describe. If it fails, the <code class="language-plaintext highlighter-rouge">css:install</code> task raises:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">task</span> <span class="ss">:install</span> <span class="k">do</span>
  <span class="n">command</span> <span class="o">=</span> <span class="no">Cssbundling</span><span class="o">::</span><span class="no">Tasks</span><span class="p">.</span><span class="nf">install_command</span>
  <span class="k">unless</span> <span class="nb">system</span><span class="p">(</span><span class="n">command</span><span class="p">)</span>
    <span class="k">raise</span> <span class="s2">"cssbundling-rails: Command install failed, ensure </span><span class="si">#{</span><span class="n">command</span><span class="p">.</span><span class="nf">split</span><span class="p">.</span><span class="nf">first</span><span class="si">}</span><span class="s2"> is installed"</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>That is the message we saw, and it is built from <code class="language-plaintext highlighter-rouge">command.split.first</code>. It points you at Bun as the problem, when Bun was never part of the application to begin with.</p>

<h2 id="where-the-regression-came-from">Where the Regression Came From</h2>

<p>The git history explains how a change meant to help Bun users introduced a bug that impacted Yarn users.</p>

<p><a href="https://github.com/rails/cssbundling-rails/pull/165">PR #165</a>, “Improve package manager detection for Bun 1.2 compatibility”, was merged in March 2025 and shipped in 1.4.3. Bun 1.2 made a new text-based <code class="language-plaintext highlighter-rouge">bun.lock</code> the default lockfile in place of the older binary <code class="language-plaintext highlighter-rouge">bun.lockb</code>, and <code class="language-plaintext highlighter-rouge">bun install --yarn</code> produces a <code class="language-plaintext highlighter-rouge">yarn.lock</code>. To cover those cases, the PR broadened Bun detection to accept <code class="language-plaintext highlighter-rouge">bun.lockb</code>, <code class="language-plaintext highlighter-rouge">bun.lock</code>, or <code class="language-plaintext highlighter-rouge">yarn.lock</code>.</p>

<p>That last option is the problem. Every Yarn project has a <code class="language-plaintext highlighter-rouge">yarn.lock</code>, so detection meant for a narrow Bun workflow started capturing ordinary Yarn projects too, on any machine where Bun was installed.</p>

<p><a href="https://github.com/rails/cssbundling-rails/pull/172">PR #172</a>, “Remove yarn from bun detect tool”, commit <code class="language-plaintext highlighter-rouge">466cd57</code>, was merged in July 2025 to correct it. It stops treating <code class="language-plaintext highlighter-rouge">yarn.lock</code> as a Bun signal, so a Yarn project is detected as Yarn again.</p>

<p>It is worth knowing that this was not a simple typo. <a href="https://github.com/rails/cssbundling-rails/issues/183">Issue #183</a> is still open and asks for the opposite behavior: Bun preferred for freshly generated applications. The same few lines are being pulled in two directions, and there is no clear consensus upstream on how it should behave.</p>

<h2 id="merged-is-not-yet-released">Merged Is Not Yet Released</h2>

<p>Unfortunately the usual “just bump the version” instinct does not work in this case. The latest released gem is 1.4.3, tagged March 3, 2025. The fix, <code class="language-plaintext highlighter-rouge">466cd57</code>, was merged to <code class="language-plaintext highlighter-rouge">main</code> on July 10, 2025, but not yet released. The only way to get the fix today is to point Bundler at the commit itself:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># cssbundling-rails 1.4.3 misdetects bun for yarn projects (regression from</span>
<span class="c1"># PR #165). The fix is PR #172 / 466cd57, merged to main but unreleased as of</span>
<span class="c1"># August 2026. Unpin only once a release LATER than 1.4.3 ships with that commit.</span>
<span class="n">gem</span> <span class="s2">"cssbundling-rails"</span><span class="p">,</span> <span class="ss">github: </span><span class="s2">"rails/cssbundling-rails"</span><span class="p">,</span> <span class="ss">ref: </span><span class="s2">"466cd57"</span>
</code></pre></div></div>

<p>As a side note, when you pin a gem to a commit, it is a good idea to add a comment that describes the condition that makes it safe to remove. In this case, once a release later than 1.4.3 ships with commit 466cd57. A comment like ‘unpin after v1.4.3’ is easy to misread and does not explain why the dependency was pinned in the first place.</p>

<p>Now we can <code class="language-plaintext highlighter-rouge">bundle install</code>, and confirm that the <code class="language-plaintext highlighter-rouge">Gemfile.lock</code> records a <code class="language-plaintext highlighter-rouge">GIT</code> source pointing at the commit:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>GIT
  remote: https://github.com/rails/cssbundling-rails.git
  revision: 466cd57...
  specs:
    cssbundling-rails (1.4.3)
</code></pre></div></div>

<p>Rebuild, and precompilation detects Yarn again. That one-line pin unblocked the applications that still needed a CSS build.</p>

<h2 id="conclusion">Conclusion</h2>

<p>The alarming <code class="language-plaintext highlighter-rouge">ensure bun is installed</code> message is not about Bun at all, it is an ordinary Yarn application tripping over a detection change in <code class="language-plaintext highlighter-rouge">cssbundling-rails</code> 1.4.3, in an environment that happened to have Bun lying around. The fix exists upstream, it is simply stuck there, so a commit pin is the pragmatic move until a new version ships. A shared base image is a dependency too, so check it when a build breaks over something your code never asked for.</p>

<p>Snags like this one slowing down your Rails upgrade? <a href="/#contactus">We can help</a>.</p>]]></content><author><name>hmdros</name></author><category term="upgrades" /><summary type="html"><![CDATA[cssbundling-rails 1.4.3 misdetects Yarn projects as Bun when Bun is on PATH, breaking asset precompilation. Here is why it happens and how to unblock it.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.fastruby.io/blog/why-your-yarn-app-suddenly-looks-for-bun.png" /><media:content medium="image" url="https://www.fastruby.io/blog/why-your-yarn-app-suddenly-looks-for-bun.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Running a Ruby MCP Server in Production</title><link href="https://www.fastruby.io/blog/running-a-ruby-mcp-server-in-production.html" rel="alternate" type="text/html" title="Running a Ruby MCP Server in Production" /><published>2026-08-04T07:58:08-04:00</published><updated>2026-08-04T07:58:08-04:00</updated><id>https://www.fastruby.io/blog/running-a-ruby-mcp-server-in-production</id><content type="html" xml:base="https://www.fastruby.io/blog/running-a-ruby-mcp-server-in-production.html"><![CDATA[<p>In a previous post, <a href="https://www.ombulabs.ai/blog/ai-assistant-blog-writing-process.html">AI Assistant for Our Blog Writing Process</a>, I introduced the assistant we built to help with our blog writing. At the core of that assistant is an MCP server, which serves as the source of truth for both of our blogs. It exposes that knowledge through tools the client can call and documentation the client can read.</p>

<p>Getting an MCP server running is the easy part. Every quickstart, in every language, gives you a server that runs as a subprocess on your own machine and disappears when the client exits. That’s enough to experiment locally, but it’s a long way from something a team can rely on. Once you want to deploy it, questions about where it runs, state management, authentication, and security become your responsibility. The Ruby SDK’s defaults don’t solve most of those problems, and one of them even comes with a published security advisory.</p>

<p>In this article, we’ll cover what changes when a Ruby MCP server stops being a subprocess: the two shapes it can take in a Rails shop, why session state breaks down when running behind multiple Puma workers, the DNS rebinding vulnerability  the transport shipped with, and what the specification asks of you once a shared token is no longer enough.</p>

<!--more-->

<h2 id="from-subprocess-to-service">From subprocess to service</h2>

<p>MCP defines two <a href="https://modelcontextprotocol.io/docs/concepts/transports">transports</a>. With stdio, the client launches your server as a subprocess and talks to it over standard input and output. It is the fastest way to see something working, but it puts a copy of the server on every machine that uses it. Everybody needs the code and credentials for the database, and every change means everybody pulls. Our marketing team uses these tools too, and “clone this repository and check your Ruby version” is not a reasonable thing to ask of them.</p>

<p>Streamable HTTP puts the server in one place instead. Everyone points a client at a URL, and an update is a deploy rather than an announcement.</p>

<p>One nice thing about the Ruby implementation is that the transport is just a Rack application, so everything else is familiar:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">transport</span> <span class="o">=</span> <span class="no">MCP</span><span class="o">::</span><span class="no">Server</span><span class="o">::</span><span class="no">Transports</span><span class="o">::</span><span class="no">StreamableHTTPTransport</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="no">BlogServer</span><span class="p">.</span><span class="nf">build</span><span class="p">)</span>
<span class="n">mcp_token</span> <span class="o">=</span> <span class="no">ENV</span><span class="p">.</span><span class="nf">fetch</span><span class="p">(</span><span class="s2">"MCP_AUTH_TOKEN"</span><span class="p">)</span>

<span class="n">app</span> <span class="o">=</span> <span class="no">Rack</span><span class="o">::</span><span class="no">Builder</span><span class="p">.</span><span class="nf">new</span> <span class="k">do</span>
  <span class="n">map</span> <span class="s2">"/mcp"</span> <span class="k">do</span>
    <span class="n">use</span> <span class="no">Auth</span><span class="o">::</span><span class="no">BearerToken</span><span class="p">,</span> <span class="ss">token: </span><span class="n">mcp_token</span>
    <span class="n">run</span> <span class="n">transport</span>
  <span class="k">end</span>

  <span class="n">map</span> <span class="s2">"/up"</span> <span class="k">do</span>
    <span class="n">run</span> <span class="o">-&gt;</span><span class="p">(</span><span class="n">_env</span><span class="p">)</span> <span class="p">{</span> <span class="p">[</span><span class="mi">200</span><span class="p">,</span> <span class="p">{</span> <span class="s2">"content-type"</span> <span class="o">=&gt;</span> <span class="s2">"text/plain"</span> <span class="p">},</span> <span class="p">[</span><span class="s2">"ok"</span><span class="p">]]</span> <span class="p">}</span>
  <span class="k">end</span>
<span class="k">end</span><span class="p">.</span><span class="nf">to_app</span>

<span class="n">run</span> <span class="n">app</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">BlogServer.build</code> is our own factory, a method that returns a fresh <code class="language-plaintext highlighter-rouge">MCP::Server</code> with our tools and resources already registered. The transport wraps that server, the token comes from the environment, and everything below those two lines is just Rack.</p>

<p>Nothing in this file knows it is serving MCP, which is exactly what we want. It means everything we already know about running Rack applications still applies.</p>

<h2 id="two-shapes-for-a-server">Two shapes for a server</h2>

<p>Once it is a service, there are two places it can live, and the choice has more consequences than it first appears.</p>

<picture>
  <source srcset="/blog/assets/images/mcp-server-two-shapes-mobile.png" media="(max-width: 600px)" />
  <img src="/blog/assets/images/mcp-server-two-shapes.png" alt="Two shapes for an MCP server. On the left, a standalone service: an MCP client sends requests over HTTPS into a config.ru stack of auth middleware, StreamableHTTPTransport, and MCP::Server, which reads from an existing database or API. One server is built once at boot. On the right, the server inside an existing Rails app: the client's request passes through the authentication the app already has, into an McpController that builds one server per request with a server_context, reading the app's own models. Tools can differ per caller." />
</picture>

<p>Ours is standalone, because the data it serves lives in its own database, filled by a pipeline that reads both blog repositories. The server is a read-only layer over that, so it has no reason to be part of anything else, and a <code class="language-plaintext highlighter-rouge">config.ru</code> with Puma in front of it is the whole deployment.</p>

<p>The other shape is an MCP endpoint inside an application you already run, which is the more common case for a Rails team wanting to expose what their app already knows. The SDK supports two ways of doing it. The first mounts the transport in your routes, wrapping a server you have already built elsewhere:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">transport</span> <span class="o">=</span> <span class="no">MCP</span><span class="o">::</span><span class="no">Server</span><span class="o">::</span><span class="no">Transports</span><span class="o">::</span><span class="no">StreamableHTTPTransport</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="n">server</span><span class="p">)</span>

<span class="no">Rails</span><span class="p">.</span><span class="nf">application</span><span class="p">.</span><span class="nf">routes</span><span class="p">.</span><span class="nf">draw</span> <span class="k">do</span>
  <span class="n">mount</span> <span class="n">transport</span> <span class="o">=&gt;</span> <span class="s2">"/mcp"</span>
<span class="k">end</span>
</code></pre></div></div>

<p>That builds one server when the process boots, and every caller gets the same one. The second builds a server per request, in a controller:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">McpController</span> <span class="o">&lt;</span> <span class="no">ActionController</span><span class="o">::</span><span class="no">API</span>
  <span class="k">def</span> <span class="nf">create</span>
    <span class="n">server</span> <span class="o">=</span> <span class="no">MCP</span><span class="o">::</span><span class="no">Server</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span>
      <span class="ss">name: </span><span class="s2">"my_server"</span><span class="p">,</span>
      <span class="ss">version: </span><span class="s2">"1.0.0"</span><span class="p">,</span>
      <span class="ss">tools: </span><span class="p">[</span><span class="no">SomeTool</span><span class="p">,</span> <span class="no">AnotherTool</span><span class="p">],</span>
      <span class="ss">server_context: </span><span class="p">{</span> <span class="ss">user_id: </span><span class="n">current_user</span><span class="p">.</span><span class="nf">id</span> <span class="p">},</span>
    <span class="p">)</span>
    <span class="n">transport</span> <span class="o">=</span> <span class="no">MCP</span><span class="o">::</span><span class="no">Server</span><span class="o">::</span><span class="no">Transports</span><span class="o">::</span><span class="no">StreamableHTTPTransport</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="n">server</span><span class="p">,</span> <span class="ss">stateless: </span><span class="kp">true</span><span class="p">)</span>
    <span class="n">status</span><span class="p">,</span> <span class="n">headers</span><span class="p">,</span> <span class="n">body</span> <span class="o">=</span> <span class="n">transport</span><span class="p">.</span><span class="nf">handle_request</span><span class="p">(</span><span class="n">request</span><span class="p">)</span>

    <span class="n">render</span><span class="p">(</span><span class="ss">json: </span><span class="n">body</span><span class="p">.</span><span class="nf">first</span><span class="p">,</span> <span class="ss">status: </span><span class="n">status</span><span class="p">,</span> <span class="ss">headers: </span><span class="n">headers</span><span class="p">)</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>That is more work per request, but it has a benefit you cannot get from the mounted version: <code class="language-plaintext highlighter-rouge">current_user</code> is in scope. The tool list can differ by caller, and <code class="language-plaintext highlighter-rouge">server_context</code> carries identity down into the tools themselves, which is what per-customer scoping is built on. If the server will ever answer to more than one account, this is the shape to start from, because retrofitting identity into a server built at boot means rebuilding it.</p>

<p>Living inside the Rails app buys two more things. You inherit whatever authentication is already there, and your tools read the same models as the rest of the application, so there is no second connection to configure and no schema drift between two codebases. What you give up is isolation: MCP traffic now shares workers and a deploy cycle with everything else the app does.</p>

<h2 id="sessions-and-the-trap-in-the-ruby-sdk">Sessions, and the trap in the Ruby SDK</h2>

<p>Here is the part that makes this interesting: <code class="language-plaintext highlighter-rouge">StreamableHTTPTransport</code> stores session and SSE stream state in memory. The <a href="https://ruby.sdk.modelcontextprotocol.io/building-servers.html#streamable-http-transport">SDK’s own documentation</a> is blunt about the consequence: it must run in a single process. Puma with <code class="language-plaintext highlighter-rouge">workers &gt; 0</code>, or Unicorn, forks processes that do not share memory, and session management and open SSE connections break. Behind a load balancer, you need sticky sessions so that a client’s requests keep landing on the instance that remembers it.</p>

<picture>
  <source srcset="/blog/assets/images/mcp-sessions-and-workers-mobile.png" media="(max-width: 600px)" />
  <img src="/blog/assets/images/mcp-sessions-and-workers.png" alt="Session state and Puma workers. With session state in memory, a client's two requests pass through a load balancer: the first lands on worker 1, which holds the session and answers, and the second lands on worker 2, which has never heard of it and returns a 404. The fix is one process only, or sticky sessions. When the server is stateless, both requests can land on either worker and both are answered, because every request carries everything it needs." />
</picture>

<p>The escape hatch is <code class="language-plaintext highlighter-rouge">stateless: true</code>, which drops the session requirement and works with any process configuration. It is what we pass, along with a Puma config that defaults to no workers anyway:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">max_threads</span> <span class="o">=</span> <span class="no">ENV</span><span class="p">.</span><span class="nf">fetch</span><span class="p">(</span><span class="s2">"MCP_MAX_THREADS"</span><span class="p">,</span> <span class="mi">5</span><span class="p">).</span><span class="nf">to_i</span>
<span class="n">threads</span> <span class="n">max_threads</span><span class="p">,</span> <span class="n">max_threads</span>

<span class="n">workers_count</span> <span class="o">=</span> <span class="no">ENV</span><span class="p">.</span><span class="nf">fetch</span><span class="p">(</span><span class="s2">"WEB_CONCURRENCY"</span><span class="p">,</span> <span class="mi">0</span><span class="p">).</span><span class="nf">to_i</span>
<span class="n">workers</span> <span class="n">workers_count</span>

<span class="k">if</span> <span class="n">workers_count</span><span class="p">.</span><span class="nf">positive?</span>
  <span class="n">preload_app!</span>

  <span class="n">on_worker_boot</span> <span class="k">do</span>
    <span class="no">ActiveRecord</span><span class="o">::</span><span class="no">Base</span><span class="p">.</span><span class="nf">establish_connection</span><span class="p">(</span><span class="no">ENV</span><span class="p">.</span><span class="nf">fetch</span><span class="p">(</span><span class="s2">"DATABASE_URL"</span><span class="p">))</span> <span class="k">if</span> <span class="k">defined?</span><span class="p">(</span><span class="no">ActiveRecord</span><span class="o">::</span><span class="no">Base</span><span class="p">)</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Threads by default, workers only if someone deliberately asks for them, and if they do, the Active Record connection gets re-established after the fork rather than inherited.</p>

<p>This whole area got simpler the day before this post went out. The <a href="https://blog.modelcontextprotocol.io/posts/2026-07-28/">2026-07-28 specification</a> removes protocol-level sessions and the <code class="language-plaintext highlighter-rouge">Mcp-Session-Id</code> header entirely, along with the <code class="language-plaintext highlighter-rouge">initialize</code> handshake. Any request can now land on any instance, which means the sticky routing and shared session stores that horizontal deployments used to need are no longer part of the picture. So <code class="language-plaintext highlighter-rouge">stateless: true</code> has stopped being a workaround and turned into a description of how the transport is supposed to behave. The single-process constraint above is only yours to worry about while you are on a gem release that still implements sessions, which is the first thing to check before planning a deployment around any of it.</p>

<h2 id="host-and-origin-are-not-optional">Host and Origin are not optional</h2>

<p>Earlier this month, the <code class="language-plaintext highlighter-rouge">mcp</code> gem got a security advisory, <a href="https://rubysec.com/advisories/GHSA-rjr6-rcgv-9m7m/">GHSA-rjr6-rcgv-9m7m</a>, covering every version before 0.23.0. The Streamable HTTP transport processed every incoming JSON-RPC request without ever inspecting the HTTP <code class="language-plaintext highlighter-rouge">Host</code> or <code class="language-plaintext highlighter-rouge">Origin</code> headers.</p>

<p>That sounds mild until you think about where MCP servers actually run. A malicious page could use DNS rebinding to point its own hostname at <code class="language-plaintext highlighter-rouge">127.0.0.1</code> and then talk to an MCP server running on the visitor’s machine, invoking its tools and reading whatever they return. For a server with filesystem or credential access, that is sensitive data disclosure and, depending on the tool set, local action execution.</p>

<p>Version 0.23.0 added <code class="language-plaintext highlighter-rouge">allowed_hosts</code> and <code class="language-plaintext highlighter-rouge">allowed_origins</code>, and they are the reason those parameters appear in our transport:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">transport</span> <span class="o">=</span> <span class="no">MCP</span><span class="o">::</span><span class="no">Server</span><span class="o">::</span><span class="no">Transports</span><span class="o">::</span><span class="no">StreamableHTTPTransport</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span>
  <span class="no">BlogServer</span><span class="p">.</span><span class="nf">build</span><span class="p">,</span>
  <span class="ss">stateless: </span><span class="kp">true</span><span class="p">,</span>
  <span class="ss">enable_json_response: </span><span class="o">!</span><span class="no">ENV</span><span class="p">[</span><span class="s2">"MCP_SSE_RESPONSES"</span><span class="p">],</span>
  <span class="ss">allowed_hosts: </span><span class="n">allowed_hosts</span><span class="p">,</span>
  <span class="ss">allowed_origins: </span><span class="n">allowed_origins</span>
<span class="p">)</span>
</code></pre></div></div>

<p>An empty allowlist is not a safe default. On a current gem it means every request comes back with a 403, and on anything older than 0.23.0 it meant no check happened at all, so the two ways to get this wrong are a server nobody can reach and a server anybody can. Both look like a healthy process from the outside, so the file refuses to start rather than guess:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">allowed_hosts</span> <span class="o">=</span> <span class="no">ENV</span><span class="p">.</span><span class="nf">fetch</span><span class="p">(</span><span class="s2">"MCP_ALLOWED_HOSTS"</span><span class="p">,</span> <span class="s2">""</span><span class="p">).</span><span class="nf">split</span><span class="p">(</span><span class="s2">","</span><span class="p">).</span><span class="nf">map</span><span class="p">(</span><span class="o">&amp;</span><span class="ss">:strip</span><span class="p">).</span><span class="nf">reject</span><span class="p">(</span><span class="o">&amp;</span><span class="ss">:empty?</span><span class="p">)</span>

<span class="k">if</span> <span class="n">allowed_hosts</span><span class="p">.</span><span class="nf">empty?</span> <span class="o">&amp;&amp;</span> <span class="no">ENV</span><span class="p">[</span><span class="s2">"RACK_ENV"</span><span class="p">]</span> <span class="o">==</span> <span class="s2">"production"</span>
  <span class="k">raise</span> <span class="s2">"MCP_ALLOWED_HOSTS is unset. Set it to this deployment's host name "</span> <span class="p">\</span>
        <span class="s2">"(e.g. blog-mcp-rb.herokuapp.com) or every request will be rejected with a 403."</span>
<span class="k">end</span>
</code></pre></div></div>

<p>The same file does the same thing for a missing authentication token. Refusing to boot is an unfashionable pattern, but the alternative is a deployment that comes up green and is either open to the internet or broken for everyone, and neither is a state you want to learn about from a client. Noticing that you are on a vulnerable version in the first place is a separate concern, and <code class="language-plaintext highlighter-rouge">bundler-audit</code> is the tool for it. We covered it along with the rest of what we run in <a href="https://www.fastruby.io/blog/rails/security/ruby-security-toolkit.html">4 Essential Security Tools for Rails Apps</a>, and a young gem moving quickly is exactly the case it earns its keep on.</p>

<h2 id="from-a-shared-token-to-oauth">From a shared token to OAuth</h2>

<p>Ours is an internal tool, so authentication is a shared bearer token checked by a <a href="https://www.fastruby.io/blog/Middleware-in-Rails">Rack middleware</a>:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">authorized?</span><span class="p">(</span><span class="n">env</span><span class="p">)</span>
  <span class="n">scheme</span><span class="p">,</span> <span class="n">value</span> <span class="o">=</span> <span class="n">env</span><span class="p">[</span><span class="s2">"HTTP_AUTHORIZATION"</span><span class="p">].</span><span class="nf">to_s</span><span class="p">.</span><span class="nf">split</span><span class="p">(</span><span class="s2">" "</span><span class="p">,</span> <span class="mi">2</span><span class="p">)</span>
  <span class="k">return</span> <span class="kp">false</span> <span class="k">unless</span> <span class="n">scheme</span><span class="o">&amp;</span><span class="p">.</span><span class="nf">downcase</span> <span class="o">==</span> <span class="s2">"bearer"</span> <span class="o">&amp;&amp;</span> <span class="n">value</span>

  <span class="no">Rack</span><span class="o">::</span><span class="no">Utils</span><span class="p">.</span><span class="nf">secure_compare</span><span class="p">(</span><span class="n">value</span><span class="p">,</span> <span class="vi">@token</span><span class="p">)</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Anything that fails gets a 401 with a <code class="language-plaintext highlighter-rouge">WWW-Authenticate</code> header, and nothing downstream runs. <code class="language-plaintext highlighter-rouge">Rack::Utils.secure_compare</code> rather than <code class="language-plaintext highlighter-rouge">==</code>, because a plain string comparison returns as soon as two characters differ, which leaks how much of the token a caller got right. That is a floor. A shared token cannot tell one caller from another, cannot be scoped to part of the data, and cannot be revoked for one person without rotating it for everybody. The moment the server has users outside the team, or needs to know which of them is asking, you need OAuth.</p>

<p>That prospect sounds heavier than it is, because two separate questions tend to get run together. The first is what your server owes as an <a href="https://modelcontextprotocol.io/specification/draft/basic/authorization">OAuth 2.1 resource server</a>, and that part is yours no matter what. The second is who issues the tokens, and there the answer is usually somebody else: an identity provider you already pay for, a dedicated auth service, or your own application if it already does this. Your server does not issue tokens and does not run a login flow. What it owes is discovery and validation:</p>

<p><img src="/blog/assets/images/mcp-oauth-discovery-flow.png" alt="How a client finds out where to get a token. Step one, the MCP client sends a request with no token. Step two, the server responds 401 with a WWW-Authenticate Bearer header carrying resource_metadata and scope. Step three, the client fetches the protected resource metadata document. Step four, the server returns metadata naming its authorization server. Step five, the client discovers the authorization server and authorizes with PKCE and a resource parameter. Step six, the authorization server issues an access token bound to your server as the audience. Step seven, the client repeats the request with the bearer token. Step eight, the server validates the audience and answers." /></p>

<p>Four requirements are worth reading closely, because they are all on your side of the line. You <strong>MUST</strong> implement <a href="https://datatracker.ietf.org/doc/html/rfc9728">Protected Resource Metadata (RFC 9728)</a>, which is a JSON document naming the authorization servers that can issue tokens for you. Your 401 has to point at it:</p>

<div class="language-http highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">HTTP</span><span class="o">/</span><span class="m">1.1</span> <span class="m">401</span> <span class="ne">Unauthorized</span>
<span class="na">WWW-Authenticate</span><span class="p">:</span> <span class="s">Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource",</span>
<span class="s">                         scope="posts:read"</span>
</code></pre></div></div>

<p>You <strong>MUST</strong> validate that an access token was issued specifically for you as the intended audience, per <a href="https://www.rfc-editor.org/rfc/rfc8707.html">RFC 8707</a>, and reject anything else. A token that is valid, unexpired, and meant for a different service is not a token you may accept, and this is the requirement most likely to be skipped by someone who has previously written “verify the JWT signature” and stopped there. When a token is valid but its scopes are not enough, the server should answer with a 403 carrying <code class="language-plaintext highlighter-rouge">error="insufficient_scope"</code> and the scopes the operation actually needs, so the client can ask for them rather than guess.</p>

<p>The client’s side of that diagram, the PKCE parameters and the redirect dance, is the client’s problem, and the ecosystem largely handles it.</p>

<p>Which leaves the question of who plays the authorization server, and the specification is deliberately quiet about it: any OAuth 2.1 provider will do, so Okta, Entra, Auth0, Keycloak and friends are all fair game, as is Doorkeeper in your own Rails app if it is already an OAuth provider. That is the reassuring part. The part worth checking before you commit is that “our users can already sign in with it” is not the same question. Signing people in is authentication of humans. What an MCP client needs is an authorization server that can issue an access token scoped to your server, which is a shorter list of capabilities than most provider comparison pages lead with:</p>

<ul>
  <li>It publishes discovery metadata, either <a href="https://datatracker.ietf.org/doc/html/rfc8414">RFC 8414</a> or OpenID Connect Discovery, since clients are required to find its endpoints that way.</li>
  <li>A client can obtain a client ID, whether through Client ID Metadata Documents (what the specification now prefers), Dynamic Client Registration (allowed, and deprecated), or plain pre-registration by an administrator.</li>
  <li>It honors the <code class="language-plaintext highlighter-rouge">resource</code> parameter, so the token it issues is bound to your server as the audience rather than being a general-purpose token for the whole provider.</li>
</ul>

<p>Not every provider does all three, and the one that catches people is registration. If yours has no dynamic registration, the fallback the specification allows is pre-registering each client out of band, which is fine for a handful of known clients and painful past that. It is worth knowing which of these you have before the auth decision is made for you by whatever is already in the app.</p>

<p>The SDK will not do any of this for you. It has no authentication story at all, which is the right call for a transport, and it means the work lands in Rack middleware or a <code class="language-plaintext highlighter-rouge">before_action</code>, both of which you have written before.</p>

<h2 id="limiting-what-a-caller-can-reach">Limiting what a caller can reach</h2>

<p>Authentication answers who is calling. What they can reach is a separate question, and a tool’s blast radius is whatever its query can touch.</p>

<p>Our server only reads, and every model says so:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">Post</span> <span class="o">&lt;</span> <span class="no">ActiveRecord</span><span class="o">::</span><span class="no">Base</span>
  <span class="nb">self</span><span class="p">.</span><span class="nf">table_name</span> <span class="o">=</span> <span class="s2">"posts"</span>

  <span class="n">belongs_to</span> <span class="ss">:source</span>
  <span class="n">belongs_to</span> <span class="ss">:category</span>
  <span class="n">has_one</span> <span class="ss">:content</span><span class="p">,</span> <span class="ss">class_name: </span><span class="s2">"PostContent"</span><span class="p">,</span> <span class="ss">foreign_key: :post_id</span>

  <span class="k">def</span> <span class="nf">readonly?</span> <span class="o">=</span> <span class="kp">true</span>
<span class="k">end</span>
</code></pre></div></div>

<p>That is one line per model, and it is a guard against the common case, an accidental <code class="language-plaintext highlighter-rouge">save</code> or <code class="language-plaintext highlighter-rouge">update</code> reached from a tool that was only ever meant to read. It is not a hard guarantee: <code class="language-plaintext highlighter-rouge">update_all</code>, <code class="language-plaintext highlighter-rouge">delete_all</code>, and raw SQL all skip the instance-level check entirely. What sits on the other end of the connection is a model with a set of tools, and the boundary between answering questions about the data and changing it deserves more than a single override, a database user with no write grants being the actual floor.</p>

<p>For a server with more than one customer, the same principle applies to rows rather than to writes, and <code class="language-plaintext highlighter-rouge">server_context</code> is how identity gets there. A tool receives it as a keyword argument, which means scoping is a <code class="language-plaintext highlighter-rouge">where</code> clause like any other:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">call</span><span class="p">(</span><span class="n">query</span><span class="p">:,</span> <span class="n">server_context</span><span class="p">:)</span>
  <span class="n">posts</span> <span class="o">=</span> <span class="no">Post</span><span class="p">.</span><span class="nf">where</span><span class="p">(</span><span class="ss">account_id: </span><span class="n">server_context</span><span class="p">.</span><span class="nf">fetch</span><span class="p">(</span><span class="ss">:account_id</span><span class="p">))</span>
  <span class="c1"># ...</span>
<span class="k">end</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">fetch</code> rather than <code class="language-plaintext highlighter-rouge">[]</code> on purpose. If the context is ever missing an account, that should raise rather than quietly return a relation scoped to <code class="language-plaintext highlighter-rouge">nil</code>. The rule underneath is: never ask a description to enforce something a query can enforce. A model that is told to only look at one account will usually comply, but <em>usually</em> is not a security control.</p>

<h2 id="testing">Testing</h2>

<p>Start with the cheapest question there is, which is whether the MCP server is running at all:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl http://127.0.0.1:9292/up
</code></pre></div></div>

<p>That is the health endpoint from the Rack file, and it answers without going anywhere near the transport, so a failure there is Puma or boot configuration rather than anything to do with MCP.</p>

<p>For the protocol itself, the <a href="https://modelcontextprotocol.io/docs/tools/inspector">MCP Inspector</a> connects the way a client would. It is a Node tool, so testing a Ruby server means having <code class="language-plaintext highlighter-rouge">npx</code> around. It has a browser UI, and it also has a CLI mode which takes the same arguments a client would use, prints the JSON-RPC result, and can go in a script. Listing the tools over HTTP, with the token:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>npx @modelcontextprotocol/inspector <span class="nt">--cli</span> http://127.0.0.1:9292/mcp <span class="se">\</span>
  <span class="nt">--transport</span> http <span class="nt">--header</span> <span class="s2">"Authorization: Bearer </span><span class="nv">$MCP_AUTH_TOKEN</span><span class="s2">"</span> <span class="se">\</span>
  <span class="nt">--method</span> tools/list
</code></pre></div></div>

<p>Reading one of our own resource URIs, which exercises a different path through the server:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>npx @modelcontextprotocol/inspector <span class="nt">--cli</span> http://127.0.0.1:9292/mcp <span class="se">\</span>
  <span class="nt">--transport</span> http <span class="nt">--header</span> <span class="s2">"Authorization: Bearer </span><span class="nv">$MCP_AUTH_TOKEN</span><span class="s2">"</span> <span class="se">\</span>
  <span class="nt">--method</span> resources/read <span class="nt">--uri</span> <span class="s2">"blog://fastruby.io/style-guide"</span>
</code></pre></div></div>

<p>Those two are worth running every time you change something. <code class="language-plaintext highlighter-rouge">tools/list</code> is the only place you see your tool descriptions and input schemas the way a client actually receives them, which is a different experience from reading them in the source, and it will show you an enum that quietly became a string or an optional parameter that became required. <code class="language-plaintext highlighter-rouge">resources/read</code> goes through the read handler and, for us, the files on disk, which is where a bad path or a bad encoding turns up. Both commands also test the middleware, because they are going through it. Run either one with the token changed by a character and you should get a 401 rather than a result, on the real path, with no mocking involved.</p>

<p>Locally, the stdio entry point is still the fastest thing to point the Inspector at, since it needs no server running at all:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>npx @modelcontextprotocol/inspector bundle <span class="nb">exec </span>ruby server.rb
</code></pre></div></div>

<p>One warning about debugging any of this. When something goes wrong inside a handler, what comes back is “Internal error” and nothing else, and the detail is on stderr.</p>

<h2 id="conclusion">Conclusion</h2>

<p>In this article, we went through what a Ruby MCP server needs once it stops being a subprocess: Streamable HTTP instead of stdio, a choice between a standalone service and an endpoint inside an existing Rails app, statelessness so it survives more than one Puma worker, <code class="language-plaintext highlighter-rouge">Host</code> and <code class="language-plaintext highlighter-rouge">Origin</code> validation that a security advisory made mandatory, a shared token as the internal floor with OAuth as the answer beyond it, and read-only models and <code class="language-plaintext highlighter-rouge">server_context</code> deciding what any caller can actually reach.</p>

<p>Almost none of that is protocol code. It is Rack, Puma, OAuth, and being careful about what your tools can touch, which is to say it is the work you already know how to do. The MCP-specific part is small, and the parts most likely to hurt you are the ones that look like configuration.</p>

<p>Two caveats worth carrying. The gem is young and its API has moved between releases, so pin it and re-read the changelog rather than trusting a snippet you found, including these. And the specification itself moved this week, so anything written about sessions before the 2026-07-28 revision is now describing a protocol that no longer exists.</p>

<p>Are you looking at putting an MCP server in front of your Rails application’s data and wondering what it takes to do it safely? <a href="/#contact-us">Talk to us today!</a></p>]]></content><author><name>abizzinotto</name></author><category term="artificial-intelligence" /><summary type="html"><![CDATA[MCP servers in production: transports, session state and Puma workers, the Host and Origin advisory in the mcp gem, and moving from a shared token to OAuth.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.fastruby.io/blog/running-a-ruby-mcp-server-in-production.jpg" /><media:content medium="image" url="https://www.fastruby.io/blog/running-a-ruby-mcp-server-in-production.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Why Patching the Gem Didn’t Fix CVE-2026-66066</title><link href="https://www.fastruby.io/blog/why-patching-the-gem-didnt-fix-cve-2026-66066.html" rel="alternate" type="text/html" title="Why Patching the Gem Didn’t Fix CVE-2026-66066" /><published>2026-08-03T00:00:00-04:00</published><updated>2026-08-03T00:00:00-04:00</updated><id>https://www.fastruby.io/blog/why-patching-the-gem-didnt-fix-cve-2026-66066</id><content type="html" xml:base="https://www.fastruby.io/blog/why-patching-the-gem-didnt-fix-cve-2026-66066.html"><![CDATA[<p>You saw the advisory for <a href="https://github.com/rails/rails/security/advisories/GHSA-xr9x-r78c-5hrm">CVE-2026-66066</a>, the Active Storage vulnerability in the way Rails processes image variants with libvips. You bumped <code class="language-plaintext highlighter-rouge">activestorage</code> to the patched version, ran your tests, deployed, and moved on. That is the responsible thing to do, and for most Ruby vulnerabilities it would be the whole job.</p>

<p>This one is different. The patched version of Active Storage will not run on an old copy of libvips. It raises an exception during boot and refuses to start:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="sr">/usr/</span><span class="n">local</span><span class="o">/</span><span class="n">bundle</span><span class="o">/</span><span class="n">gems</span><span class="o">/</span><span class="n">activestorage</span><span class="o">-</span><span class="mf">8.1</span><span class="o">.</span><span class="mf">3.1</span><span class="o">/</span><span class="n">lib</span><span class="o">/</span><span class="n">active_storage</span><span class="o">/</span><span class="n">vips</span><span class="p">.</span><span class="nf">rb</span><span class="p">:</span><span class="mi">36</span><span class="ss">:in</span> <span class="s1">'&lt;compiled&gt;'</span><span class="p">:</span> <span class="n">libvips</span><span class="err">'</span><span class="n">s</span> <span class="n">unfuzzed</span> <span class="n">operations</span> <span class="n">are</span> <span class="ow">not</span> <span class="n">safe</span> <span class="n">to</span> <span class="n">use</span> <span class="n">with</span> <span class="n">untrusted</span> <span class="n">content</span><span class="p">,</span> <span class="ow">and</span> <span class="no">Active</span> <span class="no">Storage</span> <span class="n">cannot</span> <span class="n">disable</span> <span class="n">them</span><span class="o">.</span> <span class="no">Disabling</span> <span class="n">them</span> <span class="n">requires</span> <span class="n">libvips</span> <span class="mf">8.13</span> <span class="ow">or</span> <span class="n">later</span> <span class="ow">and</span> <span class="n">ruby</span><span class="o">-</span><span class="n">vips</span> <span class="mf">2.2</span><span class="o">.</span><span class="mi">1</span> <span class="ow">or</span> <span class="n">later</span><span class="o">.</span> <span class="no">Please</span> <span class="n">upgrade</span> <span class="n">libvips</span> <span class="ow">and</span> <span class="n">ruby</span><span class="o">-</span><span class="n">vips</span><span class="p">,</span> <span class="ow">or</span> <span class="n">remove</span> <span class="n">the</span> <span class="n">ruby</span><span class="o">-</span><span class="n">vips</span> <span class="n">gem</span> <span class="n">from</span> <span class="n">your</span> <span class="no">Gemfile</span><span class="o">.</span> <span class="p">(</span><span class="no">RuntimeError</span><span class="p">)</span>
</code></pre></div></div>

<p><a href="https://github.com/libvips/libvips">libvips</a> is a system library that lives in your container image, not a gem in your <code class="language-plaintext highlighter-rouge">Gemfile</code>, so bumping the gem does nothing on its own until you rebuild the image. If your image still ships the old library, you are in one of two states, and neither is the one you think you are in: either your app won’t boot, or it never received the patched gem at all and is quietly still vulnerable.</p>

<p>In this post, you’ll learn why upgrading <code class="language-plaintext highlighter-rouge">activestorage</code> doesn’t close CVE-2026-66066 on its own, how a stale container image both hides the vulnerability and blocks the fix, and why rebuilding on the wrong base still leaves you exposed.</p>

<!--more-->

<h2 id="the-cve-and-why-the-gem-bump-isnt-the-whole-fix">The CVE, and why the gem bump isn’t the whole fix</h2>

<p>CVE-2026-66066 is a vulnerability in the way Active Storage processes image variants. When Active Storage generates a variant, a thumbnail or a resized copy, it hands the work off to libvips, a fast image-processing library. The trouble is that some of libvips’s operations are not safe to run on untrusted input, and older versions of the library give you no way to turn those operations off. An attacker who can get your app to process a malicious file can abuse this. The <a href="https://github.com/rails/rails/security/advisories/GHSA-xr9x-r78c-5hrm">official Rails advisory</a> describes the impact as unauthenticated arbitrary file read that can escalate to remote code execution. Its severity rating is Critical, and public write-ups of the exploit are already circulating.</p>

<p>As of early August 2026 it is not yet listed in <a href="https://www.cisa.gov/known-exploited-vulnerabilities-catalog">CISA’s Known Exploited Vulnerabilities catalog</a>, but that is not a reason to relax. Arbitrary file read on a production host usually means your credentials and environment variables are readable, which is often enough for an attacker to move further into your infrastructure.</p>

<p>If you use Active Storage with image variants, you are very likely affected. The vips processor has been the <a href="https://guides.rubyonrails.org/v7.0/configuring.html#config-active-storage-variant-processor">default variant processor</a> since <code class="language-plaintext highlighter-rouge">config.load_defaults 7.0</code>, and it has stayed the default since, so most modern Rails apps rely on it without ever opting in. The versions that carry the fix are <code class="language-plaintext highlighter-rouge">activestorage</code> 7.2.3.2, 8.0.5.1, and 8.1.3.1.</p>

<p>Patching the gem is normally the reliable fix for a Rails CVE, and usually that is where the story ends (we have written before about <a href="/blog/dont_just_upgrade_rails_-_6_cves_your_rails_app_might_have_and_what_to_patch.html">several Rails CVEs worth checking</a>). If your app is on an <a href="https://www.fastruby.io/blog/rails-eol-why">end-of-life branch like 7.0 or 7.1</a>, there is no patched release to bump to at all, and your only options are upgrading libvips together with the mitigation below, or upgrading Rails to a supported version.</p>

<p>The fix is genuinely both/and: you need the patched <code class="language-plaintext highlighter-rouge">activestorage</code> and you need <a href="https://github.com/libvips/libvips/releases/tag/v8.13.0">libvips 8.13</a> or newer, because libvips 8.13 is the first version that can block those unsafe operations. Patched Active Storage checks for that capability when it boots, and if libvips is too old it raises the exception I showed above and refuses to start rather than run in what the advisory calls an “unsecurable environment”. The newer library does the actual securing; the gem only knows how to ask for it.</p>

<p>If you can’t bump the gem right away, the <a href="https://github.com/rails/rails/security/advisories/GHSA-xr9x-r78c-5hrm">advisory</a> documents two workarounds that still require libvips 8.13 or newer: calling <code class="language-plaintext highlighter-rouge">Vips.block_untrusted(true)</code> from an initializer (with <code class="language-plaintext highlighter-rouge">ruby-vips</code> 2.2.1 or newer), or setting the <code class="language-plaintext highlighter-rouge">VIPS_BLOCK_UNTRUSTED</code> environment variable, which libvips reads at initialization.</p>

<p>And if there is any chance your app processed a malicious upload before you patched, rotate whatever secrets were reachable from that host. To find out whether that actually happened, Rails published <a href="https://github.com/rails/rails-forensics-CVE-2026-66066">forensic tooling</a> that scans your Active Storage blobs for the crafted files the attack depends on and estimates how long you were exposed, though the maintainers note a clean result is strong evidence rather than proof.</p>

<h2 id="the-proof-same-gem-two-images">The proof: same gem, two images</h2>

<p>To make the problem concrete, I built a small demo: one Rails app, one Dockerfile, and two images that differ in exactly one thing. The app has the patched <code class="language-plaintext highlighter-rouge">activestorage</code> (8.1.3.1) and Active Storage configured with the vips variant processor. The boot check fires from the configuration and the environment, not from a request.</p>

<p>The Dockerfile takes the Debian suite as a build argument, so the only thing that changes between the two builds is the base image, and therefore the version of libvips that apt installs:</p>

<div class="language-dockerfile highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># syntax=docker/dockerfile:1</span>
<span class="k">ARG</span><span class="s"> RUBY_VERSION=3.4</span>
<span class="k">ARG</span><span class="s"> SUITE=bookworm</span>
<span class="k">FROM</span><span class="s"> docker.io/library/ruby:${RUBY_VERSION}-slim-${SUITE}</span>

<span class="c"># libvips comes from the OS package repo, NOT from a gem.</span>
<span class="c"># The base image (SUITE) decides which libvips version you get:</span>
<span class="c">#   bullseye -&gt; 8.10.x (&lt; 8.13, cannot block untrusted ops)</span>
<span class="c">#   bookworm -&gt; 8.14.x (&gt;= 8.13, can)</span>
<span class="k">RUN </span>apt-get update <span class="nt">-qq</span> <span class="se">\
</span> <span class="o">&amp;&amp;</span> apt-get <span class="nb">install</span> <span class="nt">--no-install-recommends</span> <span class="nt">-y</span> build-essential git libvips libvips-tools libpq-dev pkg-config <span class="se">\
</span> <span class="o">&amp;&amp;</span> <span class="nb">rm</span> <span class="nt">-rf</span> /var/lib/apt/lists/<span class="k">*</span>

<span class="k">WORKDIR</span><span class="s"> /app</span>
<span class="k">COPY</span><span class="s"> Gemfile Gemfile.lock ./</span>
<span class="k">RUN </span>bundle <span class="nb">install</span>
<span class="k">COPY</span><span class="s"> . .</span>

<span class="k">ENV</span><span class="s"> RAILS_ENV=production \</span>
    SECRET_KEY_BASE_DUMMY=1

<span class="c"># Print the receipts, then boot.</span>
<span class="k">CMD</span><span class="s"> ["bash", "-lc", "echo '== libvips =='; vips --version; echo '== activestorage =='; bundle list | grep activestorage; echo '== boot =='; bin/rails runner 'puts :booted_ok'"]</span>
</code></pre></div></div>

<p>Then I built the same file twice, changing only the suite:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker build -f Dockerfile.demo --build-arg SUITE=bookworm -t asdemo:new .
docker build -f Dockerfile.demo --build-arg SUITE=bullseye -t asdemo:old .
</code></pre></div></div>

<p>Each container prints its libvips version, confirms which <code class="language-plaintext highlighter-rouge">activestorage</code> is installed, and then tries to boot the app. Running the bookworm image first:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ docker run --rm asdemo:new
== libvips ==
vips-8.14.1
== activestorage ==
  * activestorage (8.1.3.1)
== boot ==
booted_ok
</code></pre></div></div>

<p>libvips 8.14.1, the patched gem, and a clean boot. Now the bullseye image, which is identical except that apt installed an older libvips:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ docker run --rm asdemo:old
== libvips ==
vips-8.10.5-Wed Apr 30 17:29:54 UTC 2025
== activestorage ==
  * activestorage (8.1.3.1)
== boot ==
/usr/local/bundle/gems/activestorage-8.1.3.1/lib/active_storage/vips.rb:36:in '&lt;compiled&gt;': libvips's unfuzzed operations are not safe to use with untrusted content, and Active Storage cannot disable them. Disabling them requires libvips 8.13 or later and ruby-vips 2.2.1 or later. Please upgrade libvips and ruby-vips, or remove the ruby-vips gem from your Gemfile. (RuntimeError)
        ... backtrace trimmed ...
</code></pre></div></div>

<p>But the bullseye image ships libvips 8.10.5, which is older than 8.13, so patched Active Storage does exactly what the advisory says it will and refuses to boot. To rule out the gem layer, <code class="language-plaintext highlighter-rouge">ruby-vips</code> is 2.3.0 in both images and the <code class="language-plaintext highlighter-rouge">Gemfile.lock</code> is identical. The only moving part is the system library. The same patched <code class="language-plaintext highlighter-rouge">Gemfile</code> produced one app that boots and one that won’t.</p>

<h2 id="rebuilding-the-image-isnt-always-enough">Rebuilding the image isn’t always enough</h2>

<p>The obvious fix is to rebuild the image, and that is the right instinct, but it comes with a trap. Rebuilding only helps if the base image you rebuild on actually carries a newer libvips, and Debian bullseye does not. Checking the bullseye base while writing this, apt still installed libvips 8.10.5, the same version the demo hit. bullseye is an older Debian release, and its package repositories, security updates included, are effectively frozen at <a href="https://packages.debian.org/bullseye/libvips-tools">libvips 8.10</a>, so a rebuild faithfully reinstalls exactly what is there.</p>

<p>The version of libvips you get is really a property of the base image’s distribution, not of how recently you rebuilt. To get a patched version of libvips, you have to move to a base whose repositories carry it. Moving from bullseye to bookworm is the simplest path, since bookworm ships <a href="https://packages.debian.org/bookworm/libvips-tools">libvips 8.14</a>. If you are pinned to an older distribution for another reason, compiling a newer libvips yourself is the alternative, though moving the base image to bookworm is usually less work and less to maintain afterward.</p>

<p>A rebuild that stays on the same old base is not a fix; it just reproduces the vulnerable environment with a fresh timestamp.</p>

<h2 id="the-image-that-quietly-stays-vulnerable">The image that quietly stays vulnerable</h2>

<p>There is a worse case than the app that refuses to boot, and it is the quiet one. The image that raises on boot at least tells you something is wrong, because the app is down and you notice right away. The dangerous image is the one that was never touched at all. It still runs the old, unpatched <code class="language-plaintext highlighter-rouge">activestorage</code> on the old libvips, it boots perfectly, and it keeps serving traffic while remaining fully exploitable. Nobody gets an exception, and nothing looks broken.</p>

<p>The instinct is to reach for image age to find these, but the age of a tag does not tell you what you need to know. An image tagged last week can still be built on a base that is two years old, and an image built months ago might be sitting on a perfectly current base. What you actually care about is whether the image was rebuilt since the base it depends on last changed. An image that has fallen behind its own base has missed whatever security updates that base shipped, libvips among them, even if its tag looks recent.</p>

<p>This is the same kind of blind spot we write about on the gem side. A tool like <a href="https://www.fastruby.io/blog/how-to-use-bundler-audit-to-keep-dependencies-secure">bundler-audit</a> scans your <code class="language-plaintext highlighter-rouge">Gemfile.lock</code> for known-vulnerable gems, but it reads the lockfile, so it has no way of knowing which version of libvips your base image installed.</p>

<p>In our experience auditing client deployments, stale base images are the rule rather than the exception. CVE-2026-66066 is a good reason to check whether your own images have drifted, and to make rebuilding them a habit rather than a one-time scramble.</p>

<h2 id="conclusion">Conclusion</h2>

<p>The fix for CVE-2026-66066 requires two things: the patched gem and a base image that ships libvips 8.13 or newer. Patching the gem without rebuilding the image leaves you in one of two bad states. Image drift is not a problem you fix once because base images drift the same way gems do, and the only thing that keeps them current is a rebuild cadence you actually stick to.</p>

<p>We can help you audit what your deployed images are actually running and find the gaps your <code class="language-plaintext highlighter-rouge">Gemfile</code> cannot see. <a href="https://www.fastruby.io/#contactus">Send us a message!</a></p>]]></content><author><name>gelseyt</name></author><category term="security" /><summary type="html"><![CDATA[Patching activestorage for CVE-2026-66066 isn't enough on its own. The fix depends on a system library in your container image, and rebuilding on the wrong base leaves you exposed.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.fastruby.io/blog/why-patching-the-gem-didnt-fix-cve-2026-66066.jpg" /><media:content medium="image" url="https://www.fastruby.io/blog/why-patching-the-gem-didnt-fix-cve-2026-66066.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Migrating from sass-rails to Dart Sass</title><link href="https://www.fastruby.io/blog/Migrating-from-sass-rails-to-Dart-Sass.html" rel="alternate" type="text/html" title="Migrating from sass-rails to Dart Sass" /><published>2026-07-30T15:21:39-04:00</published><updated>2026-07-30T15:21:39-04:00</updated><id>https://www.fastruby.io/blog/Migrating-from-sass-rails-to-Dart-Sass</id><content type="html" xml:base="https://www.fastruby.io/blog/Migrating-from-sass-rails-to-Dart-Sass.html"><![CDATA[<p>For those who have been coding CSS since the Internet Explorer 6 era, when aligning divs on the web was an art,
and there was no way to use partials or variables, the arrival of SCSS was a gift to life. At that moment,
creating partials and reusing variables for your primary colors was a delightful experience.</p>

<p>Now in 2026, for the real fans, it’s possible you’re still using <code class="language-plaintext highlighter-rouge">sass-rails</code> and you’re full of these files around your project.
So if you still want to keep using it like me, I recommend migrating to Dart Sass, which is, in fact, a simple migration,
to avoid headaches in the future and, most importantly, to start using the latest features.</p>

<!--more-->

<h2 id="historical-context">Historical context</h2>

<p>To give a bit of context, you may remember that <code class="language-plaintext highlighter-rouge">sass-rails</code> has been the default SCSS library since Rails 3.1 (2011).
Some engineers like me were using Sass before that, but that’s when it became a Rails standard.</p>

<p>Behind it, <code class="language-plaintext highlighter-rouge">sass-rails</code> originally used the Ruby implementation of Sass (the original <code class="language-plaintext highlighter-rouge">sass</code> gem). That implementation
reached its end-of-life in 2019, so starting with Rails 6 the default moved to <code class="language-plaintext highlighter-rouge">sassc-rails</code>, which used <code class="language-plaintext highlighter-rouge">LibSass</code>,
the C/C++ port of the Sass compiler. <code class="language-plaintext highlighter-rouge">LibSass</code> was built to be a faster and more portable alternative to the original
Ruby-based compiler.</p>

<p>However, <code class="language-plaintext highlighter-rouge">LibSass</code> reached its <a href="https://sass-lang.com/blog/libsass-is-end-of-life/">official end-of-life</a> (EOL) in October 2025 too. So now the recommendation is to migrate
to <code class="language-plaintext highlighter-rouge">Dart Sass</code>, which includes more features and better CSS compatibility. As the name says, it’s built in the Dart
language, and the wrapper tool uses the standalone Dart Sass executable, meaning you don’t even need Node.js installed to use it.</p>

<h2 id="why-keep-using-dart-sass">Why keep using Dart Sass?</h2>

<p>A fair question in 2026 is why bother with Sass at all when modern CSS already has native variables, nesting, and more.
The honest answer: if your project already runs on SCSS, a full rewrite to plain CSS is a big effort with little payoff.</p>

<p>Dart Sass is also the only Sass implementation still maintained. It’s the reference now, it gets all the new features first,
and it keeps the same partials, mixins, and functions you already use. So instead of throwing away years of styles, you keep
them and stay on a tool that’s still alive.</p>

<h2 id="what-changes-in-dart-sass">What Changes in Dart Sass</h2>
<p>These are the most important changes.</p>

<h3 id="-division-operator-deprecated-biggest-breaking-change"><code class="language-plaintext highlighter-rouge">/</code> division operator deprecated (biggest breaking change)</h3>
<p>Maybe you never noticed, but the slash <code class="language-plaintext highlighter-rouge">/</code> operator has two behaviors: on one hand, it works as division, but in another
context, it works as a separator. You can imagine the migraine for the devs maintaining that.</p>

<div class="language-scss highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$container-width</span><span class="p">:</span> <span class="m">1200px</span><span class="p">;</span>
<span class="nv">$grid-columns</span><span class="p">:</span> <span class="m">12</span><span class="p">;</span>

<span class="nc">.sidebar</span> <span class="p">{</span>
  <span class="c1">// as divisor</span>
  <span class="nl">width</span><span class="p">:</span> <span class="nv">$container-width</span> <span class="o">/</span> <span class="nv">$grid-columns</span><span class="p">;</span>

  <span class="c1">// as separator</span>
  <span class="nl">font</span><span class="p">:</span> <span class="m">16px</span> <span class="o">/</span> <span class="m">1</span><span class="mi">.5</span> <span class="nb">sans-serif</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>For division, you now use <code class="language-plaintext highlighter-rouge">math.div()</code>:</p>

<div class="language-scss highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">@use</span> <span class="s1">'sass:math'</span><span class="p">;</span>

<span class="nc">.sidebar</span> <span class="p">{</span>
  <span class="nl">width</span><span class="p">:</span> <span class="n">math</span><span class="o">.</span><span class="nf">div</span><span class="p">(</span><span class="nv">$container-width</span><span class="o">,</span> <span class="nv">$grid-columns</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>And when you actually want a slash separated list with variables, you use <code class="language-plaintext highlighter-rouge">list.slash()</code>:</p>

<div class="language-scss highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nc">.sidebar</span> <span class="p">{</span>
  <span class="nl">font</span><span class="p">:</span> <span class="n">list</span><span class="o">.</span><span class="nf">slash</span><span class="p">(</span><span class="nv">$font-size</span><span class="o">,</span> <span class="nv">$line-height</span><span class="p">)</span> <span class="nb">sans-serif</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="import-deprecated-in-favor-of-use-and-forward"><code class="language-plaintext highlighter-rouge">@import</code> deprecated in favor of <code class="language-plaintext highlighter-rouge">@use</code> and <code class="language-plaintext highlighter-rouge">@forward</code></h3>
<p>You probably also didn’t notice that when you do an <code class="language-plaintext highlighter-rouge">@import foo</code> on a file, all the imported values are loaded into the
global namespace. That means, for example, that all the functions become available globally.
If you’re experienced enough (and if not, just trust us), having everything available, especially on medium or big
projects, can soon become a mess of cascading rules overwriting each other. It makes compilation slow as a consequence of importing the same file multiple times, and it increases the complexity of knowing where a variable or function came from.</p>

<p>The good news is that for you it just means starting to replace <code class="language-plaintext highlighter-rouge">@import foo</code> with <code class="language-plaintext highlighter-rouge">@use foo</code>.</p>

<p>Imagine you have one file where you define the variables:</p>
<div class="language-scss highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// _variables.scss</span>
<span class="nv">$brand-color</span><span class="p">:</span> <span class="mh">#ff0000</span><span class="p">;</span>
</code></pre></div></div>

<p>On the other side, you have a file with a few functions, but it also uses the variables:</p>
<div class="language-scss highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// _mixins.scss</span>
<span class="k">@import</span> <span class="s2">"variables"</span><span class="p">;</span>

<span class="k">@mixin</span> <span class="nf">brand-border</span> <span class="p">{</span>
  <span class="nl">border</span><span class="p">:</span> <span class="m">2px</span> <span class="nb">solid</span> <span class="nv">$brand-color</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Here, <code class="language-plaintext highlighter-rouge">brand-border</code> is available globally. In your file you’d use it by doing:</p>
<div class="language-scss highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">@import</span> <span class="s2">"variables"</span><span class="p">;</span>
<span class="k">@import</span> <span class="s2">"mixins"</span><span class="p">;</span> <span class="c1">// Works because everything is globally leaked</span>

<span class="nc">.button</span> <span class="p">{</span>
  <span class="nl">color</span><span class="p">:</span> <span class="nv">$brand-color</span><span class="p">;</span>
  <span class="k">@include</span> <span class="nd">brand-border</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Now, with the latest version, the code looks similar:</p>
<div class="language-scss highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">@use</span> <span class="s2">"variables"</span><span class="p">;</span> <span class="c1">// Load variables for main.scss</span>
<span class="k">@use</span> <span class="s2">"mixins"</span><span class="p">;</span>    <span class="c1">// Load mixins for main.scss</span>

<span class="nc">.button</span> <span class="p">{</span>
  <span class="nl">color</span><span class="p">:</span> <span class="n">variables</span><span class="o">.</span><span class="nv">$brand-color</span><span class="p">;</span> <span class="c1">// Uses variables namespace</span>
  <span class="k">@include</span> <span class="nd">mixins</span><span class="o">.</span><span class="n">brand-border</span><span class="p">;</span>   <span class="c1">// Uses mixins namespace</span>
<span class="p">}</span>
</code></pre></div></div>
<p>Now it’s using namespaces and nothing is globally available.</p>

<h3 id="other-breaking-changes">Other breaking changes</h3>
<p>Finally, there’s a list of more breaking changes <a href="https://sass-lang.com/documentation/breaking-changes/">here</a>, but if you’re working on a Rails project, the most typical ones are:</p>

<p><code class="language-plaintext highlighter-rouge">Math</code> functions now require an explicit module import:</p>

<div class="language-scss highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Before</span>
<span class="nv">$value</span><span class="p">:</span> <span class="nf">round</span><span class="p">(</span><span class="m">1</span><span class="mi">.5</span><span class="p">);</span>
<span class="nv">$half</span><span class="p">:</span> <span class="nf">floor</span><span class="p">(</span><span class="m">10px</span> <span class="o">/</span> <span class="m">2</span><span class="p">);</span>

<span class="c1">// After</span>
<span class="k">@use</span> <span class="s1">'sass:math'</span><span class="p">;</span>

<span class="nv">$value</span><span class="p">:</span> <span class="n">math</span><span class="o">.</span><span class="nf">round</span><span class="p">(</span><span class="m">1</span><span class="mi">.5</span><span class="p">);</span>
<span class="nv">$half</span><span class="p">:</span> <span class="n">math</span><span class="o">.</span><span class="nf">floor</span><span class="p">(</span><span class="n">math</span><span class="o">.</span><span class="nf">div</span><span class="p">(</span><span class="m">10px</span><span class="o">,</span> <span class="m">2</span><span class="p">));</span>
</code></pre></div></div>

<p><strong>Color functions</strong> are deprecated in favor of the <code class="language-plaintext highlighter-rouge">sass:color</code> module:</p>

<div class="language-scss highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Before</span>
<span class="nv">$dark</span><span class="p">:</span> <span class="nf">darken</span><span class="p">(</span><span class="mh">#036</span><span class="o">,</span> <span class="m">10%</span><span class="p">);</span>
<span class="nv">$light</span><span class="p">:</span> <span class="nf">lighten</span><span class="p">(</span><span class="mh">#036</span><span class="o">,</span> <span class="m">10%</span><span class="p">);</span>

<span class="c1">// After</span>
<span class="k">@use</span> <span class="s1">'sass:color'</span><span class="p">;</span>

<span class="nv">$dark</span><span class="p">:</span> <span class="n">color</span><span class="o">.</span><span class="nf">adjust</span><span class="p">(</span><span class="mh">#036</span><span class="o">,</span> <span class="nv">$lightness</span><span class="o">:</span> <span class="m">-10%</span><span class="p">);</span>
<span class="nv">$light</span><span class="p">:</span> <span class="n">color</span><span class="o">.</span><span class="nf">adjust</span><span class="p">(</span><span class="mh">#036</span><span class="o">,</span> <span class="nv">$lightness</span><span class="o">:</span> <span class="m">10%</span><span class="p">);</span>
</code></pre></div></div>

<p>Other modules like <code class="language-plaintext highlighter-rouge">sass:string</code>, <code class="language-plaintext highlighter-rouge">sass:list</code>, and <code class="language-plaintext highlighter-rouge">sass:map</code> follow the same pattern. Also, global functions like <code class="language-plaintext highlighter-rouge">str-length()</code>, <code class="language-plaintext highlighter-rouge">map-get()</code>, and <code class="language-plaintext highlighter-rouge">map-keys()</code> now require an explicit <code class="language-plaintext highlighter-rouge">@use</code> declaration.</p>

<h2 id="using-the-migrator">Using the Migrator</h2>
<p>Luckily there’s a <a href="https://sass-lang.com/documentation/cli/migrator/">Sass Migrator</a> that automatically updates your
files to make it easier to migrate your project.</p>

<p>You can install it with <code class="language-plaintext highlighter-rouge">npm install -g sass-migrator</code>. After that, if your base file is <code class="language-plaintext highlighter-rouge">application.scss</code>, you can run it like this:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>sass-migrator module <span class="nt">--migrate-deps</span> app/assets/stylesheets/application.scss
</code></pre></div></div>

<p>There are a few options there. The first useful one is <code class="language-plaintext highlighter-rouge">--migrate-deps</code>, which tells the migrator to update both the explicitly specified stylesheets and any dependencies linked via <code class="language-plaintext highlighter-rouge">@use</code>, <code class="language-plaintext highlighter-rouge">@forward</code>, or <code class="language-plaintext highlighter-rouge">@import</code> rules.</p>

<h2 id="a-few-rails-tips">A few Rails tips</h2>

<p>Once you switch to the <code class="language-plaintext highlighter-rouge">dartsass-rails</code> gem, a couple of things saved us headaches.</p>

<p>First, register any extra SCSS entry point in <code class="language-plaintext highlighter-rouge">config/initializers/dartsass.rb</code>. The gem builds <code class="language-plaintext highlighter-rouge">application.scss</code> by
default, but any other entry point you have (an admin stylesheet, a separate theme, etc.) won’t be compiled by Dart Sass
unless you add it here. If you forget, those files won’t be compiled into CSS at all, or Sprockets will throw an error
since it no longer has a Sass compiler to process them:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="no">Rails</span><span class="p">.</span><span class="nf">application</span><span class="p">.</span><span class="nf">config</span><span class="p">.</span><span class="nf">dartsass</span><span class="p">.</span><span class="nf">builds</span><span class="p">.</span><span class="nf">merge!</span><span class="p">(</span>
  <span class="s2">"admin.scss"</span> <span class="o">=&gt;</span> <span class="s2">"admin.css"</span><span class="p">,</span>
  <span class="s2">"theme.scss"</span> <span class="o">=&gt;</span> <span class="s2">"theme.css"</span>
<span class="p">)</span>
</code></pre></div></div>

<p>Second, third-party files you don’t control will still throw deprecation warnings. Use <code class="language-plaintext highlighter-rouge">--quiet-deps</code> to silence the
noise from those while keeping warnings for your own code:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="no">Rails</span><span class="p">.</span><span class="nf">application</span><span class="p">.</span><span class="nf">config</span><span class="p">.</span><span class="nf">dartsass</span><span class="p">.</span><span class="nf">build_options</span> <span class="o">&lt;&lt;</span> <span class="s2">"--quiet-deps"</span>
</code></pre></div></div>

<p>And once your own code is clean, turn the deprecations you already fixed into build errors so they don’t creep back in:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="no">Rails</span><span class="p">.</span><span class="nf">application</span><span class="p">.</span><span class="nf">config</span><span class="p">.</span><span class="nf">dartsass</span><span class="p">.</span><span class="nf">build_options</span> <span class="o">&lt;&lt;</span> <span class="s2">"--fatal-deprecation=import"</span>
<span class="no">Rails</span><span class="p">.</span><span class="nf">application</span><span class="p">.</span><span class="nf">config</span><span class="p">.</span><span class="nf">dartsass</span><span class="p">.</span><span class="nf">build_options</span> <span class="o">&lt;&lt;</span> <span class="s2">"--fatal-deprecation=mixed-decls"</span>
</code></pre></div></div>

<h2 id="conclusion">Conclusion</h2>

<p>Migrating from Sass Rails to Dart Sass isn’t as scary as it sounds. The Migrator does most of the heavy lifting, and the
rest is mostly swapping <code class="language-plaintext highlighter-rouge">@import</code> for <code class="language-plaintext highlighter-rouge">@use</code> and adding a few module imports. Do it now while it’s still a small migration,
and you get to keep your SCSS files and stay on a tool that’s actually maintained.</p>

<p>Planning a bigger Rails upgrade and want a hand? <a href="https://www.fastruby.io/#contact-us">We can help you get there!</a></p>]]></content><author><name>juliolucero</name></author><category term="upgrades" /><summary type="html"><![CDATA[LibSass is now end-of-life, so it's time to move your Rails app off sass-rails. Here's how to migrate to Dart Sass: what breaks, how the migrator helps, and the Rails gotchas to watch for.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.fastruby.io/blog/migrate-from-Sass-Rails-to-Dart-Sass.png" /><media:content medium="image" url="https://www.fastruby.io/blog/migrate-from-Sass-Rails-to-Dart-Sass.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Tracking LLM Latency &amp;amp; Cost with Rails Events</title><link href="https://www.fastruby.io/blog/instrumenting-llm-calls-in-rails-with-event-notify.html" rel="alternate" type="text/html" title="Tracking LLM Latency &amp;amp; Cost with Rails Events" /><published>2026-07-28T08:47:50-04:00</published><updated>2026-07-28T08:47:50-04:00</updated><id>https://www.fastruby.io/blog/instrumenting-llm-calls-in-rails-with-event-notify</id><content type="html" xml:base="https://www.fastruby.io/blog/instrumenting-llm-calls-in-rails-with-event-notify.html"><![CDATA[<p>Wiring an LLM into a Rails app takes a handful of lines. Understanding what it actually <em>costs</em> you (feature by feature, user by user) is harder. Most providers and SDKs already report tokens, latency, and even cost, but that data lives in <em>their</em> dashboard. It’s disconnected from your requests, your users, and the feature that made the call. And it sits apart from the APM and logs where you already watch the rest of your app.</p>

<p>In a previous post, we introduced <a href="/blog/rails-event-notify.html"><strong><code class="language-plaintext highlighter-rouge">Rails.event.notify(...)</code></strong></a>, the tool-agnostic Event Reporter shipping in Rails 8.1. In this post, we’ll put it to work on a real problem: instrumenting every LLM call in your app so token usage, latency, and cost become structured events you can log, graph, and forward to any APM or data warehouse.</p>

<!--more-->

<h2 id="why-instrument-llm-calls">Why instrument LLM calls?</h2>

<p>Before writing any code, it helps to be specific about what we’re after. Four things, really. <strong>Cost</strong>, from the raw token counts up to the dollar figure on the invoice. <strong>Latency</strong>, so a slow model or a bloated prompt shows up before your users feel it. <strong>Failures</strong>, because a timeout or a rate limit shouldn’t just vanish. And, most of all, <strong>attribution</strong>: not just that a call happened, but which feature, user, and request set it off.</p>

<p><code class="language-plaintext highlighter-rouge">ActiveSupport::Notifications</code> could get us there, but we’d be writing custom glue code for every APM vendor. The Event Reporter gives us one structured emission point instead, and lets each subscriber decide what to do with it.</p>

<blockquote>
  <p><strong>Prerequisites</strong>: This tutorial assumes Rails 8.1 (for <code class="language-plaintext highlighter-rouge">Rails.event</code>) and any LLM client that reports token usage. The examples use the <a href="https://github.com/crmne/ruby_llm"><code class="language-plaintext highlighter-rouge">ruby_llm</code></a> gem, but the pattern works with the official <code class="language-plaintext highlighter-rouge">anthropic</code>/<code class="language-plaintext highlighter-rouge">openai</code> SDKs or a raw <code class="language-plaintext highlighter-rouge">Net::HTTP</code> call just as well.</p>
</blockquote>

<h2 id="the-starting-point-an-uninstrumented-service">The starting point: an uninstrumented service</h2>

<p>Here’s a typical, uninstrumented service that summarizes some text:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/services/summarizer.rb</span>
<span class="k">class</span> <span class="nc">Summarizer</span>
  <span class="no">MODEL</span> <span class="o">=</span> <span class="s2">"claude-haiku-4-5"</span>

  <span class="k">def</span> <span class="nf">initialize</span><span class="p">(</span><span class="n">text</span><span class="p">)</span>
    <span class="vi">@text</span> <span class="o">=</span> <span class="n">text</span>
  <span class="k">end</span>

  <span class="k">def</span> <span class="nf">call</span>
    <span class="n">chat</span> <span class="o">=</span> <span class="no">RubyLLM</span><span class="p">.</span><span class="nf">chat</span><span class="p">(</span><span class="ss">model: </span><span class="no">MODEL</span><span class="p">)</span>
    <span class="n">response</span> <span class="o">=</span> <span class="n">chat</span><span class="p">.</span><span class="nf">ask</span><span class="p">(</span><span class="s2">"Summarize the following text:</span><span class="se">\n\n</span><span class="si">#{</span><span class="vi">@text</span><span class="si">}</span><span class="s2">"</span><span class="p">)</span>
    <span class="n">response</span><span class="p">.</span><span class="nf">content</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>It works, but it’s a black box. We have no idea how many tokens that summary consumed, what it cost, or how long the LLM took to respond. Let’s improve that.</p>

<h2 id="emitting-a-structured-event-around-the-call">Emitting a structured event around the call</h2>

<p>The Event Reporter’s core method is <code class="language-plaintext highlighter-rouge">Rails.event.notify(name, payload)</code>. We wrap the call, measure the duration with a monotonic clock, and emit everything we care about:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/services/summarizer.rb</span>
<span class="k">class</span> <span class="nc">Summarizer</span>
  <span class="no">MODEL</span> <span class="o">=</span> <span class="s2">"claude-haiku-4-5"</span>

  <span class="k">def</span> <span class="nf">initialize</span><span class="p">(</span><span class="n">text</span><span class="p">)</span>
    <span class="vi">@text</span> <span class="o">=</span> <span class="n">text</span>
  <span class="k">end</span>

  <span class="k">def</span> <span class="nf">call</span>
    <span class="n">started_at</span> <span class="o">=</span> <span class="no">Process</span><span class="p">.</span><span class="nf">clock_gettime</span><span class="p">(</span><span class="no">Process</span><span class="o">::</span><span class="no">CLOCK_MONOTONIC</span><span class="p">)</span>

    <span class="n">chat</span> <span class="o">=</span> <span class="no">RubyLLM</span><span class="p">.</span><span class="nf">chat</span><span class="p">(</span><span class="ss">model: </span><span class="no">MODEL</span><span class="p">)</span>
    <span class="n">response</span> <span class="o">=</span> <span class="n">chat</span><span class="p">.</span><span class="nf">ask</span><span class="p">(</span><span class="s2">"Summarize the following text:</span><span class="se">\n\n</span><span class="si">#{</span><span class="vi">@text</span><span class="si">}</span><span class="s2">"</span><span class="p">)</span>

    <span class="n">duration_ms</span> <span class="o">=</span> <span class="p">((</span><span class="no">Process</span><span class="p">.</span><span class="nf">clock_gettime</span><span class="p">(</span><span class="no">Process</span><span class="o">::</span><span class="no">CLOCK_MONOTONIC</span><span class="p">)</span> <span class="o">-</span> <span class="n">started_at</span><span class="p">)</span> <span class="o">*</span> <span class="mi">1000</span><span class="p">).</span><span class="nf">round</span>

    <span class="no">Rails</span><span class="p">.</span><span class="nf">event</span><span class="p">.</span><span class="nf">notify</span><span class="p">(</span>
      <span class="s2">"llm.completion"</span><span class="p">,</span>
      <span class="ss">model: </span><span class="no">MODEL</span><span class="p">,</span>
      <span class="ss">input_tokens: </span><span class="n">response</span><span class="p">.</span><span class="nf">input_tokens</span><span class="p">,</span>
      <span class="ss">output_tokens: </span><span class="n">response</span><span class="p">.</span><span class="nf">output_tokens</span><span class="p">,</span>
      <span class="ss">total_tokens: </span><span class="n">response</span><span class="p">.</span><span class="nf">input_tokens</span> <span class="o">+</span> <span class="n">response</span><span class="p">.</span><span class="nf">output_tokens</span><span class="p">,</span>
      <span class="ss">duration_ms: </span><span class="n">duration_ms</span>
    <span class="p">)</span>

    <span class="n">response</span><span class="p">.</span><span class="nf">content</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Every summary now publishes a single <code class="language-plaintext highlighter-rouge">llm.completion</code> event. Notice what we <em>don’t</em> put in the payload: no prompt text, no user content. Keep payloads small and free of PII, just IDs, token counts, and durations.</p>

<h2 id="listening-with-a-subscriber">Listening with a subscriber</h2>

<p>An event that no one listens to does nothing. Subscribers are plain Ruby objects that implement an <code class="language-plaintext highlighter-rouge">#emit(event)</code> method, where <code class="language-plaintext highlighter-rouge">event</code> is a hash containing <code class="language-plaintext highlighter-rouge">:name</code>, <code class="language-plaintext highlighter-rouge">:payload</code>, <code class="language-plaintext highlighter-rouge">:tags</code>, <code class="language-plaintext highlighter-rouge">:context</code>, <code class="language-plaintext highlighter-rouge">:timestamp</code>, and <code class="language-plaintext highlighter-rouge">:source_location</code>.</p>

<p>Let’s start with a subscriber that writes a compact <code class="language-plaintext highlighter-rouge">key=value</code> log line and forwards metrics to an APM. It lives under <code class="language-plaintext highlighter-rouge">app/subscribers/</code>, so Rails autoloads it:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/subscribers/llm_event_subscriber.rb</span>
<span class="k">class</span> <span class="nc">LlmEventSubscriber</span>
  <span class="k">def</span> <span class="nf">emit</span><span class="p">(</span><span class="n">event</span><span class="p">)</span>
    <span class="k">return</span> <span class="k">unless</span> <span class="n">event</span><span class="p">[</span><span class="ss">:name</span><span class="p">]</span> <span class="o">==</span> <span class="s2">"llm.completion"</span>

    <span class="n">payload</span> <span class="o">=</span> <span class="n">event</span><span class="p">[</span><span class="ss">:payload</span><span class="p">]</span>

    <span class="no">Rails</span><span class="p">.</span><span class="nf">logger</span><span class="p">.</span><span class="nf">info</span><span class="p">(</span>
      <span class="s2">"[llm.completion] model=</span><span class="si">#{</span><span class="n">payload</span><span class="p">[</span><span class="ss">:model</span><span class="p">]</span><span class="si">}</span><span class="s2"> "</span> <span class="p">\</span>
      <span class="s2">"tokens=</span><span class="si">#{</span><span class="n">payload</span><span class="p">[</span><span class="ss">:total_tokens</span><span class="p">]</span><span class="si">}</span><span class="s2"> "</span> <span class="p">\</span>
      <span class="s2">"duration_ms=</span><span class="si">#{</span><span class="n">payload</span><span class="p">[</span><span class="ss">:duration_ms</span><span class="p">]</span><span class="si">}</span><span class="s2">"</span>
    <span class="p">)</span>

    <span class="c1"># Forward to your APM of choice (Datadog, AppSignal, New Relic, etc.):</span>
    <span class="no">MyAPM</span><span class="p">.</span><span class="nf">distribution</span><span class="p">(</span>
      <span class="s2">"llm.duration_ms"</span><span class="p">,</span>
      <span class="n">payload</span><span class="p">[</span><span class="ss">:duration_ms</span><span class="p">],</span>
      <span class="ss">model: </span><span class="n">payload</span><span class="p">[</span><span class="ss">:model</span><span class="p">]</span>
    <span class="p">)</span>
    <span class="no">MyAPM</span><span class="p">.</span><span class="nf">counter</span><span class="p">(</span>
      <span class="s2">"llm.total_tokens"</span><span class="p">,</span>
      <span class="n">payload</span><span class="p">[</span><span class="ss">:total_tokens</span><span class="p">],</span>
      <span class="ss">model: </span><span class="n">payload</span><span class="p">[</span><span class="ss">:model</span><span class="p">]</span>
    <span class="p">)</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Then register an instance once, at boot:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/initializers/llm_events.rb</span>
<span class="no">Rails</span><span class="p">.</span><span class="nf">event</span><span class="p">.</span><span class="nf">subscribe</span><span class="p">(</span><span class="no">LlmEventSubscriber</span><span class="p">.</span><span class="nf">new</span><span class="p">)</span>
</code></pre></div></div>

<p>The same event now lands in your logs <em>and</em> your dashboards, without the service object knowing anything about AppSignal, Datadog, or New Relic. Swap the subscriber’s body and you’ve swapped vendors.</p>

<h2 id="from-tokens-to-dollars">From tokens to dollars</h2>

<p>Tokens are only half the story. The number your finance team cares about is cost. Providers charge different rates for <strong>input</strong> and <strong>output</strong> tokens: the prompt you send costs less, the text the model generates back costs more. The exact rates vary by model. So we keep a small lookup table that maps each model to its two rates, in the same units the pricing pages use: U.S. dollars per one million tokens.</p>

<p>There’s a catch, though: a hand-maintained table like this goes stale fast. Providers release new models and adjust prices regularly, and every change means another edit to keep in sync. If you’re using <code class="language-plaintext highlighter-rouge">ruby_llm</code>, you can skip the table entirely. The gem keeps its own <a href="https://rubyllm.com/models/#calculating-costs">pricing registry and calculates cost for you</a>: a response exposes <code class="language-plaintext highlighter-rouge">response.cost.total</code>, and <code class="language-plaintext highlighter-rouge">model.cost_for(response.tokens)</code> returns a <code class="language-plaintext highlighter-rouge">RubyLLM::Cost</code> broken down by component, <code class="language-plaintext highlighter-rouge">input</code>, <code class="language-plaintext highlighter-rouge">output</code>, <code class="language-plaintext highlighter-rouge">cache_read</code>, <code class="language-plaintext highlighter-rouge">cache_write</code>, <code class="language-plaintext highlighter-rouge">thinking</code> and the <code class="language-plaintext highlighter-rouge">total</code> across all of them. When your client hands you cost directly, emit that value and let the gem take care of staying current.</p>

<p>Not every LLM client reports cost, though, so it’s worth knowing how to compute it yourself. In that case we keep the table and the math inside the subscriber, so cost is computed once and every consumer of the event gets it for free:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/subscribers/llm_event_subscriber.rb</span>
<span class="k">class</span> <span class="nc">LlmEventSubscriber</span>
  <span class="c1"># Per-model pricing in USD per 1 million tokens.</span>
  <span class="c1">#   input  =&gt; rate for the tokens you send (the prompt)</span>
  <span class="c1">#   output =&gt; rate for the tokens the model generates back</span>
  <span class="c1"># Copy these numbers from your provider's pricing page and keep them in sync.</span>
  <span class="no">PRICING</span> <span class="o">=</span> <span class="p">{</span>
    <span class="s2">"claude-haiku-4-5"</span> <span class="o">=&gt;</span> <span class="p">{</span> <span class="ss">input: </span><span class="mf">1.00</span><span class="p">,</span> <span class="ss">output: </span><span class="mf">5.00</span> <span class="p">},</span>
    <span class="s2">"claude-sonnet-5"</span>  <span class="o">=&gt;</span> <span class="p">{</span> <span class="ss">input: </span><span class="mf">3.00</span><span class="p">,</span> <span class="ss">output: </span><span class="mf">15.00</span> <span class="p">}</span>
  <span class="p">}.</span><span class="nf">freeze</span>

  <span class="k">def</span> <span class="nf">emit</span><span class="p">(</span><span class="n">event</span><span class="p">)</span>
    <span class="k">return</span> <span class="k">unless</span> <span class="n">event</span><span class="p">[</span><span class="ss">:name</span><span class="p">]</span> <span class="o">==</span> <span class="s2">"llm.completion"</span>

    <span class="n">payload</span> <span class="o">=</span> <span class="n">event</span><span class="p">[</span><span class="ss">:payload</span><span class="p">]</span>
    <span class="n">cost</span> <span class="o">=</span> <span class="n">estimate_cost</span><span class="p">(</span><span class="n">payload</span><span class="p">)</span>

    <span class="no">Rails</span><span class="p">.</span><span class="nf">logger</span><span class="p">.</span><span class="nf">info</span><span class="p">(</span>
      <span class="s2">"[llm.completion] model=</span><span class="si">#{</span><span class="n">payload</span><span class="p">[</span><span class="ss">:model</span><span class="p">]</span><span class="si">}</span><span class="s2"> "</span> <span class="p">\</span>
      <span class="s2">"tokens=</span><span class="si">#{</span><span class="n">payload</span><span class="p">[</span><span class="ss">:total_tokens</span><span class="p">]</span><span class="si">}</span><span class="s2"> "</span> <span class="p">\</span>
      <span class="s2">"duration_ms=</span><span class="si">#{</span><span class="n">payload</span><span class="p">[</span><span class="ss">:duration_ms</span><span class="p">]</span><span class="si">}</span><span class="s2"> "</span> <span class="p">\</span>
      <span class="s2">"cost_usd=</span><span class="si">#{</span><span class="n">cost</span><span class="p">.</span><span class="nf">round</span><span class="p">(</span><span class="mi">6</span><span class="p">)</span><span class="si">}</span><span class="s2">"</span>
    <span class="p">)</span>

    <span class="c1"># The duration_ms and total_tokens metrics are</span>
    <span class="c1"># omitted to keep the example focused on cost.</span>

    <span class="no">MyAPM</span><span class="p">.</span><span class="nf">distribution</span><span class="p">(</span>
      <span class="s2">"llm.cost_usd"</span><span class="p">,</span>
      <span class="n">cost</span><span class="p">,</span>
      <span class="ss">model: </span><span class="n">payload</span><span class="p">[</span><span class="ss">:model</span><span class="p">]</span>
    <span class="p">)</span>
  <span class="k">end</span>

  <span class="kp">private</span>

  <span class="k">def</span> <span class="nf">estimate_cost</span><span class="p">(</span><span class="n">payload</span><span class="p">)</span>
    <span class="n">prices</span> <span class="o">=</span> <span class="no">PRICING</span><span class="p">[</span><span class="n">payload</span><span class="p">[</span><span class="ss">:model</span><span class="p">]]</span>
    <span class="k">return</span> <span class="mf">0.0</span> <span class="k">unless</span> <span class="n">prices</span>

    <span class="n">input_cost</span> <span class="o">=</span> <span class="n">payload</span><span class="p">[</span><span class="ss">:input_tokens</span><span class="p">]</span> <span class="o">*</span> <span class="n">prices</span><span class="p">[</span><span class="ss">:input</span><span class="p">]</span> <span class="o">/</span> <span class="mf">1_000_000.0</span>
    <span class="n">output_cost</span> <span class="o">=</span> <span class="n">payload</span><span class="p">[</span><span class="ss">:output_tokens</span><span class="p">]</span> <span class="o">*</span> <span class="n">prices</span><span class="p">[</span><span class="ss">:output</span><span class="p">]</span> <span class="o">/</span> <span class="mf">1_000_000.0</span>
    <span class="n">input_cost</span> <span class="o">+</span> <span class="n">output_cost</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>The registration in the initializer stays exactly the same. Now every completion carries a dollar figure you can sum, chart, and alert on.</p>

<h2 id="attributing-calls-to-features-and-users">Attributing calls to features and users</h2>

<p>Aggregate token counts are useful, but “which feature is eating my budget?” and “how much did this user cost this month?” are the questions that actually drive decisions. The Event Reporter answers both with two mechanisms: <strong>tags</strong> and <strong>context</strong>.</p>

<p><strong>Tags</strong> help identify which <em>feature or operation</em> triggered the call. Wrap the call in <code class="language-plaintext highlighter-rouge">Rails.event.tagged</code>, using a keyword so the tag carries a real value rather than just a label:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/services/summarizer.rb</span>
<span class="k">def</span> <span class="nf">call</span>
  <span class="no">Rails</span><span class="p">.</span><span class="nf">event</span><span class="p">.</span><span class="nf">tagged</span><span class="p">(</span><span class="ss">feature: </span><span class="s2">"summarization"</span><span class="p">)</span> <span class="k">do</span>
    <span class="c1"># ...measure and notify as before...</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p><strong>Context</strong> describes <em>the environment</em> an event happens in, and it’s typically set once per request. A great place is a controller <code class="language-plaintext highlighter-rouge">before_action</code> or the base controller:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/controllers/application_controller.rb</span>
<span class="k">class</span> <span class="nc">ApplicationController</span> <span class="o">&lt;</span> <span class="no">ActionController</span><span class="o">::</span><span class="no">Base</span>
  <span class="n">before_action</span> <span class="ss">:set_llm_context</span>

  <span class="kp">private</span>

  <span class="k">def</span> <span class="nf">set_llm_context</span>
    <span class="no">Rails</span><span class="p">.</span><span class="nf">event</span><span class="p">.</span><span class="nf">set_context</span><span class="p">(</span>
      <span class="ss">request_id: </span><span class="n">request</span><span class="p">.</span><span class="nf">request_id</span><span class="p">,</span>
      <span class="ss">user_id: </span><span class="n">current_user</span><span class="o">&amp;</span><span class="p">.</span><span class="nf">id</span>
    <span class="p">)</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>From now on, every <code class="language-plaintext highlighter-rouge">llm.completion</code> event automatically carries the tags and context, with no extra work in the service object. A full event handed to your subscriber now looks like this:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span>
  <span class="ss">name: </span><span class="s2">"llm.completion"</span><span class="p">,</span>
  <span class="ss">payload: </span><span class="p">{</span>
    <span class="ss">model: </span><span class="s2">"claude-haiku-4-5"</span><span class="p">,</span>
    <span class="ss">input_tokens: </span><span class="mi">2000</span><span class="p">,</span>
    <span class="ss">output_tokens: </span><span class="mi">500</span><span class="p">,</span>
    <span class="ss">total_tokens: </span><span class="mi">2500</span><span class="p">,</span>
    <span class="ss">duration_ms: </span><span class="mi">842</span>
  <span class="p">},</span>
  <span class="ss">tags: </span><span class="p">{</span> <span class="ss">feature: </span><span class="s2">"summarization"</span> <span class="p">},</span>
  <span class="ss">context: </span><span class="p">{</span> <span class="ss">request_id: </span><span class="s2">"abc123"</span><span class="p">,</span> <span class="ss">user_id: </span><span class="mi">456</span> <span class="p">},</span>
  <span class="ss">timestamp: </span><span class="mi">1738964843208679035</span><span class="p">,</span>
  <span class="ss">source_location: </span><span class="p">{</span> <span class="ss">filepath: </span><span class="s2">"app/services/summarizer.rb"</span><span class="p">,</span> <span class="ss">lineno: </span><span class="mi">14</span><span class="p">,</span> <span class="ss">label: </span><span class="s2">"Summarizer#call"</span> <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Update the subscriber to read them:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">emit</span><span class="p">(</span><span class="n">event</span><span class="p">)</span>
  <span class="k">return</span> <span class="k">unless</span> <span class="n">event</span><span class="p">[</span><span class="ss">:name</span><span class="p">]</span> <span class="o">==</span> <span class="s2">"llm.completion"</span>

  <span class="n">payload</span> <span class="o">=</span> <span class="n">event</span><span class="p">[</span><span class="ss">:payload</span><span class="p">]</span>
  <span class="c1"># `tags` is a hash. Because we tagged with `feature:`, we can read the value</span>
  <span class="c1"># straight from the :feature key, with no assumptions about ordering.</span>
  <span class="n">feature</span> <span class="o">=</span> <span class="n">event</span><span class="p">[</span><span class="ss">:tags</span><span class="p">][</span><span class="ss">:feature</span><span class="p">]</span>
  <span class="n">user_id</span> <span class="o">=</span> <span class="n">event</span><span class="p">[</span><span class="ss">:context</span><span class="p">][</span><span class="ss">:user_id</span><span class="p">]</span>

  <span class="no">Rails</span><span class="p">.</span><span class="nf">logger</span><span class="p">.</span><span class="nf">info</span><span class="p">(</span>
    <span class="s2">"[llm.completion] feature=</span><span class="si">#{</span><span class="n">feature</span><span class="si">}</span><span class="s2"> user_id=</span><span class="si">#{</span><span class="n">user_id</span><span class="si">}</span><span class="s2"> "</span> <span class="p">\</span>
    <span class="s2">"model=</span><span class="si">#{</span><span class="n">payload</span><span class="p">[</span><span class="ss">:model</span><span class="p">]</span><span class="si">}</span><span class="s2"> cost_usd=</span><span class="si">#{</span><span class="n">estimate_cost</span><span class="p">(</span><span class="n">payload</span><span class="p">).</span><span class="nf">round</span><span class="p">(</span><span class="mi">6</span><span class="p">)</span><span class="si">}</span><span class="s2">"</span>
  <span class="p">)</span>
<span class="k">end</span>
</code></pre></div></div>

<h2 id="a-reusable-instrumentation-helper">A reusable instrumentation helper</h2>

<p>By now we’ll want this measure-and-notify block around <em>every</em> LLM call, not just the summarizer. Let’s extract it into a helper so every call site stays consistent:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/services/llm_instrumentation.rb</span>
<span class="k">module</span> <span class="nn">LlmInstrumentation</span>
  <span class="c1"># Wraps any block that returns an object responding to</span>
  <span class="c1"># #input_tokens and #output_tokens, and emits an llm.completion event.</span>
  <span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">instrument</span><span class="p">(</span><span class="n">model</span><span class="p">:,</span> <span class="n">feature</span><span class="p">:)</span>
    <span class="no">Rails</span><span class="p">.</span><span class="nf">event</span><span class="p">.</span><span class="nf">tagged</span><span class="p">(</span><span class="ss">feature: </span><span class="n">feature</span><span class="p">)</span> <span class="k">do</span>
      <span class="n">started_at</span> <span class="o">=</span> <span class="no">Process</span><span class="p">.</span><span class="nf">clock_gettime</span><span class="p">(</span><span class="no">Process</span><span class="o">::</span><span class="no">CLOCK_MONOTONIC</span><span class="p">)</span>

      <span class="n">response</span> <span class="o">=</span> <span class="k">yield</span>

      <span class="n">duration_ms</span> <span class="o">=</span> <span class="p">((</span><span class="no">Process</span><span class="p">.</span><span class="nf">clock_gettime</span><span class="p">(</span><span class="no">Process</span><span class="o">::</span><span class="no">CLOCK_MONOTONIC</span><span class="p">)</span> <span class="o">-</span> <span class="n">started_at</span><span class="p">)</span> <span class="o">*</span> <span class="mi">1000</span><span class="p">).</span><span class="nf">round</span>

      <span class="no">Rails</span><span class="p">.</span><span class="nf">event</span><span class="p">.</span><span class="nf">notify</span><span class="p">(</span>
        <span class="s2">"llm.completion"</span><span class="p">,</span>
        <span class="ss">model: </span><span class="n">model</span><span class="p">,</span>
        <span class="ss">input_tokens: </span><span class="n">response</span><span class="p">.</span><span class="nf">input_tokens</span><span class="p">,</span>
        <span class="ss">output_tokens: </span><span class="n">response</span><span class="p">.</span><span class="nf">output_tokens</span><span class="p">,</span>
        <span class="ss">total_tokens: </span><span class="n">response</span><span class="p">.</span><span class="nf">input_tokens</span> <span class="o">+</span> <span class="n">response</span><span class="p">.</span><span class="nf">output_tokens</span><span class="p">,</span>
        <span class="ss">duration_ms: </span><span class="n">duration_ms</span>
      <span class="p">)</span>

      <span class="n">response</span>
    <span class="k">end</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>The service object shrinks back to almost what we started with, now fully instrumented:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/services/summarizer.rb</span>
<span class="k">class</span> <span class="nc">Summarizer</span>
  <span class="no">MODEL</span> <span class="o">=</span> <span class="s2">"claude-haiku-4-5"</span>

  <span class="k">def</span> <span class="nf">initialize</span><span class="p">(</span><span class="n">text</span><span class="p">)</span>
    <span class="vi">@text</span> <span class="o">=</span> <span class="n">text</span>
  <span class="k">end</span>

  <span class="k">def</span> <span class="nf">call</span>
    <span class="n">response</span> <span class="o">=</span> <span class="no">LlmInstrumentation</span><span class="p">.</span><span class="nf">instrument</span><span class="p">(</span><span class="ss">model: </span><span class="no">MODEL</span><span class="p">,</span> <span class="ss">feature: </span><span class="s2">"summarization"</span><span class="p">)</span> <span class="k">do</span>
      <span class="no">RubyLLM</span><span class="p">.</span><span class="nf">chat</span><span class="p">(</span><span class="ss">model: </span><span class="no">MODEL</span><span class="p">).</span><span class="nf">ask</span><span class="p">(</span><span class="s2">"Summarize the following text:</span><span class="se">\n\n</span><span class="si">#{</span><span class="vi">@text</span><span class="si">}</span><span class="s2">"</span><span class="p">)</span>
    <span class="k">end</span>

    <span class="n">response</span><span class="p">.</span><span class="nf">content</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<h2 id="capturing-the-failures">Capturing the failures</h2>

<p>Surfacing failed calls is just as important as tracking successful ones. Emit a separate event on error so timeouts and rate limits don’t disappear silently:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/services/llm_instrumentation.rb</span>
<span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">instrument</span><span class="p">(</span><span class="n">model</span><span class="p">:,</span> <span class="n">feature</span><span class="p">:)</span>
  <span class="no">Rails</span><span class="p">.</span><span class="nf">event</span><span class="p">.</span><span class="nf">tagged</span><span class="p">(</span><span class="ss">feature: </span><span class="n">feature</span><span class="p">)</span> <span class="k">do</span>
    <span class="n">started_at</span> <span class="o">=</span> <span class="no">Process</span><span class="p">.</span><span class="nf">clock_gettime</span><span class="p">(</span><span class="no">Process</span><span class="o">::</span><span class="no">CLOCK_MONOTONIC</span><span class="p">)</span>

    <span class="k">begin</span>
      <span class="n">response</span> <span class="o">=</span> <span class="k">yield</span>
    <span class="k">rescue</span> <span class="o">=&gt;</span> <span class="n">error</span>
      <span class="n">duration_ms</span> <span class="o">=</span> <span class="p">((</span><span class="no">Process</span><span class="p">.</span><span class="nf">clock_gettime</span><span class="p">(</span><span class="no">Process</span><span class="o">::</span><span class="no">CLOCK_MONOTONIC</span><span class="p">)</span> <span class="o">-</span> <span class="n">started_at</span><span class="p">)</span> <span class="o">*</span> <span class="mi">1000</span><span class="p">).</span><span class="nf">round</span>
      <span class="no">Rails</span><span class="p">.</span><span class="nf">event</span><span class="p">.</span><span class="nf">notify</span><span class="p">(</span>
        <span class="s2">"llm.error"</span><span class="p">,</span>
        <span class="ss">model: </span><span class="n">model</span><span class="p">,</span>
        <span class="ss">error_class: </span><span class="n">error</span><span class="p">.</span><span class="nf">class</span><span class="p">.</span><span class="nf">name</span><span class="p">,</span>
        <span class="ss">duration_ms: </span><span class="n">duration_ms</span>
      <span class="p">)</span>
      <span class="k">raise</span>
    <span class="k">end</span>

    <span class="n">duration_ms</span> <span class="o">=</span> <span class="p">((</span><span class="no">Process</span><span class="p">.</span><span class="nf">clock_gettime</span><span class="p">(</span><span class="no">Process</span><span class="o">::</span><span class="no">CLOCK_MONOTONIC</span><span class="p">)</span> <span class="o">-</span> <span class="n">started_at</span><span class="p">)</span> <span class="o">*</span> <span class="mi">1000</span><span class="p">).</span><span class="nf">round</span>

    <span class="no">Rails</span><span class="p">.</span><span class="nf">event</span><span class="p">.</span><span class="nf">notify</span><span class="p">(</span>
      <span class="s2">"llm.completion"</span><span class="p">,</span>
      <span class="ss">model: </span><span class="n">model</span><span class="p">,</span>
      <span class="ss">input_tokens: </span><span class="n">response</span><span class="p">.</span><span class="nf">input_tokens</span><span class="p">,</span>
      <span class="ss">output_tokens: </span><span class="n">response</span><span class="p">.</span><span class="nf">output_tokens</span><span class="p">,</span>
      <span class="ss">total_tokens: </span><span class="n">response</span><span class="p">.</span><span class="nf">input_tokens</span> <span class="o">+</span> <span class="n">response</span><span class="p">.</span><span class="nf">output_tokens</span><span class="p">,</span>
      <span class="ss">duration_ms: </span><span class="n">duration_ms</span>
    <span class="p">)</span>

    <span class="n">response</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Add a branch to your subscriber for <code class="language-plaintext highlighter-rouge">"llm.error"</code>, and now your dashboards show a failure rate alongside cost and latency, three basic signals that you need to observe on every LLM-powered feature.</p>

<h2 id="feeding-a-data-warehouse">Feeding a data warehouse</h2>

<p>Because the payloads are already structured, a warehouse-oriented subscriber is just another <code class="language-plaintext highlighter-rouge">#emit</code> implementation. Buffer the events and ship them to your analytics pipeline:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/subscribers/warehouse_subscriber.rb</span>
<span class="k">class</span> <span class="nc">WarehouseSubscriber</span>
  <span class="k">def</span> <span class="nf">emit</span><span class="p">(</span><span class="n">event</span><span class="p">)</span>
    <span class="k">return</span> <span class="k">unless</span> <span class="n">event</span><span class="p">[</span><span class="ss">:name</span><span class="p">].</span><span class="nf">start_with?</span><span class="p">(</span><span class="s2">"llm."</span><span class="p">)</span>

    <span class="no">LlmEventBufferJob</span><span class="p">.</span><span class="nf">perform_later</span><span class="p">(</span>
      <span class="ss">name: </span><span class="n">event</span><span class="p">[</span><span class="ss">:name</span><span class="p">],</span>
      <span class="ss">payload: </span><span class="n">event</span><span class="p">[</span><span class="ss">:payload</span><span class="p">],</span>
      <span class="ss">tags: </span><span class="n">event</span><span class="p">[</span><span class="ss">:tags</span><span class="p">],</span>
      <span class="ss">context: </span><span class="n">event</span><span class="p">[</span><span class="ss">:context</span><span class="p">],</span>
      <span class="ss">timestamp: </span><span class="n">event</span><span class="p">[</span><span class="ss">:timestamp</span><span class="p">]</span>
    <span class="p">)</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Register it alongside the first subscriber, in the same initializer:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/initializers/llm_events.rb</span>
<span class="no">Rails</span><span class="p">.</span><span class="nf">event</span><span class="p">.</span><span class="nf">subscribe</span><span class="p">(</span><span class="no">LlmEventSubscriber</span><span class="p">.</span><span class="nf">new</span><span class="p">)</span>
<span class="no">Rails</span><span class="p">.</span><span class="nf">event</span><span class="p">.</span><span class="nf">subscribe</span><span class="p">(</span><span class="no">WarehouseSubscriber</span><span class="p">.</span><span class="nf">new</span><span class="p">)</span>
</code></pre></div></div>

<p>Once the data lands, per-user cost reports, per-feature budgets, and model A/B comparisons are all just queries.</p>

<h2 id="best-practices-for-llm-events">Best practices for LLM events</h2>

<p>A few habits keep this instrumentation clean as it grows. Stick to a clear <code class="language-plaintext highlighter-rouge">resource.action</code> namespace, like <code class="language-plaintext highlighter-rouge">llm.completion</code> and <code class="language-plaintext highlighter-rouge">llm.error</code>, so related events group together. Keep payloads free of PII. Compute cost in the subscriber rather than at the call site, so pricing lives in one place and your service objects don’t need to know about it. Instrument once through a shared helper, so every call site emits consistent events. And always instrument failures, because an error rate is as important as a cost figure.</p>

<h2 id="conclusion">Conclusion</h2>

<p>LLM features don’t have to be black boxes. One <code class="language-plaintext highlighter-rouge">Rails.event.notify</code> call in the right spot turns an opaque API request into an event you can log, cost out, graph, or ship to a warehouse, and none of your service objects have to know where it ends up. As AI keeps creeping into your latency budget and your monthly bill, that visibility is what stops it from getting away from you.</p>

<p>Building AI features into your Rails app, or getting to Rails 8.1 so you can use the Event Reporter? <a href="/#contactus">Let’s talk</a>.</p>]]></content><author><name>hmdros</name></author><category term="artificial-intelligence" /><summary type="html"><![CDATA[A hands-on guide to instrumenting LLM calls in a Rails app using the Rails 8.1 Event Reporter, so you can track token usage, latency, and cost per request.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.fastruby.io/blog/tracking-llm-latency-&amp;-cost-with-rails-events.png" /><media:content medium="image" url="https://www.fastruby.io/blog/tracking-llm-latency-&amp;-cost-with-rails-events.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>