<?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>Giant Robots Podcast Ep 614:  AI Code Audits</title>
  <link rel="alternate" href="https://thoughtbot.com/blog/giant-robots-podcast-ep-614-ai-code-audits"/>
  <author>
    <name>Chad Pytel and Sami Birnbaum</name>
  </author>
  <id>https://thoughtbot.com/blog/giant-robots-podcast-ep-614-ai-code-audits</id>
  <published>2026-06-18T00:00:00+00:00</published>
  <updated>2026-06-18T14:23:48Z</updated>
  <content type="html">Our hosts Chad and Sami team up this week to discuss AI code bases and whether they can be built to be developer friendly and with best practices in mind.</content>
  <summary>Our hosts Chad and Sami team up this week to discuss AI code bases and whether they can be built to be developer friendly and with best practices in mind.</summary>
  <thoughtbot:auto_social_share>false</thoughtbot:auto_social_share>
</entry>
<entry>
  <title>Meet thoughtbot at Brighton Ruby 2026</title>
  <link rel="alternate" href="https://thoughtbot.com/blog/meet-thoughtbot-at-brighton-ruby-2026"/>
  <author>
    <name>Rémy Hannequin</name>
  </author>
  <id>https://thoughtbot.com/blog/meet-thoughtbot-at-brighton-ruby-2026</id>
  <published>2026-06-18T00:00:00+00:00</published>
  <updated>2026-06-17T14:59:42Z</updated>
  <content type="html">&lt;p&gt;Brighton Ruby 2026 will take place in a few days and the thoughtbot team will be
there to meet you all in real life, learn from all the great talks, and enjoy a
day by the English coast.&lt;/p&gt;

&lt;p&gt;We love Brighton Ruby and enjoyed it for many years. It is a single-day,
single-track conference packed with great energy and great people.&lt;/p&gt;

&lt;p&gt;This year we will have 5 thoughtbotters attending:&lt;/p&gt;

&lt;p&gt;&lt;img src="https://images.thoughtbot.com/8sth2cjnqsrcqzqwi024demlwabm_Ali_Slater.png" alt="Aji Slater's portrait"&gt;&lt;/p&gt;

&lt;p&gt;Aji will be at Brighton Ruby for the first time! They are always happy to talk
about ruby game development, recent conversations on
&lt;a href="https://bikeshed.thoughtbot.com"&gt;The Bike Shed&lt;/a&gt;, tracking reading lists on
&lt;a href="https://app.thestorygraph.com/profile/doodlingdev"&gt;Storygraph&lt;/a&gt;, or (let’s see
what else… &lt;em&gt;::rummages through bag of hobbies::&lt;/em&gt;) linguistic anthropology.
Come say hello!&lt;/p&gt;

&lt;p&gt;&lt;img src="https://images.thoughtbot.com/lwltzeyrmhro7xrbz2l9bbzqxp7v_Chad_Pytel.png" alt="Chad Pytel's portrait"&gt;&lt;/p&gt;

&lt;p&gt;Chad is thoughtbot’s founder and CEO, host of the
&lt;em&gt;Giant Robots Smashing Into Other Giant Robots&lt;/em&gt;‘s
&lt;a href="https://podcast.thoughtbot.com/"&gt;podcast&lt;/a&gt; and eternal player of D&amp;amp;D.&lt;/p&gt;

&lt;p&gt;&lt;img src="https://images.thoughtbot.com/pkyl5eoqpbz36lrquink7xqjusk9_Mina_Slater.png" alt="Mina Slater's portrait"&gt;&lt;/p&gt;

&lt;p&gt;Mina is based in Edinburgh, Scotland, and this will be her first time at
Brighton Ruby. She’s interested in infrastructure as code and closing the gap
between operations responsibilities and application development, is an avid
marathon runner, always looking to strike up a conversation about the works of
Brandon Sanderson or show off pictures of her dogs, Dottie and Henson.&lt;/p&gt;

&lt;p&gt;&lt;img src="https://images.thoughtbot.com/yvj3ky5sd803aoy3704pp4fut63z_rob.png" alt="Rob Whittaker's portrait"&gt;&lt;/p&gt;

&lt;p&gt;Rob is our EMEA Development Director, based in Holmes Chapel, Cheshire and most
likely you have seen him already in Brighton Ruby or other conferences.&lt;/p&gt;

&lt;p&gt;He has a not-so-quiet obsession with best practices and striving for
improvement. He likes to hunt down delicious beers and coffee in his spare time.
Despite the recent ups and downs, he’s an avid Stoke City fan, which is only a
testament to his determination!&lt;/p&gt;

&lt;p&gt;&lt;img src="https://images.thoughtbot.com/rlx4eih0fe93p9fd3sna6io6why1_Sarah%20Lima%202026%20(1).png" alt="Sarah Lima's portrait"&gt;&lt;/p&gt;

&lt;p&gt;Sarah is a developer and team lead, based in Porto, Portugal, and originally
from Brazil. She enjoys working with Ruby especially, but also on any technology
that enables projects to move forward. She loves playings sports like volleyball
and has a great movies and TV series culture.&lt;/p&gt;

&lt;p&gt;&lt;img src="https://images.thoughtbot.com/038aqq7cxmnqu1fwguk5jlj8lq5h_Remy_Hannequin.png" alt="Rémy Hannequin's portrait"&gt;&lt;/p&gt;

&lt;p&gt;And finally, we will also have a speaker at the conference this year: myself,
Rémy Hannequin. I am happy to share that I will be giving a talk on time
management and surprises with Ruby.&lt;/p&gt;

&lt;p&gt;I am based in Paris, France, and I have a serious passion for astronomy. I
created multiple open source projects to combine Ruby and astronomy, with the
main one being &lt;a href="https://github.com/rhannequin/astronoby"&gt;Astronoby&lt;/a&gt;, a Ruby gem to allows to compute celestial events and
positions with extreme precision.&lt;/p&gt;

&lt;p&gt;If you’re attending, come say hello! We’re always up for talking about Ruby,
Rails, that new gem we’re excited about, the eternal Vim vs VS Code debate or we
can just share a drink and talk about something else! Keep an eye on
&lt;a href="https://thoughtbot.social/@thoughtbot"&gt;thoughtbot on Mastodon&lt;/a&gt; and
&lt;a href="https://ruby.social/@purinkle"&gt;Rob’s personal account&lt;/a&gt; to see where we’ll be
hanging out.&lt;/p&gt;

&lt;p&gt;We can’t wait to share the experience with you.&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/2-years-of-hackfests"&gt;2 Years Of Hackfests&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://thoughtbot.com/blog/thoughtbot-at-rubyconf-2023"&gt;thoughtbot at RubyConf 2023&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://thoughtbot.com/blog/railsconf2012"&gt;Meet thoughtbot at Railsconf 2012&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;&lt;/aside&gt;
</content>
  <summary>Brighton Ruby 2026 will take place in two weeks, thoughtbot will be attending and speaking, let's meet!</summary>
  <thoughtbot:auto_social_share>true</thoughtbot:auto_social_share>
</entry>
<entry>
  <title>The mistake I didn't realise I was making when designing workshops</title>
  <link rel="alternate" href="https://thoughtbot.com/blog/the-mistake-i-didn-t-realise-i-was-making-when-designing-workshops"/>
  <author>
    <name>Bethan Ashley</name>
  </author>
  <id>https://thoughtbot.com/blog/the-mistake-i-didn-t-realise-i-was-making-when-designing-workshops</id>
  <published>2026-06-17T00:00:00+00:00</published>
  <updated>2026-06-16T09:44:06Z</updated>
  <content type="html">&lt;h2 id="the-checklist-i-expected"&gt;
  
    The checklist I expected
  
&lt;/h2&gt;

&lt;p&gt;Last week I attended a workshop on neuroinclusivity in learning design.&lt;/p&gt;

&lt;p&gt;I expected to come away with a checklist.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Use larger fonts&lt;/li&gt;
&lt;li&gt;Send slides in advance&lt;/li&gt;
&lt;li&gt;Offer cameras off&lt;/li&gt;
&lt;li&gt;Use a dyslexia-friendly typeface&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Instead, the biggest takeaway was that there is &lt;strong&gt;no checklist&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Hold on - I know you want something tangible, it’s coming - stay with me.&lt;/p&gt;
&lt;h2 id="the-assumption-i-hadn39t-questioned"&gt;
  
    The assumption I hadn’t questioned
  
&lt;/h2&gt;

&lt;p&gt;The facilitator challenged a belief I hadn’t questioned before: we often talk about neurodiversity as if it describes a group of people.&lt;/p&gt;

&lt;p&gt;The workshop argued that neurodiversity is the natural variation in how humans think, focus, process information and communicate.&lt;/p&gt;

&lt;p&gt;That reframing changes the problem entirely.&lt;/p&gt;
&lt;h2 id="why-this-matters-for-design"&gt;
  
    Why this matters for design
  
&lt;/h2&gt;

&lt;p&gt;When we think in categories, we tend to design for ourselves and then add accommodations afterwards. We build the workshop, the meeting, the presentation or the product, based on our own preferences and then ask, “now how do we make this accessible?”&lt;/p&gt;

&lt;p&gt;When we think in variation, we start by accepting that people will experience the same thing differently.&lt;/p&gt;

&lt;p&gt;The workshop wasn’t really about fonts or slide templates. It was about design choices.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How much information do you put on a slide?&lt;/p&gt;

&lt;p&gt;Do people know why they’re learning something?&lt;/p&gt;

&lt;p&gt;Can they contribute in different ways?&lt;/p&gt;

&lt;p&gt;Have you considered sensory load, attention span, or processing time?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 id="a-familiar-product-challenge"&gt;
  
    A familiar product challenge
  
&lt;/h2&gt;

&lt;p&gt;As a product person, the parallel felt familiar: even if you start with the customer, there’s still a risk of designing for how something is &lt;em&gt;assumed&lt;/em&gt; to be experienced, rather than how it actually is - a gap that only closes with context and testing.&lt;/p&gt;

&lt;p&gt;Whether you’re designing software, a workshop, a conference talk or a team meeting, the principle feels surprisingly similar:&lt;/p&gt;

&lt;p&gt;Start with the expectation that people will experience the same thing differently. &lt;em&gt;Design from there.&lt;/em&gt;&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/priority-determines-product"&gt;Priority Determines Product&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://thoughtbot.com/blog/chicken-accessories-for-chickens"&gt;Chicken Accessories For Chickens&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://thoughtbot.com/blog/tips-for-joining-an-existing-project"&gt;Tips for Joining an Existing Project 💡&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;&lt;/aside&gt;
</content>
  <summary>The hidden reason good products, meetings and workshops still fail: they depend on a single way of seeing, thinking and processing.</summary>
  <thoughtbot:auto_social_share>true</thoughtbot:auto_social_share>
</entry>
<entry>
  <title>The Bike Shed Ep 502:  Apps That Make Our Work Go</title>
  <link rel="alternate" href="https://thoughtbot.com/blog/the-bike-shed-ep-502-apps-that-make-our-work-go"/>
  <author>
    <name>Joël Quenneville and Sally Hall</name>
  </author>
  <id>https://thoughtbot.com/blog/the-bike-shed-ep-502-apps-that-make-our-work-go</id>
  <published>2026-06-16T00:00:00+00:00</published>
  <updated>2026-06-16T14:20:27Z</updated>
  <content type="html">Aji and Sally are back together again, this time to discuss the different apps they use to make their workflows and To Do lists easier and quicker to achieve.</content>
  <summary>Aji and Sally are back together again, this time to discuss the different apps they use to make their workflows and To Do lists easier and quicker to achieve.</summary>
  <thoughtbot:auto_social_share>false</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>
