<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:thoughtbot="https://thoughtbot.com/feeds/">
  <title>Giant Robots Smashing Into Other Giant Robots</title>
  <subtitle>Written by thoughtbot, your expert partner for design and development.
</subtitle>
  <id>https://robots.thoughtbot.com/</id>
  <link href="https://thoughtbot.com/blog"/>
  <link href="https://feed.thoughtbot.com" rel="self"/>
  <updated>2026-06-19T00:00:00+00:00</updated>
  <author>
    <name>thoughtbot</name>
  </author>
<entry>
  <title>The Playwright debugging tool Rails devs aren't using</title>
  <link rel="alternate" href="https://thoughtbot.com/blog/the-playwright-debugging-tool-rails-devs-aren-t-using"/>
  <author>
    <name>Justin Toniazzo</name>
  </author>
  <id>https://thoughtbot.com/blog/the-playwright-debugging-tool-rails-devs-aren-t-using</id>
  <published>2026-06-19T00:00:00+00:00</published>
  <updated>2026-06-18T19:33:53Z</updated>
  <content type="html">&lt;p&gt;Playwright ships with a &lt;a href="https://en.wikipedia.org/wiki/Flight_recorder"&gt;black-box recorder&lt;/a&gt; that records every detail of every test, giving you a treasure trove of information for debugging flaky tests. Most Rails apps I’ve worked on don’t use it.&lt;/p&gt;

&lt;p&gt;It’s called the &lt;a href="https://playwright.dev/docs/trace-viewer"&gt;Trace Viewer&lt;/a&gt;, and if you’re running Capybara with Playwright via &lt;a href="https://playwright-ruby-client.vercel.app/"&gt;&lt;code&gt;playwright-ruby-client&lt;/code&gt;&lt;/a&gt;, turning it on takes a single block in &lt;code&gt;rails_helper.rb&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id="what39s-in-a-playwright-trace"&gt;
  
    What’s in a Playwright trace?
  
&lt;/h2&gt;

&lt;p&gt;When Playwright records a trace, you get back a &lt;code&gt;.zip&lt;/code&gt; you can replay in the Trace Viewer. The viewer is essentially a DVR for your test run. For every action Playwright took, every &lt;code&gt;click&lt;/code&gt;, &lt;code&gt;fill_in&lt;/code&gt;, and navigation, you get:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;before-and-after screenshot&lt;/strong&gt;, so you can see what the page looked like at each moment&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;DOM at that point in time&lt;/strong&gt;, fully inspectable like in your devtools&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;network requests&lt;/strong&gt; in flight, with status codes, body, and timing&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;JavaScript console&lt;/strong&gt; output&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;timeline&lt;/strong&gt; of everything that happened&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;img src="https://images.thoughtbot.com/h90wgy5l1gaujkor9ntod93qbpwu_Screenshot%202026-05-15%20at%2011.18.43%E2%80%AFAM.png" alt="plawright trace viewer"&gt;&lt;/p&gt;

&lt;p&gt;Compare that to what you usually get when a test fails on CI: A screenshot, and maybe a stack trace pointing at &lt;code&gt;expected to find "Saved"&lt;/code&gt;. With a trace, you can scrub through the test like a video, and pinpoint exactly where things went wrong.&lt;/p&gt;
&lt;h2 id="enable-playwright-traces-in-your-rails-app"&gt;
  
    Enable Playwright traces in your Rails app
  
&lt;/h2&gt;

&lt;p&gt;Drop this into your &lt;code&gt;rails_helper.rb&lt;/code&gt;. It assumes you’re using &lt;a href="https://playwright-ruby-client.vercel.app/docs/article/guides/rails_integration"&gt;&lt;code&gt;playwright-ruby-client&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;You may need to change &lt;code&gt;type: :system&lt;/code&gt; to &lt;code&gt;type: :feature&lt;/code&gt; if that’s how you set up your browser-based tests.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;RSpec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;configure&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
  &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;before&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:each&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;type: :system&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;example&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
    &lt;span class="n"&gt;driver&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Capybara&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;current_session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;driver&lt;/span&gt;
    &lt;span class="k"&gt;next&lt;/span&gt; &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="n"&gt;driver&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;respond_to?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:start_tracing&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;driver&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start_tracing&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="ss"&gt;screenshots: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;snapshots: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;sources: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;title: &lt;/span&gt;&lt;span class="n"&gt;example&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;full_description&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;example&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:playwright_tracing&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kp"&gt;true&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;after&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:each&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;type: :system&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;example&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
    &lt;span class="k"&gt;next&lt;/span&gt; &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="n"&gt;example&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:playwright_tracing&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;trace_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;example&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="no"&gt;Capybara&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;current_session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;driver&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stop_tracing&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;example&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exception&lt;/span&gt;
      &lt;span class="n"&gt;output&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;RSpec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;configuration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;output_stream&lt;/span&gt;
      &lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;puts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Playwright trace: &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;rescue&lt;/span&gt; &lt;span class="no"&gt;Playwright&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Error&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start_with?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Must start tracing before stopping"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;trace_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;example&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;example&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exception&lt;/span&gt;
      &lt;span class="no"&gt;Rails&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;root&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s2"&gt;"tmp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s2"&gt;"playwright-traces"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;
          &lt;span class="n"&gt;example&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;full_description&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parameterize&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;150&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
          &lt;span class="s2"&gt;"-&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="no"&gt;Time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_i&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="s2"&gt;".zip"&lt;/span&gt;
        &lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;
      &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;
      &lt;span class="no"&gt;File&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;NULL&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;This starts a trace before every feature spec and only saves it if the test failed. When that happens, you’ll see a line like this printed:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Playwright trace: /Users/jutonz/code/thoughtbot/testing/tmp/playwright-traces/it-works-1775576941.zip
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;A few things in the snippet might catch your eye (the &lt;code&gt;before(:each)&lt;/code&gt; instead of &lt;code&gt;around&lt;/code&gt;, the &lt;code&gt;rescue&lt;/code&gt;, the conditional path). They’re like that for a reason. Copy as-is, or expand below for the rationale.&lt;/p&gt;

&lt;details&gt;
&lt;summary&gt;Why the snippet looks the way it does&lt;/summary&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;before(:each)&lt;/code&gt; over &lt;code&gt;around&lt;/code&gt;.&lt;/strong&gt; Capybara only swaps from RackTest to Playwright after it sees the spec’s &lt;code&gt;:js&lt;/code&gt; (or &lt;code&gt;js: true&lt;/code&gt;) metadata. That swap happens after the &lt;code&gt;around&lt;/code&gt; block, so a hook running there would still see the RackTest driver and &lt;code&gt;respond_to?(:start_tracing)&lt;/code&gt; would always be &lt;code&gt;false&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;rescue Playwright::Error&lt;/code&gt;.&lt;/strong&gt; If &lt;code&gt;start_tracing&lt;/code&gt; failed for any reason, e.g. if a test modifies some low level driver config, &lt;code&gt;stop_tracing&lt;/code&gt; will throw a “Must start tracing before stopping” error. Catching it won’t fail an otherwise passing test because of a tracing issue.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Conditional &lt;code&gt;trace_path&lt;/code&gt; / &lt;code&gt;File::NULL&lt;/code&gt;.&lt;/strong&gt; Playwright requires you to write a trace somewhere once you’ve started one. Since we don’t care about traces for passing tests, we discard them by writing them to the &lt;code&gt;/dev/null&lt;/code&gt; &lt;a href="https://en.wikipedia.org/wiki/Null_device"&gt;null device&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;/details&gt;
&lt;h2 id="download-traces-from-ci-for-debugging"&gt;
  
    Download traces from CI for debugging
  
&lt;/h2&gt;

&lt;p&gt;You’ll want to keep failed traces around for inspection. In GitHub Actions, add an upload step that runs even when the suite fails:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Upload Playwright traces&lt;/span&gt;
  &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;failure()&lt;/span&gt;
  &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/upload-artifact@v4&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;playwright-traces&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tmp/playwright-traces&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Now you can download traces after a failed test.&lt;/p&gt;
&lt;h2 id="open-a-trace"&gt;
  
    Open a trace
  
&lt;/h2&gt;

&lt;p&gt;Once you’ve downloaded a trace zip, open it with:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight shell"&gt;&lt;code&gt;npx playwright show-trace path/to/trace.zip
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;That spins up the Trace Viewer in a local browser. The &lt;a href="https://playwright.dev/docs/trace-viewer"&gt;Playwright docs&lt;/a&gt; cover the UI in detail.&lt;/p&gt;
&lt;h2 id="gotchas"&gt;
  
    Gotchas
  
&lt;/h2&gt;

&lt;p&gt;A couple of things worth knowing once traces are on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Traces only show Playwright actions, not Capybara or RSpec.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;click_on("Save")&lt;/code&gt; call will appear as &lt;code&gt;Click&lt;/code&gt; in the trace, since that’s the underlying Playwright action that’s performed. Usually it’s easy enough to map the Playwright action to the Capybara version, but it won’t be exactly the same.&lt;/p&gt;

&lt;p&gt;The trace recorder is also capable of capturing &lt;a href="https://playwright-ruby-client.vercel.app/docs/article/guides/rspec_integration"&gt;Playwright-based assertions&lt;/a&gt;, but if you’re using Capybara, these won’t show up. Capybara does assertions itself, rather than delegating to the driver. This is because Selenium and other drivers don’t support built-in assertions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;There’s a small I/O cost.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Recording is mostly disk-bound. In my experience the cost is negligible, but if your CI is I/O-constrained, you might notice it as new flakiness. If that happens, you can store traces in a tmpfs or ramdisk by passing &lt;code&gt;tracesDir&lt;/code&gt; to the driver:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;Capybara&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Playwright&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Driver&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;# ... existing options ...&lt;/span&gt;
  &lt;span class="ss"&gt;tracesDir: &lt;/span&gt;&lt;span class="no"&gt;ENV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"CI"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;present?&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;"/mnt/ramdisk/playwright-traces"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kp"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;CircleCI exposes &lt;a href="https://circleci.com/docs/guides/execution-managed/using-docker/#ram-disks"&gt;&lt;code&gt;/mnt/ramdisk&lt;/code&gt;&lt;/a&gt; by default. On other providers you can &lt;a href="https://docs.oracle.com/cd/E18752_01/html/817-5093/fscreate-99040.html"&gt;create a tmpfs directory manually&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id="debug-don39t-guess"&gt;
  
    Debug, don’t guess
  
&lt;/h2&gt;

&lt;p&gt;Before traces, my approach to flaky CI was guess, re-run, and hope. Now I open the artifact, scrub through the test, and see the actual problem. The difference is night and day, and the setup is one block of code.&lt;/p&gt;

&lt;p&gt;If you’re running Playwright through Capybara, you’ve already got everything you need. Turn it on.&lt;/p&gt;

&lt;aside class="related-articles"&gt;&lt;h2&gt;If you enjoyed this post, you might also like:&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://thoughtbot.com/blog/your-flaky-tests-might-be-time-dependent"&gt;Your flaky tests might be time dependent&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://thoughtbot.com/blog/debugging-why-your-specs-have-slowed-down"&gt;Debugging Why Your Specs Have Slowed Down&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://thoughtbot.com/blog/automating-barcode-scanner-tests-with-capybara"&gt;Automating barcode scanner tests with Capybara&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;&lt;/aside&gt;
</content>
  <summary> Playwright records every test in cinematic detail. Most Rails devs aren't watching.</summary>
  <thoughtbot:auto_social_share>true</thoughtbot:auto_social_share>
</entry>
<entry>
  <title>AI crawlers are inflating your view counts</title>
  <link rel="alternate" href="https://thoughtbot.com/blog/ai-crawlers-are-inflating-your-view-counts"/>
  <author>
    <name>Trésor Bireke</name>
  </author>
  <id>https://thoughtbot.com/blog/ai-crawlers-are-inflating-your-view-counts</id>
  <published>2026-06-16T00:00:00+00:00</published>
  <updated>2026-06-15T22:50:50Z</updated>
  <content type="html">&lt;p&gt;Your most-viewed page might be one no human has ever opened. That is what &lt;strong&gt;AI crawlers&lt;/strong&gt; have done to view tracking in 2026.&lt;/p&gt;

&lt;p&gt;I ran into this problem on a production app that needed engagement tracking. The first version tracked everything server-side, the way Rails apps have done analytics for years. It broke within a day.&lt;/p&gt;
&lt;h2 id="the-problem-crawlers-inflate-every-count"&gt;
  
    The problem: crawlers inflate every count
  
&lt;/h2&gt;

&lt;p&gt;We used &lt;a href="https://github.com/ankane/ahoy"&gt;Ahoy&lt;/a&gt; for tracking. Each controller action called &lt;code&gt;ahoy.track&lt;/code&gt; while rendering the page, and every event rolled up into a denormalized counter column with &lt;code&gt;counter_culture&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The issue is that server-side tracking fires on every request, including bots. AI crawlers like Meta-ExternalAgent, Bytespider, and Baiduspider were making roughly 100,000 requests per day. They were not attacking the site, just reading to feed training pipelines.&lt;/p&gt;

&lt;p&gt;Ahoy has bot detection built in. It uses the &lt;code&gt;device_detector&lt;/code&gt; gem to check user agents and skips known bots. That list catches Googlebot and older crawlers, but it misses the new wave of AI crawlers. As a result, every one of those requests created an &lt;code&gt;Ahoy::Event&lt;/code&gt; row and incremented the corresponding counters.&lt;/p&gt;

&lt;p&gt;Our view counts were not measuring human interest. They were measuring how hungry the scrapers were that week.&lt;/p&gt;
&lt;h2 id="fix-one-require-javascript"&gt;
  
    Fix one: require JavaScript
  
&lt;/h2&gt;

&lt;p&gt;Chasing user agent strings is a losing game. New crawlers appear faster than blocklists update. But there is one thing AI crawlers reliably do not do, and that is execute JavaScript.&lt;/p&gt;

&lt;p&gt;So we moved view tracking out of the controllers. Pages declare what is trackable as a data attribute, and a small Stimulus controller fires a beacon after the page loads.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;element&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;viewTrackerFired&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;true&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;element&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;viewTrackerFired&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;true&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fire&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fire&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;requestIdleCallback&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;requestIdleCallback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fire&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2000&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fire&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;A few details mattered here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;requestIdleCallback&lt;/code&gt; defers the beacon until the browser is idle, so tracking never competes with rendering. The 2-second timeout guarantees it still fires on busy pages.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;keepalive: true&lt;/code&gt; on the fetch lets the request survive the user navigating away immediately.&lt;/li&gt;
&lt;li&gt;The fired flag guards against Turbo reconnecting the controller and double-counting.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Crawlers fetch the HTML and move on. Real browsers run the beacon and get counted. View counts dropped sharply the day this deployed. That was the fix landing, not a regression.&lt;/p&gt;
&lt;h2 id="fix-two-the-bots-found-the-beacon"&gt;
  
    Fix two: the bots found the beacon
  
&lt;/h2&gt;

&lt;p&gt;Three days later, the tracking endpoint &lt;code&gt;/track/events&lt;/code&gt; was the most-crawled path on the site. Crawlers do not execute JavaScript, but they do parse it. The endpoint URL sits in the markup as a data attribute, so the scrapers extracted it and started requesting it directly.&lt;/p&gt;

&lt;p&gt;None of those requests created events, but they still burned through the full Rails stack for nothing. The fix was two cheap layers.&lt;/p&gt;

&lt;p&gt;First, robots.txt for the well-behaved bots:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Disallow: /track/
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Second, a guard in the controller for everyone else:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TrackingEventsController&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationController&lt;/span&gt;
  &lt;span class="n"&gt;before_action&lt;/span&gt; &lt;span class="ss"&gt;:reject_bots&lt;/span&gt;

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

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;reject_bots&lt;/span&gt;
    &lt;span class="n"&gt;head&lt;/span&gt; &lt;span class="ss"&gt;:no_content&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="no"&gt;DeviceDetector&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;user_agent&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;bot?&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Any request with a bot user agent gets a 204 before the action runs. No parsing, no resource lookups, no database work. The well-behaved crawlers respect robots.txt and never arrive, and the rest get the cheapest possible response.&lt;/p&gt;
&lt;h2 id="the-takeaway"&gt;
  
    The takeaway
  
&lt;/h2&gt;

&lt;p&gt;Server-side analytics was built for a web that no longer exists. In 2026, a meaningful share of your traffic comes from AI crawlers, so counting views on the server measures scraper appetite, not audience.&lt;/p&gt;

&lt;p&gt;The defense is not one clever trick. It is stacked cheap layers: robots.txt for the bots that ask permission, a user agent check that returns early for the ones that announce themselves, and a JavaScript beacon for the bots that do neither.&lt;/p&gt;

&lt;p&gt;Check your own numbers. If your view counts have never had a suspicious cliff in them, the bot tax is probably still baked in.&lt;/p&gt;

&lt;aside class="related-articles"&gt;&lt;h2&gt;If you enjoyed this post, you might also like:&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://thoughtbot.com/blog/how-to-use-chatgpt-to-find-custom-software-consultants"&gt;How to Use ChatGPT to Find Custom Software Consultants&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://thoughtbot.com/blog/how-upgrading-ruby-broke-javascript"&gt;How Upgrading Ruby Broke JavaScript&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://thoughtbot.com/blog/ai-for-business-adoption-challenges-people"&gt;AI for Business: Adoption challenges - people&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;&lt;/aside&gt;
</content>
  <summary>AI crawlers now make up most of your site's traffic, and server-side analytics counts every one of them. Your view counts measure scrapers, not readers. Here is how to fix it.</summary>
  <thoughtbot:auto_social_share>true</thoughtbot:auto_social_share>
</entry>
</feed>
