<?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-13T18:12:25-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">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 Test 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 Test 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><entry><title type="html">Rake Beyond Rails: A Build Tool You Know</title><link href="https://www.fastruby.io/blog/rake-beyond-rails-the-build-tool-you-already-know.html" rel="alternate" type="text/html" title="Rake Beyond Rails: A Build Tool You Know" /><published>2026-07-23T00:00:00-04:00</published><updated>2026-07-23T00:00:00-04:00</updated><id>https://www.fastruby.io/blog/rake-beyond-rails-the-build-tool-you-already-know</id><content type="html" xml:base="https://www.fastruby.io/blog/rake-beyond-rails-the-build-tool-you-already-know.html"><![CDATA[<p>Most Rails developers have crossed paths with <a href="https://github.com/ruby/rake">Rake</a> (usually
to run a migration, seed a database, or clear out test data). If you’re like me, you may have
quietly filed Rake away under “that place where Rails keeps database tasks.” But Rake is far
more powerful than that.</p>

<p>It isn’t just a Rails helper: it’s a full-blown, general-purpose build
system, sitting quietly in your project, ready to automate almost anything. And the best part?
It speaks Ruby. That means, as a Rails developer, you’re already fluent in the language your
build tool understands. In this post, I want to open your eyes to the wider world of Rake, beyond
migrations and seeds, into using it as a central hub for building, scripting, and orchestrating
your entire workflow.</p>

<!--more-->

<h2 id="what-makes-rake-a-great-build-tool">What Makes Rake a (great) Build Tool?</h2>

<p>What makes Rake such a useful build tool is its simplicity paired with real structure. At its
core, Rake lets you define tasks along with their dependencies—so if one task relies on another,
Rake automatically figures out the correct order to run everything.</p>

<p>You don’t have to micro-manage the sequence; you simply declare the relationships, and Rake
takes care of the orchestration. Tasks can be completely standalone or chained together into full
workflows.</p>

<p>To keep things tidy, you can group related tasks using namespaces, which means your Rakefile
doesn’t devolve into a long, messy script. And because Rake is Ruby, you get all the flexibility
of a real programming language: conditions, loops, helper methods, constants and whatever you need.
It’s code, not configuration.</p>

<p>Plus, discovery is built-in: a quick <code class="language-plaintext highlighter-rouge">rake --tasks</code> gives you a clear overview of available commands
(as long as you’ve added <code class="language-plaintext highlighter-rouge">desc</code> blocks), making your automation self-documenting and easy for
your team to adopt.</p>

<h2 id="rails-and-rake-clearing-up-the-overlap">Rails and Rake: Clearing Up the Overlap</h2>

<p>Since Rails 5.0, the <code class="language-plaintext highlighter-rouge">rails</code> command has quietly wrapped many Rake tasks under the hood. That
means <code class="language-plaintext highlighter-rouge">rails db:migrate</code> and <code class="language-plaintext highlighter-rouge">rake db:migrate</code> usually do the same thing and Rails simply delegates
to Rake behind the scenes. You can verify this yourself: running <code class="language-plaintext highlighter-rouge">rake --tasks</code> and <code class="language-plaintext highlighter-rouge">rails --tasks</code>
in a Rails app will show significant overlap.</p>

<p>So why reach for rake directly? Because the Rails command only exposes tasks that Rails itself
knows about. The moment you define your own custom tasks (like the Docker and setup workflows
we’ll see later) rake is how you run them. It’s also what you’ll use outside of a Rails environment:
in standalone Ruby scripts, non-Rails projects, or CI pipelines where loading the full Rails
stack would be overkill. Knowing both are available, and when to use each, gives you the full picture.</p>

<h2 id="types-of-tasks-you-can-automate-in-a-rails-project-using-rake">Types of Tasks You Can Automate in a Rails Project Using Rake</h2>

<p>One of the biggest strengths of Rake is how easily it adapts to all parts of your project (not
just database chores). Once you start seeing it as a build tool, entire areas of your workflow
become fair game for automation.</p>

<h3 id="setup--bootstrapping">Setup &amp; Bootstrapping</h3>

<p>Rake is perfect for onboarding tasks like <code class="language-plaintext highlighter-rouge">app:setup</code>, where you might chain actions such as creating
the database, installing JavaScript dependencies, seeding default config files, or preparing
environment variables. Instead of documenting these steps in a README, you can package them
into a single, reliable command.</p>

<h3 id="database-maintenance">Database Maintenance</h3>

<p>Most Rails developers already use Rake here (seeding data, cleaning test tables, or resetting
everything with a fresh database). But Rake can go beyond the basics: think sample data generators,
anonymizers, or maintenance routines that run before a deploy.</p>

<h3 id="development-workflow">Development Workflow</h3>

<p>Rake can also replace shell scripts for everyday dev tasks like running the test suite, linting
code, precompiling assets, or bundling front-end builds. Instead of remembering long commands,
you get tidy shortcuts like <code class="language-plaintext highlighter-rouge">rake test:all</code> or <code class="language-plaintext highlighter-rouge">rake lint</code>.</p>

<h3 id="operations--devops">Operations &amp; DevOps</h3>

<p>Need to deploy? Back up the database? Sync files to S3 or clear caches? Rake can orchestrate
these too. It’s especially useful when you want the same task to run locally or in CI, making
deployments repeatable and version-controlled.</p>

<h3 id="file--content-automation">File &amp; Content Automation</h3>

<p>Rake shines when generating or processing files: reports, CSV exports, documentation, static
site assets, or scheduled data imports. With full access to Rails models and Ruby’s standard
library, you can automate business workflows directly from your codebase.</p>

<h2 id="advanced-rake-techniques">Advanced Rake Techniques</h2>

<p>Once you’re comfortable defining simple tasks, Rake opens the door to more advanced features
that elevate it from a utility script into a true build system. These features allow you to
build smarter workflows, generate files efficiently, pass dynamic input, and interact with the
rest of your tooling.</p>

<h3 id="passing-arguments-to-tasks">Passing Arguments to Tasks</h3>

<p>Rake supports inline arguments, which is perfect for tasks that need parameters. For example,
generating a report for a specific year might look like this:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>rake report:generate[2024]
</code></pre></div></div>

<p>Accessing that argument inside the task lets you build flexible, reusable commands without hardcoding values.</p>
<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">namespace</span> <span class="ss">:report</span> <span class="k">do</span>
  <span class="n">desc</span> <span class="s2">"Generate report for a given year"</span>
  <span class="n">task</span> <span class="ss">:generate</span><span class="p">,</span> <span class="p">[</span><span class="ss">:year</span><span class="p">]</span> <span class="k">do</span> <span class="o">|</span><span class="n">_</span><span class="p">,</span> <span class="n">args</span><span class="o">|</span>
    <span class="n">year</span> <span class="o">=</span> <span class="n">args</span><span class="p">[</span><span class="ss">:year</span><span class="p">]</span> <span class="o">||</span> <span class="no">Time</span><span class="p">.</span><span class="nf">now</span><span class="p">.</span><span class="nf">year</span>
    <span class="nb">puts</span> <span class="s2">"Generating report for </span><span class="si">#{</span><span class="n">year</span><span class="si">}</span><span class="s2">"</span>
    <span class="c1"># Report generation logic here</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<h3 id="file-tasks-with-dependency-awareness">File Tasks with Dependency Awareness</h3>

<p>Rake is capable of <em>file-based tasks</em>, which only run if an input file has changed, similar to
Make. This is ideal for generating compiled assets, documentation, or exports without doing unnecessary work.</p>

<h3 id="setting-default-tasks">Setting Default Tasks</h3>

<p>You can define a <code class="language-plaintext highlighter-rouge">default</code> task so that running plain <code class="language-plaintext highlighter-rouge">rake</code> triggers something useful, like
linting and testing:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">task</span> <span class="ss">default: </span><span class="p">[</span><span class="ss">:lint</span><span class="p">,</span> <span class="ss">:test</span><span class="p">]</span>
</code></pre></div></div>

<p>This is a neat productivity boost, especially on CI or during local development.</p>

<h3 id="shelling-out-to-external-commands">Shelling Out to External Commands</h3>

<p>Need to interact with the system? Rake can easily call tools like <code class="language-plaintext highlighter-rouge">yarn</code>, <code class="language-plaintext highlighter-rouge">bundle</code>, or <code class="language-plaintext highlighter-rouge">docker</code> using the <code class="language-plaintext highlighter-rouge">sh</code> helper:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">sh</span> <span class="s1">'docker-compose up -d'</span>
</code></pre></div></div>

<p>This allows you to centralize multi-step builds or deployment scripts directly in Ruby.</p>

<h3 id="managing-environment-variables-in-rake">Managing Environment Variables in Rake</h3>

<p>Sometimes tasks require configuration: API keys, modes, targets, or flags. The simplest way is to pass environment variables inline:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">MY_VALUE</span><span class="o">=</span>value rake deploy
</code></pre></div></div>

<p>Inside Rake, you can access it with:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">value</span> <span class="o">=</span> <span class="no">ENV</span><span class="p">[</span><span class="s1">'MY_VALUE'</span><span class="p">]</span> <span class="o">||</span> <span class="s1">'default_value'</span>
</code></pre></div></div>

<p>This works well for one-off overrides, but it becomes cumbersome when you’re juggling multiple variables.</p>

<p>In a Rails application, a cleaner approach is to use <strong>dotenv-rails</strong> and a <code class="language-plaintext highlighter-rouge">.env</code> file. Rails
automatically loads these variables before running your Rake tasks, making them available through
<code class="language-plaintext highlighter-rouge">ENV</code> without cluttering your command line. This keeps secrets organized and reusable across tasks, consoles, and even development scripts.</p>

<h2 id="real-examples-code-snippets">Real Examples (Code Snippets!)</h2>

<p>A Rakefile is more than just a list of commands, it’s a fully valid Ruby program. That means
you can leverage Ruby’s syntax, methods, and control structures to define tasks that are flexible,
chainable, and reusable.</p>

<p>One particularly powerful approach is to replace much of your <code class="language-plaintext highlighter-rouge">bin/setup</code> script with Rake tasks.
By doing this, you turn a one-off shell script into a structured, discoverable build process.
You already have a build tool sitting in your Rails app, it’s called Rake, so there’s no need
to hide important automation in shell scripts. In the example below, we’ll see how a
typical bin/setup script can be refactored into clear, namespaced Rake tasks that are easy to
run, extend, and maintain.</p>

<p>For convenience, and to keep things familiar, we can still keep a thin <code class="language-plaintext highlighter-rouge">bin/setup</code> script that simply
calls our Rake task. Your <code class="language-plaintext highlighter-rouge">bin/setup</code> could be as simple as:</p>
<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">#!/usr/bin/env ruby</span>

<span class="nb">system</span><span class="p">(</span><span class="s2">"bin/rake app:setup"</span><span class="p">)</span>
</code></pre></div></div>

<p>A corresponding Rakefile could define the <code class="language-plaintext highlighter-rouge">app:setup</code> task like this:</p>
<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">namespace</span> <span class="ss">:app</span> <span class="k">do</span>
  <span class="n">desc</span> <span class="s2">"Setup application for development"</span>
  <span class="n">task</span> <span class="ss">:setup</span><span class="p">,</span> <span class="p">[</span><span class="ss">:reset</span><span class="p">]</span> <span class="o">=&gt;</span> <span class="p">[</span><span class="s2">"app:dependencies"</span><span class="p">,</span> <span class="s2">"db:prepare"</span><span class="p">,</span> <span class="s2">"app:cleanup"</span><span class="p">]</span> <span class="k">do</span> <span class="o">|</span><span class="n">_</span><span class="p">,</span> <span class="n">args</span><span class="o">|</span>
    <span class="k">if</span> <span class="n">args</span><span class="p">[</span><span class="ss">:reset</span><span class="p">]</span> <span class="o">==</span> <span class="s2">"reset"</span>
      <span class="no">Rake</span><span class="o">::</span><span class="no">Task</span><span class="p">[</span><span class="s2">"db:reset"</span><span class="p">].</span><span class="nf">invoke</span>
    <span class="k">end</span>
    <span class="nb">puts</span> <span class="s2">"Application setup complete."</span>
  <span class="k">end</span>

  <span class="n">desc</span> <span class="s2">"Install dependencies"</span>
  <span class="n">task</span> <span class="ss">:dependencies</span> <span class="k">do</span>
    <span class="n">sh</span> <span class="s2">"bundle check || bundle install"</span>
  <span class="k">end</span>

  <span class="n">desc</span> <span class="s2">"Cleanup logs and tmp"</span>
  <span class="n">task</span> <span class="ss">:cleanup</span> <span class="k">do</span>
    <span class="n">sh</span> <span class="s2">"bin/rails log:clear tmp:clear"</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>If your project runs in Docker locally, you’ve probably spent more time than you’d like typing
long <code class="language-plaintext highlighter-rouge">docker-compose</code> commands. Rake can simplify this by letting you define tasks that wrap
common Docker operations. You can organize these tasks into namespaces that match your mental
model, like <code class="language-plaintext highlighter-rouge">docker:up</code>, <code class="language-plaintext highlighter-rouge">docker:down</code>, or <code class="language-plaintext highlighter-rouge">docker:logs</code>, so they’re easy to remember and chain
together. And with <code class="language-plaintext highlighter-rouge">desc</code> blocks, these tasks become self-documenting: running <code class="language-plaintext highlighter-rouge">rake --tasks</code>
will give you a clear list of available commands along with helpful descriptions. In the example
below, we’ll see how Rake can turn a set of verbose Docker commands into a simple, maintainable,
and discoverable workflow.</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="no">CORE_SERVICES</span> <span class="o">=</span> <span class="s1">'web db'</span><span class="p">.</span><span class="nf">freeze</span>

<span class="n">namespace</span> <span class="ss">:docker</span> <span class="k">do</span>
  <span class="n">desc</span> <span class="s2">"Stop Docker containers"</span>
  <span class="n">task</span> <span class="ss">:down</span> <span class="k">do</span>
    <span class="n">sh</span> <span class="s2">"docker-compose down"</span>
  <span class="k">end</span>

  <span class="n">desc</span> <span class="s2">"Start core Docker containers, optionally specifying additional services (e.g. rake docker:up[redis])"</span>
  <span class="n">task</span> <span class="ss">:up</span><span class="p">,</span> <span class="p">[</span><span class="ss">:more_services</span><span class="p">]</span> <span class="k">do</span> <span class="o">|</span><span class="n">_</span><span class="p">,</span> <span class="n">args</span><span class="o">|</span>
    <span class="n">more_services</span> <span class="o">=</span> <span class="n">args</span><span class="p">[</span><span class="ss">:more_services</span><span class="p">]</span> <span class="o">||</span> <span class="s2">""</span>
    <span class="n">sh</span> <span class="s2">"docker-compose up -d </span><span class="si">#{</span><span class="no">CORE_SERVICES</span><span class="si">}</span><span class="s2"> </span><span class="si">#{</span><span class="n">more_services</span><span class="si">}</span><span class="s2">"</span>
  <span class="k">end</span>

  <span class="n">desc</span> <span class="s2">"Start all Docker containers in detached mode"</span>
  <span class="n">task</span> <span class="ss">:"up:all"</span> <span class="k">do</span>
    <span class="n">sh</span> <span class="s2">"docker-compose up -d"</span>
  <span class="k">end</span>

  <span class="n">desc</span> <span class="s2">"Restart Docker containers"</span>
  <span class="n">task</span> <span class="ss">restart: </span><span class="p">[</span><span class="ss">:down</span><span class="p">,</span> <span class="ss">:up</span><span class="p">]</span>

  <span class="n">desc</span> <span class="s2">"View logs for all services or a specific service (e.g. rake docker:logs[web])"</span>
  <span class="n">task</span> <span class="ss">:logs</span><span class="p">,</span> <span class="p">[</span><span class="ss">:service</span><span class="p">]</span> <span class="k">do</span> <span class="o">|</span><span class="n">_</span><span class="p">,</span> <span class="n">args</span><span class="o">|</span>
    <span class="n">service</span> <span class="o">=</span> <span class="n">args</span><span class="p">[</span><span class="ss">:service</span><span class="p">]</span> <span class="o">||</span> <span class="s2">""</span>
    <span class="n">sh</span> <span class="s2">"docker-compose logs -f </span><span class="si">#{</span><span class="n">service</span><span class="si">}</span><span class="s2">"</span>
  <span class="k">end</span>

  <span class="n">desc</span> <span class="s2">"Build Docker image"</span>
  <span class="n">task</span> <span class="ss">:build</span> <span class="k">do</span>
    <span class="n">sh</span> <span class="s2">"docker-compose build"</span>
  <span class="k">end</span>

  <span class="n">desc</span> <span class="s2">"Rebuild image and restart app dev services"</span>
  <span class="n">task</span> <span class="ss">:rebuild</span> <span class="o">=&gt;</span> <span class="p">[</span><span class="ss">:down</span><span class="p">,</span> <span class="ss">:build</span><span class="p">,</span> <span class="ss">:up</span><span class="p">,</span> <span class="ss">:'db:prepare'</span><span class="p">]</span> <span class="k">do</span>
    <span class="nb">puts</span> <span class="s2">"Rebuild complete"</span>
  <span class="k">end</span>

  <span class="n">desc</span> <span class="s2">"Stop containers and remove volumes. Use task docker:clobber to remove volumes"</span>
  <span class="n">task</span> <span class="ss">clean: </span><span class="p">[</span><span class="ss">:down</span><span class="p">]</span> <span class="k">do</span>
    <span class="n">sh</span> <span class="s2">"docker-compose rm -f"</span>
  <span class="k">end</span>

  <span class="n">desc</span> <span class="s2">"Stop containers and remove volumes. Use task docker:clean to keep volumes"</span>
  <span class="n">task</span> <span class="ss">:clobber</span> <span class="k">do</span>
    <span class="n">sh</span> <span class="s2">"docker-compose down -v --remove-orphans"</span>
    <span class="n">sh</span> <span class="s2">"docker-compose rm -f"</span>
  <span class="k">end</span>

  <span class="n">desc</span> <span class="s2">"Open a shell in the web service"</span>
  <span class="n">task</span> <span class="ss">:shell</span> <span class="k">do</span>
    <span class="n">sh</span> <span class="s2">"docker-compose run --rm web /bin/bash"</span>
  <span class="k">end</span>

  <span class="n">desc</span> <span class="s2">"Open a Rails console in the web service"</span>
  <span class="n">task</span> <span class="ss">:"shell:app"</span> <span class="o">=&gt;</span> <span class="p">[</span><span class="ss">:up</span><span class="p">]</span> <span class="k">do</span>
    <span class="n">sh</span> <span class="s2">"docker-compose run --rm web bin/rails c"</span>
  <span class="k">end</span>

  <span class="n">desc</span> <span class="s2">"Open a database console in the web service"</span>
  <span class="n">task</span> <span class="ss">:"shell:db"</span> <span class="o">=&gt;</span> <span class="p">[</span><span class="ss">:up</span><span class="p">]</span> <span class="k">do</span>
    <span class="n">sh</span> <span class="s2">"docker-compose run --rm web bin/rails dbconsole"</span>
  <span class="k">end</span>

  <span class="n">desc</span> <span class="s2">"Run a command in the web service (e.g. rake docker:run['bin/rails db:migrate']), defaults to opening a bash shell"</span>
  <span class="n">task</span> <span class="ss">:run</span><span class="p">,</span> <span class="p">[</span><span class="ss">:cmd</span><span class="p">]</span> <span class="o">=&gt;</span> <span class="p">[</span><span class="ss">:up</span><span class="p">]</span> <span class="k">do</span> <span class="o">|</span><span class="n">_</span><span class="p">,</span> <span class="n">args</span><span class="o">|</span>
    <span class="n">cmd</span> <span class="o">=</span> <span class="n">args</span><span class="p">[</span><span class="ss">:cmd</span><span class="p">]</span> <span class="o">||</span> <span class="s2">"bin/bash"</span>
    <span class="n">sh</span> <span class="s2">"docker-compose run --rm web </span><span class="si">#{</span><span class="n">cmd</span><span class="si">}</span><span class="s2">"</span>
  <span class="k">end</span>

  <span class="n">desc</span> <span class="s2">"Run linting and formatting (requires web service to be running)"</span>
  <span class="n">task</span> <span class="ss">:lint</span> <span class="o">=&gt;</span> <span class="p">[</span><span class="ss">:up</span><span class="p">]</span> <span class="k">do</span>
    <span class="n">sh</span> <span class="s2">"docker-compose run --rm web bin/rake rubocop"</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>We defined a set of Docker-related tasks with <code class="language-plaintext highlighter-rouge">desc</code> blocks for documentation. This means that
we can easily see what commands are available by running <code class="language-plaintext highlighter-rouge">rake -T docker</code>, and each task encapsulates
common Docker operations in a clear, maintainable way.</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>❯ rake <span class="nt">-T</span> docker
rake docker:build              <span class="c"># Build Docker image</span>
rake docker:clean              <span class="c"># Stop containers</span>
rake docker:clobber            <span class="c"># Stop containers and remove volumes</span>
rake docker:down               <span class="c"># Stop Docker containers</span>
rake docker:lint               <span class="c"># Run linting and formatting (requires web service to be running)</span>
rake docker:logs[service]      <span class="c"># View logs for all services or a specific service (e.g</span>
rake docker:rebuild            <span class="c"># Rebuild image and restart app dev services</span>
rake docker:restart            <span class="c"># Restart Docker containers</span>
rake docker:run[cmd]           <span class="c"># Run a command in the web service (e.g</span>
rake docker:shell              <span class="c"># Open a shell in the web service</span>
rake docker:shell:app          <span class="c"># Open a Rails console in the web service</span>
rake docker:shell:db           <span class="c"># Open a database console in the web service</span>
rake docker:up[more_services]  <span class="c"># Start core Docker containers, optionally specifying additional services (e.g</span>
rake docker:up:all             <span class="c"># Start all Docker containers in detached mode</span>
</code></pre></div></div>

<h2 id="conclusion--rediscover-rake">Conclusion – Rediscover Rake</h2>

<p>Rake is a powerful automation tool that you already have at your fingertips, yet many developers
limit it to simple Rails tasks. In reality, Rake is Ruby-native, readable, version-controlled,
and versatile enough to run in any project where Ruby is available.</p>

<p>It can replace Makefiles, Bash scripts, and even parts of your CI workflow, centralizing automation in one maintainable place. By moving scattered scripts and repetitive commands into Rake tasks, you unlock a single,
discoverable system that your team can understand and extend. As a Rails developer, you already
know the language, now it’s time to rediscover Rake and unleash its full potential. Looking for staff augmentation for your next project? <a href="/#contactus">We can help!</a></p>]]></content><author><name>fbuys</name></author><category term="ruby" /><summary type="html"><![CDATA[Discover the untapped potential of Rake as a powerful build tool beyond its common use in Rails. Learn how to streamline your workflows using Rake.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.fastruby.io/blog/rake-beyond-rails-banner.jpg" /><media:content medium="image" url="https://www.fastruby.io/blog/rake-beyond-rails-banner.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Automate Tech Debt Audits with Claude Code</title><link href="https://www.fastruby.io/blog/tech-debt-audit-with-claude-code.html" rel="alternate" type="text/html" title="Automate Tech Debt Audits with Claude Code" /><published>2026-07-21T06:00:00-04:00</published><updated>2026-07-21T06:00:00-04:00</updated><id>https://www.fastruby.io/blog/tech-debt-audit-with-claude-code</id><content type="html" xml:base="https://www.fastruby.io/blog/tech-debt-audit-with-claude-code.html"><![CDATA[<p>Today I’m excited to share a new open source project: A Claude Code skill to assess technical debt in a Ruby on Rails application. It leverages some of the libraries that we have open sourced and maintained for a long time.</p>

<p>Over the years, we’ve written about many of the tools we use: <a href="https://www.fastruby.io/blog/code-quality/introducing-skunk-stink-score-calculator.html">Skunk</a>
for combining code quality and code coverage data, <a href="https://www.fastruby.io/blog/how-to-use-bundler-audit-to-keep-dependencies-secure.html">bundler-audit</a> for security vulnerabilities in your dependencies,
<a href="https://www.fastruby.io/blog/ruby-dependency-freshness.html">libyear-bundler</a> for measuring dependency freshness in a Ruby application,
and <a href="https://www.fastruby.io/blog/code-quality/code-coverage/rubycritic-4-2-0-simplecov-support.html">RubyCritic</a> for churn vs. complexity analysis.</p>

<p>The challenge? Running all these tools manually takes time and interpreting the results across multiple reports
can be tedious.</p>

<p>What if we could automate the entire audit process and generate a comprehensive report with a single command?</p>

<p>In this article, I’ll show you how we built a Claude Code skill that does exactly that, in minutes!</p>

<!--more-->

<h2 id="what-is-claude-code">What is Claude Code?</h2>

<p><a href="https://claude.com/product/claude-code">Claude Code</a> is Anthropic’s official CLI tool for Claude. It allows you to interact with Claude directly from your terminal, and it can read files, run commands, and make changes to your codebase.</p>

<p>One of its most powerful features is <a href="https://code.claude.com/docs/en/skills">skills</a>. Skills are reusable instructions that teach Claude how to perform specific tasks.</p>

<p>Skills are stored as markdown files in your project’s <code class="language-plaintext highlighter-rouge">.claude/skills/</code> directory and can be invoked with slash commands
like <code class="language-plaintext highlighter-rouge">/tech-debt-audit</code>.</p>

<h2 id="the-problem-were-solving">The Problem We’re Solving</h2>

<p>When we onboard a new client project, we need to quickly assess the health of their Rails application. This means running
multiple tools:</p>

<ol>
  <li><strong>Security</strong>: <code class="language-plaintext highlighter-rouge">bundler-audit</code>, Brakeman, <code class="language-plaintext highlighter-rouge">bundler-leak</code></li>
  <li><strong>Dependencies</strong>: <code class="language-plaintext highlighter-rouge">next_rails</code>, <code class="language-plaintext highlighter-rouge">libyear-bundler</code></li>
  <li><strong>Code Coverage</strong>: SimpleCov</li>
  <li><strong>Churn vs. Complexity</strong>: RubyCritic</li>
  <li><strong>Churn vs. Complexity vs. Code Coverage</strong>: Skunk</li>
</ol>

<p>Each tool has its own installation requirements, command-line interface, and output format. A typical audit might involve:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>gem <span class="nb">install </span>bundler-audit <span class="o">&amp;&amp;</span> bundle-audit check <span class="nt">--update</span>
gem <span class="nb">install </span>brakeman <span class="o">&amp;&amp;</span> brakeman <span class="nt">--no-pager</span> <span class="nt">-q</span>
gem <span class="nb">install </span>bundler-leak <span class="o">&amp;&amp;</span> bundle-leak check <span class="nt">--update</span>
gem <span class="nb">install </span>next_rails <span class="o">&amp;&amp;</span> bundle_report outdated
gem <span class="nb">install </span>libyear-bundler <span class="o">&amp;&amp;</span> libyear-bundler <span class="nt">--all</span>
<span class="nv">COVERAGE</span><span class="o">=</span><span class="nb">true </span>bundle <span class="nb">exec </span>rspec
gem <span class="nb">install </span>rubycritic <span class="o">&amp;&amp;</span> rubycritic app lib <span class="nt">--no-browser</span> <span class="nt">--format</span> console
gem <span class="nb">install </span>skunk <span class="o">&amp;&amp;</span> skunk app lib
gem <span class="nb">install </span>rails_stats <span class="o">&amp;&amp;</span> rails-stats
</code></pre></div></div>

<p>That’s a lot of commands to remember and run. And then you need to interpret all the output and compile it into a coherent
report.</p>

<p>Some of these steps are more involved than they look.</p>

<p>Getting fresh coverage data, for example, means re-running the whole test suite, and in CI that can also mean downloading
and merging resultsets across parallel jobs before Skunk can read them.</p>

<p>The more the audit grows, the more time consuming it becomes.</p>

<h2 id="the-solution-a-tech-debt-audit-skill">The Solution: A Tech Debt Audit Skill</h2>

<p>We created a Claude Code skill that automates this entire process. Here’s how you could set up your own:</p>

<h3 id="step-1-create-the-skill-directory">Step 1: Create the Skill Directory</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">mkdir</span> <span class="nt">-p</span> .claude/skills/tech-debt-audit
</code></pre></div></div>

<h3 id="step-2-create-the-skillmd-file">Step 2: Create the SKILL.md File</h3>

<p>A skill is a single <code class="language-plaintext highlighter-rouge">SKILL.md</code> file at <code class="language-plaintext highlighter-rouge">.claude/skills/tech-debt-audit/SKILL.md</code>. It has two parts: a YAML front matter
block at the top that configures the skill, followed by the plain-English instructions Claude follows when you run it.</p>

<p>Here’s what a minimal frontmatter block looks like:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">---</span>
<span class="na">name</span><span class="pi">:</span> <span class="s">tech-debt-audit</span>
<span class="na">description</span><span class="pi">:</span> <span class="s">Generate a comprehensive technical debt audit for a codebase.</span>
<span class="na">allowed-tools</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="s">Bash(gem install*)</span>
  <span class="pi">-</span> <span class="s">Bash(bundle*)</span>
  <span class="pi">-</span> <span class="s">Bash(brakeman*)</span>
  <span class="pi">-</span> <span class="s">Bash(skunk*)</span>
  <span class="pi">-</span> <span class="s">Bash(rubycritic*)</span>
  <span class="pi">-</span> <span class="s">Bash(COVERAGE=true*)</span>
  <span class="pi">-</span> <span class="s">Read</span>
  <span class="pi">-</span> <span class="s">Glob</span>
  <span class="pi">-</span> <span class="s">Grep</span>
<span class="nn">---</span>
</code></pre></div></div>

<p>The three keys that matter:</p>

<ul>
  <li><strong><code class="language-plaintext highlighter-rouge">name</code></strong>: the slash command used to invoke the skill (<code class="language-plaintext highlighter-rouge">/tech-debt-audit</code>)</li>
  <li><strong><code class="language-plaintext highlighter-rouge">description</code></strong>: a one-line summary Claude uses to decide when the skill is relevant</li>
  <li><strong><code class="language-plaintext highlighter-rouge">allowed-tools</code></strong>: the commands the skill is permitted to run without prompting, so the audit can install and
run each tool unattended</li>
</ul>

<p>Below the frontmatter code, the instructions tell Claude how to run the audit. There might be key insights you need
to tell Claude. For example: <strong>You must run your test suite with coverage enabled before running Skunk</strong>.</p>

<p><a href="https://github.com/fastruby/skunk">Skunk</a> reads SimpleCov’s coverage data from the <code class="language-plaintext highlighter-rouge">coverage/</code> directory, so without
fresh data your SkunkScores will be inaccurate.</p>

<p>You don’t have to write all of this from scratch. The full <code class="language-plaintext highlighter-rouge">SKILL.md</code>, including the complete <code class="language-plaintext highlighter-rouge">allowed-tools</code> list and a
report template, is in the <a href="https://github.com/fastruby/tech-debt-skill">fastruby/tech-debt-skill</a> repository.</p>

<h3 id="step-3-run-the-audit">Step 3: Run the Audit</h3>

<p>Once the skill is set up, you can run it with:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>claude /tech-debt-audit
</code></pre></div></div>

<p>Claude will automatically:</p>

<ol>
  <li>Detect your project type (Ruby/Rails, JavaScript, or both)</li>
  <li>Install all required tools</li>
  <li>Run the test suite with coverage enabled</li>
  <li>Execute each audit tool</li>
  <li>Generate a comprehensive report</li>
</ol>

<h2 id="what-the-report-looks-like">What the Report Looks Like</h2>

<p>The skill produces a single, self-contained HTML report. The screenshots below come from running it against a sample
Rails app.</p>

<h3 id="executive-summary-with-health-score">Executive Summary with Health Score</h3>

<p>At the top, an executive summary rolls everything up into a single health score and a per-category breakdown:</p>

<p><img src="/blog/assets/images/tech-debt-audit/executive-summary.png" alt="Executive summary: overall health score and per-category scores for security, dependencies, complexity, coverage, and maintainability" /></p>

<h3 id="security-vulnerabilities">Security Vulnerabilities</h3>

<p>The report lists known advisories from <code class="language-plaintext highlighter-rouge">bundler-audit</code> and <a href="https://trivy.dev/">Trivy</a>, grouped by severity, alongside Brakeman’s static analysis
and <code class="language-plaintext highlighter-rouge">bundler-leak</code>’s memory-leak check:</p>

<p><img src="/blog/assets/images/tech-debt-audit/security.png" alt="Security section: bundler-audit advisories by severity, Brakeman EOL warnings, and Trivy filesystem findings" /></p>

<h3 id="dependency-freshness">Dependency Freshness</h3>

<p>It also reports how far behind your dependencies are, using <code class="language-plaintext highlighter-rouge">next_rails</code> for the outdated list and <code class="language-plaintext highlighter-rouge">libyear-bundler</code> for the
freshness metric:</p>

<p><img src="/blog/assets/images/tech-debt-audit/dependency-freshness.png" alt="Dependency freshness: outdated gems with current vs. latest versions, and total libyears behind" /></p>

<h3 id="skunk-score-analysis">Skunk Score Analysis</h3>

<p>The Skunk analysis ranks files by churn-weighted risk, so the ones with high complexity and low coverage rise to the top:</p>

<p><img src="/blog/assets/images/tech-debt-audit/skunk-score.png" alt="Skunk score table ranking files by SkunkScore, churn, and coverage" /></p>

<p>Files with high Skunk scores, high churn, and low coverage by your automated test suite are prime candidates for
refactoring or adding tests.</p>

<h3 id="prioritized-recommendations">Prioritized Recommendations</h3>

<p>Finally, the report distills everything above into a short list of the highest-impact actions:</p>

<p><img src="/blog/assets/images/tech-debt-audit/recommendations.png" alt="Top recommended actions: patch security vulnerabilities, upgrade to supported Ruby and Rails, and refactor the top complexity hotspots" /></p>

<h2 id="why-fresh-coverage-data-matters">Why Fresh Coverage Data Matters</h2>

<p>During development of this skill, we discovered an important gotcha.</p>

<p>When we first ran Skunk, it reported that <code class="language-plaintext highlighter-rouge">newsletter_service.rb</code> had 0% coverage and a SkunkScore of 440.</p>

<p>But after running the test suite with <code class="language-plaintext highlighter-rouge">COVERAGE=true bundle exec rspec</code>, the same file showed 67% coverage and a SkunkScore of 145.</p>

<p>The difference?</p>

<p>Skunk reads coverage data from SimpleCov’s <code class="language-plaintext highlighter-rouge">.resultset.json</code> file. If that file is stale (or missing), Skunk assumes 0%
coverage and penalizes files heavily.</p>

<p>That’s why our skill always runs the test suite first:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># For RSpec projects</span>
<span class="nv">COVERAGE</span><span class="o">=</span><span class="nb">true </span>bundle <span class="nb">exec </span>rspec

<span class="c"># For Minitest projects</span>
<span class="nv">COVERAGE</span><span class="o">=</span><span class="nb">true </span>bundle <span class="nb">exec </span>rake <span class="nb">test</span>
</code></pre></div></div>

<h2 id="scoring-guidelines">Scoring Guidelines</h2>

<p>The skill uses a 100-point scoring system across five categories:</p>

<h3 id="security-20-points">Security (20 points)</h3>

<ul>
  <li>20: No vulnerabilities</li>
  <li>15: Low severity only</li>
  <li>10: Some medium severity</li>
  <li>5: High severity issues</li>
  <li>0: Critical vulnerabilities</li>
</ul>

<h3 id="dependencies-20-points">Dependencies (20 points)</h3>

<ul>
  <li>20: &lt;10% outdated, &lt;5 libyears</li>
  <li>15: &lt;20% outdated, &lt;15 libyears</li>
  <li>10: &lt;40% outdated, &lt;30 libyears</li>
  <li>5: &lt;60% outdated, &lt;50 libyears</li>
  <li>0: &gt;60% outdated or &gt;50 libyears</li>
</ul>

<h3 id="coverage-20-points">Coverage (20 points)</h3>

<ul>
  <li>20: &gt;90% coverage</li>
  <li>15: 70-90% coverage</li>
  <li>10: 50-70% coverage</li>
  <li>5: 30-50% coverage</li>
  <li>0: &lt;30% coverage</li>
</ul>

<h3 id="complexity-20-points">Complexity (20 points)</h3>

<ul>
  <li>20: No files with complexity &gt;10</li>
  <li>15: Few files with complexity 11-20</li>
  <li>10: Some files with complexity 21-50</li>
  <li>5: Many files with high complexity</li>
  <li>0: Files with complexity &gt;50</li>
</ul>

<h3 id="maintainability-20-points">Maintainability (20 points)</h3>

<ul>
  <li>20: Excellent setup, CI, documentation</li>
  <li>15: Good setup with minor gaps</li>
  <li>10: Adequate but needs improvement</li>
  <li>5: Significant maintainability issues</li>
  <li>0: Major maintainability problems</li>
</ul>

<h2 id="extending-the-skill">Extending the Skill</h2>

<p>The beauty of Claude Code skills is that they’re just markdown files. You can easily extend the skill to:</p>

<ul>
  <li>Add JavaScript analysis with <code class="language-plaintext highlighter-rouge">npm audit</code> and/or <a href="https://github.com/upgradejs/upjs-plato"><code class="language-plaintext highlighter-rouge">upjs-plato</code></a></li>
  <li>Include performance metrics from your APM
    <ul>
      <li>If you’re using Sentry, you could integrate this skill with <a href="https://mcp.sentry.dev">Sentry’s MCP server</a>.</li>
    </ul>
  </li>
  <li>Check for EOL Ruby/Rails versions</li>
  <li>Integrate with your CI/CD pipeline</li>
</ul>

<h2 id="final-thoughts">Final Thoughts</h2>

<p>Technical debt is inevitable, but it doesn’t have to be invisible. Considering we are all using AI to generate
a lot of the code that we ship to production, setting up proper guardrails for humans and robots is crucial.</p>

<p>By automating our tech debt audits with Claude Code, we can:</p>

<ol>
  <li><strong>Get a baseline</strong>: Know exactly where you stand before starting any upgrade or refactoring work</li>
  <li><strong>Track progress</strong>: Run the audit regularly to see if your SkunkScore average is going up or down</li>
  <li><strong>Prioritize effectively</strong>: Focus on files with high churn, high complexity, and low coverage</li>
  <li><strong>Communicate clearly</strong>: Share the report with stakeholders to justify technical investments</li>
</ol>

<p>The skill we built combines the power of tools like <a href="https://github.com/fastruby/skunk">Skunk</a>,
<a href="https://github.com/rubysec/bundler-audit">bundler-audit</a>,
<a href="https://github.com/presidentbeef/brakeman">Brakeman</a>, and <a href="https://github.com/whitesmith/rubycritic">RubyCritic</a> into
a single, automated workflow. No more running commands manually or piecing together reports from multiple sources.</p>

<p>Want to try it yourself? The skill is open source at <a href="https://github.com/fastruby/tech-debt-skill">fastruby/tech-debt-skill</a>. Install it into any Rails project with:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">mkdir</span> <span class="nt">-p</span> .claude/skills
git clone https://github.com/fastruby/tech-debt-skill.git .claude/skills/tech-debt-audit
</code></pre></div></div>

<p>What tools or metrics would you like to include in your application’s tech debt audit? Send us a comment on
<a href="https://ruby.social/@FastRuby">social media</a> or <a href="https://github.com/fastruby/tech-debt-skill/issues/new">open an issue in GitHub</a>!</p>

<p>Need help assessing and paying down technical debt in your Rails application? <a href="https://www.fastruby.io/">Contact us for a tech debt assessment!</a></p>]]></content><author><name>etagwerker</name></author><category term="technical-debt" /><summary type="html"><![CDATA[How to write a Claude Code skill that automates tech debt audits for Rails applications, combining security, dependencies, coverage, and complexity.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.fastruby.io/blog/tech-debt-audit-claude-code.jpg" /><media:content medium="image" url="https://www.fastruby.io/blog/tech-debt-audit-claude-code.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>