<?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet href="/feed.xsl" type="text/xsl"?>
<feed xml:lang="en-US" xmlns="http://www.w3.org/2005/Atom">
  <id>tag:stanko.io,2005:/feed</id>
  <link rel="alternate" type="text/html" href="https://stanko.io/"/>
  <link rel="self" type="application/atom+xml" href="https://stanko.io/feed"/>
  <link rel="first" type="application/atom+xml" href="https://stanko.io/feed"/>
  <link rel="next" type="application/atom+xml" href="https://stanko.io/feed/page/2"/>
  <link rel="last" type="application/atom+xml" href="https://stanko.io/feed/page/2"/>
  <title>Stanko Krtalic Rusendic</title>
  <updated>2026-08-05T07:57:18Z</updated>
  <entry>
    <id>tag:stanko.io,2005:Article/1073200335</id>
    <published>2026-08-05T07:26:18Z</published>
    <updated>2026-08-05T07:57:18Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/a-year-at-37signals-8s17uw9GPHgD"/>
    <title>A year at 37signals</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;p&gt;A year ago I had my first day at 37signals (the people behind Basecamp, HEY and
Fizzy).&lt;/p&gt;
&lt;p&gt;This was a dream come true for me.&lt;/p&gt;
&lt;p&gt;I wanted to work there ever since I read Rework over a decade ago. The books,
podcasts and talks that followed showed me that we also share a similar
philosophy towards work and the craft of coding. That just cemented my resolve.&lt;/p&gt;
&lt;p&gt;Not to mention that it&amp;#39;s the birthplace of Rails! The single piece of software
that made me decide to turn my programming hobby into my career.&lt;/p&gt;
&lt;p&gt;I had an incredible year, so I want to share how I got here and what I learned
that might not be obvious when looking from the outside-in.&lt;/p&gt;
&lt;h3 id="getting-the-job"&gt;Getting the job&lt;/h3&gt;
&lt;p&gt;I&amp;#39;m not sure how many times I applied for a job here over the years. It must be
at least six, maybe more.&lt;/p&gt;
&lt;p&gt;Out of those I passed to the final round twice.&lt;/p&gt;
&lt;p&gt;The first round is notoriously difficult, which is quite funny because it&amp;#39;s just
a simple cover letter.&lt;/p&gt;
&lt;p&gt;We write a lot internally. Nearly all communication is written and most of it is
long-form comments and notes. Being able to write is essential.&lt;/p&gt;
&lt;p&gt;I wasn&amp;#39;t a good writer. It took me a few attempts to realize that. But I was
determined to get better at it, so I worked on it.&lt;/p&gt;
&lt;p&gt;The first step to writing better is to read more. Not just technical books,
self-help and similar, but stories, essays, opinion pieces. Learn what&amp;#39;s out
there and expand your vocabulary and literary horizon, then forge your own style
from the things you like.&lt;/p&gt;
&lt;p&gt;A funny side-effect of reading more is that I started to hate &lt;a href="https://stanko.io/corporate-newspeak-v2E1RTcmIUfi"&gt;corporate
newspeak&lt;/a&gt; - it&amp;#39;s so bland and
dull that I immediately tune out as soon as I start to read or listen to it now.
To me it&amp;#39;s like communicating in memes.&lt;/p&gt;
&lt;p&gt;The second step is to write more. I started writing more often on this blog,
which helped with long-form technical writing. But later on I started keeping a
diary. Nothing fancy, I&amp;#39;d just jot down what happened every day and some
thoughts bouncing around in my head - this helped me in general, not just with
writing.&lt;/p&gt;
&lt;p&gt;The third step is to learn how to write a cover letter. This took several
attempts and it boils down to: be yourself.&lt;/p&gt;
&lt;p&gt;I always felt a pressure to make the cover letter sound professional. You know,
the things they tell you in school: serious tone, respect, warm but also not too
personal.&lt;/p&gt;
&lt;p&gt;That&amp;#39;s terrible advice. And I realized that only after I started hating
corporate newspeak. It makes you sound the same as everyone else, and dull on
top of that.&lt;/p&gt;
&lt;p&gt;A cover letter should explain why you want to work there. It&amp;#39;s your chance to
tell your story. It&amp;#39;s supposed to be personal, fun and interesting.&lt;/p&gt;
&lt;p&gt;The second round is a take-home exercise.&lt;/p&gt;
&lt;p&gt;I was never worried about this. For me Rails and the OSS that 37signals puts out
has always been &lt;a href="https://stanko.io/building-twice-a-clone-of-once-gJKxLYCe26Ak"&gt;a treasure trove to explore and learn
from&lt;/a&gt; so I was
familiar with their style, and I already spent a lot of time internalizing why
they structure code like they do.&lt;/p&gt;
&lt;p&gt;I could write a whole essay about this, but to keep it short: ergonomics and
domain design are important; the code should read like prose.&lt;/p&gt;
&lt;p&gt;Lastly there is the interview.&lt;/p&gt;
&lt;p&gt;I fumbled the first one. I was unbelievably anxious and over-thought and
over-explained every question; the conversation didn&amp;#39;t flow. But I got good
feedback from it and spent a lot of time improving on it. The second time around
I was relaxed and we had a fun chat.&lt;/p&gt;
&lt;p&gt;Then, one evening I got an email ominously titled &amp;quot;Some news&amp;quot; telling me that
they&amp;#39;d like me to join the company! I was over the moon!&lt;/p&gt;
&lt;h3 id="working-here"&gt;Working here&lt;/h3&gt;
&lt;p&gt;I have a hazy memory of the first few weeks - they felt like a dream. What I
remember the most is reading, and reading a lot.&lt;/p&gt;
&lt;p&gt;As soon as I started working on actual tasks a new feeling hit me: doubt. I
started to second-guess my own work and this thought formed in my mind that I
just lucked out with the hiring process and that someone would soon notice that
my work wasn&amp;#39;t any good.&lt;/p&gt;
&lt;p&gt;This feeling lingered on for months and passed only when I started getting fewer
comments on my PRs.&lt;/p&gt;
&lt;p&gt;It sounds like a curse, and it felt like it, but now I see that it was also a
boon. It motivated me to get better and to learn. I figured that&amp;#39;s the only
thing that would cure that feeling, and it did.&lt;/p&gt;
&lt;p&gt;Then came &lt;a href="https://www.fizzy.do/"&gt;Fizzy&lt;/a&gt;&amp;#39;s launch.&lt;/p&gt;
&lt;p&gt;I got to work on Fizzy as soon as I joined. My first task was to &lt;a href="https://github.com/basecamp/fizzy/pull/857"&gt;add
FizzyAsk&lt;/a&gt; - an LLM-powered chat
where you could tell Fizzy what to do or ask it questions about your data.&lt;/p&gt;
&lt;p&gt;After adding it I barely ever used it. And to my surprise &lt;a href="https://github.com/basecamp/fizzy/pull/1389"&gt;it was completely
scrapped&lt;/a&gt; alongside all AI features
after a month.&lt;/p&gt;
&lt;p&gt;This was the first time I ever saw features get cut - not pivoted, not moved,
not put on hold - after they were built and before they were even released.&lt;/p&gt;
&lt;p&gt;The reason behind the cut was eye-opening: &lt;a href="https://world.hey.com/jorge/delightful-or-nothing-56d86b31"&gt;delightful or
nothing&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Imagine an overgrown garden with hundreds of different plants in it - that&amp;#39;s
what the products of most companies look like to me: an eclectic collection of
ever-growing features. The garden is &amp;quot;designed&amp;quot; by adding whatever plants the
neighbours added to their gardens this week. Everything is overgrown because
nobody has the time to care for so many plants, and even if they do they don&amp;#39;t
know how. The plants that are cared for are overshadowed by the overgrowth.&lt;/p&gt;
&lt;p&gt;By comparison, what I saw with Fizzy was gardening. There was a vision. The
garden had just a few flowers, but all of them were curated to work together and
arranged to be delightful. If something new didn&amp;#39;t work with what was already
there it wouldn&amp;#39;t make the cut, or we&amp;#39;d remove some of the old flowers to make
room for the new ones.&lt;/p&gt;
&lt;p&gt;I talked to a few friends about this and some of them had an interesting
comment: &amp;quot;Isn&amp;#39;t that soul-crushing? You spend so much time building something
just to see it go down the drain.&amp;quot;&lt;/p&gt;
&lt;p&gt;No, it isn&amp;#39;t. In fact, it&amp;#39;s liberating. Not all ideas are good, but you can&amp;#39;t
know that ahead of time. Sometimes you have to try them to understand why they
don&amp;#39;t work. It would be far more soul-draining to have to support an experiment
for years than to admit that it wasn&amp;#39;t very good and bury it right away.&lt;/p&gt;
&lt;p&gt;Whenever I talk about this I think of Buddhist mandalas. These are intricate
artworks made of colored sand that can take weeks to complete. Once they are
done they are admired for a while and then swept up and thrown into a river.
Nothing is permanent.&lt;/p&gt;
&lt;p&gt;Later on I was tasked with building a new magic-link-based auth system. Sounds
easy, right? It wasn&amp;#39;t.&lt;/p&gt;
&lt;p&gt;Fizzy was built on two new in-house gems:
&lt;a href="https://github.com/basecamp/activerecord-tenanted"&gt;activerecord-tenanted&lt;/a&gt; and
&lt;a href="https://www.youtube.com/watch?v=lcved9uEV5U"&gt;beamer&lt;/a&gt;. AR-tenanted separated
each tenant into their own database, and Beamer replicated those SQLite
databases to the whole fleet of servers. The catch was that there had to be a
central identity database for sign-in, but each server was the writer for a few
tenant databases, so you could never write to that database and then the
tenanted database in the same request because the two databases would be on
different servers most of the time.&lt;/p&gt;
&lt;p&gt;As I was untangling that I realized that I was used to working with the latest
release of Ruby and Rails, even with Rails edge. But this was different. We were
ahead of Rails edge. We were building what might become Rails edge at some point
and dogfooding it ourselves to work out the kinks!&lt;/p&gt;
&lt;p&gt;This can sometimes become a challenge. The magic links took two weeks to
implement and I spent a good part of those two weeks wondering if it could even
be done.&lt;/p&gt;
&lt;p&gt;Some of my work on Fizzy did make it into Rails. I added Crockford&amp;#39;s Base32 to
Fizzy for magic link codes and that became
&lt;a href="https://github.com/rails/rails/pull/56288"&gt;&lt;code&gt;SecureRandom.base32&lt;/code&gt;&lt;/a&gt;. Later on I
added passkeys to Fizzy, and &lt;a href="https://github.com/rails/rails/pull/57100"&gt;that might also land in
Rails&lt;/a&gt; as &lt;code&gt;has_passkeys&lt;/code&gt; in Active
Record.&lt;/p&gt;
&lt;p&gt;After the launch we shifted into cycle work. This was the first time I
experienced Shape Up.&lt;/p&gt;
&lt;p&gt;Shape Up has two modes: cycle work and &lt;a href="https://world.hey.com/bb/new-product-mode-48a43806"&gt;new-product
mode&lt;/a&gt;. Cycle work is the
thing most people associate with Shape Up - six week cycles with cooldown
periods in between. In new product mode there&amp;#39;s no fixed cycle, no appetite, no
kick-off, no pitch. It&amp;#39;s like jazz, things are made up as we go.&lt;/p&gt;
&lt;p&gt;After working in both modes I realized that what I thought of as &amp;quot;agile
development&amp;quot; was just waterfall with extra steps.&lt;/p&gt;
&lt;p&gt;Take scrum as an example: two week sprints, task pointing, standups. It sounds
quite similar to Shape Up: six week cycles, appetite, check-ins and hill-charts.&lt;/p&gt;
&lt;p&gt;The magic is in what happens behind the scenes.&lt;/p&gt;
&lt;p&gt;In every company I ever worked at there was something like a &amp;quot;product roadmap&amp;quot; -
a document that laid out each feature that would be added in the fiscal year and
in which order they would be worked on. Then scrum gets slapped on top of that.
So now you are working in cycles, but there is this Gantt chart called the
&amp;quot;roadmap&amp;quot; in the background that suspiciously looks like a waterfall and if
anything slips the cascades become a bit wider. &lt;a href="https://stanko.io/scrum-is-for-time-estimates-not-projects-23ULmuBVSMqo"&gt;Scrum isn&amp;#39;t there to get things
done, it&amp;#39;s there to keep you on
schedule&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;In Shape Up the plan is laid out six weeks at a time. It isn&amp;#39;t set in stone, it
can shift; projects are dropped, shortened or extended. The important part is
that after the six weeks are over a new plan is laid out that takes into account
not only what was done in the last six weeks but also what happened in the
world.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://world.hey.com/jason/like-a-squirrel-bd821787"&gt;Jason Fried had an excellent article about this where he compared it to running
like a squirrel&lt;/a&gt;. A
squirrel runs a little, then stops, looks around, picks a new direction, and
then runs some more. As it does that it takes its surroundings into account. It
goes in a direction but it regularly re-evaluates. That&amp;#39;s agile.&lt;/p&gt;
&lt;p&gt;I&amp;#39;ve seen so many serendipitous situations where one project became the
groundwork for another that was just the right fit at the moment. You can&amp;#39;t plan
this, you have to see and feel the previous work to form a vision of what you
could build with it next.&lt;/p&gt;
&lt;p&gt;These are just the most eye-opening things I learned.&lt;/p&gt;
&lt;p&gt;Last week was a big week for me because the company decided to keep me! After
the first year everyone goes through a review that asks one question: knowing
what the company knows now, would they hire you again? A huge weight has been
lifted off my shoulders even though I knew the review would go well.&lt;/p&gt;
&lt;p&gt;I&amp;#39;m grateful I get to work alongside and learn from my amazing colleagues at
37signals.&lt;/p&gt;
&lt;p&gt;When I joined I expected a challenge, I wanted to learn and to grow, and that&amp;#39;s
exactly what I got. I&amp;#39;m glad I managed to rise to it. This really was a dream
come true.&lt;/p&gt;&lt;/div&gt;
</content>
    <summary type="html">A year ago I had my first day at 37signals (the people behind Basecamp, HEY and Fizzy).

This was a dream come true for me.

I wanted to work there ever since I read Rework over a decade ago. The books, podcasts and talks that followed showed me that we also share a similar philosophy towards...</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Snap/37</id>
    <published>2026-06-03T04:21:25Z</published>
    <updated>2026-06-03T04:21:25Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/snaps/soca-valley-C4wEfGoWTXNS"/>
    <title>Soca Valley</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;/div&gt;
</content>
    <summary type="html"/>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Snap/36</id>
    <published>2026-06-03T04:21:25Z</published>
    <updated>2026-06-03T04:21:25Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/snaps/mountain-plains-in-soca-valley-SjMJuSWvQpiI"/>
    <title>Mountain plains in Soca Valley</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;/div&gt;
</content>
    <summary type="html"/>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Snap/35</id>
    <published>2026-06-03T04:21:25Z</published>
    <updated>2026-06-03T04:21:25Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/snaps/fog-creeping-in-on-the-hillside-KFl9vCOBsl5T"/>
    <title>Fog creeping in on the hillside</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;/div&gt;
</content>
    <summary type="html"/>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Snap/34</id>
    <published>2026-06-03T04:21:25Z</published>
    <updated>2026-06-03T04:21:25Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/snaps/fog-over-soca-valley-ZLrAInXQXlVq"/>
    <title>Fog over Soca Valley</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;/div&gt;
</content>
    <summary type="html"/>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Snap/33</id>
    <published>2026-06-03T04:21:25Z</published>
    <updated>2026-06-03T04:21:25Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/snaps/soca-valley-in-early-morning-3RbtiRstxRE6"/>
    <title>Soca Valley in early morning</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;/div&gt;
</content>
    <summary type="html"/>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Snap/32</id>
    <published>2026-06-03T04:21:25Z</published>
    <updated>2026-06-03T04:21:25Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/snaps/lamb-in-soca-valley-JkXLyOAQlXEb"/>
    <title>Lamb in Soca Valley</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;/div&gt;
</content>
    <summary type="html"/>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Snap/31</id>
    <published>2026-06-03T04:21:25Z</published>
    <updated>2026-06-03T04:21:25Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/snaps/sheep-in-soca-vallye-YVSCGaaS3M1U"/>
    <title>Sheep in Soca vallye</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;/div&gt;
</content>
    <summary type="html"/>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Talk/1050011312</id>
    <published>2026-02-14T14:10:40Z</published>
    <updated>2026-02-14T14:11:33Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/talks/working-at-37signals-the-birthplace-of-ruby-on-rails-EYGo2LNyOqQ3"/>
    <title>Working at 37signals: The birthplace of Ruby On Rails</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;p&gt;Last July I started working at &lt;a href="https://37signals.com/"&gt;37signals&lt;/a&gt;, the company
behind Basecamp, Hey &amp;amp; Fizzy and the place from which the very first version or
Ruby On Rails was extracted back in 2004.&lt;/p&gt;
&lt;p&gt;It&amp;#39;s still the company with the largest influence on the direction of Rails
development with major contributions like ActiveStorage, SolidQueue, Turbo &amp;amp;
Stimulus.&lt;/p&gt;
&lt;p&gt;I&amp;#39;ll talk about how the company works and what it&amp;#39;s like to work there.&lt;/p&gt;&lt;/div&gt;
</content>
    <summary type="html">Last July I started working at 37signals, the company behind Basecamp, Hey &amp; Fizzy and the place from which the very first version or Ruby On Rails was extracted back in 2004.

It's still the company with the largest influence on the direction of Rails development with major contributions like...</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Snap/29</id>
    <published>2026-01-28T10:09:11Z</published>
    <updated>2026-03-08T17:45:33Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/snaps/venice-vEnaIJhYkbKj"/>
    <title>Venice</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;/div&gt;
</content>
    <summary type="html"/>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Snap/28</id>
    <published>2026-01-28T09:43:00Z</published>
    <updated>2026-03-08T17:45:33Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/snaps/venice-avMwbBvCzqtF"/>
    <title>Venice</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;/div&gt;
</content>
    <summary type="html"/>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Snap/27</id>
    <published>2026-01-27T16:15:25Z</published>
    <updated>2026-03-08T17:45:33Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/snaps/venice-6Q7N3UsKMi0j"/>
    <title>Venice</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;/div&gt;
</content>
    <summary type="html"/>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Snap/26</id>
    <published>2026-01-27T15:03:14Z</published>
    <updated>2026-03-08T17:45:33Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/snaps/venice-ygGhGgDlhgOz"/>
    <title>Venice</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;/div&gt;
</content>
    <summary type="html"/>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Snap/25</id>
    <published>2026-01-27T15:02:31Z</published>
    <updated>2026-03-08T17:45:33Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/snaps/venice-UcD6A2ZdQyiM"/>
    <title>Venice</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;/div&gt;
</content>
    <summary type="html"/>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Snap/24</id>
    <published>2026-01-27T15:01:05Z</published>
    <updated>2026-03-08T17:45:33Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/snaps/venice-UMXapsuRpLjh"/>
    <title>Venice</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;/div&gt;
</content>
    <summary type="html"/>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Snap/30</id>
    <published>2026-01-21T10:01:39Z</published>
    <updated>2026-03-08T17:47:31Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/snaps/zsa-voyager-lego-gameboy-dmg-GxhDOhG57sQd"/>
    <title>ZSA Voyager &amp; Lego Gameboy DMG</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;/div&gt;
</content>
    <summary type="html"/>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Snap/23</id>
    <published>2026-01-07T11:04:34Z</published>
    <updated>2026-03-09T08:42:37Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/snaps/robin-in-the-snow-PmdBY4grPtdX"/>
    <title>Robin in the snow</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;/div&gt;
</content>
    <summary type="html"/>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Snap/22</id>
    <published>2025-10-26T08:38:49Z</published>
    <updated>2026-03-08T17:18:53Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/snaps/halloween-2rDZGjPjjybU"/>
    <title>Halloween</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;/div&gt;
</content>
    <summary type="html"/>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Snap/21</id>
    <published>2025-09-05T09:34:52Z</published>
    <updated>2026-03-08T17:15:54Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/snaps/amsterdam-jDHEarezVLi2"/>
    <title>Amsterdam</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;/div&gt;
</content>
    <summary type="html"/>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Snap/20</id>
    <published>2025-09-05T08:33:21Z</published>
    <updated>2026-03-08T17:15:54Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/snaps/amsterdam-Oo0vQbjHNI7Z"/>
    <title>Amsterdam</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;/div&gt;
</content>
    <summary type="html"/>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Snap/19</id>
    <published>2025-09-05T08:28:42Z</published>
    <updated>2026-03-08T17:15:54Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/snaps/amsterdam-oLi46yD4eOd9"/>
    <title>Amsterdam</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;/div&gt;
</content>
    <summary type="html"/>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Snap/18</id>
    <published>2025-09-05T08:26:29Z</published>
    <updated>2026-03-08T17:15:54Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/snaps/amsterdam-6buxiicObFiH"/>
    <title>Amsterdam</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;/div&gt;
</content>
    <summary type="html"/>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Snap/17</id>
    <published>2025-09-05T08:25:55Z</published>
    <updated>2026-03-08T17:15:54Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/snaps/amsterdam-MXfBXsv8BDC7"/>
    <title>Amsterdam</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;/div&gt;
</content>
    <summary type="html"/>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Snap/16</id>
    <published>2025-09-04T21:10:15Z</published>
    <updated>2026-03-08T17:15:54Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/snaps/amsterdam-mMCLYWCHXu0b"/>
    <title>Amsterdam</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;/div&gt;
</content>
    <summary type="html"/>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Snap/15</id>
    <published>2025-09-04T08:28:52Z</published>
    <updated>2026-03-08T17:15:54Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/snaps/amsterdam-5hjfLmGYrdBW"/>
    <title>Amsterdam</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;/div&gt;
</content>
    <summary type="html"/>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Snap/14</id>
    <published>2025-08-24T05:38:55Z</published>
    <updated>2026-03-08T17:08:57Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/snaps/soca-cRWYytJYzbCa"/>
    <title>Soča</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;/div&gt;
</content>
    <summary type="html"/>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Snap/13</id>
    <published>2025-08-23T19:46:20Z</published>
    <updated>2026-03-08T17:08:57Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/snaps/soca-IkJ8BfKQ5QfD"/>
    <title>Soča</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;/div&gt;
</content>
    <summary type="html"/>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Snap/12</id>
    <published>2025-08-23T16:49:51Z</published>
    <updated>2026-03-08T17:08:56Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/snaps/soca-DZfwxabi8BAJ"/>
    <title>Soča</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;/div&gt;
</content>
    <summary type="html"/>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Snap/11</id>
    <published>2025-08-23T16:43:01Z</published>
    <updated>2026-03-08T17:08:56Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/snaps/soca-5PDKDkuvvxUC"/>
    <title>Soča</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;/div&gt;
</content>
    <summary type="html"/>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Snap/10</id>
    <published>2025-08-23T16:16:14Z</published>
    <updated>2026-03-08T17:08:56Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/snaps/soca-Q1tMQy7dMVGc"/>
    <title>Soča</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;/div&gt;
</content>
    <summary type="html"/>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Snap/9</id>
    <published>2025-08-23T15:03:40Z</published>
    <updated>2026-03-08T17:08:56Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/snaps/soca-AFSuKHQGFPV4"/>
    <title>Soča</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;/div&gt;
</content>
    <summary type="html"/>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Snap/8</id>
    <published>2025-08-23T13:14:41Z</published>
    <updated>2026-03-08T17:08:56Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/snaps/soca-uL3XAkufdcrz"/>
    <title>Soča</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;/div&gt;
</content>
    <summary type="html"/>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Snap/7</id>
    <published>2025-08-23T13:14:18Z</published>
    <updated>2026-03-08T17:08:56Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/snaps/soca-UId9264Na1K4"/>
    <title>Soča</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;/div&gt;
</content>
    <summary type="html"/>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Snap/6</id>
    <published>2025-08-23T13:12:25Z</published>
    <updated>2026-03-08T17:06:22Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/snaps/soca-SbrH2NnK0EN5"/>
    <title>Soča</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;/div&gt;
</content>
    <summary type="html"/>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Snap/5</id>
    <published>2025-08-23T13:12:03Z</published>
    <updated>2026-03-08T17:06:22Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/snaps/soca-qlUyvXjMKSAY"/>
    <title>Soča</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;/div&gt;
</content>
    <summary type="html"/>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Snap/4</id>
    <published>2025-08-23T12:22:54Z</published>
    <updated>2026-03-08T17:06:22Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/snaps/soca-j0eCfpl2p8bg"/>
    <title>Soča</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;/div&gt;
</content>
    <summary type="html"/>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Snap/3</id>
    <published>2025-08-23T08:15:11Z</published>
    <updated>2026-03-08T17:06:22Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/snaps/soca-FlIY4gXIN8f1"/>
    <title>Soča</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;/div&gt;
</content>
    <summary type="html"/>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Snap/2</id>
    <published>2025-08-23T07:50:20Z</published>
    <updated>2026-03-08T17:06:22Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/snaps/soca-khlmHHkmuPCM"/>
    <title>Soča</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;/div&gt;
</content>
    <summary type="html"/>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Snap/1</id>
    <published>2025-08-23T07:44:58Z</published>
    <updated>2026-03-08T17:06:21Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/snaps/soca-wTtw2YEvSnRm"/>
    <title>Soča</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;/div&gt;
</content>
    <summary type="html"/>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Article/46</id>
    <published>2025-06-12T14:00:00Z</published>
    <updated>2026-03-11T13:32:32Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/clean-air-ai-AHcddmIf21lt"/>
    <title>Clean air &amp; AI</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;p&gt;One of my favorite activities is hiking. I&amp;#39;m fortunate to live close to a
mountain where I can go for a hike any time I want - often early in the morning,
before work.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg attachment--expandable" data-full-src="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_8969.jpeg/2400w.jpg"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 3024; --attachment-height: 4032; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2012%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRlIAAABXRUJQVlA4IEYAAADwAQCdASoMABAABABoJbACdADhQSINRgAAyoGwk114sh%2Bq2CdqeicrPI8EKRoGoPXRqfJF9DTPsYwvXHOPXVvGaXXJ%2FAAA%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #3f6066;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_8969.jpeg/480w.webp 480w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_8969.jpeg/800w.webp 800w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_8969.jpeg/1200w.webp 1200w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_8969.jpeg/1600w.webp 1600w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_8969.jpeg/2400w.webp 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="A winding road on the way to Medvednica" loading="lazy" decoding="async" class="attachment__image" width="3024" height="4032" src="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_8969.jpeg/1200w.jpg" srcset="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_8969.jpeg/480w.jpg 480w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_8969.jpeg/800w.jpg 800w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_8969.jpeg/1200w.jpg 1200w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_8969.jpeg/1600w.jpg 1600w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_8969.jpeg/2400w.jpg 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;A winding road on the way to Medvednica&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;I love the trek over hills and valleys, through forests and streams, the
creaking of trees in the wind, the rustling of leaves, the murmur of streams,
and the discussions with friends along the way.&lt;/p&gt;
&lt;p&gt;I love the destination - the view, the tranquility, the bean stew with sausage
at a hiking lodge, cuddling up to a fireplace in the winter.&lt;/p&gt;
&lt;p&gt;But most of all I love the mountain air.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg attachment--expandable" data-full-src="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_8385.jpeg/2400w.jpg"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 3024; --attachment-height: 4032; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2012%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRkgAAABXRUJQVlA4IDwAAADwAQCdASoMABAABABoJbACdAD0MUzofEAA8ZdDRVWcT75kpsZSj6Jff8jjno6nAUP%2BNvaQxP4i6VCAAAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #6c95b6;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_8385.jpeg/480w.webp 480w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_8385.jpeg/800w.webp 800w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_8385.jpeg/1200w.webp 1200w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_8385.jpeg/1600w.webp 1600w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_8385.jpeg/2400w.webp 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Zagreb, as seen from Sljeme, covered with clouds" loading="lazy" decoding="async" class="attachment__image" width="3024" height="4032" src="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_8385.jpeg/1200w.jpg" srcset="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_8385.jpeg/480w.jpg 480w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_8385.jpeg/800w.jpg 800w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_8385.jpeg/1200w.jpg 1200w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_8385.jpeg/1600w.jpg 1600w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_8385.jpeg/2400w.jpg 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Zagreb, as seen from Sljeme, covered with clouds&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;It smells different, it’s light, relaxing, refreshing, and clean.&lt;/p&gt;
&lt;p&gt;The difference is most striking on the way down - at one point, the air starts
to feel heavy, turns dusty, and takes on an odor.&lt;/p&gt;
&lt;p&gt;One winter morning, as the trail gave way to pavement and the outskirts of the
city, a sharp sulfur smell - almost like gunpowder - hit me and stayed with me
all the way home.&lt;/p&gt;
&lt;p&gt;Something wasn’t right. The air wasn’t usually that bad. I checked the AQI for
Zagreb - it was the fifth worst in the world that day.&lt;/p&gt;
&lt;p&gt;I immediately bought a cheap air purifier off IKEA and decided to make an air
quality monitor.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg attachment--expandable" data-full-src="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_0769.jpeg/2400w.jpg"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 3024; --attachment-height: 4032; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2012%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRlYAAABXRUJQVlA4IEoAAAAQAgCdASoMABAABABoJZwAAuc%2FRlKJ5KgAAP7wKEwvjdWYsb4vlXN8QJAbPnJAy3IomUm%2F03MkkA%2F5jZxSyo%2BkeLywLvw%2FATRIAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #999a95;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_0769.jpeg/480w.webp 480w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_0769.jpeg/800w.webp 800w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_0769.jpeg/1200w.webp 1200w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_0769.jpeg/1600w.webp 1600w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_0769.jpeg/2400w.webp 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Breadboard with the first prototype of my air quality monitor" loading="lazy" decoding="async" class="attachment__image" width="3024" height="4032" src="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_0769.jpeg/1200w.jpg" srcset="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_0769.jpeg/480w.jpg 480w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_0769.jpeg/800w.jpg 800w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_0769.jpeg/1200w.jpg 1200w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_0769.jpeg/1600w.jpg 1600w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_0769.jpeg/2400w.jpg 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Breadboard with the first prototype of my air quality monitor&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;I made a quick prototype using parts I bought off AliExpress. After ironing out
a few kinks, I ended up with something functional - &lt;a href="https://github.com/monorkin/air_quality_box"&gt;Air Quality
Box&lt;/a&gt;.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg attachment--expandable" data-full-src="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/776d4c55-fe36-468f-b480-6a6b2cdb9e0e.jpeg/2096w.jpg"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 2096; --attachment-height: 3724; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%209%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRmAAAABXRUJQVlA4IFQAAADwAQCdASoJABAABABoJZACdADbIp8Gv0AA%2Bp0YuHnQsjXPKAz5hyZobEt%2BlO1EV2FgfKquuam%2FFiaWpmksWXp4xVnicO54mbqLEW4ylRZyfJ5AAAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #6c7a75;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/776d4c55-fe36-468f-b480-6a6b2cdb9e0e.jpeg/480w.webp 480w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/776d4c55-fe36-468f-b480-6a6b2cdb9e0e.jpeg/800w.webp 800w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/776d4c55-fe36-468f-b480-6a6b2cdb9e0e.jpeg/1200w.webp 1200w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/776d4c55-fe36-468f-b480-6a6b2cdb9e0e.jpeg/1600w.webp 1600w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/776d4c55-fe36-468f-b480-6a6b2cdb9e0e.jpeg/2096w.webp 2096w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="The Air Quality Box" loading="lazy" decoding="async" class="attachment__image" width="2096" height="3724" src="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/776d4c55-fe36-468f-b480-6a6b2cdb9e0e.jpeg/1200w.jpg" srcset="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/776d4c55-fe36-468f-b480-6a6b2cdb9e0e.jpeg/480w.jpg 480w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/776d4c55-fe36-468f-b480-6a6b2cdb9e0e.jpeg/800w.jpg 800w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/776d4c55-fe36-468f-b480-6a6b2cdb9e0e.jpeg/1200w.jpg 1200w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/776d4c55-fe36-468f-b480-6a6b2cdb9e0e.jpeg/1600w.jpg 1600w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/776d4c55-fe36-468f-b480-6a6b2cdb9e0e.jpeg/2096w.jpg 2096w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;The Air Quality Box&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;To turn the rat&amp;#39;s nest of a prototype into something presentable, I designed a
custom PCB that connected all the components and packed them as tightly as
possible in a enclosure that I printed. It was as small as I could make it given
the parts and air flow constraints needed for everything to work properly.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg attachment--expandable" data-full-src="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/ecfac0d9-983b-4eae-9a7b-5cb10d2a527c.jpeg/2096w.jpg"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 2096; --attachment-height: 3724; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%209%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRloAAABXRUJQVlA4IE4AAADQAQCdASoJABAABABoJbACdAD0pCf9gAD%2BCctEJPcu8L6xCUM9X0Q4M9ZqPum6MMXUq7lnA87e6gyxqxgW%2FwO4IWfmVoYpClR4vuKAAAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #54695a;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/ecfac0d9-983b-4eae-9a7b-5cb10d2a527c.jpeg/480w.webp 480w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/ecfac0d9-983b-4eae-9a7b-5cb10d2a527c.jpeg/800w.webp 800w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/ecfac0d9-983b-4eae-9a7b-5cb10d2a527c.jpeg/1200w.webp 1200w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/ecfac0d9-983b-4eae-9a7b-5cb10d2a527c.jpeg/1600w.webp 1600w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/ecfac0d9-983b-4eae-9a7b-5cb10d2a527c.jpeg/2096w.webp 2096w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="The tightly packed inside of the Air Quality Box" loading="lazy" decoding="async" class="attachment__image" width="2096" height="3724" src="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/ecfac0d9-983b-4eae-9a7b-5cb10d2a527c.jpeg/1200w.jpg" srcset="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/ecfac0d9-983b-4eae-9a7b-5cb10d2a527c.jpeg/480w.jpg 480w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/ecfac0d9-983b-4eae-9a7b-5cb10d2a527c.jpeg/800w.jpg 800w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/ecfac0d9-983b-4eae-9a7b-5cb10d2a527c.jpeg/1200w.jpg 1200w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/ecfac0d9-983b-4eae-9a7b-5cb10d2a527c.jpeg/1600w.jpg 1600w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/ecfac0d9-983b-4eae-9a7b-5cb10d2a527c.jpeg/2096w.jpg 2096w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;The tightly packed inside of the Air Quality Box&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;It integrated with Home Assistant and displayed measurements on its screen. This
allowed me to automate my IKEA air purifier, and I could read the air quality
right from my desk without any app.&lt;/p&gt;
&lt;p&gt;That little boxed served me for four years and taught me a lot about air
quality:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Higher CO2 levels in a room cause headaches and drowsiness&lt;/li&gt;
&lt;li&gt;Air with very low particulares and 40% humidity feels like mountain air&lt;/li&gt;
&lt;li&gt;VOCs and high humidity make the air feel heavy&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;But it had two major flaws.&lt;/p&gt;
&lt;p&gt;First, its design was very industrial - &lt;em&gt;people either loved it or hated it&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;Second, the UI was clunky - &lt;em&gt;you couldn’t just glance at it and see what’s
wrong, you had to wait for all the measurements to cycle through.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;At first, I started working on a V2 which was heavily inspired by IKEA’s
Vindriktning. It had a single light bar that told you an overall score of the
air quality, and a small screen at the top that showed what needed fixing.&lt;/p&gt;
&lt;p&gt;Then I came across the Awair Element. It had everything I wanted - a beautiful
enclosure, it shows everything at a glance, it can integrate with Home
Assistant. So I just bought one, and I have been very happy with it for the past
6 months.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--jpg" data-full-src="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/20250610_174929.jpg/2400w.jpg"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 4000; --attachment-height: 1848; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%207%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRkYAAABXRUJQVlA4IDoAAADQAQCdASoQAAcABABoJZQCdAEe9YH9MAD%2BXvX%2FZ6SLVDsg%2BEYxfZ7pjiqQqyaAKfNlDC8%2BY6Q1f0AA%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #7e7269;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/20250610_174929.jpg/480w.webp 480w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/20250610_174929.jpg/800w.webp 800w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/20250610_174929.jpg/1200w.webp 1200w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/20250610_174929.jpg/1600w.webp 1600w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/20250610_174929.jpg/2400w.webp 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="The Awair Element that I bought" loading="lazy" decoding="async" class="attachment__image" width="4000" height="1848" src="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/20250610_174929.jpg/1200w.jpg" srcset="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/20250610_174929.jpg/480w.jpg 480w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/20250610_174929.jpg/800w.jpg 800w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/20250610_174929.jpg/1200w.jpg 1200w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/20250610_174929.jpg/1600w.jpg 1600w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/20250610_174929.jpg/2400w.jpg 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;The Awair Element that I bought&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;Its default screen shows five bars and a number. The number is the score of the
air quality based on all measurements. Each bar represents a measurement:
temperature, humidity, CO2, VOC, and particulates. When a value is within the
target range, the bar shows a single dot. If it’s too high or too low, it
displays two to five dots, depending on how far it has drifted.&lt;/p&gt;
&lt;p&gt;I just have one minor annoyance with this interface - you can&amp;#39;t tell if a
measurement is too low or too high at a glance.&lt;/p&gt;
&lt;p&gt;Say the humidity bar shows three dots - is it too humid or too dry? To find out,
I have to pull out my phone, open the app, and read the value. Or I can reach
around the device, press a button a few times until it displays the humidity
measurement, and then cycle back to the score display. It isn&amp;#39;t terrible, just
annoying.&lt;/p&gt;
&lt;p&gt;But I had an idea for how to fix this. I could create an app that lives in the
system tray and shows the current air quality score. When clicked, it expands to
show all measurements. That way I can quickly check what&amp;#39;s wrong - no phone, no
fiddling with buttons.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png attachment--expandable" data-full-src="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/pasted-image-20250611155058.png/464w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 464; --attachment-height: 534; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2014%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRjIAAABXRUJQVlA4ICYAAABQAQCdASoOABAABABoJQBOgCgAAP7tnuHBvf4lP9F4fO3m9wAAAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #364054;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/pasted-image-20250611155058.png/464w.webp 464w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Demo of the system tray app" loading="lazy" decoding="async" class="attachment__image" width="464" height="534" src="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/pasted-image-20250611155058.png/464w.png" srcset="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/pasted-image-20250611155058.png/464w.png 464w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Demo of the system tray app&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;This wouldn&amp;#39;t be my first Linux Desktop app. Years ago, I made one using
&lt;a href="https://wiki.archlinux.org/title/Qt"&gt;Qt&lt;/a&gt;,
&lt;a href="https://en.wikipedia.org/wiki/QML"&gt;QML&lt;/a&gt; and &lt;a href="https://www.rust-lang.org/"&gt;Rust&lt;/a&gt;,
so I knew what I had to do.&lt;/p&gt;
&lt;p&gt;There would be two parts to the app - the app itself and the system tray widget.
The app discovers air quality monitors on the network, gathers measurements, and
handles settings. The system tray widget displays the gathered measurements. The
two would communicate with each other via
&lt;a href="https://wiki.archlinux.org/title/D-Bus"&gt;D-Bus&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://stanko.io/building-twice-a-clone-of-once-gJKxLYCe26Ak"&gt;Over the last few weeks, I&amp;#39;ve been sharpening my Go
skills&lt;/a&gt;, so I
decided to write this app in &lt;a href="https://go.dev/"&gt;Go&lt;/a&gt;. And since I use
&lt;a href="https://wiki.archlinux.org/title/GNOME"&gt;GNOME&lt;/a&gt; as my Desktop environment these
days, I went with &lt;a href="https://wiki.archlinux.org/title/GTK"&gt;GTK&lt;/a&gt; instead of Qt - it
fits in better visually.&lt;/p&gt;
&lt;p&gt;Which brings me to AI.&lt;/p&gt;
&lt;p&gt;Until now, I&amp;#39;ve used AI as a pair programmer. I’m in the driver’s seat, and when
I want to bounce ideas around or look something up, I ask the AI.&lt;/p&gt;
&lt;p&gt;To me, that’s the most natural way to work with an AI. It allows me to stay in
flow - I can focused on keeping the code clear, modeling the domain, and solving
the real problem, while the AI allows me to tap into a vast pool of knowledge in
a moment&amp;#39;s notice. No more digging through SEO spam for that one useful link - I
get exactly what I need, when I need it.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/pasted-image-20250612094946.png/2400w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 2801; --attachment-height: 1989; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2011%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRi4AAABXRUJQVlA4ICIAAAAwAQCdASoQAAsABABoJZQAA3AA%2FvA8f6BSk%2F1OQNovgAAA%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #23222b;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/pasted-image-20250612094946.png/480w.webp 480w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/pasted-image-20250612094946.png/800w.webp 800w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/pasted-image-20250612094946.png/1200w.webp 1200w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/pasted-image-20250612094946.png/1600w.webp 1600w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/pasted-image-20250612094946.png/2400w.webp 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="ChatGPT and NVIM side by side" loading="lazy" decoding="async" class="attachment__image" width="2801" height="1989" src="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/pasted-image-20250612094946.png/1200w.png" srcset="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/pasted-image-20250612094946.png/480w.png 480w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/pasted-image-20250612094946.png/800w.png 800w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/pasted-image-20250612094946.png/1200w.png 1200w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/pasted-image-20250612094946.png/1600w.png 1600w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/pasted-image-20250612094946.png/2400w.png 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;ChatGPT and NVIM side by side&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;This approach helped me a lot while learning Go. Whenever I hit a wall, I could
ask the AI how it would solve the problem and get a few new things to read up
on. In a way, it felt like having a mentor.&lt;/p&gt;
&lt;p&gt;This time, I decided to flip things around and let the AI drive while I guide
it. I wanted to see if there was a better way to work with AI than what I&amp;#39;d been
using. So I installed &lt;a href="https://docs.anthropic.com/en/docs/claude-code/overview"&gt;Claude
Code&lt;/a&gt; and bought $50
worth of API credits.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;(Since this week, if you have a Pro subscription to Claude, Claude Code is
included - no API credits needed)&lt;/em&gt;&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/pasted-image-20250612103759.png/1884w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 1884; --attachment-height: 982; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%208%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRiwAAABXRUJQVlA4ICAAAACQAQCdASoQAAgABABoJZQAAudZt7gA%2Fu6Xw1xVsS8AAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #242430;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/pasted-image-20250612103759.png/480w.webp 480w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/pasted-image-20250612103759.png/800w.webp 800w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/pasted-image-20250612103759.png/1200w.webp 1200w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/pasted-image-20250612103759.png/1600w.webp 1600w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/pasted-image-20250612103759.png/1884w.webp 1884w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="The initial prompt given to Claude Code" loading="lazy" decoding="async" class="attachment__image" width="1884" height="982" src="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/pasted-image-20250612103759.png/1200w.png" srcset="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/pasted-image-20250612103759.png/480w.png 480w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/pasted-image-20250612103759.png/800w.png 800w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/pasted-image-20250612103759.png/1200w.png 1200w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/pasted-image-20250612103759.png/1600w.png 1600w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/pasted-image-20250612103759.png/1884w.png 1884w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;The initial prompt given to Claude Code&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;And that&amp;#39;s how I - or rather &lt;em&gt;we&lt;/em&gt; - made &lt;a href="https://github.com/monorkin/gnome-desktop-air-monitor"&gt;GNOME Desktop Air
Monitor&lt;/a&gt;.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--mp4"&gt; &lt;video class="attachment__image--sized placeholder" style="--attachment-width: 880; --attachment-height: 716; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2013%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRkgAAABXRUJQVlA4IDwAAACwAQCdASoQAA0ABABoJZQAAudZpk1gAP4JO7oaT0ylBwuWgSPnExuAMmOOHW5XsC6BfR6T%2FOnL1J5eAAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #c7cbcb;" controls autoplay playsinline loop muted poster="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/screencast-from-2025-06-10-17-24-02-1.jpg/880w.jpg" width="880" height="716"&gt; &lt;source src="https://media.stanko.io/originals/articles/clean-air-ai-AHcddmIf21lt/screencast-from-2025-06-10-17-24-02-1.mp4" type="video/mp4"&gt; &lt;/video&gt; &lt;figcaption class="attachment__caption"&gt;Demo of GNOME Desktop Air Monitor&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;I didn&amp;#39;t like this way of us working together - for me, the pair programming
approach just works better. Letting the AI drive seemed to amplify both of our
weaknesses.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;First, the AI is very goal-oriented.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;It only cares about the outcome - in many ways it behaves like a &lt;a href="https://en.wikipedia.org/wiki/Greedy_algorithm"&gt;greedy
algorithm&lt;/a&gt;, making choices that
seem best in the moment, without regard for long-term consequences.&lt;/p&gt;
&lt;p&gt;This isn&amp;#39;t bad per se. Sometimes this leads to efficient, pragmatic solutions.
But more often, it results in &lt;a href="https://en.wikipedia.org/wiki/Spaghetti_code"&gt;spaghetti
code&lt;/a&gt; - one hack piled on top of
another, just to reach the goal. It doesn&amp;#39;t care how maintainable, extendable or
readable the solution is - that isn&amp;#39;t its goal.&lt;/p&gt;
&lt;p&gt;To counter that, I had to be very explicit. I had to explain not just &lt;em&gt;what&lt;/em&gt; I
wanted it to do, but also &lt;em&gt;how&lt;/em&gt; I wanted it to do it.&lt;/p&gt;
&lt;p&gt;For example, I asked it to create an index screen that shows all devices, then a
show screen for individual devices, and finally a settings screen.&lt;/p&gt;
&lt;p&gt;The result? One giant app.go file. All three screens were in it. All their
logic, all the app setup and window management, everything. There was an App
struct that held the state of every button, list and graph that could ever
appear, with cryptic names like &amp;quot;button&amp;quot;, &amp;quot;settingsButton&amp;quot;, &amp;quot;actionList&amp;quot;, etc.
There were three methods - &amp;quot;showIndex&amp;quot;, &amp;quot;showSettings&amp;quot; and &amp;quot;showDevice&amp;quot; - that
were nearly identical, each resets the state of every UI element before
switching screens.&lt;/p&gt;
&lt;p&gt;I had to explicitly ask it: &lt;em&gt;&amp;quot;Do you think it would be better to create a struct
for each screen and keep its state there? You could add a reset and a show
method there too&amp;quot;&lt;/em&gt;. It agreed - and refactored the code.&lt;/p&gt;
&lt;p&gt;I tried to improve the naming in the same way. But it kept giving me worse and
worse suggestions until I gave up and renamed everything manually.&lt;/p&gt;
&lt;p&gt;By the end, my prompts had become so detailed that I felt like I was
micro-managing someone. And that&amp;#39;s just not how I want to work. For me, trust -
in someone doing their job and doing it well - is essential. If that trust isn&amp;#39;t
there, then explaining exactly what needs to be done is often more work than
just doing it myself.&lt;/p&gt;
&lt;p&gt;I talked to a friend about this and he said something along the lines of &lt;em&gt;&amp;quot;you
have to treat it like a junior developer&amp;quot;&lt;/em&gt;. But that&amp;#39;s the problem - &lt;em&gt;it isn&amp;#39;t a
junior developer&lt;/em&gt;. It doesn&amp;#39;t learn from its mistakes. You can&amp;#39;t explain to it
in which situation one approach is better than another. It won&amp;#39;t conclude that
it produced garbage or that it&amp;#39;s painting itself into a corner - as long as the
goal was &lt;em&gt;technically&lt;/em&gt; achieved.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Second, it doesn&amp;#39;t understand the sunken cost fallacy.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The AI will almost never give up on a solution. It will almost always build on
top of what already exists. It doesn&amp;#39;t question, it doesn&amp;#39;t suggest, it just
marches on. In that regard, it&amp;#39;s similar to a junior developer.&lt;/p&gt;
&lt;p&gt;This gets worse and worse as you iterate on your project. At some point you have
to step in and explain to it that the current approach just doesn&amp;#39;t work, and
that it should try something else.&lt;/p&gt;
&lt;p&gt;I didn’t really see this as a serious issue until my girlfriend - who can&amp;#39;t
code - asked ChatGPT to help her build an iOS app. She described what she
wanted, and it generated a React Native app that did exactly what she asked.
Then she asked it to add live notifications - at the time, an iOS-only feature
that required native code to get it working. Instead of switching tools, it
piled on hack after hack to make it work. It ultimately failed.&lt;/p&gt;
&lt;p&gt;Then I told it to recreate the app in Swift - and it nailed it on the first try.&lt;/p&gt;
&lt;p&gt;If I didn&amp;#39;t know what it had to do - and didn&amp;#39;t tell it explicitly - we&amp;#39;d still
be spinning around in circles, trying every hack known to man or machine.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Third, it doesn&amp;#39;t generalize.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;I often found myself in awe of both its brilliance and its stupidity in the span
of five lines of code. It would implement a pattern that felt like the right
solution - elegant, compresses the complexity just enough, clean - but only in
the narrow context of the problem it was solving at the time. It never
generalized the pattern or applied it across the rest of the code. And that’s
what’s so frustrating - it has access to vast knowledge, the entire codebase,
and limitless memory - you&amp;#39;d expect better.&lt;/p&gt;
&lt;p&gt;But enough of the negatives. I also discovered some strengths.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;It&amp;#39;s brilliant at writing documentation from existing code.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Last week, &lt;a href="https://github.com/monorkin/croatia/"&gt;I started writing a new gem&lt;/a&gt;
and it crossed my mind to try letting Claude write the documentation for it. To
my surprise, it produced well-worded, concise descriptions, complete with good
examples and relevant links - even if a few of the links turned out to be dead.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;It&amp;#39;s decent at writing tests.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;I also let it write a few tests, and they turned out surprisingly solid. It
covered a lot of edge cases I might’ve missed on a first pass, and the structure
was generally clean and readable. Occasionally, it repeated itself more than
necessary - but as a starting point, it saved time.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;It&amp;#39;s excellent for scaffolding.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The most enjoyable way to work with an AI agent, for me, was to let it handle
the first pass - generate the rough structure. Then I’d come in and rewrite the
code the way I like it. That initial scaffold gave me something to react to, and
it helped me move faster.&lt;/p&gt;
&lt;p&gt;All in all, I learned that I love programming as much as I love solving
problems.&lt;/p&gt;
&lt;p&gt;Letting the AI drive helped me see that. It made the coding faster - but it also
took the joy out of shaping the code, thinking through the domain, and making
things beautiful. That’s the part I don’t want to give up.&lt;/p&gt;
&lt;p&gt;Working &lt;em&gt;with&lt;/em&gt; AI, as a partner, is what feels best to me. It can help me move
faster, think broader, and stay in flow.&lt;/p&gt;&lt;/div&gt;
</content>
    <summary type="html">One of my favorite activities is hiking. I'm fortunate to live close to a mountain where I can go for a hike any time I want - often early in the morning, before work.

I love the trek over hills and valleys, through forests and streams, the creaking of trees in the wind, the rustling of leaves,...</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Article/45</id>
    <published>2025-05-24T09:00:00Z</published>
    <updated>2026-02-15T09:37:07Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN"/>
    <title>My last PHP app: How I Fell for Ruby</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;p&gt;Last week I found a backup of the very last PHP app I ever wrote. The backup was
from 2009, but the app itself was written in 2007 - 18 years ago!&lt;/p&gt;
&lt;p&gt;Back then I was in my late teens and the world was &lt;em&gt;(or at least felt)&lt;/em&gt;
completely different. Out of nostalgia, I spent some time exploring the source
code and even got it to run &lt;em&gt;(for the most part)&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;This reminded me of how different and quirky the web used to be, and how I
discovered Ruby.&lt;/p&gt;
&lt;h3 id="what-was-the-app-for"&gt;What was the app for?&lt;/h3&gt;
&lt;p&gt;My friends and I used to play Call of Duty 2 together after school - &lt;em&gt;seems like
the world hasn&amp;#39;t changed that much after all&lt;/em&gt; - and we got good at it. So we
organized into a team, called a clan, and started playing against other clans
for fun.&lt;/p&gt;
&lt;p&gt;Every clan we looked up to at the time had its own website where it put up its
latest games, demos of those games &lt;em&gt;(a demo is a file that allows you to replay
a game and spectate)&lt;/em&gt;, they had a forum, news, and so on. So we wanted to have
our own website, but we were extremely tight on money.&lt;/p&gt;
&lt;p&gt;The most that we could spend - all of us together - on a website was around 3€
per month or 35€ per year. To put that into context, 3€ could buy you 2 large
pizzas at the time - we were financing this with pocket money.&lt;/p&gt;
&lt;p&gt;All hosted website engines were way too expensive for us. We had just enough
money to pay for PHP hosting to a local company. For the domain we got a free
&lt;a href="https://en.wikipedia.org/wiki/.su"&gt;.su domain&lt;/a&gt; from a sketchy registrar. And
now we had to make a website.&lt;/p&gt;
&lt;p&gt;At the time I used to write small programs to solve my math homework or just to
play around with my computer, and I just learned HTML, so I volunteered to make
the website.&lt;/p&gt;
&lt;p&gt;Looking back, implementing a website with news, a calendar, file uploads and a
comment system was very ambitious for a first project. But I didn&amp;#39;t know that at
the time so I gave it a try and failed. There were too many moving parts which
caused too many bugs, and on top of that the file storage logic that I wrote -
&lt;em&gt;because I didn&amp;#39;t know that databases existed&lt;/em&gt; - kept corrupting files forcing
me to recreate everything every now and then.&lt;/p&gt;
&lt;p&gt;Somehow I came across &lt;a href="http://www.webspell.org/"&gt;webSPELL&lt;/a&gt; - a CMS for clan
websites. That solved most of my problems. Now all I had to do was create a
template, add and change a few things. All in all I made five websites using PHP
and webSPELL - three different iterations of our website, and two websites for
other clans.&lt;/p&gt;
&lt;p&gt;The last one I made was the third iteration of our website in 2007. Here it is
in most of its former glory.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/pasted-image-20250523082554.png/1192w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 1192; --attachment-height: 983; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2013%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRlAAAABXRUJQVlA4IEQAAADQAQCdASoQAA0ABABoJaQAAphO2RgYAAD8tDN%2BVgMDg6zpEo7qsRGpp87P31IBEyDWNKfnI2Yrq07IOhQcTBn4GGoAAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #646464;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/pasted-image-20250523082554.png/480w.webp 480w, https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/pasted-image-20250523082554.png/800w.webp 800w, https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/pasted-image-20250523082554.png/1192w.webp 1192w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="The website, mostly working" loading="lazy" decoding="async" class="attachment__image" width="1192" height="983" src="https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/pasted-image-20250523082554.png/1192w.png" srcset="https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/pasted-image-20250523082554.png/480w.png 480w, https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/pasted-image-20250523082554.png/800w.png 800w, https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/pasted-image-20250523082554.png/1192w.png 1192w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;The website, mostly working&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;It even had a visitor counter - this was the Google Analytics of the time.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png attachment--expandable" data-full-src="https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/pasted-image-20250523082935.png/327w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 327; --attachment-height: 335; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRjoAAABXRUJQVlA4IC4AAADQAQCdASoQABAABABoJaQAAugv4rb%2FgAD%2B8QwmpenJlXFuDQBos6ejXL%2Bu1UAA%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #1f1f1f;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/pasted-image-20250523082935.png/327w.webp 327w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="The visitor counter that was in the bottom-right corner" loading="lazy" decoding="async" class="attachment__image" width="327" height="335" src="https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/pasted-image-20250523082935.png/327w.png" srcset="https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/pasted-image-20250523082935.png/327w.png 327w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;The visitor counter that was in the bottom-right corner&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;This was written for PHP 4 and I&amp;#39;m running it in a Docker container with PHP 7
so some things - like the news, forum and stats tickers at the top - just broke
during the update. I&amp;#39;m honestly amazed that I only had to do a handful of
changes to get the app to boot with PHP 7.&lt;/p&gt;
&lt;p&gt;Digging through the code made me remember how &lt;strong&gt;different the Web was back
then&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;For starters the app rendered &lt;a href="https://en.wikipedia.org/wiki/XHTML"&gt;XHTML&lt;/a&gt;
because &lt;a href="https://en.wikipedia.org/wiki/HTML5"&gt;HTML 5&lt;/a&gt; didn&amp;#39;t exist yet and &lt;a href="https://en.wikipedia.org/wiki/HTML#HTML4_variations"&gt;HTML
4&lt;/a&gt; was difficult to work
with.&lt;/p&gt;
&lt;pre class="highlight" data-language="html"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;lt;!&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;DOCTYPE&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; html&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; PUBLIC&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;-//W3C//DTD XHTML 1.0 Transitional//EN&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;html&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; xmlns&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;http://www.w3.org/1999/xhtml&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;The encoding was set to
&lt;a href="https://en.wikipedia.org/wiki/ISO/IEC_8859-1"&gt;iso-8859-1&lt;/a&gt; to get support for
&lt;a href="https://en.wikipedia.org/wiki/Gaj%27s_Latin_alphabet"&gt;Croatian diacritics&lt;/a&gt;.
&lt;a href="https://en.wikipedia.org/wiki/UTF-8"&gt;UTF-8&lt;/a&gt; did exist at the time but a lot of
software just didn&amp;#39;t support (&lt;a href="https://en.wikipedia.org/wiki/Windows_98"&gt;Windows
98&lt;/a&gt; was still very common) so the best
thing I could do was to use iso-8859-1 as it would render the characters
correctly for most people.&lt;/p&gt;
&lt;pre class="highlight" data-language="html"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;meta&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; http-equiv&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;Content-Type&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; content&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;text/html; charset=iso-8859-1&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; /&amp;gt;&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;Then the wild part - this whole site is one giant HTML table with an iframe in
it.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png attachment--expandable" data-full-src="https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/pasted-image-20250523082715.png/721w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 721; --attachment-height: 868; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2013%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRjQAAABXRUJQVlA4ICgAAACwAQCdASoNABAABABoJZQAAudFzMOgAP7r9JgNTkrPJ2QAScgBZAAA%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #292a32;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/pasted-image-20250523082715.png/480w.webp 480w, https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/pasted-image-20250523082715.png/721w.webp 721w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="DOM of the website" loading="lazy" decoding="async" class="attachment__image" width="721" height="868" src="https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/pasted-image-20250523082715.png/721w.png" srcset="https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/pasted-image-20250523082715.png/480w.png 480w, https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/pasted-image-20250523082715.png/721w.png 721w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;DOM of the website&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;DIVs did exist, but they were much harder to position than table cells -
&lt;a href="https://developer.mozilla.org/en-US/docs/Learn_web_development/Core/CSS_layout/Flexbox"&gt;flexbox&lt;/a&gt;
didn&amp;#39;t exist - so they were mostly used as &lt;em&gt;DIV&lt;/em&gt;iders. And the nice thing about
tables is that you can set a fixed width to every cell using the &lt;em&gt;width&lt;/em&gt;
attribute - this was the only way you could do templates back then.&lt;/p&gt;
&lt;p&gt;A template started out as big picture that was sliced into box-shaped sections
like a jigsaw puzzle. Then you&amp;#39;d make an HTML table that puts the puzzle back
together. You&amp;#39;d make each table cell the exact width and height of your slice
and then set the slice image as the background image of that cell. If you had a
layout that wouldn&amp;#39;t fit into a single table you&amp;#39;d nest HTML tables to get the
layout you want.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/slicing.png/1095w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 1095; --attachment-height: 885; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2013%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRkIAAABXRUJQVlA4IDYAAADQAQCdASoQAA0ABABoJaQAAudsj0fdsAD%2B8%2B1jiu0VWmA2AjR5HiQ7L3ZCdCYKaJTHx3o6QAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #f0f0f0;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/slicing.png/480w.webp 480w, https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/slicing.png/800w.webp 800w, https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/slicing.png/1095w.webp 1095w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="An example of how slicing worked" loading="lazy" decoding="async" class="attachment__image" width="1095" height="885" src="https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/slicing.png/1095w.png" srcset="https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/slicing.png/480w.png 480w, https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/slicing.png/800w.png 800w, https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/slicing.png/1095w.png 1095w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;An example of how slicing worked&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;Slicing was an art. A big part of the job was to create slices that could be
tiled. This saved on bandwidth which, compared to today&amp;#39;s 100+ Mbps speeds, were
barely able to provide a meager 2 Mbps. This also meant that you&amp;#39;d often have to
slice something in such a way that seams and tiling wasn&amp;#39;t obvious.&lt;/p&gt;
&lt;p&gt;E.g. the horizontal bars on the top and bottom would get turned into a 1px wide
image that would tile horizontally. The vertical bars would be turned in a 1px
high tiling image. This also allowed the template to scale to fit any size
content and would often reduce a 1MB image down to 1kb or less if you used a
GIF.&lt;/p&gt;
&lt;p&gt;Another interesting thing - this site was exactly 1011px wide. Most monitors at
the time were either 1024px or 1280px wide, so the site would fill most of the
screen. Responsiveness just wasn&amp;#39;t a thing - the iPhone had just come out and
there was literally no way to detect the screen size.&lt;/p&gt;
&lt;p&gt;This produces hilarious results on today&amp;#39;s monitors. E.g. this is what the site
looks like on my ultra-wide.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/pasted-image-20250523134844.png/2400w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 4992; --attachment-height: 1002; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%203%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRjgAAABXRUJQVlA4ICwAAADQAQCdASoQAAMABABoJaQAAxdCgjfIwAD2p9yAIA%2B%2F%2Fwwt7JPVXMENgYAAAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #7f7f7f;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/pasted-image-20250523134844.png/480w.webp 480w, https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/pasted-image-20250523134844.png/800w.webp 800w, https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/pasted-image-20250523134844.png/1200w.webp 1200w, https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/pasted-image-20250523134844.png/1600w.webp 1600w, https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/pasted-image-20250523134844.png/2400w.webp 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="What it looks like on a 40&amp;quot; ultra-wide" loading="lazy" decoding="async" class="attachment__image" width="4992" height="1002" src="https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/pasted-image-20250523134844.png/1200w.png" srcset="https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/pasted-image-20250523134844.png/480w.png 480w, https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/pasted-image-20250523134844.png/800w.png 800w, https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/pasted-image-20250523134844.png/1200w.png 1200w, https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/pasted-image-20250523134844.png/1600w.png 1600w, https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/pasted-image-20250523134844.png/2400w.png 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;What it looks like on a 40&amp;quot; ultra-wide&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;And on my iPhone 14&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png attachment--expandable" data-full-src="https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/pasted-image-20250523135013.png/424w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 424; --attachment-height: 927; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%207%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRjwAAABXRUJQVlA4IDAAAACwAQCdASoHABAABABoJaQAAvd7jTdAAPulese%2FmGxLUn4RG64%2Fseec5hsY8pAAAAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #3a3a3a;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/pasted-image-20250523135013.png/424w.webp 424w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="What it looks like on an iPhone 14" loading="lazy" decoding="async" class="attachment__image" width="424" height="927" src="https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/pasted-image-20250523135013.png/424w.png" srcset="https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/pasted-image-20250523135013.png/424w.png 424w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;What it looks like on an iPhone 14&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;I can barely read the text, and I&amp;#39;ll need a toothpick to click on a link.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://www.youtube.com/watch?v=qObzgUfCl28"&gt;&lt;strong&gt;Ruby, Ruby, Ruby, Ruby&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Over the winter break of 2008, I stumbled across a tweet praising Ruby on Rails
as an elegant alternative to PHP. The next day, my mom asked me if I could build
a website for her real estate business - perfect timing.&lt;/p&gt;
&lt;p&gt;I decided to give Rails a try. webSPELL wasn&amp;#39;t a good fit for a real estate
website, and I was ready for something new.&lt;/p&gt;
&lt;p&gt;Something about Ruby just clicked. The code read like a story. Ruby&amp;#39;s syntax
felt natural and obvious in a way PHP never had. Suddenly programming wasn&amp;#39;t
just about hacking things together - it was expressive, joyful, and beautiful.&lt;/p&gt;
&lt;p&gt;I remember comparing how I used to join first and last names in PHP:&lt;/p&gt;
&lt;pre class="highlight" data-language="php"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;implode&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot; &amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; [$first_name&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; $last_name]&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;to how it looked in Ruby:&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;[first_name&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; last_name]&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;join&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot; &amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;Ruby felt like I was talking to the data. I&amp;#39;d say &amp;quot;Hey Array, join your elements
together with this separator&amp;quot;. PHP, by contrast, felt like rummaging through a
toolbox hoping the right function was in there somewhere.&lt;/p&gt;
&lt;p&gt;Simple database queries became poetic:&lt;/p&gt;
&lt;pre class="highlight" data-language="php"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;$user&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;safe_query&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;SELECT&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; *&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; FROM&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;PREFIX&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;users WHERE first_name = &amp;#39;John&amp;#39; LIMIT 1&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;)&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;vs.&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;user &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; User&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;find_by_first_name&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;John&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;Rails didn’t just simplify the work - it made me fall in love with writing code.
After that, there was no turning back. I decided to turn my programming hobby
into my career.&lt;/p&gt;
&lt;p&gt;I went to college to learn computer science, protocols, and telecommunications.
Then my first job was at a digital agency writing Rails apps to help bootstrap
businesses - many of which are still around. And I&amp;#39;ve been fortunate enough to
work with Ruby and with Rails on every job I&amp;#39;ve had since.&lt;/p&gt;
&lt;p&gt;That dusty old PHP app didn&amp;#39;t age well, but I&amp;#39;m glad I found it.&lt;/p&gt;
&lt;p&gt;It brought back memories of a time when building things felt like magic, which
reminded me how I stumbled into Ruby. One tiny decision I made one winter ended
up shaping my whole career.&lt;/p&gt;
&lt;p&gt;I didn&amp;#39;t know it then, but I was writing the first lines of a lifelong love
story - one that carried me from Call of Duty matches and sliced-up tables to a
profession built on joy, clarity, and code that reads like prose.&lt;/p&gt;&lt;/div&gt;
</content>
    <summary type="html">Last week I found a backup of the very last PHP app I ever wrote. The backup was from 2009, but the app itself was written in 2007 - 18 years ago!

Back then I was in my late teens and the world was (or at least felt) completely different. Out of nostalgia, I spent some time exploring the source...</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Article/44</id>
    <published>2025-05-16T08:00:00Z</published>
    <updated>2026-03-09T14:22:17Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/building-twice-a-clone-of-once-gJKxLYCe26Ak"/>
    <title>Building Twice: A clone of Once</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;p&gt;When Campfire - the first Once product - came out, a few of us at Ruby Zagreb
pitched in to buy a copy.&lt;/p&gt;
&lt;p&gt;Prometheus had brought fire from Mount Olympus, and we came to see the blaze. We
were finally seeing how the people at 37signals - the birthplace of Rails -
build Rails apps. And as a bonus, we could finally ditch our free Slack Channel!&lt;/p&gt;
&lt;p&gt;After days of exploring the source code, the time came to set up Campfire. The
purchase email included a shell command that looks like this&lt;/p&gt;
&lt;pre class="highlight" data-language="bash"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;/bin/bash&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; -c&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;$(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;curl&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; -fsSL&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; https://auth.once.com/install/1111-1111-1111-1111&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;I ran that, &lt;a href="https://stanko.io/running-campfire-behind-traefik-LrGQM0GE0FVd"&gt;and after solving a few
issues&lt;/a&gt;, I had a
Docker container running Campfire.&lt;/p&gt;
&lt;p&gt;This is a super elegant way to distribute apps! But how did it work? I wasn’t
sure so I decided to replicate it and see.&lt;/p&gt;
&lt;h3 id="figuring-out-how-once-works"&gt;Figuring out how Once works&lt;/h3&gt;
&lt;p&gt;&lt;a href="https://stanko.io/how-i-stumbled-upon-strada-while-forwarding-an-email-3W8mbS5KSKXs"&gt;Just like last
time&lt;/a&gt;,
I started to unravel this ball of yarn by following a thread - the shell script.&lt;/p&gt;
&lt;p&gt;Since all it does is execute a script it gets from the Internet I went to see
what that script was&lt;/p&gt;
&lt;pre class="highlight" data-language="bash"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;curl&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; -fsSL&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; https://auth.once.com/install/1111-1111-1111-1111&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;#!/bin/bash&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;set&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; -e&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;echo&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;Installing the ONCE command...&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;echo&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;sudo&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; true&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;command_path&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;https://auth.once.com/install/1111-1111-1111-1111?arch=$(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;arch&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;)&amp;amp;platform=$(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;uname&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;)&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;curl&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; -s&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; $command_path &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;gt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; .once-tmp&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;sudo&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; mv&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; .once-tmp&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; /usr/local/bin/once&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;sudo&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; chmod&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; +x&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; /usr/local/bin/once&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;if&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; [[ &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;$OSTYPE&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; ==&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;darwin&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;*&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; ]]; &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;then&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;  # Don&amp;#39;t use sudo on macOS; Docker Desktop is likely to be running as the user&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;  /usr/local/bin/once&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; setup&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; 1111-1111-1111-1111&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;else&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;  sudo&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; /usr/local/bin/once&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; setup&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; 1111-1111-1111-1111&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;fi&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;This downloads a binary for your OS and CPU architecture, installs it, and then
runs it passing in your license key.&lt;/p&gt;
&lt;p&gt;As this was a binary I couldn’t just open it up and see it’s code so I resorted
to simple reverse engineering using the &lt;a href="https://www.man7.org/linux/man-pages/man1/strings.1.html"&gt;&lt;em&gt;strings&lt;/em&gt;
command&lt;/a&gt; - it returns
all things that look like C strings from a given file.&lt;/p&gt;
&lt;pre class="highlight" data-language="plain"&gt;&lt;span class="line"&gt;&lt;span&gt;... &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;crypto/tls.masterSecretLabel &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;crypto/tls.extendedMasterSecretLabel &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;crypto/tls.keyExpansionLabel &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;crypto/tls.clientFinishedLabel &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;crypto/tls.serverFinishedLabel &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;mime/multipart..inittask &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;mime/multipart.ErrMessageTooLarge &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;mime/multipart.multipartFiles &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;mime/multipart.multipartMaxParts &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;mime/multipart.emptyParams &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;mime/multipart.multipartMaxHeaders &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;mime/multipart.quoteEscaper &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;net/textproto.errMessageTooLarge &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;net/textproto.colon net/textproto.nl &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;net/textproto.commonHeader &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;net/textproto.commonHeaderOnce &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;net..inittask &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;net.rfc6724policyTable &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;net.confOnce &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;net.confVal &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;net.netdns &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;... &lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;There were a lot of strings that looked like &lt;a href="https://go.dev/"&gt;Go&lt;/a&gt; packages,
functions and methods. I soon found a few references to
&lt;a href="https://github.com/spf13/cobra"&gt;cobra&lt;/a&gt; - a Go package for building CLI tools -
so I was sure that I was looking at a compiled Go app.&lt;/p&gt;
&lt;pre class="highlight" data-language="plain"&gt;&lt;span class="line"&gt;&lt;span&gt;... &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;github.com/spf13/cobra.init &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;github.com/spf13/cobra.map.init.0 &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;github.com/spf13/cobra.GetActiveHelpConfig &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;github.com/spf13/cobra.activeHelpEnvVar &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;github.com/spf13/cobra.legacyArgs &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;github.com/spf13/cobra.(*Command).HasSubCommands &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;github.com/spf13/cobra.(*Command).HasParent &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;github.com/spf13/cobra.NoArgs &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;github.com/spf13/cobra.writePreamble &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;... &lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;This was very fortunate because I’ve started using Go at work lately and I knew
that it includes a lot of metadata in its binaries (this can be turned off but
isn’t by default). So I looked around and found the whole structure of the app,
and all the method signatures.&lt;/p&gt;
&lt;pre class="highlight" data-language="plain"&gt;&lt;span class="line"&gt;&lt;span&gt;once-cli/ &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;├── cmd/ &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;│ └── once/ &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;│ └── main.go &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;│ &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;└── internal/ &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    ├── archiver/ &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    │ ├── compressor.go &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    │ └── uncompressor.go &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    │ &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    ├── cmd/ &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    │ ├── auto_update.go &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    │ ├── auto_update_off.go &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    │ ├── auto_update_on.go &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    │ ├── data.go &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    │ ├── data_backup.go &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    │ ├── data_restore.go &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    │ ├── password.go &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    │ ├── password_reset.go &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    │ ├── root.go &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    │ ├── setup.go &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    │ ├── start.go &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    │ ├── status.go &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    │ ├── stop.go &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    │ ├── update.go &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    │ └── util.go &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    │ &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    ├── config/ &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    │ ├── config.go &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    │ ├── environment.go &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    │ └── verifier.go &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    │ &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    ├── docker/ &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    │ └── docker.go &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    │ &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    ├── networking/ &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    │ ├── dns_check.go &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    │ ├── network_check.go &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    │ └── ssl_traffic_check.go &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    │ &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    └── tui/ &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;      ├── backup/ &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;      │ └── backup.go &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;      ├── components/ &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;      │ └── task_list.go &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;      ├── question/ &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;      │ └── question.go &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;      ├── restore/ &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;      │ └── restore.go &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;      ├── runner/ &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;      │ └── runner.go &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;      ├── setup/ &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;      │ ├── installer.go &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;      │ ├── registration.go &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;      │ └── setup.go &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;      └── util.go &lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;And I found three URLs&lt;/p&gt;
&lt;pre class="highlight" data-language="plain"&gt;&lt;span class="line"&gt;&lt;span&gt;https://auth.once.com/install/ &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;https://auth.once.com/verify/%d &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;registry.once.com &lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;The first one was the same as the download URL, the second one obviously
verifies something but I didn’t know what. My guess is for the password reset
functionality (look at &lt;em&gt;once-cli/internal/cmd/password_reset.go&lt;/em&gt;) because it
interpolates a number at the end (&lt;a href="https://pkg.go.dev/fmt"&gt;that&amp;#39;s the &lt;em&gt;%d&lt;/em&gt;&lt;/a&gt;).
The third one looks like a private Docker registry.&lt;/p&gt;
&lt;p&gt;Can I login to the Docker registry?&lt;/p&gt;
&lt;p&gt;After set up, Once creates a config file where it stores how you configured it.&lt;/p&gt;
&lt;pre class="highlight" data-language="json"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;// contents of /root/.config/once/config.json&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;token&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;1111-1111-1111-1111&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;product&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;campfire&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;product_name&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;Campfire&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;email_address&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;stanko@example.com&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;ssl_domain&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;chat.rubyzg.org&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;validation_token&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;PHONY_VALIDATION_KEY&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;secret_key_base&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;PHONY_SECRET_KEY_BASE&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;vapid_private_key&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;PHONY_PRIVATE_KEY&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;vapid_public_key&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;PHONY_PUBLIC_KEY&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;storage_location&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;/var/once/campfire&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;cron_hour&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 2&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;once_binary_etag&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;Notice that it stores the license key and my email. Also notice that it calls
the license key &amp;quot;token&amp;quot;. My guess is that it uses the license key as an auth
token with the registry to pull updates. That&amp;#39;s a common way to do token auth
with Docker Registries. Let&amp;#39;s try that.&lt;/p&gt;
&lt;pre class="highlight" data-language="bash"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;docker&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; login&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;  --username&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;#39;stanko@example.com&amp;#39;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;  --password&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;#39;1111-1111-1111-1111&amp;#39;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;  &amp;#39;https://registry.once.com&amp;#39;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;Login&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; Succeeded&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;Bingo!&lt;/p&gt;
&lt;p&gt;Now, where did it get my email, the product, and product name from? These
weren&amp;#39;t asked during set up. I&amp;#39;m guessing that the install URL is in the binary
because it can respond with &lt;em&gt;HTML / text&lt;/em&gt; for the install script but it can
probably also respond to &lt;em&gt;JSON&lt;/em&gt;. Let&amp;#39;s try that&lt;/p&gt;
&lt;pre class="highlight" data-language="bash"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;curl&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; https://auth.once.com/install/1111-1111-1111-1111.json&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; |&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; jq&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;  &amp;quot;token&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;1111-1111-1111-1111&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;  &amp;quot;product&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;campfire&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;  &amp;quot;product_name&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;Campfire&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;  &amp;quot;email_address&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;stanko@example.com&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;  &amp;quot;ssl_domain&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; null,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;  &amp;quot;validation_token&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;&amp;lt;some-base64-string&amp;gt;&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;Two for two!&lt;/p&gt;
&lt;p&gt;I was curious about the validation token as it was obviously a Base64 string,
but decoding it returned complete gibberish - it doesn&amp;#39;t even look like
unicode - so I assume that it&amp;#39;s either completely random or an encrypted
payload. Maybe that&amp;#39;s what the verify endpoint is for?&lt;/p&gt;
&lt;p&gt;My guess is that the token is used to update the &amp;quot;ssl_domain&amp;quot; field with the
Once auth server after the install completes or during the DNS check. That would
mean that the install endpoint probably also responds to a POST request.&lt;/p&gt;
&lt;p&gt;Anyway, I now had a good understanding of how Once works.&lt;/p&gt;
&lt;h3 id="how-once-works"&gt;How Once works&lt;/h3&gt;
&lt;p&gt;Once consists of three main parts:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;The Auth server&lt;/li&gt;
&lt;li&gt;The private Docker Registry&lt;/li&gt;
&lt;li&gt;The CLI app&lt;/li&gt;
&lt;/ol&gt;
&lt;figure class="attachment attachment--preview attachment--png attachment--expandable" data-full-src="https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/once_overview.png/911w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 911; --attachment-height: 1058; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2014%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRlAAAABXRUJQVlA4IEQAAADQAQCdASoOABAABABoJaScC2B8AAAeQAD%2B8PZccsKfHy%2BjdTP7%2F5k1Am7rna5e%2BJf7HMruIWzCb3DWsHN3e7L67qJAAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #ededed;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/once_overview.png/480w.webp 480w, https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/once_overview.png/800w.webp 800w, https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/once_overview.png/911w.webp 911w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="An overview of Once" loading="lazy" decoding="async" class="attachment__image" width="911" height="1058" src="https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/once_overview.png/911w.png" srcset="https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/once_overview.png/480w.png 480w, https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/once_overview.png/800w.png 800w, https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/once_overview.png/911w.png 911w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;An overview of Once&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;T&lt;em&gt;he Auth server&lt;/em&gt; handles token validation for the registry, distribution of the
CLI binary, license key inspection, and probably &lt;a href="https://shopify.dev/docs/api/webhooks?reference=toml#list-of-topics-orders/create"&gt;the order created webhook from
Shopify&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;The CLI&lt;/em&gt; handles Docker installation and configuration on the client, pulling
and running of product Docker images, product container management,
automatic-updates, backups, restores, and a few other things.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;The private Docker Registry&lt;/em&gt; is, well, just a Docker Registry... It stores and
distributes Docker images.&lt;/p&gt;
&lt;p&gt;The license key you get is used as an access token to the registry. With it the
CLI can pull images from the registry. That token probably grants access to a
single repository in the registry - just for the product you purchased.&lt;/p&gt;
&lt;h3 id="building-twice"&gt;Building Twice&lt;/h3&gt;
&lt;p&gt;Now the fun part, making a clone of Once.&lt;/p&gt;
&lt;p&gt;I decided to call this project &amp;quot;Twice&amp;quot; as a joke on the fact that it&amp;#39;s a clone
of Once - it&amp;#39;s a second implementation. &lt;a href="https://github.com/monorkin/twice"&gt;The source code is available on
GitHub&lt;/a&gt; if you want to explore it, build,
modify, or run Twice for yourself.&lt;/p&gt;
&lt;p&gt;My main motivation is to learn more about how Once is set up, but a nice side
effect is that I&amp;#39;ll get a distribution system out of it - in the case that I
decide to make an app and sell it. With that in mind, my primary goal is to
implement just the setup CLI command. I&amp;#39;ll work on everything else later.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://stanko.io/building-simpl-77CAzym51p5a"&gt;Just like with Simpliki&lt;/a&gt;, I&amp;#39;ll
start from the center and work myself out. In the case of Twice the center is
the CLI app - specifically the setup command.&lt;/p&gt;
&lt;p&gt;As I&amp;#39;ve been working with Go at work lately, I felt confident in using it for
the CLI. This would be a nice exercise to sharpen my Go skills. Otherwise I&amp;#39;d
probably go with Rust as I have more experience with it, or I&amp;#39;d give
&lt;a href="https://github.com/tamatebako/tebako"&gt;Tebako&lt;/a&gt; a try - a new way to package Ruby
apps into a single binary.&lt;/p&gt;
&lt;p&gt;So I started a new Go project, added cobra to it and &lt;a href="https://cobra.dev/#getting-started"&gt;followed the getting
started guide&lt;/a&gt; to create a CLI app with a
&lt;em&gt;setup&lt;/em&gt; command that accepts one argument - &lt;em&gt;the license key&lt;/em&gt;.&lt;/p&gt;
&lt;pre class="highlight" data-language="plain"&gt;&lt;span class="line"&gt;&lt;span&gt;go run cmd/twice/main.go setup --help&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;Install all the necessary dependencies to run a product and then installs the product associated with the given license key&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;Usage:&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;  twice setup &amp;lt;license-key&amp;gt; [flags]&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;Flags:&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;  -h, --help   help for setup&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;At this point I got lost in the weeds. Once has this nice &lt;a href="https://en.wikipedia.org/wiki/Text-based_user_interface"&gt;TUI (terminal user
interface)&lt;/a&gt; that walks
you through the setup process so, naturally, I wanted to make a nice TUI too.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--mp4"&gt; &lt;video class="attachment__image--sized placeholder" style="--attachment-width: 1890; --attachment-height: 1012; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%209%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRigAAABXRUJQVlA4IBwAAABQAQCdASoQAAkABABoJZwABAAAAP7wnkRJeAAA%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #1b1b26;" controls autoplay playsinline loop muted poster="https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/twice_tui_demo.jpg/1200w.jpg" width="1890" height="1012"&gt; &lt;source src="https://media.stanko.io/originals/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/twice_tui_demo.mp4" type="video/mp4"&gt; &lt;/video&gt; &lt;figcaption class="attachment__caption"&gt;Twice TUI&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;I spent a couple of hours working on it, I got quite far, but in the end I
realized the TUI didn&amp;#39;t matter. It was just slowing me down, so I ditched it and
replaced it with good old &lt;em&gt;puts&lt;/em&gt;.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--mp4"&gt; &lt;video class="attachment__image--sized placeholder" style="--attachment-width: 1892; --attachment-height: 1012; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%209%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRiwAAABXRUJQVlA4ICAAAACQAQCdASoQAAkABABoJZQAAud7WpAA%2Fu8cE%2FuuQl4AAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #1c1c27;" controls autoplay playsinline loop muted poster="https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/twice_cli_demo.jpg/1200w.jpg" width="1892" height="1012"&gt; &lt;source src="https://media.stanko.io/originals/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/twice_cli_demo.mp4" type="video/mp4"&gt; &lt;/video&gt; &lt;figcaption class="attachment__caption"&gt;Twice CLI&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;This was waaaaay quicker to implement and allowed me to focus on the actual
logic - like &lt;a href="https://github.com/monorkin/twice/blob/c22d6361becc4b5c66947762e5dc7d09090da576/cli/internal/docker/check.go"&gt;figuring out if docker was installed, is it
running&lt;/a&gt;,
&lt;a href="https://github.com/monorkin/twice/blob/c22d6361becc4b5c66947762e5dc7d09090da576/cli/internal/api/license.go"&gt;writing an API
client&lt;/a&gt;,
&lt;a href="https://github.com/monorkin/twice/blob/c22d6361becc4b5c66947762e5dc7d09090da576/cli/internal/docker/pull.go"&gt;interacting with
Docker&lt;/a&gt;,
etc. - instead of rendering a fancy UI.&lt;/p&gt;
&lt;p&gt;I like the version with the TUI more - it feels refined - and I think I&amp;#39;ll
revisit it once I&amp;#39;m more comfortable with Go and &lt;a href="https://github.com/charmbracelet/bubbletea"&gt;Bubble
tea&lt;/a&gt; (the library that renders the
TUI).&lt;/p&gt;
&lt;p&gt;As I was building the CLI I started the Auth app and mocked a few endpoints just
to get the setup command done. I didn&amp;#39;t implement any UI and relied heavily on
&lt;em&gt;&amp;quot;bin/rails db:fixtures:load&amp;quot;&lt;/em&gt; to give me some data to work with. These
endpoints were plain Rails controllers that rendered JSON.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/original_models.png/1435w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 1435; --attachment-height: 998; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2011%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRkIAAABXRUJQVlA4IDYAAACwAQCdASoQAAsABABoJaQAAl1%2FZQ4AAP7t4ZkK%2B%2BQWpQL6SgJvPcakBbzUu29whjfjWDVIAAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #ededed;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/original_models.png/480w.webp 480w, https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/original_models.png/800w.webp 800w, https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/original_models.png/1200w.webp 1200w, https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/original_models.png/1435w.webp 1435w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="The models I had for Twice" loading="lazy" decoding="async" class="attachment__image" width="1435" height="998" src="https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/original_models.png/1200w.png" srcset="https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/original_models.png/480w.png 480w, https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/original_models.png/800w.png 800w, https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/original_models.png/1200w.png 1200w, https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/original_models.png/1435w.png 1435w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;The models I had for Twice&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;With most of the CLI out of the way, and with some of the Auth app done I turned
my attention to the Docker Registry.&lt;/p&gt;
&lt;p&gt;There are a few Docker Registries out there:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;a href="https://distribution.github.io/distribution/"&gt;CNCF Registry&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/goharbor/harbor"&gt;Harbor&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/SUSE/Portus"&gt;Portus&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;I liked the idea of having a Registry that&amp;#39;s written in Rails so I tried Portus,
but I couldn&amp;#39;t get it to run locally - there are multiple known issues and the
project was archived 2 years ago.&lt;/p&gt;
&lt;p&gt;Harbor is a conglomeration of micro services. When I saw that I had to run 4 or
more containers just to get the thing going I gave up.&lt;/p&gt;
&lt;p&gt;This left me with Registry. It has everything I need - access control via token
auth to a configurable auth server. It has no UI or API, but this is a plus as I
don&amp;#39;t have to think about customers trying to login to the registry UI or
interacting with it other than pulling images. And I can make the system
stateless, thus less error-prone.&lt;/p&gt;
&lt;p&gt;All I had to do to get the registry working is to generate a self-signed
certificate&lt;/p&gt;
&lt;pre class="highlight" data-language="bash"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;openssl&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; req&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; -x509&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; -newkey&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; rsa:4096&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; -nodes&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;    -keyout&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; ./auth.key&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;    -out&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; ./auth.crt&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;    -days&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 365&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;    -subj&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;/CN=registry-auth&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;    -addext&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;basicConstraints=critical,CA:true&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;    -addext&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;keyUsage=keyCertSign,digitalSignature&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;    -addext&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;subjectAltName=DNS:registry-auth&amp;quot;&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;And set a few environment variables that configure it to use token auth, tell it
to use my auth server, and to verify the auth tokens using the certificate I
just generated&lt;/p&gt;
&lt;pre class="highlight" data-language="bash"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# Use token auth&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;REGISTRY_AUTH&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;token&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# Use `http://localhost:3000/registry/auth` as the auth server&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;REGISTRY_AUTH_TOKEN_REALM&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;http://localhost:3000/registry/auth&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# This isn&amp;#39;t too important&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;REGISTRY_AUTH_TOKEN_SERVICE&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;registry&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# This has to match what you return from the auth server and what&amp;#39;s in the certificate&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;REGISTRY_AUTH_TOKEN_ISSUER&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;registry-auth&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# This specifies which certificate to use to verify tokens&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;REGISTRY_AUTH_TOKEN_ROOTCERTBUNDLE&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;/certs/auth.pem&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;That&amp;#39;s it. Here is a Docker compose file for it&lt;/p&gt;
&lt;pre class="highlight" data-language="yaml"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;services&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  registry&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    image&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; registry:2&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;    # On Linux&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    network_mode&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; host&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;    # On Mac&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;    # ports:&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;    #   - 5000:5000&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    environment&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      - &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;REGISTRY_AUTH=token&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      - &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;REGISTRY_AUTH_TOKEN_REALM=http://localhost:3000/registry/auth&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      - &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;REGISTRY_AUTH_TOKEN_SERVICE=registry&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      - &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;REGISTRY_AUTH_TOKEN_ISSUER=registry-auth&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      - &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;REGISTRY_AUTH_TOKEN_ROOTCERTBUNDLE=/certs/auth.pem&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      - &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;REGISTRY_STORAGE_DELETE_ENABLED=true&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    volumes&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      - &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;./registry/tmp/registry_data:/var/lib/registry&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      - &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;./registry/certs:/certs:ro&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;Now the only thing left was to finish the Auth server.&lt;/p&gt;
&lt;p&gt;First, I tackled registry token auth. This requires a single endpoint that
responds to both POST and GET requests&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;match &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;/registry/auth&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; to:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;registry#auth&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; as:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :registry_auth&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; via:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; %i[ get post ]&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;The endpoint receives the users email and token via HTTP basic auth and responds
with a JSON payload that looks like this&lt;/p&gt;
&lt;pre class="highlight" data-language="json"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;token&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;signed.jwt.token&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;expires_in&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 300&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;issued_at&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;2025-05-15T16:55:23Z&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;The token from the response is a JWT that contains a list of access
permissions - these define which actions the user can perform.&lt;/p&gt;
&lt;pre class="highlight" data-language="json"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;iss&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;registry-auth&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt; // Has to match the value set in REGISTRY_AUTH_TOKEN_ISSUER&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;sub&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;stankoexample.com&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt; // Username of the person logging in&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;aud&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;registry&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt; // Has to match REGISTRY_AUTH_TOKEN_SERVICE&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;exp&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 1747329069&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;nbf&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 1747328784&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;iat&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 1747328784&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;jti&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;2aae6c35c94fcfb415dbe95f408b9ce91ee846ed&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;access&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; [&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	{&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;	  &amp;quot;type&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;repository&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;	  &amp;quot;name&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;campfire&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;	  &amp;quot;actions&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; [&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;pull&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	}&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  ]&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;The tricky part was to sign the token with the certificate such that the
registry would accept it as valid. This took me some time to figure out. To get
it to work I had to set two additional JWT &lt;a href="https://curity.io/resources/learn/self-contained-jwts/"&gt;headers called KID and
X5C&lt;/a&gt;, which tell the
registry exactly which certificate it should validate the token with. Both
headers are derived from the signing certificate itself.&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;KID&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; OpenSSL&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Digest&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;  .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;new&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;SHA256&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;  .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;update&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;PRIVATE_KEY&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;public_key&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;to_der&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;  .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;hexdigest&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;  .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;upcase&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;  .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;scan&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;/.{1,2}/&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;  .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;join&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;:&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;X5C&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Base64&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;strict_encode64&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;PUBLIC_KEY&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;to_der&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;Putting it all together, the controller looks like this&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; auth&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  authenticate_or_request_with_http_basic &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;do&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; |&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;email&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; license_key&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;|&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    user &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; User&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;find_by&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;email_address:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; email)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    deny_access &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;and&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; return&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; if&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; user&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;blank?&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    license &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; user&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;licenses&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;find_by_key&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(license_key)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    token &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; user&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;generate_registry_access_token_to_product&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(license&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;product&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; service:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; params[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:service&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;])&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    deny_access &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;and&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; return&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; if&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; token&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;blank?&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    payload &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;      token:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; token&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;to_s&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;      expires_in:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; token&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;duration&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;to_i&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;      issued_at:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; token&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;issued_at&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;iso8601&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;    render(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;json:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; payload&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; status:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :ok&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;Notice that &lt;a href="https://github.com/monorkin/twice/blob/c22d6361becc4b5c66947762e5dc7d09090da576/auth/app/models/docker_registry/token.rb"&gt;the token is an
object&lt;/a&gt;.
This provides a nice domain language
(&lt;a href="https://en.wikipedia.org/wiki/Domain-driven_design"&gt;DDD&lt;/a&gt; domain language) that
makes it easier to work with, and reason about, the token. If I want the
duration or the issued at time I can ask the token for it; if I want to turn it
into a string I can do that too.&lt;/p&gt;
&lt;p&gt;At this point I noticed that I didn&amp;#39;t implement a way to push images to the
registry. To fix that I introduced a new type of user - the Developer.
&lt;a href="https://guides.rubyonrails.org/association_basics.html#single-table-inheritance-sti"&gt;STI&lt;/a&gt;
made the most sense for this, so I converted the customers table to a users
table, added a type column and a few extra columns.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/final_models.png/1422w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 1422; --attachment-height: 1120; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2013%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRkYAAABXRUJQVlA4IDoAAADQAQCdASoQAA0ABABoJZwAAu1%2FHLJgAAD%2B8BoUa9UtjFZnhBk7g9b17%2BZCAgBOrIjdXBp8dIPDOgAA%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #ececec;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/final_models.png/480w.webp 480w, https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/final_models.png/800w.webp 800w, https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/final_models.png/1200w.webp 1200w, https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/final_models.png/1422w.webp 1422w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="The models in Twice after adding developers" loading="lazy" decoding="async" class="attachment__image" width="1422" height="1120" src="https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/final_models.png/1200w.png" srcset="https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/final_models.png/480w.png 480w, https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/final_models.png/800w.png 800w, https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/final_models.png/1200w.png 1200w, https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/final_models.png/1422w.png 1422w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;The models in Twice after adding developers&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;Unlike Customers, Developers login with a password tied to their account and
they get full access to the registry.&lt;/p&gt;
&lt;p&gt;Second, the install endpoint. This was straight forward&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# config.routes.rb&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;get &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;/install/:license_key&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; to:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;install#install&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; as:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :install&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; defaults:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; { &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;format:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :text&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# app/controllers/install_controller.rb&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; install&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  respond_to &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;do&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; |&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;format&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;|&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;    format&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;text&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;    format&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;json&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;And then I just had to create two separate view files. One for the script in
&lt;em&gt;app/views/install/install.text.erb&lt;/em&gt;&lt;/p&gt;
&lt;pre class="highlight" data-language="bash"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;#!/usr/bin/env bash&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;set&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; -e&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;echo&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;Installing the TWICE command...&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;echo&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;sudo&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; true&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;command_path&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;&amp;lt;%= install_download_url %&amp;gt;?arch=$(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;uname&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; -m&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;)&amp;amp;platform=$(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;uname&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;)&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;curl&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; -s&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;$command_path&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; -o&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; .twice_tmp&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;sudo&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; mv&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; .twice_tmp&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; /usr/local/bin/twice&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;sudo&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; chmod&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; +x&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; /usr/local/bin/twice&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;if&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; [[ &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;$OSTYPE&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; ==&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;darwin&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;*&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; ]]; &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;then&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;  # Don&amp;#39;t use sudo on macOS; Docker Desktop is likely to be running as the user&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;  /usr/local/bin/twice&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; setup&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;%=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; @license.key&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; %&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;else&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;  sudo&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; /usr/local/bin/twice&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; setup&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;%=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; @license.key&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; %&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;fi&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;And another in &lt;em&gt;app/views/install/install.json.jbuilder&lt;/em&gt;&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;json&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;key&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; @license&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;key&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;json&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;owner&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  json&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(@license&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;owner&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :id&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :email_address&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;json&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;product&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  json&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(@license&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;product&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :id&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :name&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :repository&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  json&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;registry&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; ENV&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;fetch&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;REGISTRY_URL&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;localhost:5000&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;Notice that I strayed a bit from 37signals&amp;#39; implementation when it comes to
downloading the binary. I decided to separate that out into another controller
action because the install endpoint already does too many things in my opinion,
and adding file sending to that seems like way too much responsibility for one
endpoint.&lt;/p&gt;
&lt;p&gt;I also made the decision that the auth server would directly send the file to
the client instead of e.g. redirecting to something like S3. This makes it
easier to self-host twice, and due to a trick it’s no less performant.&lt;/p&gt;
&lt;p&gt;Here is the download action&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; download&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  platform &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; sanitize_file_name_part(params[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:platform&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;])&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  arch &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; sanitize_file_name_part(params[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:arch&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;])&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  filename &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; [&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;twice&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; platform&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; arch]&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;compact&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;join&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;-&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  file_path &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Pathname&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;new&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Rails&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;root&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;join&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;storage&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; filename))&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  if&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; file_path&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;exist?&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    send_file file_path&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; type:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;application/octet-stream&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; filename:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; filename&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  else&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    head &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:not_found&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;It assumes that the requested binary will be called
&lt;em&gt;&amp;quot;twice-#{platform}-#{arch}&amp;quot;&lt;/em&gt;, then it goes and checks if such a file exists
in the &lt;em&gt;storage&lt;/em&gt; directory, and if it does it sends it.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/twice_cli_download_demo.png/888w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 888; --attachment-height: 378; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%207%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRjIAAABXRUJQVlA4ICYAAADQAQCdASoQAAcABABoJZwAAveFlU7YAAD%2B7pJrntx6%2FRVwWSCgAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #2c2c2d;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/twice_cli_download_demo.png/480w.webp 480w, https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/twice_cli_download_demo.png/800w.webp 800w, https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/twice_cli_download_demo.png/888w.webp 888w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="The download endpoint in action" loading="lazy" decoding="async" class="attachment__image" width="888" height="378" src="https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/twice_cli_download_demo.png/888w.png" srcset="https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/twice_cli_download_demo.png/480w.png 480w, https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/twice_cli_download_demo.png/800w.png 800w, https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/twice_cli_download_demo.png/888w.png 888w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;The download endpoint in action&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;In production this won&amp;#39;t actually stream the file from Ruby, &lt;a href="https://api.rubyonrails.org/classes/ActionController/DataStreaming.html#method-i-send_file"&gt;instead it sets a
special
header&lt;/a&gt;
that tells &lt;a href="https://github.com/basecamp/thruster"&gt;Thruster&lt;/a&gt; to send the file
which makes things way more efficient.&lt;/p&gt;
&lt;p&gt;Third, I added authentication using the new auth generator in Rails 8, and a UI
for managing customers, developers, products and licenses.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--mp4"&gt; &lt;video class="attachment__image--sized placeholder" style="--attachment-width: 1194; --attachment-height: 984; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2013%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRjoAAABXRUJQVlA4IC4AAACwAQCdASoQAA0ABABoJaQAAudFy7wAAP7VF9v59pwAQVIEnAUBTmFSV1bfNAAA%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #eaeaea;" controls autoplay playsinline loop muted poster="https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/auth_ui_demo.jpg/1194w.jpg" width="1194" height="984"&gt; &lt;source src="https://media.stanko.io/originals/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/auth_ui_demo.mp4" type="video/mp4"&gt; &lt;/video&gt; &lt;figcaption class="attachment__caption"&gt;Demo of the Auth app UI&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;And that was it. Twice was done. Here is the demo&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--mp4"&gt; &lt;video class="attachment__image--sized placeholder" style="--attachment-width: 1892; --attachment-height: 996; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%208%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRiwAAABXRUJQVlA4ICAAAACQAQCdASoQAAgABABoJZwAAuTqYAAA%2Fux0u09PIl4AAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #1f1f2a;" controls autoplay playsinline loop muted poster="https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/twice_demo.jpg/1200w.jpg" width="1892" height="996"&gt; &lt;source src="https://media.stanko.io/originals/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/twice_demo.mp4" type="video/mp4"&gt; &lt;/video&gt; &lt;figcaption class="attachment__caption"&gt;Demo of Twice installing an app&lt;/figcaption&gt; &lt;/figure&gt;
&lt;h3 id="some-loose-thoughts"&gt;Some loose thoughts&lt;/h3&gt;
&lt;p&gt;When writing about building Simpliki I left out an important part - what I
intentionally didn&amp;#39;t do.&lt;/p&gt;
&lt;p&gt;For Twice, I intentionally didn&amp;#39;t implement domain name tracking as I think that
it&amp;#39;s not important. It can&amp;#39;t prevent people from running the docker container
how and where they want. It would add complexity and I don&amp;#39;t see the benefit of
it, so I ditched it.&lt;/p&gt;
&lt;p&gt;I do see the utility in all the backup and management commands that the Once CLI
has, which that I didn&amp;#39;t implement in Twice. I intend to return to that at some
point. But they aren&amp;#39;t important for distributing apps so I skipped them.&lt;/p&gt;
&lt;p&gt;Twice, currently, has the same problem as Once - it doesn&amp;#39;t play well with other
apps or &lt;a href="https://kamal-deploy.org/"&gt;Kamal&lt;/a&gt;. This isn&amp;#39;t a problem if you rent a
VPS specifically to run a Once app, but if you have a large bare metal machine
like me then this is cumbersome. I&amp;#39;d like to change it so that it deploys
&lt;a href="https://github.com/basecamp/kamal-proxy"&gt;kamal-proxy&lt;/a&gt; and then proxies requests
to different apps through it. This would allow it to run multiple apps on the
same server.&lt;/p&gt;
&lt;p&gt;That&amp;#39;s also why the Twice CLI doesn&amp;#39;t keep a config file - I&amp;#39;d like to make it
so that you can run multiple apps, so the config has to support that. This was
extra work that I think isn&amp;#39;t important for now. And I&amp;#39;d like to be able to
install an app from different Twice instances on the same server. Something like&lt;/p&gt;
&lt;pre class="highlight" data-language="bash"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;twice&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; setup&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; 1111-1111-1111-1111@auth.foo.com&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;twice&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; setup&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; 2222-2222-2222-2222@auth.bar.com&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;This would make it more versatile - but, again, that&amp;#39;s way beyond the scope of
creating a clone to learn how Once works.&lt;/p&gt;
&lt;p&gt;I liked working with Go. After years of using Rust, having a garbage collector
and not having to think about results, unwrapping, lifetimes or borrowing was a
breath of fresh air. Though, the way it declares modules still doesn&amp;#39;t sit right
with me. If you didn&amp;#39;t look at the source code, each directory is its own
module, and all files in the directory get essentially concatenated together
into that module at compile time. It works, and I can&amp;#39;t point out any problems
with that, but it feels weird.&lt;/p&gt;
&lt;p&gt;The folks at 37signals made a really elegant distribution system for their apps.
I had a lot of fun figuring out how it works and I learned a lot along the way.&lt;/p&gt;&lt;/div&gt;
</content>
    <summary type="html">When Campfire - the first Once product - came out, a few of us at Ruby Zagreb pitched in to buy a copy.

Prometheus had brought fire from Mount Olympus, and we came to see the blaze. We were finally seeing how the people at 37signals - the birthplace of Rails - build Rails apps. And as a bonus,...</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Article/43</id>
    <published>2025-05-06T09:00:00Z</published>
    <updated>2026-03-09T09:32:25Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/adding-mcp-to-a-rails-app-NG9zkX3dyPq1"/>
    <title>Adding MCP to a Rails app</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;p&gt;Last week we had an AI hackathon at work during which we added an MCP server to
our Rails app. This was much easier than I expected, the result was both
impressive and scary, and I learned a few things about MCP that I didn&amp;#39;t expect.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--mp4"&gt; &lt;video class="attachment__image--sized placeholder" style="--attachment-width: 638; --attachment-height: 664; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2015%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRioAAABXRUJQVlA4IB4AAABQAQCdASoPABAABABoJZwABAAAAP7vLnwwL1HAAAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #303236;" controls autoplay playsinline loop muted poster="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/screencast-from-2025-05-06-10-58-48-1-web.jpg/638w.jpg" width="638" height="664"&gt; &lt;source src="https://media.stanko.io/originals/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/screencast-from-2025-05-06-10-58-48-1-web.mp4" type="video/mp4"&gt; &lt;/video&gt; &lt;figcaption class="attachment__caption"&gt;Demo of Claude using our app via MCP to solve a support request&lt;/figcaption&gt; &lt;/figure&gt;
&lt;h3 id="goal"&gt;Goal&lt;/h3&gt;
&lt;p&gt;My team wanted to help our support team by automating some laborious tasks, like
cross referencing data, by offloading the bulk of the work to an AI agent. This
would make support’s work easier, responses quicker, and in some cases it would
free up my team too - an all around win.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;To allow the AI to interact with our app - like a person would - we decided to
add MCP to it.&lt;/em&gt;&lt;/p&gt;
&lt;h3 id="what-is-mcp"&gt;What is MCP?&lt;/h3&gt;
&lt;p&gt;MCP, or Model Context Protocol, is a protocol through which LLMs can interact
with different tools and resources to accomplish tasks.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://modelcontextprotocol.io/introduction"&gt;The protocol specifics are fairly dry so I&amp;#39;ll gloss over
them&lt;/a&gt;. What you need to know is
that in MCP there exists a server and a client.&lt;/p&gt;
&lt;p&gt;A server provides tools, prompts, and resources (I&amp;#39;ll focus only on tools).
While a client connects to one, or multiple, servers and exposes their tools to
an LLM. The client then makes API requests to the Servers to use tools when the
LLM tells it to.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/quick-sheets-page-33.png/1305w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 1305; --attachment-height: 1220; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2015%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRkYAAABXRUJQVlA4IDoAAADQAQCdASoQAA8ABABoJaQAAuQry6pygAD%2B9hKksUnbgDBhyNHS%2BWto5Zgo7cRV1d9jRBw%2BOe8HgmAA%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #f1f1f1;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/quick-sheets-page-33.png/480w.webp 480w, https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/quick-sheets-page-33.png/800w.webp 800w, https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/quick-sheets-page-33.png/1200w.webp 1200w, https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/quick-sheets-page-33.png/1305w.webp 1305w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Overview of how the MCP client and server interact" loading="lazy" decoding="async" class="attachment__image" width="1305" height="1220" src="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/quick-sheets-page-33.png/1200w.png" srcset="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/quick-sheets-page-33.png/480w.png 480w, https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/quick-sheets-page-33.png/800w.png 800w, https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/quick-sheets-page-33.png/1200w.png 1200w, https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/quick-sheets-page-33.png/1305w.png 1305w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Overview of how the MCP client and server interact&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;&lt;em&gt;In essence - the Client uses the LLM as a brain to plan out how to solve a
problem and the Servers as tools to implement the solution.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;A tool can be anything from a query, like searching for a user, to a mutation,
like creating a coupon code - anything the server is allowed to do on behalf of
the LLM.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;E.g. You could ask a client to &lt;em&gt;&amp;quot;Find a file called meeting_notes.txt&amp;quot;&lt;/em&gt;, it
would then use the LLM to interpret what that means and plan out how it would do
that with the tools it has available. It gets a list of tools from the MCP
servers it&amp;#39;s connected to.&lt;/p&gt;
&lt;p&gt;The LLM might respond with something like &lt;em&gt;&amp;quot;{action: &amp;#39;tool_call&amp;#39;, tool:
&amp;#39;find_file&amp;#39;, arguments: [&amp;#39;meeting_notes.txt&amp;#39;]}&amp;quot;&lt;/em&gt; and the client would go and
make a request to an MCP server that exposes a tool called find_file.&lt;/p&gt;
&lt;p&gt;The server then responds with something like &lt;em&gt;&amp;quot;{path:
&amp;#39;/home/user/meeting_notes.txt&amp;#39;, contents: &amp;#39;...&amp;#39;}&amp;quot;&lt;/em&gt; and the client passes that
on to the LLM which then decides what to do with that information.&lt;/p&gt;
&lt;h3 id="mcp-and-rails"&gt;MCP and Rails&lt;/h3&gt;
&lt;p&gt;I used the &lt;a href="https://github.com/yjacquin/fast-mcp"&gt;fast-mcp gem&lt;/a&gt; to add MCP
support to our app.&lt;/p&gt;
&lt;pre class="highlight" data-language="shell"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;bundle&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; add&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; fast-mcp&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;bin/rails&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; generate&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; fast_mcp:install&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;This gave me two new directories - &lt;em&gt;app/tools&lt;/em&gt; and &lt;em&gt;app/resources&lt;/em&gt; - and a class
name conflict.&lt;/p&gt;
&lt;p&gt;The resources directory contains an &lt;em&gt;ApplicationResource&lt;/em&gt; class, but our app
already has a different &lt;em&gt;ApplicationResource&lt;/em&gt; class in &lt;em&gt;app/models&lt;/em&gt; that we use
to represent resources from 3rd party REST APIs.&lt;/p&gt;
&lt;p&gt;To solve the conflict I decided to namespace everything MCP-related under an
&lt;em&gt;MCP&lt;/em&gt; namespace.&lt;/p&gt;
&lt;p&gt;First I moved the two new directories into an &lt;em&gt;mcp&lt;/em&gt; directory&lt;/p&gt;
&lt;pre class="highlight" data-language="shell"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;mkdir&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; app/mcp&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;mv&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; app/tools&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; app/mcp/tools&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;mv&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; app/resources&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; app/mcp/resources&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;But now the classes were namespaced with &lt;em&gt;Tools&lt;/em&gt; and &lt;em&gt;Resources&lt;/em&gt; instead of with
&lt;em&gt;MCP&lt;/em&gt; because Zeitwerk treats each directory in app/ as a root directory. To
reconfigure Zeitwerk I added the following to &lt;em&gt;config/initializers/zeitwerk.rb&lt;/em&gt;&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# config/initializers/zeitwerk.rb&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;module&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; MCP&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Rails&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;autoloaders&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;main&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;tap&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; |&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;autoloader&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;|&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;  Dir&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;glob&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Rails&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;root&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;join&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;app/mcp/*&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;))&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;each&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; |&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;file&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;|&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    next&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; unless&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; File&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;directory?&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(file)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    autoloader&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;push_dir&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(file&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; namespace:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; MCP&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;That tells Zeitwerk to treat every directory in &lt;em&gt;app/mcp&lt;/em&gt; as a root directory
with an &lt;em&gt;MCP&lt;/em&gt; namespace.&lt;/p&gt;
&lt;p&gt;Now &lt;em&gt;app/mcp/resources/application_resource.rb&lt;/em&gt; defines
&lt;em&gt;MCP::ApplicationResource&lt;/em&gt; instead of &lt;em&gt;Resources::ApplicationResource&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;With that out of the way I could finally run my server&lt;/p&gt;
&lt;pre class="highlight" data-language="shell"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;bin/rails&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; s&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;h3 id="the-client"&gt;The Client&lt;/h3&gt;
&lt;p&gt;Now that the server was up and running I wanted to see if I could connect to it.
The easiest way to do that is through cURL&lt;/p&gt;
&lt;pre class="highlight" data-language="shell"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;curl&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; -v&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; http://localhost:3000/mcp/sse&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;*&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; Host localhost:3000 was resolved.&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;*&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; IPv6: ::1&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;*&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; IPv4: 127.0.0.1&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;*&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;   Trying [::1]:3000...&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;*&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; Connected to localhost (&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;:1&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;) port 3000&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;*&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; using HTTP/1.x&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;gt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; GET /mcp/sse HTTP/1.1&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;gt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; Host: localhost:3000&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;gt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; User-Agent: curl/8.13.0&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;gt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; Accept: &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;*&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;*&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;*&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; Request completely sent off&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; HTTP/1.1 200 OK&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; Content-Type: text/event-stream&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; Cache-Control: no-cache, no-store, must-revalidate&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; Connection: keep-alive&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; X-Accel-Buffering: no&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; Access-Control-Allow-Origin: &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;*&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; Access-Control-Allow-Methods: GET, OPTIONS&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; Access-Control-Allow-Headers: Content-Type&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; Access-Control-Max-Age: 86400&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; Keep-Alive: timeout&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;600&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; Pragma: no-cache&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; Expires: 0&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;*&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; no chunk, no close, no size. Assume close to signal &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;lt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; SSE&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; connection&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; established&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;event:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; endpoint&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;data:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; /mcp/messages?&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;retry:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 100&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; keep-alive&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 1&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; keep-alive&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 2&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; keep-alive&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 3&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; keep-alive&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 4&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; keep-alive&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 5&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;event:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; message&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;data:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; {&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;jsonrpc&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;2.0&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;method&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;ping&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;id&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;:715239&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;It seems to be working.&lt;/p&gt;
&lt;p&gt;But to actually use an LLM with the server I had to find a proper MCP client.
This was way more difficult than I thought it would be, mostly because I&amp;#39;m on
Linux.&lt;/p&gt;
&lt;p&gt;By far the most well-known MCP client is &lt;a href="https://claude.ai/download"&gt;Claude
Desktop&lt;/a&gt;, but it isn&amp;#39;t available on Linux even
though it&amp;#39;s an Electron app... I tested a few open-source alternatives but in
the end a coworker told me that &lt;a href="https://zed.dev/"&gt;Zed (the text editor)&lt;/a&gt; has an
MCP client built-in and it&amp;#39;s available on Linux.&lt;/p&gt;
&lt;pre class="highlight" data-language="shell"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;paru&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; -Sy&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; zed-preview-bin&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;zeditor&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; .&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;&lt;em&gt;(To get MCP support you have to join the&lt;/em&gt; &lt;a href="https://zed.dev/ai/agent"&gt;&lt;em&gt;Agentic Editing
Beta&lt;/em&gt;&lt;/a&gt;&lt;em&gt;)&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;To configure Zed I had to give it a shell command with which to start the
server. This is a bit odd as I want Zed to connect to an already running
server - my app - but MCP, at the moment, doesn&amp;#39;t support connecting to remote
servers. This is a planned feature that&amp;#39;s supposed to release by the end of the
year - but I need it now.&lt;/p&gt;
&lt;p&gt;Luckily, someone had the same problem as I and created
&lt;a href="https://github.com/geelen/mcp-remote"&gt;mcp-remote&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;With that I configure Zed by giving it the following command&lt;/p&gt;
&lt;pre class="highlight" data-language="shell"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;npx&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; mcp-remote@0.0.22&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; http://localhost:3000/mcp/sse&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; --allow-http&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; --skip-browser-auth&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;Fast MCP generates a sample tool that, given a User ID and a prefix, returns a
greeting for that user. With that in mind I asked&lt;/p&gt;
&lt;pre class="highlight" data-language="javascript"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;Can you greet a User &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;with&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; ID&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 636166899&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;?&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; &lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;To my surprise it didn&amp;#39;t respond with a greeting but started digging through the
codebase&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505124833.png/635w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 635; --attachment-height: 348; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%209%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRjQAAABXRUJQVlA4ICgAAABQAQCdASoQAAkABABoJZwABAAAAP7usVuY855tuU8Up%2BjujEbgIAAA%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #373944;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505124833.png/480w.webp 480w, https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505124833.png/635w.webp 635w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Claude digging through the code base to figure out how to greet a user" loading="lazy" decoding="async" class="attachment__image" width="635" height="348" src="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505124833.png/635w.png" srcset="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505124833.png/480w.png 480w, https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505124833.png/635w.png 635w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Claude digging through the code base to figure out how to greet a user&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;After some digging around I soon discovered that, while the server was running,
it wasn&amp;#39;t advertising any tools.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505125117.png/622w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 622; --attachment-height: 148; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%204%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRjIAAABXRUJQVlA4ICYAAADQAQCdASoQAAQABABoJZwC7AEO%2BnboAAD%2B4tZy45PhKcEv8G4AAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #393e4a;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505125117.png/480w.webp 480w, https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505125117.png/622w.webp 622w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="My MCP server advertising 0 tools to the client" loading="lazy" decoding="async" class="attachment__image" width="622" height="148" src="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505125117.png/622w.png" srcset="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505125117.png/480w.png 480w, https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505125117.png/622w.png 622w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;My MCP server advertising 0 tools to the client&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;The problem was that the initializer for Fast MCP uses the
&lt;a href="https://api.rubyonrails.org/classes/Class.html#method-i-descendants"&gt;descendants&lt;/a&gt;
method to load all tools that inherit from ApplicationTool.&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;server&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;register_tools&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;*&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;MCP&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;ApplicationTool&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;descendants&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;server&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;register_resources&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;*&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;MCP&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;ApplicationResource&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;descendants&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;That method returns all classes that inherit from a given class, but a caveat of
that method is that it returns only descendants that have been loaded so far.&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Foo&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;descendants&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# =&amp;gt; []&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; Bar&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; Foo&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Foo&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;descendants&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# =&amp;gt; [Bar]&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;Since, in development, Rails disabled eager loading no descendants of
&lt;em&gt;ApplicationTool&lt;/em&gt; are loaded when the initializer runs, and therefore no tools
get registered with the server.&lt;/p&gt;
&lt;p&gt;There are 2 easy ways around this - either enable eager loading in development
(at least for this), or manually register each individual tool.&lt;/p&gt;
&lt;p&gt;I opted to go with eager loading as we already had it configured to trigger when
an ENV variable is set, we use that in some tests.&lt;/p&gt;
&lt;p&gt;This has the downside of requiring a server restart whenever you want to test
something, but as you also have to restart your MCP client so that it pulls in
the new server changes this wasn&amp;#39;t a big deal for me.&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# config/environment/development.rb&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Rails&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;application&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;configure&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;  # Eager load all classes if the EAGER_LOAD ENV is set&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  config&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;eager_load&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; !!&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;ENV&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;#39;EAGER_LOAD&amp;#39;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;  # ...&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;So all I had to do is restart the server with&lt;/p&gt;
&lt;pre class="highlight" data-language="shell"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;EAGER_LOAD&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;1&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; bin/rails&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; s&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;I entered the same prompt again, and... it didn&amp;#39;t work. It started searching
through the codebase again.&lt;/p&gt;
&lt;p&gt;After some more digging I concluded that, to test my MCP server I&amp;#39;d have to
create a new Profile&lt;/p&gt;
&lt;div class="attachment-gallery attachment-gallery--3"&gt; &lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505130656.png/636w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 636; --attachment-height: 334; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%208%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRjAAAABXRUJQVlA4ICQAAABQAQCdASoQAAgABABoJZwABAAAAP7vVjRHi4DZksorV4eQAAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #30313c;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505130656.png/480w.webp 480w, https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505130656.png/636w.webp 636w" sizes="(width &gt;= 768px) 520px, 50vw"&gt; &lt;img alt="Location of the Profile tab in Zed" loading="lazy" decoding="async" class="attachment__image" width="636" height="334" src="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505130656.png/636w.png" srcset="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505130656.png/480w.png 480w, https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505130656.png/636w.png 636w" sizes="(width &gt;= 768px) 520px, 50vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Location of the Profile tab in Zed&lt;/figcaption&gt; &lt;/figure&gt;&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505130722.png/745w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 745; --attachment-height: 339; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%207%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRjAAAABXRUJQVlA4ICQAAACQAQCdASoQAAcABABoJZwAAuP2ZsAA%2FuSqQE4chaVtYeQAAAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #353540;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505130722.png/480w.webp 480w, https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505130722.png/745w.webp 745w" sizes="(width &gt;= 768px) 520px, 50vw"&gt; &lt;img alt="The Configure Profile section of the Profiles tab in Zed" loading="lazy" decoding="async" class="attachment__image" width="745" height="339" src="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505130722.png/745w.png" srcset="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505130722.png/480w.png 480w, https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505130722.png/745w.png 745w" sizes="(width &gt;= 768px) 520px, 50vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;The Configure Profile section of the Profiles tab in Zed&lt;/figcaption&gt; &lt;/figure&gt;&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505130837.png/643w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 643; --attachment-height: 251; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%206%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRiwAAABXRUJQVlA4ICAAAABwAQCdASoQAAYABABoJZwC7AFAAAD%2B7hNzxEuugQAAAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #353a45;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505130837.png/480w.webp 480w, https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505130837.png/643w.webp 643w" sizes="(width &gt;= 768px) 520px, 50vw"&gt; &lt;img alt="The new Profile button in Zed" loading="lazy" decoding="async" class="attachment__image" width="643" height="251" src="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505130837.png/643w.png" srcset="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505130837.png/480w.png 480w, https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505130837.png/643w.png 643w" sizes="(width &gt;= 768px) 520px, 50vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;The new Profile button in Zed&lt;/figcaption&gt; &lt;/figure&gt; &lt;/div&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505130903.png/596w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 596; --attachment-height: 141; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%204%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRioAAABXRUJQVlA4IB4AAABQAQCdASoQAAQABABoJZwABAAAAP7uE24ApNtQgAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #373843;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505130903.png/480w.webp 480w, https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505130903.png/596w.webp 596w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Naming a new profile in Zed" loading="lazy" decoding="async" class="attachment__image" width="596" height="141" src="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505130903.png/596w.png" srcset="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505130903.png/480w.png 480w, https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505130903.png/596w.png 596w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Naming a new profile in Zed&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;And then configure that Profile&amp;#39;s tools to include only my MCP server&amp;#39;s tools.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505130943.png/623w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 623; --attachment-height: 173; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%204%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRioAAABXRUJQVlA4IB4AAABwAQCdASoQAAQABABoJZQC7AFAAAD%2B7dIBwdTAAAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #3a3e4b;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505130943.png/480w.webp 480w, https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505130943.png/623w.webp 623w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Configuring tools for the new profile" loading="lazy" decoding="async" class="attachment__image" width="623" height="173" src="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505130943.png/623w.png" srcset="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505130943.png/480w.png 480w, https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505130943.png/623w.png 623w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Configuring tools for the new profile&lt;/figcaption&gt; &lt;/figure&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505131014.png/588w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 588; --attachment-height: 431; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2012%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRiwAAABXRUJQVlA4ICAAAABQAQCdASoQAAwABABoJZwABAAAAP7uUCFK%2FGI3AQAAAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #363944;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505131014.png/480w.webp 480w, https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505131014.png/588w.webp 588w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Enabling individual tools from different MCP servers" loading="lazy" decoding="async" class="attachment__image" width="588" height="431" src="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505131014.png/588w.png" srcset="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505131014.png/480w.png 480w, https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505131014.png/588w.png 588w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Enabling individual tools from different MCP servers&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;With that configured I tried the prompt again and this time it worked!&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505131112.png/636w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 636; --attachment-height: 372; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%209%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRiwAAABXRUJQVlA4ICAAAABQAQCdASoQAAkABABoJZwABAAAAP7vLmupwUvysPIAAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #31323d;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505131112.png/480w.webp 480w, https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505131112.png/636w.webp 636w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Claude sending a greeting to a User through the MCP server" loading="lazy" decoding="async" class="attachment__image" width="636" height="372" src="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505131112.png/636w.png" srcset="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505131112.png/480w.png 480w, https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505131112.png/636w.png 636w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Claude sending a greeting to a User through the MCP server&lt;/figcaption&gt; &lt;/figure&gt;
&lt;h3 id="building-a-tool"&gt;Building a tool&lt;/h3&gt;
&lt;p&gt;From here the team and I spent the day building various tools that would allow
us to automate common support requests.&lt;/p&gt;
&lt;p&gt;I implemented a simple building search tool&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;module&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; MCP&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; SearchBuildingsTool&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ApplicationTool&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    tool_name &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;search_buildings&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    description &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;Searches for buildings&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    arguments &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;      optional(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:building_ids&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;)&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;array&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:integer&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;description&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;IDs of the Buildings to look up&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;      optional(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:query&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;)&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;maybe&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:string&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;description&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;The search query&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; call&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(building_ids: &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;nil&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; query: &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;nil&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      buildings &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Building&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;all&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      if&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; building_ids&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;present?&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;        buildings &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; buildings&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;where&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;id:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; building_ids)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      if&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; query&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;present?&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;        buildings &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; buildings&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;where&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;name LIKE :query OR handle LIKE :query&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; query:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;%&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;#{query}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;%&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      buildings&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;	    .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;limit&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;50&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;	    .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;order&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;name:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :desc&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;	    .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;to_json&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;only:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; [&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:id&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :name&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :handle&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :address&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;])&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;that worked quite well&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505134334.png/633w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 633; --attachment-height: 311; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%208%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRjAAAABXRUJQVlA4ICQAAACwAQCdASoQAAgABABoJZwAAudV78eAAP7kBNaKV3DT4jcBAAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #383944;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505134334.png/480w.webp 480w, https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505134334.png/633w.webp 633w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Demo of the Building search tool" loading="lazy" decoding="async" class="attachment__image" width="633" height="311" src="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505134334.png/633w.png" srcset="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505134334.png/480w.png 480w, https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505134334.png/633w.png 633w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Demo of the Building search tool&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;Then I wanted to implement Access Log search. But to search Access Logs you need
a building ID. I wasn&amp;#39;t sure if the LLM could combine multiple tools by first
searching for the building, getting its ID and then searching for the access
logs with that ID. And I couldn&amp;#39;t find a definitive answer online so I hoped for
the best and implemented the following&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;module&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; MCP&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; SearchAccessLogsTool&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ApplicationTool&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    tool_name &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;search_access_logs&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    description &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;Searches through Access Logs and returns the fist 25 results&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    arguments &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;      required(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:building_id&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;)&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;filled&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:integer&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;description&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;ID of the Building that we are searching in&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;      optional(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:query&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;)&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;maybe&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:string&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;description&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;The search query&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; call&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(building_id: &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;nil&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; query: &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;nil&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      building &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; begin&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;        Building&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;find&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(building_id)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      rescue&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; ActiveRecord&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;RecordNotFound&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;        return&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;Building not found&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	  building&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;	    .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;access_logs&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;	    .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;search&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;({ &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;query:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; query })&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;	    .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;order&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;logged_at:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :desc&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;	    .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;limit&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;25&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;	    .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;to_json&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;only:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; [&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;	      :id&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;	      :logged_at&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;	      :grantor&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;	      :description&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;	      :access_point_id&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;	      :status&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	    ])&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;To my surprise this worked! It reused the ID it found in the previous response
to search for the access logs.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png attachment--expandable" data-full-src="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505134942.png/636w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 636; --attachment-height: 1361; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%207%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRiwAAABXRUJQVlA4ICAAAABQAQCdASoHABAABABoJZwABAAAAP7ur7bgSQhibahAAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #373843;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505134942.png/480w.webp 480w, https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505134942.png/636w.webp 636w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Claude reusing previous result to solve new problems" loading="lazy" decoding="async" class="attachment__image" width="636" height="1361" src="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505134942.png/636w.png" srcset="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505134942.png/480w.png 480w, https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505134942.png/636w.png 636w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Claude reusing previous result to solve new problems&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;I was curious if it could actually combine tools so I started a new chat and
asked the same question.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png attachment--expandable" data-full-src="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505135346.png/634w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 634; --attachment-height: 1096; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%209%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRi4AAABXRUJQVlA4ICIAAABQAQCdASoJABAABABoJZwABAAAAP7utUB8RqzbGom2oQAA%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #373944;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505135346.png/480w.webp 480w, https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505135346.png/634w.webp 634w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Claude combining multiple tools in a single response to solve a problem" loading="lazy" decoding="async" class="attachment__image" width="634" height="1096" src="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505135346.png/634w.png" srcset="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505135346.png/480w.png 480w, https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505135346.png/634w.png 634w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Claude combining multiple tools in a single response to solve a problem&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;The response blew my mind. It could combine tools! To me, this was both
incredible and scary. Kind of like seeing primates &lt;a href="https://www.youtube.com/watch?v=FA3LN4vqtlM"&gt;use
tools&lt;/a&gt; or &lt;a href="https://www.bbc.com/worklife/article/20180406-what-monkeys-can-teach-us-about-money"&gt;develop their own
economy&lt;/a&gt;.&lt;/p&gt;
&lt;h3 id="takeaways"&gt;Takeaways&lt;/h3&gt;
&lt;p&gt;MCP is a very &amp;quot;loose&amp;quot; protocol. It does allow you to specify what inputs your
tool has, which are required and which are optional, but it&amp;#39;s not expressive
enough specify what the input should look like.&lt;/p&gt;
&lt;p&gt;For example, in the Access Logs search I didn&amp;#39;t specify what the query string
should look like - my mistake - so the LLM just takes its best guess when you
ask it something complex like &amp;quot;Can you give me all access logs created in the
last hour in the building Crimson?&amp;quot;.&lt;/p&gt;
&lt;p&gt;I expected it to get all the access logs and then filter them on its own, but
that&amp;#39;s not what happened. Instead it passed &lt;em&gt;&amp;quot;logged_at:&amp;gt;=2025-05-05T07:36:31&amp;quot;&lt;/em&gt;
as the query. And, honestly, I can&amp;#39;t blame it.&lt;/p&gt;
&lt;p&gt;I later refined my tools with very detailed descriptions like this one&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;module&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; MCP&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; SearchAccessLogsTool&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ApplicationTool&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    tool_name &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;search_access_logs&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    description &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;&amp;lt;&amp;lt;~MD&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;      Searches for Access Logs in a given building and returns&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;      a JSON string containing an array of up to 25 objects.&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;      The list is sorted in descending order by the time &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;      the access log was logged.&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;      Inputs:&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;      - building_id [Integer] (the ID of the building in which to search for access logs)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;      - query [String, null] (a plain string that can contain any part ot the name of the grantor, the id of the access log, the description, the id of the associated access point)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;      - logged_after [String, null] (An ISO8601 timestamp of the earliest time a log was recorded)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;      - logged_before [String, null] (An ISO8601 timestamp of the latest time a log was recorded)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;      - statuses [Array[String], null] (A comma-separated list of statuses)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;      Output:&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;      - id [Integer] (The ID of the access log record)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;      - logged_at [String] (ISO8601 timestamp of the time the log was recorded)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;      - grantor [String, null] (Name of the person that granted this access, a null value means that the system granted the access)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;      - description [String] (A description of what happened)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;      - access_point_id [Integer] (ID of the access point that was accessed)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;      - status [String] (The access point&amp;#39;s state after this access)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;      Statuses:&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;      - open - means that the access point was unlocked and that someone opened it&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;      - closed - means that the access point was unlocked, opened and then closed&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;      - left_ajar - means that the access point was unlocked, and then left open for more than 1min&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;      - denied - the access point never unlocked&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;      &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;      If the building couldn&amp;#39;t be found you&amp;#39;ll get a message back stating that.&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;    MD&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    arguments &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;      required(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:building_id&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;        .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;filled&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:integer&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;        .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;description&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;ID of the Building that we are searching in&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;      optional(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:query&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;        .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;maybe&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:string&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;        .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;description&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;The search query which can contain any part of the grantors name, id of the record, id of the access point, or part of the description; any other input will be ignored&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;      optional(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:status&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;	    .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;maybe&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:string&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;	    .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;description&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;Comma-separated list of statuses - valid statuses are open, closed, left_ajar, denied; any other statuses will be ignored&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; call&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;**&lt;/span&gt;&lt;span style="color:var(--astro-code-token-parameter)"&gt;args&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;      # ...&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;The more information that I gave the LLM the better the result was. Something to
watch out for is that you:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Describe the output &lt;em&gt;(it&amp;#39;s as important as describing the input&lt;/em&gt;)&lt;/li&gt;
&lt;li&gt;Describe error states&lt;/li&gt;
&lt;li&gt;List all possible enum values and describe what each value means&lt;/li&gt;
&lt;li&gt;Explicitly state what format you expect for an input (what kind of timestamp
do you expect, can it be nullable, etc.)&lt;/li&gt;
&lt;li&gt;Explicitly state what&amp;#39;s not allowed &lt;em&gt;(e.g. additional enum values besides the
ones listed)&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;One more thing. With smaller LLMs, like Llama 3 7b, I had to normalize input
values. E.g. I&amp;#39;d tell it that I expect &lt;em&gt;&amp;quot;building&amp;quot;&lt;/em&gt; as an enum value but it
would pass me &lt;em&gt;&amp;quot;Buildings&amp;quot;&lt;/em&gt;. Or I&amp;#39;d tell it that a field is a nullable String
but it would still pass an empty string when it had no value. But Rails makes it
really easy to accommodate for such mistakes.&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;value&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;presence&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;amp;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;to_s&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;amp;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;strip&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;amp;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;singularize&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;amp;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;underscore&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;h3 id="conclusion"&gt;Conclusion&lt;/h3&gt;
&lt;p&gt;Integrating our existing app with AI was surprisingly easy. We just had to
expose tools and the LLM would figure out how to use them. In 8 hours we managed
to set up an MCP server, configure our clients, and expose enough tools to speed
up a dozen common support requests, which is amazing.&lt;/p&gt;
&lt;p&gt;In this hackathon, being a fully remote team with members in the EU and the US
felt like a superpower. The EU team solved all the setup problems, so by the
time the US team came online they could jump straight into building tools and
testing ideas. For the price of 8 hours we got 14.&lt;/p&gt;
&lt;p&gt;I still want to explore how to add permissions and some guard rails so that the
LLM can&amp;#39;t do too much damage if it goes haywire. I intentionally skipped that
during the hackathon as it wasn&amp;#39;t important.&lt;/p&gt;
&lt;p&gt;This project was a technical success, but I&amp;#39;m not sure if it&amp;#39;s the right
direction to go in. This integration makes it so that developers get rarely
involved in some types of support requests. As a developer, support is less
about solving a problem and is more about figuring out customers&amp;#39; pain points
and how to make the overall experience better.&lt;/p&gt;
&lt;p&gt;I&amp;#39;m not sure how to strike a good balance between automating laborious tasks and
surfacing common problems. That&amp;#39;s also something I&amp;#39;ll have to explore.&lt;/p&gt;&lt;/div&gt;
</content>
    <summary type="html">Last week we had an AI hackathon at work during which we added an MCP server to our Rails app. This was much easier than I expected, the result was both impressive and scary, and I learned a few things about MCP that I didn't expect.

Goal

My team wanted to help our support team by automating...</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Article/42</id>
    <published>2025-04-24T06:00:00Z</published>
    <updated>2026-03-12T12:11:10Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/building-simpl-77CAzym51p5a"/>
    <title>Building Simpl息</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;p&gt;This week I went for a coffee with a friend. As we talked the topic of hobbies
came up. I mentioned that I was having a lot of fun working on a toy project
over the past week, and then I showed it to him.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--mp4"&gt; &lt;video class="attachment__image--sized placeholder" style="--attachment-width: 504; --attachment-height: 942; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%209%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRkYAAABXRUJQVlA4IDoAAADwAQCdASoJABAABABoJaQAD4fwaOoUwgAA%2Fvd2Wuu%2FGS6yLFkdZAmaU7uJtPVlewD6RkzKgmKJkAAA%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #161616;" controls autoplay playsinline loop muted poster="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/screencast-from-2025-04-23-12-55-40.jpg/504w.jpg" width="504" height="942"&gt; &lt;source src="https://media.stanko.io/originals/articles/building-simpl-77CAzym51p5a/screencast-from-2025-04-23-12-55-40.mp4" type="video/mp4"&gt; &lt;/video&gt; &lt;figcaption class="attachment__caption"&gt;Demo&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;Him: So you are dabbling in React?&lt;/p&gt;
&lt;p&gt;Me: No, this is just Rails.&lt;/p&gt;
&lt;p&gt;Him: Which libraries did you use?&lt;/p&gt;
&lt;p&gt;Me: None, this is as vanilla as it gets.&lt;/p&gt;
&lt;p&gt;Him: What!?&lt;/p&gt;
&lt;h3 id="backstory"&gt;Backstory&lt;/h3&gt;
&lt;p&gt;For the past 12 months, each month I built an app within a strict time-box of 12
hours.&lt;/p&gt;
&lt;p&gt;If the project went overtime, and wasn’t at most 4 hours away from completion,
I’d abandon it. If a project failed, and a lot of them did in the beginning, I’d
reflect what led to that and tried to fix it in the next one.&lt;/p&gt;
&lt;p&gt;My goal is to practice pragmatic decision making and to curb &lt;a href="https://stanko.io/fight-perfection-roooUsSEfduw"&gt;my perfectionism -
which has started to show
again&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The time-box really helped me make better decisions. At first all my apps failed
because I either over-estimated my speed, or under-estimated the complexity and
unknowns. But around the third one I got better at both. Then came the hard
part - learning to strike a balance between quality work, functionality, and not
obsessing over details.&lt;/p&gt;
&lt;h3 id="simpliki"&gt;Simpliki&lt;/h3&gt;
&lt;p&gt;Simpliki is the 12th app I’ve built.&lt;/p&gt;
&lt;p&gt;It’s an app for practicing diaphragmatic breathing.&lt;/p&gt;
&lt;p&gt;I got the idea for it from my girlfriend. She uses a similar app in her
mindfulness practice.&lt;/p&gt;
&lt;p&gt;The name is a combination of “simple” and the Japanese word “iki”, which means
“breath”.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/image.png/1264w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 1264; --attachment-height: 550; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%207%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRioAAABXRUJQVlA4IB4AAAAwAQCdASoQAAcABABoJaQAA3AA%2FvED4EINL3AAAAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #f1f1f1;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/image.png/480w.webp 480w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/image.png/800w.webp 800w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/image.png/1200w.webp 1200w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/image.png/1264w.webp 1264w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Google Translate result for translating &amp;quot;breath&amp;quot; to Japanese" loading="lazy" decoding="async" class="attachment__image" width="1264" height="550" src="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/image.png/1200w.png" srcset="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/image.png/480w.png 480w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/image.png/800w.png 800w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/image.png/1200w.png 1200w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/image.png/1264w.png 1264w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Google Translate result for translating &amp;quot;breath&amp;quot; to Japanese&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;You can practice breathing with Simpliki at
&lt;a href="https://simpliki.app/"&gt;simpliki.app&lt;/a&gt;. And you can &lt;a href="https://github.com/monorkin/simpliki"&gt;peruse the source-code on
GitHub&lt;/a&gt;.&lt;/p&gt;
&lt;h3 id="starting-from-the-center"&gt;Starting from the center&lt;/h3&gt;
&lt;p&gt;The best way to finish a project is to start from the center and work your way
from there. I focus on what makes the app work - what makes it what it is - and
tackle everything else later. That way I can cut less important bits if I have
to.&lt;/p&gt;
&lt;p&gt;For Simpliki the center is the breathing exercise - the place where you practice
diaphragmatic breathing - without that there is no Simpliki.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_1.png/1620w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 1620; --attachment-height: 400; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%204%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRioAAABXRUJQVlA4IB4AAAAwAQCdASoQAAQABABoJaQAA3AA%2FvJI3wLBLtnAAAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #fcfcfc;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_1.png/480w.webp 480w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_1.png/800w.webp 800w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_1.png/1200w.webp 1200w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_1.png/1600w.webp 1600w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_1.png/1620w.webp 1620w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Simpliki's center" loading="lazy" decoding="async" class="attachment__image" width="1620" height="400" src="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_1.png/1200w.png" srcset="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_1.png/480w.png 480w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_1.png/800w.png 800w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_1.png/1200w.png 1200w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_1.png/1600w.png 1600w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_1.png/1620w.png 1620w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Simpliki&amp;#39;s center&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;But I also need a way to get to the breathing exercise - an index screen or
something similar.&lt;/p&gt;
&lt;p&gt;Personally, when I want to relax and practice mindfulness I don’t like to have
too many options to choose from - it takes me out of the experience. So I
decided to add a “home” screen with a single button that links to a random
exercise.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_2.png/1620w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 1620; --attachment-height: 550; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%205%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRigAAABXRUJQVlA4IBwAAAAwAQCdASoQAAUABABoJaQAA3AA%2FvJSZ8ZfGwAA%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #fcfbfc;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_2.png/480w.webp 480w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_2.png/800w.webp 800w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_2.png/1200w.webp 1200w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_2.png/1600w.webp 1600w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_2.png/1620w.webp 1620w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Simpliki's two centers" loading="lazy" decoding="async" class="attachment__image" width="1620" height="550" src="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_2.png/1200w.png" srcset="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_2.png/480w.png 480w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_2.png/800w.png 800w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_2.png/1200w.png 1200w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_2.png/1600w.png 1600w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_2.png/1620w.png 1620w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Simpliki&amp;#39;s two centers&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;My girlfriend likes to practice specific exercises so I added an “Explore”
button that leads to an index page with all exercises.&lt;/p&gt;
&lt;p&gt;An app can have many centers interconnected with different features. I think of
the exercise and the home screen as two separate centers, and the index is just
a feature between them.&lt;/p&gt;
&lt;p&gt;I learned this from &lt;a href="https://en.wikipedia.org/wiki/Christopher_Alexander"&gt;Christopher Alexander&amp;#39;s design
theory&lt;/a&gt;. The most apt
analogy I found is &lt;a href="https://www.researchgate.net/publication/361518671_Finding_the_Center_Christopher_Alexander's_Concept_of_Centers"&gt;in a paper that compares this design principle to patterns
of a
carpet&lt;/a&gt;.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_3.png/1620w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 1620; --attachment-height: 650; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%206%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRiwAAABXRUJQVlA4ICAAAAAwAQCdASoQAAYABABoJaQAA3AA%2FvIP3svEFHil70gAAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #f8f8f8;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_3.png/480w.webp 480w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_3.png/800w.webp 800w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_3.png/1200w.webp 1200w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_3.png/1600w.webp 1600w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_3.png/1620w.webp 1620w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Simpliki's two centers and the features between them" loading="lazy" decoding="async" class="attachment__image" width="1620" height="650" src="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_3.png/1200w.png" srcset="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_3.png/480w.png 480w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_3.png/800w.png 800w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_3.png/1200w.png 1200w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_3.png/1600w.png 1600w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_3.png/1620w.png 1620w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Simpliki&amp;#39;s two centers and the features between them&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;Now that I have an idea what I&amp;#39;m building I&amp;#39;ll create a breadboard so that I can
work out problems on paper instead of experimenting in code. I skipped this step
for Simpliki as it was, well, simple. But the breadboard would look something
like this.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_breadboard.png/1620w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 1620; --attachment-height: 850; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%208%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRjgAAABXRUJQVlA4ICwAAACwAQCdASoQAAgABABoJaQAAxZgveyIAP71HZZP2GNUKye5d9m7oWicnsAAAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #ededed;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_breadboard.png/480w.webp 480w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_breadboard.png/800w.webp 800w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_breadboard.png/1200w.webp 1200w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_breadboard.png/1600w.webp 1600w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_breadboard.png/1620w.webp 1620w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="The breadboard" loading="lazy" decoding="async" class="attachment__image" width="1620" height="850" src="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_breadboard.png/1200w.png" srcset="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_breadboard.png/480w.png 480w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_breadboard.png/800w.png 800w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_breadboard.png/1200w.png 1200w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_breadboard.png/1600w.png 1600w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_breadboard.png/1620w.png 1620w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;The breadboard&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;This also helps me figure out the domain language - what models I have and what
their attributes and methods are called.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_domain.png/1620w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 1620; --attachment-height: 470; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%205%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRiwAAABXRUJQVlA4ICAAAACwAQCdASoQAAUABABoJaQAAudMszkAAP72EMDXXAAAAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #f9f9f9;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_domain.png/480w.webp 480w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_domain.png/800w.webp 800w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_domain.png/1200w.webp 1200w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_domain.png/1600w.webp 1600w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_domain.png/1620w.webp 1620w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="The domain" loading="lazy" decoding="async" class="attachment__image" width="1620" height="470" src="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_domain.png/1200w.png" srcset="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_domain.png/480w.png 480w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_domain.png/800w.png 800w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_domain.png/1200w.png 1200w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_domain.png/1600w.png 1600w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_domain.png/1620w.png 1620w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;The domain&lt;/figcaption&gt; &lt;/figure&gt;
&lt;h3 id="design"&gt;Design&lt;/h3&gt;
&lt;p&gt;I had a clear vibe I wanted to go for - &lt;a href="https://endel.io/"&gt;Endel&lt;/a&gt; +
&lt;a href="https://www.headspace.com/"&gt;Headspace&lt;/a&gt;. I like Endel&amp;#39;s minimalist and
monochrome style - it has a mystical, simple, and concise vibe that I wanted to
replicate. And I find the organic, whimsical, animated shapes and play button
from Headspace calming - exactly the vibe I want for a mindfulness app.&lt;/p&gt;
&lt;div class="attachment-gallery attachment-gallery--2"&gt; &lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_endel.png/1290w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 1290; --attachment-height: 2796; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%207%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRkYAAABXRUJQVlA4IDoAAADQAQCdASoHABAABABoJaQAAxf5dBiwWAD%2B8SMRaGBCq4aGfk2To3%2BCi5kb0M7qMXbNZmgwnWA9%2BdAA%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #1c1c1c;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_endel.png/480w.webp 480w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_endel.png/800w.webp 800w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_endel.png/1200w.webp 1200w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_endel.png/1290w.webp 1290w" sizes="(width &gt;= 768px) 520px, 50vw"&gt; &lt;img alt="Endel" loading="lazy" decoding="async" class="attachment__image" width="1290" height="2796" src="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_endel.png/1200w.png" srcset="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_endel.png/480w.png 480w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_endel.png/800w.png 800w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_endel.png/1200w.png 1200w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_endel.png/1290w.png 1290w" sizes="(width &gt;= 768px) 520px, 50vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Endel&lt;/figcaption&gt; &lt;/figure&gt;&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_headspace.png/1290w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 1290; --attachment-height: 2796; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%207%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRk4AAABXRUJQVlA4IEIAAAAwAgCdASoHABAABABoJbACdLoAAslGKCMwAAD%2B06Cg8WYaEfophaMLcEUgPbrgpj%2F4tLM9aFi3ocm5pB7KBqQwAAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #f5ba45;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_headspace.png/480w.webp 480w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_headspace.png/800w.webp 800w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_headspace.png/1200w.webp 1200w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_headspace.png/1290w.webp 1290w" sizes="(width &gt;= 768px) 520px, 50vw"&gt; &lt;img alt="Headspace" loading="lazy" decoding="async" class="attachment__image" width="1290" height="2796" src="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_headspace.png/1200w.png" srcset="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_headspace.png/480w.png 480w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_headspace.png/800w.png 800w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_headspace.png/1200w.png 1200w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_headspace.png/1290w.png 1290w" sizes="(width &gt;= 768px) 520px, 50vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Headspace&lt;/figcaption&gt; &lt;/figure&gt; &lt;/div&gt;
&lt;p&gt;So I decided to combine the two for Simpliki. I&amp;#39;d use Endel&amp;#39;s color-scheme and
the animated organic shapes from Headspace.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png attachment--expandable" data-full-src="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/pasted-image-20250423095110.png/496w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 496; --attachment-height: 941; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%208%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRlAAAABXRUJQVlA4IEQAAADwAQCdASoIABAABABoJaQAAtzyDq%2B5SAAA%2FvaKubGVzOAzoXB297vtgcDwXa0kmO5A12P1yPK1ULrBDxvTpM%2B4%2FleAAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #181818;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/pasted-image-20250423095110.png/480w.webp 480w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/pasted-image-20250423095110.png/496w.webp 496w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Simpliki's UI" loading="lazy" decoding="async" class="attachment__image" width="496" height="941" src="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/pasted-image-20250423095110.png/496w.png" srcset="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/pasted-image-20250423095110.png/480w.png 480w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/pasted-image-20250423095110.png/496w.png 496w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Simpliki&amp;#39;s UI&lt;/figcaption&gt; &lt;/figure&gt;
&lt;h3 id="starting-a-new-project"&gt;Starting a new project&lt;/h3&gt;
&lt;p&gt;I decided to use Tailwind because I like working with it. And I chose SQLite as
the database because it&amp;#39;s the simplest one to use out of the presets. I honestly
don&amp;#39;t even need a database for this app, but it&amp;#39;s easier to use one than not.&lt;/p&gt;
&lt;pre class="highlight" data-language="shell"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;rails&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; new&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; simpliki&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; --css=tailwind&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;Then in that project I generated my models and a controller&lt;/p&gt;
&lt;pre class="highlight" data-language="shell"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;bin/rails&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; g&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; model&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; Exercise&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; name:string&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;bin/rails&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; g&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; model&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; Exercise::Step&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; exercise:belongs_to&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; action:string&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; duration:integer&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; position:integer&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;bin/rails&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; g&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; scaffold_controller&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; Exercise&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;I removed everything that I didn&amp;#39;t need, added a &amp;quot;home&amp;quot; action to the
controller, a view for that action, and pointed the root route to that action.&lt;/p&gt;
&lt;p&gt;Then I created a few fixtures for
&lt;a href="https://github.com/monorkin/simpliki/blob/main/test/fixtures/exercises.yml"&gt;Exercises&lt;/a&gt;
and
&lt;a href="https://github.com/monorkin/simpliki/blob/main/test/fixtures/exercise/steps.yml"&gt;Exercise::Steps&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Over the past 5 years I&amp;#39;ve completely changed my mind about fixtures. I used to
think that they are finicky and convoluted compared to factories. But I&amp;#39;ve come
to like them more after I&amp;#39;ve seen the downsides of factories over the years -
they are extremely slow, it&amp;#39;s hard to manage what will get created or you have
to define every single record you want to create in every single test.&lt;/p&gt;
&lt;p&gt;My favorite advantage of fixtures is:&lt;/p&gt;
&lt;pre class="highlight" data-language="shell"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;bin/rails&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; db:fixtures:load&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;This will take all my fixtures and put them in my current &lt;em&gt;(development or
production)&lt;/em&gt; database.&lt;/p&gt;
&lt;p&gt;Now I don&amp;#39;t need to create a way to add or edit Exercises, or add and edit them
manually through the console, before I can start working on the show page. I can
just load in a few exercises and use them. Best of all, I get to see and use the
exact same data that&amp;#39;s in my tests - no more trying to figure out what went
wrong, I can just open the same Exercise and see.&lt;/p&gt;
&lt;p&gt;Then I started working on the views. At first I just added links between the
pages, added a header that said &amp;quot;Simpl息&amp;quot; on each page, added a plain HTML
&amp;quot;Practice&amp;quot; button on the exercise show page, and created a very simple index
page that shows a list of exercises.&lt;/p&gt;
&lt;h3 id="the-ruby-tag"&gt;The ruby tag&lt;/h3&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/pasted-image-20250423133538.png/752w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 752; --attachment-height: 388; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%208%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRk4AAABXRUJQVlA4IEIAAADQAQCdASoQAAgABABoJaQAAud5hM6MAAD%2B9KEaMMsqlDpXFZmHPkdpI4a%2FGxZCf0GcAGTrLL6PlCmp%2F025pd1AAAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #252525;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/pasted-image-20250423133538.png/480w.webp 480w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/pasted-image-20250423133538.png/752w.webp 752w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Simpliki's logo" loading="lazy" decoding="async" class="attachment__image" width="752" height="388" src="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/pasted-image-20250423133538.png/752w.png" srcset="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/pasted-image-20250423133538.png/480w.png 480w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/pasted-image-20250423133538.png/752w.png 752w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Simpliki&amp;#39;s logo&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;Then I remembered reading about the &lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/ruby"&gt;HTML ruby
element&lt;/a&gt;
which is used to show the pronunciation of characters. I thought it would be a
nice way to show where the app&amp;#39;s name comes from.&lt;/p&gt;
&lt;pre class="highlight" data-language="html"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;h1&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; id&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;logo&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;font-serif&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;span&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;mt-4&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;Simpl&amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;span&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;ruby&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	  息&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	  &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;rt&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;text-gray-500&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;iki&amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;rt&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;ruby&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;h1&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;It took about a minute to add and style the &amp;quot;iki&amp;quot; - plain HTML is amazing.&lt;/p&gt;
&lt;h3 id="animating-svgs"&gt;Animating SVGs&lt;/h3&gt;
&lt;p&gt;Next I wanted to animate the play button. I&amp;#39;ve done similar things before so I
was quite confident that I could do it quickly.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--mp4"&gt; &lt;video class="attachment__image--sized placeholder" style="--attachment-width: 596; --attachment-height: 546; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2015%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRmwAAABXRUJQVlA4IGAAAAAQAgCdASoQAA8ABABoJaQAAsQk7n71%2FZ0AAP724OeoYHZnaZfHC71Q7VjLSEyjBui%2B20VNPV0tTxDhQsQCm0N7Sf5CgPxWG5EULGQEcY7qvc7SkdJIRWTGvBvHRjuTgAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #676767;" controls autoplay playsinline loop muted poster="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/screencast-from-2025-04-23-14-32-42-1.jpg/596w.jpg" width="596" height="546"&gt; &lt;source src="https://media.stanko.io/originals/articles/building-simpl-77CAzym51p5a/screencast-from-2025-04-23-14-32-42-1.mp4" type="video/mp4"&gt; &lt;/video&gt; &lt;figcaption class="attachment__caption"&gt;Demo of the circle animation&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;The basic idea is to have an SVG with a single path that consists of 16 points
with &lt;a href="https://en.wikipedia.org/wiki/B%C3%A9zier_curve"&gt;Bezier curves&lt;/a&gt; that form
a circle. The number of points determines how organic and uneven the circle will
look in the end - 8 is too blocky, 24 is too smooth, 16 is just right.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_basic_circle.png/1620w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 1620; --attachment-height: 500; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%205%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRi4AAABXRUJQVlA4ICIAAACQAQCdASoQAAUABABoJaQAAuP3fIAA%2FvTRZFX61rCiAAAA%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #f8f8f8;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_basic_circle.png/480w.webp 480w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_basic_circle.png/800w.webp 800w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_basic_circle.png/1200w.webp 1200w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_basic_circle.png/1600w.webp 1600w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_basic_circle.png/1620w.webp 1620w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Basic SVG circle with 16 points and a Bezier curve" loading="lazy" decoding="async" class="attachment__image" width="1620" height="500" src="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_basic_circle.png/1200w.png" srcset="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_basic_circle.png/480w.png 480w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_basic_circle.png/800w.png 800w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_basic_circle.png/1200w.png 1200w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_basic_circle.png/1600w.png 1600w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_basic_circle.png/1620w.png 1620w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Basic SVG circle with 16 points and a Bezier curve&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;Then, every 10ms I change the position of each point slightly and adjust their
Bezier curve points to animate the circle.&lt;/p&gt;
&lt;p&gt;To make it undulate - move like there are ripples going across it - I can pull
or push one point after another in or out, and then adjust the Bezier curve
points to make it look hand-drawn.&lt;/p&gt;
&lt;p&gt;That&amp;#39;s an old computer science trick I learned in college. It sounds
complicated, but it&amp;#39;s quite straightforward.&lt;/p&gt;
&lt;p&gt;There is just one thing you have to know - if you plot &lt;em&gt;x = cos(a)&lt;/em&gt; and &lt;em&gt;y =
sin(a)&lt;/em&gt; where &lt;em&gt;a&lt;/em&gt; is any value between 0 and 2&lt;em&gt;PI&lt;/em&gt; you&amp;#39;ll get a circle of
radius 1. Then to get any size circle you just multiply everything by the radius
you want.&lt;/p&gt;
&lt;pre class="highlight" data-language="js"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; NUM_POINTS&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 16&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; RADIUS&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 100&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;function&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; generatePoints&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(time) {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; circlePoints&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; []&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  for&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; (&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;let&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; index &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;; index &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; NUM_POINTS&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;; i&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;++&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;    // Calculate at what angle the point is supposed to be&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; angle&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; (&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;point&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.index &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; NUM_POINTS&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;) &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;*&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Math&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;PI&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; *&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 2&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;    // Convert the angle to coordinates&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; x&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; RADIUS&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; *&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Math&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.cos&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(angle)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; y&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; RADIUS&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; *&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Math&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.sin&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(angle)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;    circlePoints&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.push&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;([x&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; y])&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  return&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; circlePoints&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/pasted-image-20250423150750.png/620w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 620; --attachment-height: 550; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2014%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRmYAAABXRUJQVlA4IFoAAAAwAgCdASoQAA4ABABoJaQAD4ounrKC10XwAAD%2B94JSFOdaL%2BNU6o5QRXt8JR9MlpP1uQI7Bt90a4yQAOrQm2WItngKT0ZxvJ0DWUy1xj3vzV%2BFVJEAGEoAAAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #626262;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/pasted-image-20250423150750.png/480w.webp 480w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/pasted-image-20250423150750.png/620w.webp 620w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="The most basic circle" loading="lazy" decoding="async" class="attachment__image" width="620" height="550" src="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/pasted-image-20250423150750.png/620w.png" srcset="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/pasted-image-20250423150750.png/480w.png 480w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/pasted-image-20250423150750.png/620w.png 620w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;The most basic circle&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;To form waves, I can change the radius for each point using any function that
also includes COS and SIN. That way each point moves in its own little circle
which, when put all together, looks like a wave. And to make it consistent the
SIN and COS have to include the current time.&lt;/p&gt;
&lt;pre class="highlight" data-language="js"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; NUM_POINTS&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 16&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; RADIUS&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 100&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; RIPPLE_FREQ&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.5&lt;/span&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt; // determines how quickly the waves move&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; RIPPLE_AMPLITUDE&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 5&lt;/span&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt; // determines how big the waves are&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; circlePoints&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; []&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;function&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; generatePoints&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(time) {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; circlePoints&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; []&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  for&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; (&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;let&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; index &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;; index &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; NUM_POINTS&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;; i&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;++&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;    // Calculate at what angle the point is supposed to be&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; angle&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; (&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;point&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.index &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; NUM_POINTS&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;) &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;*&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Math&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;PI&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; *&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 2&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;    // Generate a new radius&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;    // the values were completely arbitrarily chosen&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;    // you could also just use sin(time) + cos(time)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;    // this just looks nice to me&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; rippleY&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Math&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.sin&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(time &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;*&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; RIPPLE_FREQ&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; +&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; angle)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; rippleX&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Math&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.cos&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(time &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;*&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; RIPPLE_FREQ&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; *&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.7&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; +&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; angle &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;*&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 1.3&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; ripple&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; (rippleY &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;*&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.6&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; +&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; rippleX &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;*&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.4&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;) &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;*&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; RIPPLE_AMPLITUDE&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;    // Convert the angle to coordinates&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; x&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; RADIUS&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; *&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Math&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.cos&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(angle) &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;+&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; (ripple &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;*&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Math&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.cos&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(angle))&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; y&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; RADIUS&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; *&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Math&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.sin&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(angle) &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;+&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; (ripple &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;*&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Math&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.sin&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(angle))&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;    circlePoints&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.push&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;([x&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; y])&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  return&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; circlePoints&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/pasted-image-20250423150838.png/550w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 550; --attachment-height: 544; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRnIAAABXRUJQVlA4IGYAAABQAgCdASoQABAABABoJaQAD4fvqlHIRB5NzAAA%2FveB1xNPFx%2Bzfa7oI%2FD3SRkqt1KCaeMzI9YL1u8iZodH%2BIdiuhp6yNUsfc9lo64cmjEj7dNrNWQ7fUebShwj4ErDATsH3RVAAAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #6f6f6f;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/pasted-image-20250423150838.png/480w.webp 480w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/pasted-image-20250423150838.png/550w.webp 550w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Circle with ripples" loading="lazy" decoding="async" class="attachment__image" width="550" height="544" src="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/pasted-image-20250423150838.png/550w.png" srcset="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/pasted-image-20250423150838.png/480w.png 480w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/pasted-image-20250423150838.png/550w.png 550w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Circle with ripples&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;&lt;a href="https://github.com/monorkin/simpliki/blob/main/app/javascript/controllers/circle_animation_controller.js#L88-L92"&gt;Then I can just move the bezier curve anchors in the same way to get a
hand-drawn
look.&lt;/a&gt;&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png attachment--expandable" data-full-src="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/pasted-image-20250423151922.png/503w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 503; --attachment-height: 519; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRngAAABXRUJQVlA4IGwAAACQAgCdASoQABAABABoJaQAFEB7LYvNVvZ%2BfVNfoAD%2B9uL%2FSzIJ66%2F3o65Uj4LScVo8LIGbWWsm794J9ZR53%2ByCSAbzlH7%2F6WSnACLw8ZSpaPiMT%2FeHXT6XW986OAuh8brfPCgsta%2BfA2YAAAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #808080;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/pasted-image-20250423151922.png/480w.webp 480w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/pasted-image-20250423151922.png/503w.webp 503w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Circle with ripples and fixed Bezier curves" loading="lazy" decoding="async" class="attachment__image" width="503" height="519" src="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/pasted-image-20250423151922.png/503w.png" srcset="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/pasted-image-20250423151922.png/480w.png 480w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/pasted-image-20250423151922.png/503w.png 503w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Circle with ripples and fixed Bezier curves&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;The end effect is subtle, but it makes the circle look friendlier.&lt;/p&gt;
&lt;h3 id="view-transitions"&gt;View transitions&lt;/h3&gt;
&lt;p&gt;Next I animated the view transitions. This takes maybe 10 minutes but makes the
app feel way more modern and responsive.&lt;/p&gt;
&lt;p&gt;First I &lt;a href="https://turbo.hotwired.dev/handbook/drive#view-transitions"&gt;enabled view transitions in Turbo
Drive&lt;/a&gt; by adding
this meta tag to the head section of my layout&lt;/p&gt;
&lt;pre class="highlight" data-language="html"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;meta&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; name&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;view-transition&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; content&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;same-origin&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; /&amp;gt;&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;Just adding this one line will already add a fade effect to all page
transitions. Then to move individual elements around, like the logo, I have to
add it a &lt;em&gt;view-transition-name&lt;/em&gt; CSS property with a unique name.&lt;/p&gt;
&lt;pre class="highlight" data-language="css"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;#logo&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;  view-transition-name&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; logo&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;After that the browser animates the transitions on its own.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--mp4"&gt; &lt;video class="attachment__image--sized placeholder" style="--attachment-width: 850; --attachment-height: 1510; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2013%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRk4AAABXRUJQVlA4IEIAAACwAQCdASoNABAABABoJaQAAudf%2FWoAAP73dNXZZldjc8%2BhzjGGNOpleq%2Bng8OOFxQx3YjjpfT9i8KMxIy%2FDUg2gAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #202020;" controls autoplay playsinline loop muted poster="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/screencast-from-2025-04-23-15-35-32.jpg/850w.jpg" width="850" height="1510"&gt; &lt;source src="https://media.stanko.io/originals/articles/building-simpl-77CAzym51p5a/screencast-from-2025-04-23-15-35-32.mp4" type="video/mp4"&gt; &lt;/video&gt; &lt;figcaption class="attachment__caption"&gt;Demo of the logo moving with page navigation&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;For the explore page I wanted to add an animation where the list slides in from
the bottom of the page. Luckily this can be done with a few lines of CSS.&lt;/p&gt;
&lt;pre class="highlight" data-language="css"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;@keyframes&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; slide-to-top-from-out-of-view {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  0% {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;    transform&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; translateY&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;(100&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;vh&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;)&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;    opacity&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  100% {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;    transform&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; translateY&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;(0)&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;    opacity&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 1&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;}&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;#exercises&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;  view-transition-name&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; exercises&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;}&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;html&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;:not&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;([&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;data-same-page-visit&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;])[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;data-turbo-visit-direction&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;forward&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;]::view-transition-new(exercises) {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;  animation&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.25&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;s&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; ease-out slide-to-top-from-out-of-view both&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;This tells the browser to apply a &lt;em&gt;slide-to-top-from-out-of-view&lt;/em&gt; animation for
any new exercises list that appear.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--mp4"&gt; &lt;video class="attachment__image--sized placeholder" style="--attachment-width: 850; --attachment-height: 1510; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2013%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRlYAAABXRUJQVlA4IEoAAADQAQCdASoNABAABABoJaQAAudN6%2BIlgAD%2B93P%2B4VN6b5frvlDM%2BdKJMcttCCYcDhlH6P0%2BMFyIZcPP1btpjTW6BiQXKEux0AAAAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #212121;" controls autoplay playsinline loop muted poster="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/screencast-from-2025-04-23-15-23-44.jpg/850w.jpg" width="850" height="1510"&gt; &lt;source src="https://media.stanko.io/originals/articles/building-simpl-77CAzym51p5a/screencast-from-2025-04-23-15-23-44.mp4" type="video/mp4"&gt; &lt;/video&gt; &lt;figcaption class="attachment__caption"&gt;Demo of the exercise list sliding in from the bottom of the screen&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;There is one caveat, View Transitions aren&amp;#39;t supported on Firefox. This only
works on Chromium and WebKit based browsers. As one of the 2% of global Firefox
users I don&amp;#39;t mind, the app still works without the transitions.&lt;/p&gt;
&lt;h3 id="the-breathing-animation"&gt;The breathing animation&lt;/h3&gt;
&lt;p&gt;Now that I have an animated circle I want to create a breathing animation to
help people pace their breath.&lt;/p&gt;
&lt;p&gt;The circle should grow when you have to breathe in, and shrink when you have to
breathe out.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--mp4"&gt; &lt;video class="attachment__image--sized placeholder" style="--attachment-width: 658; --attachment-height: 710; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2015%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRmIAAABXRUJQVlA4IFYAAAAwAgCdASoPABAABABoJaQAAsRD0TEeugmUAAD%2B94JHAhJFmKyQPGcliVICmdzORmdedJFZi6%2ByDcdhVa9Jx4%2BqIueNmEQI5WlKrn3YDJPzDXeqAIAAAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #494949;" controls autoplay playsinline loop muted poster="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/screencast-from-2025-04-23-16-31-36.jpg/658w.jpg" width="658" height="710"&gt; &lt;source src="https://media.stanko.io/originals/articles/building-simpl-77CAzym51p5a/screencast-from-2025-04-23-16-31-36.mp4" type="video/mp4"&gt; &lt;/video&gt; &lt;figcaption class="attachment__caption"&gt;Demo of the breathing animation&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;To animate this I need to create three circles - a maximum indicator, a minimum
indicator and a progress indicator.&lt;/p&gt;
&lt;p&gt;The maximum indicator is static and just has an opacity of 10%. The minimum
indicator is mostly static after an initial animation that moves it in place.
And the progress indicator constantly shrinks and grows as needed.&lt;/p&gt;
&lt;p&gt;But rendering one animated SVG at 60 FPS is already quite taxing, and
synchronizing three undulation animations would be a headache. Luckily plain
HTML saves the day again with SVG symbols.&lt;/p&gt;
&lt;p&gt;In an SVG you can define any set of paths or shapes as a symbol and give it an
ID. Then you can render the same symbol in any other SVG in the same HTML with
the &lt;em&gt;use&lt;/em&gt; tag. You can even change the fill and stroke properties on the &lt;em&gt;use&lt;/em&gt;
tag to get different versions of the same shape. And when you update the shape
in the symbol it immediately reflects in all other SVGs using that symbol.&lt;/p&gt;
&lt;pre class="highlight" data-language="html"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;&amp;lt;!-- Defines a symbol &amp;quot;foo&amp;quot; as a circle --&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;svg&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; xmlns&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;http://www.w3.org/2000/svg&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;symbol&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; id&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;foo&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; viewBox&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;0 0 100 100&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;circle&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; cx&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;50&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; cy&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;50&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; r&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;50&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; /&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;symbol&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;svg&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;&amp;lt;!-- Renders a red circle with a black stroke --&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;svg&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; xmlns&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;http://www.w3.org/2000/svg&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; viewBox&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;0 0 100 100&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;use&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; href&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;#foo&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; fill&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;red&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; stroke&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;black&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; stroke-width&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;2&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;use&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;svg&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;&amp;lt;!-- Renders a blue circle --&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;svg&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; xmlns&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;http://www.w3.org/2000/svg&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; viewBox&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;0 0 100 100&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;use&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; href&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;#foo&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; fill&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;blue&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;use&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;svg&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;With a symbol I just have to animate one SVG and then I can reuse the same
animated shape in any other SVG on the same page. &lt;a href="https://github.com/monorkin/simpliki/blob/main/app/helpers/circle_helper.rb#L16"&gt;I&amp;#39;ve created a helper to make
things more
readable.&lt;/a&gt;&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;circle_id &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; dom_id(@exercise&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :circle&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# Renders an animated circle as a symbol with the id circle_id&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;animated_circle(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;symbol:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; circle_id&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; class:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;hidden&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;)&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; +&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# Renders the minimum indicator&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;animated_circle(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;use:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; circle_id&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; class:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;absolute inset-0 z-30 w-full select-none&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; stroke:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;black&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; stroke_width:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 2&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; name:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;exercise-circle-min&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; data:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; { &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;exercise_target:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;minCircle&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; })&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; +&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# Renders the progress indicator&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;animated_circle(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;use:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; circle_id&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; class:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;absolute inset-0 z-20 w-full select-none&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; data:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; { &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;exercise_target:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;progressCircle&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; })&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; +&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# Renders the maximum indicator&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;animated_circle(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;use:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; circle_id&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; class:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;absolute inset-0 z-10 w-full select-none opacity-10&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; name:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;exercise-circle-max&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; data:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; { &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;exercise_target:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;maxCircle&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; })&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;With that out of the way I now have to animate the progress circle. I read about
the &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Web_Animations_API/Using_the_Web_Animations_API"&gt;Web Animations
API&lt;/a&gt;
a few weeks ago and it seemed like just the right tool for the job.&lt;/p&gt;
&lt;p&gt;It was my first time using that API and I think I hit every edge case there is
with it.&lt;/p&gt;
&lt;p&gt;Long story short, don&amp;#39;t use fill forwards and instead manually set your desired
state with the &lt;em&gt;onfinish&lt;/em&gt; callback. For some reason, fill forwards applied a new
CSS rule every time an animation ran. After a few minutes I had a hundred
animation style blocks in my Inspection window, all overriding each other. Also,
remove any transitions on the animated element as that will make the animation
wonky in Firefox (but works fine everywhere else) - the browser will handle the
transition for you.&lt;/p&gt;
&lt;p&gt;After I figured that out, the rest was straightforward&lt;/p&gt;
&lt;pre class="highlight" data-language="js"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;async&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; function&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; inhale&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(duration&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; element) {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  return&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; new&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Promise&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(resolve &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&amp;gt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; animation&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; this&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;progressCircleTarget&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.animate&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;([&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      { transform&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; `scale(0.25)`&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; }&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      { transform&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; `scale(1.0)`&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    ]&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      duration&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; duration &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;*&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 1000&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      easing&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;linear&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    })&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;    animation&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;onfinish&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; () &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&amp;gt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;      this&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;progressCircleTarget&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;style&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.transform &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; `scale(1.0)`&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;      resolve&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;()&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;}&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;async&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; function&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; exhale&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(duration&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; element) {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  return&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; new&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Promise&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(resolve &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&amp;gt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; animation&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; this&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;progressCircleTarget&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.animate&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;([&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      { transform&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; `scale(1.0)`&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; }&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      { transform&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; `scale(0.25)`&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    ]&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      duration&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; duration &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;*&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 1000&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      easing&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;linear&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    })&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;    animation&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;onfinish&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; () &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&amp;gt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;      this&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;progressCircleTarget&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;style&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.transform &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; `scale(0.25)`&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;      resolve&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;()&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;&lt;a href="https://github.com/monorkin/simpliki/blob/main/app/javascript/controllers/exercise_controller.js"&gt;Now I just had to pass the exercise steps to the Stimulus controller, and run
the
animation&lt;/a&gt;.
This just meant that I had to define a &lt;em&gt;steps&lt;/em&gt; value of type Array and serialize
the steps as JSON on the HTML element.&lt;/p&gt;
&lt;h3 id="sound"&gt;Sound&lt;/h3&gt;
&lt;p&gt;With the animation done I wanted to also add an audio cue that you should
breathe in or out.&lt;/p&gt;
&lt;p&gt;At first I thought that the simplest way to add sound was to play some sound
file - like a gong or a flute - at different playback speeds for different
durations, and pitch it down to cue and exhale.&lt;/p&gt;
&lt;p&gt;This turned out to be a dead end. I could change the playback speed but at 0.5x
playback the sound of a gong starts to sound like Hell&amp;#39;s bells.&lt;/p&gt;
&lt;p&gt;Then I remembered the &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API"&gt;Web Audio
API&lt;/a&gt; with which
you can generate sounds. &lt;a href="https://www.youtube.com/watch?v=OAsTq5SKnEw"&gt;I found a nice tutorial that explains how to make a
bell sound&lt;/a&gt;, but I was out of my
depth here and decided to reach for an LLM so that I don&amp;#39;t run out of time.&lt;/p&gt;
&lt;p&gt;I created a scaffold class with the interfaces I wanted to have and asked
ChatGPT to implement the class so that it generates &amp;quot;a nice and relaxing sound
that inspires breathing in a mindfulness app I&amp;#39;m working on, kind of like Endel
or Headspace&amp;quot;. &lt;a href="https://github.com/monorkin/simpliki/blob/main/app/javascript/models/sound_player.js"&gt;After a bit of back and forth I had something that I thought was
decent&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;But it didn&amp;#39;t work on my iPhone... Turns out that Apple disables Web Audio
playback if your ringer is set to silent, even though the volume is up - odd
choice, but ok.&lt;/p&gt;
&lt;p&gt;You can listen to the sound at &lt;a href="https://simpliki.app/"&gt;simpliki.app&lt;/a&gt;, just pick
any exercise and start it. If you are on an iPhone be sure to set your phone
&lt;em&gt;OFF&lt;/em&gt; silent.&lt;/p&gt;
&lt;h3 id="scroll-snapping"&gt;Scroll snapping&lt;/h3&gt;
&lt;p&gt;As I was working on the breathing animation I noticed that I forgot to record if
you are supposed to breathe with your nose or mouth, so I had to go back, add a
migration, and update the fixtures.&lt;/p&gt;
&lt;p&gt;Then I thought that showing a short description about the exercise would be nice
so I added ActionText, and a &lt;a href="https://github.com/monorkin/simpliki/blob/main/test/fixtures/action_text/rich_texts.yml"&gt;few fixtures for
it&lt;/a&gt;.&lt;/p&gt;
&lt;pre class="highlight" data-language="shell"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;bin/rails&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; action_text:install&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;But now that the exercise page could have an overflow and users would have to
scroll I wanted the scrollbar to snap if you were close to the top of the
screen. That way the exercise circle would always be in focus and you can&amp;#39;t
accidentally scroll away.&lt;/p&gt;
&lt;p&gt;Once again HTML, or rather CSS, saves the day - this time with scroll-snap.&lt;/p&gt;
&lt;p&gt;I just added the &lt;a href="https://tailwindcss.com/docs/scroll-snap-type"&gt;&lt;em&gt;snap-y&lt;/em&gt; and
&lt;em&gt;snap-mandatory&lt;/em&gt;&lt;/a&gt; Tailwind
classes to the container, and
&lt;a href="https://tailwindcss.com/docs/scroll-snap-align"&gt;&lt;em&gt;snap-start&lt;/em&gt;&lt;/a&gt; to the container
of the exercise circle - that&amp;#39;s it. Now the browser snaps to the top when you
scroll near the top of the page.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--mp4"&gt; &lt;video class="attachment__image--sized placeholder" style="--attachment-width: 850; --attachment-height: 1512; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2013%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRloAAABXRUJQVlA4IE4AAAAwAgCdASoNABAABABoJaQAAu193HWLoeFMWAD%2B94DK1NeZsxFe14ki69lTHdjqNYcLzeYJ3x8Fdx%2FvH%2BSSgr6d9lgB6ub9GxEXjDkYAAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #1f1f1f;" controls autoplay playsinline loop muted poster="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/screencast-from-2025-04-23-17-30-35.jpg/850w.jpg" width="850" height="1512"&gt; &lt;source src="https://media.stanko.io/originals/articles/building-simpl-77CAzym51p5a/screencast-from-2025-04-23-17-30-35.mp4" type="video/mp4"&gt; &lt;/video&gt; &lt;figcaption class="attachment__caption"&gt;Demo of the page snapping to the top when you scroll near the top&lt;/figcaption&gt; &lt;/figure&gt;
&lt;h3 id="full-text-search"&gt;Full text search&lt;/h3&gt;
&lt;p&gt;Since I had four hours left at this point I decided to add full-text search to
the index page.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://www.teloslabs.co/post/full-text-search-with-rails-and-sqlite"&gt;This too was surprisingly easy thanks to an article by Sergio
Alvarez&lt;/a&gt;.
All I needed to do was to create a special kind of virtual table&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; AddFullTextSearchToExercises&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ActiveRecord&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;Migration&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;8.0&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; change&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    create_virtual_table &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:exercise_full_text_search_vectors&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :fts5&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; %w[name description]&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;Populate it and then query it. &lt;a href="https://github.com/monorkin/simpliki/blob/main/app/models/exercise/searchable.rb"&gt;I created a concern under the Exercise
class&lt;/a&gt;
that does that. That way the search logic is nicely self-contained.&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# frozen_string_literal: true&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; Exercise&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  module&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; Searchable&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    extend&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; ActiveSupport&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Concern&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;    DEFAULT_LIMIT&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 25&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;    VECTOR_TABLE_NAME&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; FullTextSearchVector&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;table_name&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    included &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      has_one &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:full_text_search_vector&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; foreign_key:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :rowid&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; dependent:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :destroy&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      after_save &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:create_or_update_full_text_search_vector&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    class_methods &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; search&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-parameter)"&gt;query&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;        return&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; all &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;if&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; query&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;blank?&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;        where(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;#{&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;VECTOR_TABLE_NAME&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; MATCH ?&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; escape_fts_match_value(query))&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;          .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;joins&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:full_text_search_vector&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;          .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;order&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;bm25(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;#{&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;VECTOR_TABLE_NAME&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;)&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;          .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;distinct&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; escape_fts_match_value&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-parameter)"&gt;input&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;        input&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;to_s&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;          .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;gsub&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;#39;&amp;quot;&amp;#39;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;#39;&amp;quot;&amp;quot;&amp;#39;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;          .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;split&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;/\s+/&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;          .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;map&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; { &lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;|&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;word&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;|&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; %Q(&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;#{word}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;)&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;          .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;join&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot; &amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; create_or_update_all_full_text_search_vectors&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;        all&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;find_each&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;amp;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:create_or_update_full_text_search_vector&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; create_or_update_full_text_search_vector&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      params &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;        id:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; id&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;        name:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; name&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;        description:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; description&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;to_plain_text&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      sql &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; if&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; full_text_search_vector&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;        ActiveRecord&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Base&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;sanitize_sql_array&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;          [&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;            &amp;quot;UPDATE &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;#{&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;VECTOR_TABLE_NAME&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; SET name = :name, description = :description WHERE rowid = :id&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;            params&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;          ]&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;        )&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      else&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;        ActiveRecord&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Base&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;sanitize_sql_array&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;          [&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;            &amp;quot;INSERT INTO &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;#{&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;VECTOR_TABLE_NAME&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; (rowid, name, description) VALUES (:id, :name, :description)&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;            params&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;          ]&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;        )&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      self&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;connection&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;execute&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(sql)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;With that I can do a full-text search across all exercises&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Exercise&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;search&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;box&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;h3 id="pagination"&gt;Pagination&lt;/h3&gt;
&lt;p&gt;Now that I had search I wanted to add pagination. &lt;a href="https://stanko.io/how-i-stumbled-upon-strada-while-forwarding-an-email-3W8mbS5KSKXs"&gt;Years ago while exploring
what Strada/Turbo Native
was&lt;/a&gt;
before it was released I saw that 37signals has a really neat way of handling
pagination.&lt;/p&gt;
&lt;p&gt;They have a &amp;quot;pagination&amp;quot; controller that goes and fetches the HTML of the next
page, parses it to get the contents of itself from the next page, and then adds
that contents to the current page.&lt;/p&gt;
&lt;p&gt;No need for special routes or turbo-stream handlers, you just render the
paginated page like always and let the controller do the rest. So I tried to
recreate that.&lt;/p&gt;
&lt;p&gt;First I tried to fetch the next page and parse it using
&lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/DOMParser"&gt;DOMParser&lt;/a&gt; to get
the next page&amp;#39;s document. My first stab worked quite well.&lt;/p&gt;
&lt;pre class="highlight" data-language="js"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  async #&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;fetchNextPageDocument&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;() {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; url&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; this&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;nextPageLinkTarget&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.href&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; response&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; await&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; fetch&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(url&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      headers&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;        &amp;quot;Accept&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;text/html&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    })&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    if&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; (&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;response&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.ok) {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      return&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; new&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; DOMParser&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;()&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.parseFromString&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;await&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; response&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.text&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;()&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;text/html&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    } &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;else&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;      console&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.error&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;Failed to fetch next page:&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; response&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.status)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      return&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; null&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  }&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;Then all I had to do was append the children from the new document to the
current one&lt;/p&gt;
&lt;pre class="highlight" data-language="js"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;	const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; doc&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; await&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; this&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.#fetchNextPageDocument&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;()&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; newList&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; doc&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.querySelector&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;`[data-&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;${&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;this&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.identifier&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;-target=&amp;quot;list&amp;quot;]`&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;    Array&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.from&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;newList&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.children)&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.forEach&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(child &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&amp;gt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;      this&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;listTarget&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.appendChild&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;child&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.cloneNode&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;true&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;));&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    });&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; newNextPageLink&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; doc&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.querySelector&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;`[data-&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;${&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;this&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.identifier&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;-target=&amp;quot;nextPageLink&amp;quot;]`&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    if&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; (newNextPageLink) {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;      this&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;nextPageLinkTarget&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.replaceWith&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;newNextPageLink&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.cloneNode&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;true&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;))&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    } &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;else&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;      this&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;nextPageLinkTarget&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.remove&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;()&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    }&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;&lt;a href="https://github.com/monorkin/simpliki/blob/main/app/javascript/controllers/pagination_controller.js"&gt;And it worked like a
charm&lt;/a&gt;.
I just render the next page like always and let the controller do the rest.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--mp4"&gt; &lt;video class="attachment__image--sized placeholder" style="--attachment-width: 850; --attachment-height: 1512; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2013%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRjYAAABXRUJQVlA4ICoAAAAwAQCdASoNABAABABoJaQAA3AA%2FvI63WV8raZ3MQfIrHmHUIaIiIx%2F4AA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #0c0c0c;" controls autoplay playsinline loop muted poster="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/screencast-from-2025-04-23-18-34-33.jpg/850w.jpg" width="850" height="1512"&gt; &lt;source src="https://media.stanko.io/originals/articles/building-simpl-77CAzym51p5a/screencast-from-2025-04-23-18-34-33.mp4" type="video/mp4"&gt; &lt;/video&gt; &lt;figcaption class="attachment__caption"&gt;Demo of the pagination controller loading in more exercises&lt;/figcaption&gt; &lt;/figure&gt;
&lt;h3 id="auth-create-update--delete"&gt;Auth, create, update &amp;amp; delete&lt;/h3&gt;
&lt;p&gt;Since I still had time left I decided to implement CRUD for exercises. &lt;a href="https://guides.rubyonrails.org/security.html#authentication"&gt;Here I
decided to use the new auth
generator&lt;/a&gt;. It gave
me Users and Sessions, all I now needed to do was add views.&lt;/p&gt;
&lt;p&gt;I quickly wrote a nested form Stimulus controller, some CSS to style the inputs
and called it a day.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--mp4"&gt; &lt;video class="attachment__image--sized placeholder" style="--attachment-width: 850; --attachment-height: 1512; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2013%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRjAAAABXRUJQVlA4ICQAAAAwAQCdASoNABAABABoJaQAA3AA%2FvM7MHZzh1j7F9fqwjmoAAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #050505;" controls autoplay playsinline loop muted poster="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/screencast-from-2025-04-23-18-40-17.jpg/850w.jpg" width="850" height="1512"&gt; &lt;source src="https://media.stanko.io/originals/articles/building-simpl-77CAzym51p5a/screencast-from-2025-04-23-18-40-17.mp4" type="video/mp4"&gt; &lt;/video&gt; &lt;figcaption class="attachment__caption"&gt;Demo of the sign in process and CRUD actions&lt;/figcaption&gt; &lt;/figure&gt;
&lt;h3 id="conclusion"&gt;Conclusion&lt;/h3&gt;
&lt;p&gt;I wanted to share how I built Simpliki because everyone I show it to seems to
think that it&amp;#39;s some SPA made with the latest technology trend when in reality
it&amp;#39;s just &lt;a href="https://github.com/monorkin/simpliki/blob/main/Gemfile"&gt;a vanilla Rails
app&lt;/a&gt; that uses a few
browser APIs.&lt;/p&gt;
&lt;p&gt;Modern browsers are capable of a lot that just a few years ago required cutting
edge technology and all the complexity that comes with it.&lt;/p&gt;
&lt;p&gt;But I also wanted to sing the praises of time-boxing your work and working on
toy-projects. It helped me develop a healthier way to think and break an old
habit.&lt;/p&gt;&lt;/div&gt;
</content>
    <summary type="html">This week I went for a coffee with a friend. As we talked the topic of hobbies came up. I mentioned that I was having a lot of fun working on a toy project over the past week, and then I showed it to him.

Him: So you are dabbling in React?

Me: No, this is just Rails.

Him: Which libraries did...</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Article/41</id>
    <published>2025-03-03T07:00:00Z</published>
    <updated>2026-02-15T09:37:07Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/adversarial-web-services-Vjfkr3DF9LfG"/>
    <title>Adversarial Web Services</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;p&gt;My first job was at a digital agency. In addition to app development we also
hosted the apps that we&amp;#39;d develop for our clients. This allowed us to avoid
fiddly managed hosts, and since we ran everything on a beefy bare-metal server
we didn&amp;#39;t have to manage dozens of machines at different providers. All in all,
this was a good setup.&lt;/p&gt;
&lt;p&gt;Then, one Friday morning, I came into the office and went to clock in using an
internal app. But the app just timed out. We soon noticed that all internal apps
and our client&amp;#39;s apps were timing out, our bare-metal server was completely
unresponsive, and then came the bombshell - both hard drives in the server had
failed.&lt;/p&gt;
&lt;p&gt;Luckily we had hourly backups. Or so we thought. We found out the hard way that
our backup script had a bug that caused it to create empty backups for the past
few months. So we had no viable backup to restore from.&lt;/p&gt;
&lt;p&gt;We spent the next 22 hours provisioning a new machine, re-deploying projects to
it, recovering databases and files from the old machine and uploading them to
the new one. By some miracle we managed to recover everything.&lt;/p&gt;
&lt;p&gt;This incident left a sour taste in everybody&amp;#39;s mouth. Everything was back to
normal, but nobody was confident that it would stay that way for long. We lost
confidence in our tools and ourselves. The next week we were told that we’d be
abandoning our bare-metal server and would move all our apps to AWS so that we
wouldn&amp;#39;t have to monitor the hardware and backups were built-in.&lt;/p&gt;
&lt;p&gt;Since that Friday, for more than a decade now, I&amp;#39;ve used AWS at every job I had.&lt;/p&gt;
&lt;p&gt;I&amp;#39;ve spent countless hours working with its services, configuring and
misconfiguring them, optimizing them for performance and cost. And in my opinion
it just isn&amp;#39;t worth it for most people.&lt;/p&gt;
&lt;h3 id="aws-makes-things-easy-not-simple"&gt;AWS makes things easy, not simple&lt;/h3&gt;
&lt;p&gt;Some people think that AWS-hosted services are somehow magical 1-click solutions
that you pay for and forget about. But the truth is far from that.&lt;/p&gt;
&lt;p&gt;While you can set up a MySQL/Redis/Kafka instance in just a few easy clicks,
after that you have to configure, tweak, monitor and manage that service like
you would if you&amp;#39;d host it yourself.&lt;/p&gt;
&lt;p&gt;In other words, the complexity is the same, but the setup is easier.&lt;/p&gt;
&lt;p&gt;In fact, it&amp;#39;s even more complex than running your own MySQL/Postgres/Redis/...
server because AWS introduces its own arbitrary limitations on what you can and
can&amp;#39;t do. And on top of that it also introduces new concepts that you&amp;#39;ll have to
learn and manage - Floating IPs, Load Balancers, CPU credits, Network credits,
IOPS credits, EBS credits, VPCs, Security Groups, IAM roles...&lt;/p&gt;
&lt;p&gt;If you don’t learn these things, you will eventually either encounter a problem
you can’t fix or receive an exorbitant bill from AWS.&lt;/p&gt;
&lt;p&gt;Probably the best example of this is EC2. You can set up an instance in a
minute. And you can probably deploy something to it with minimal knowledge of
Linux and/or Docker. But what will you do, for example, if you run out of IOPS
credits? Or if Docker refuses to start after a mandatory update? Or your
instance suddenly changes its IP address?&lt;/p&gt;
&lt;p&gt;Setting up a VPS is extremely easy, but if you don&amp;#39;t know Linux, networking and
some AWS gotchas, but managing and monitoring the instance is still complex.&lt;/p&gt;
&lt;p&gt;Think of it as sending an email. It&amp;#39;s simple, all you have to do is open up
Gmail, write a message, enter a recipient and hit send. But under the hood what
is actually happening is a complex exchange between email servers that involves
IMAP, DKIM, SPF, DNS, Spam detection, Spoofing detection, reputation management,
firewalls, storage, and more.&lt;/p&gt;
&lt;p&gt;I hope that some of the acronyms and concepts of the complexity of email servers
are foreign to you because that&amp;#39;s my whole point.&lt;/p&gt;
&lt;p&gt;By buying email from Gmail you are giving Google money so that you don&amp;#39;t have to
deal with the complexities of running an email server. But at AWS you are giving
Amazon money to get your own server. They will set it up for you, but then it&amp;#39;s
up to you to deal with the complexity of running that server.&lt;/p&gt;
&lt;h3 id="take-everything-with-a-grain-of-salt"&gt;Take everything with a grain of salt&lt;/h3&gt;
&lt;p&gt;One of the biggest mistakes I ever made was to trust AWS marketing at face
value. Their marketing often omits important or inconvenient details.&lt;/p&gt;
&lt;p&gt;Figuring out what you are &lt;em&gt;really&lt;/em&gt; buying requires you to read the product&amp;#39;s
documentation - not one page, not the pricing page, but all of it - &lt;a href="https://old.reddit.com/r/aws/comments/bv70k8/aurora_postgres_disastrous_experience/"&gt;and even
then you have to take it with a grain of
salt&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Take the incident &lt;a href="https://stanko.io/monitoring-actioncable-GdeaeHfIU4Yk"&gt;I wrote about 2 weeks
ago&lt;/a&gt;. At work we use
ElastiCache Redis as a PubSub provider for our WebSockets. One day all our
devices started disconnecting randomly. After weeks of investigation it turned
out that we hit a CPU cap even though the instance claimed it was at 10% CPU
during the incident. This happened because we were using a node type that&amp;#39;s
capped at 10% CPU which is, conveniently and vaguely, only mentioned in the
documentation while the pricing page says that we get 2 full vCPUs.&lt;/p&gt;
&lt;p&gt;Or how API Gateway offers WebSockets. &lt;a href="https://docs.aws.amazon.com/apigateway/latest/developerguide/limits.html#apigateway-execution-service-websocket-limits-table"&gt;But you can&amp;#39;t have a connection that
lasts longer than
2h&lt;/a&gt;,
&lt;a href="https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-websocket-api-route-keys-connect-disconnect.html#apigateway-websocket-api-routes-about-disconnect"&gt;and you can&amp;#39;t reliably know if a client has
disconnected&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Or how Aurora offers unprecedented speed and infinite storage. &lt;a href="https://aws.amazon.com/about-aws/whats-new/2022/10/amazon-aurora-postgresql-14-4-version/"&gt;But it might
corrupt your
database&lt;/a&gt;.
&lt;a href="https://plaid.com/blog/exploring-performance-differences-between-amazon-aurora-and-vanilla-mysql/"&gt;And high load on a read-replica degrades the primary&amp;#39;s performance negating the
benefit of having the
replica&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;There are many more examples.&lt;/p&gt;
&lt;p&gt;But the gist of the story is that you have to carefully navigate AWS&amp;#39;
documentation labyrinth - and it really is a labyrinth - to gather as much
information as you can before you commit to a purchase. And even then you aren&amp;#39;t
100% sure what you&amp;#39;ve purchased or how much it will cost you in the end.&lt;/p&gt;
&lt;p&gt;Some might say that this is a &amp;quot;skill issue&amp;quot;, or just &amp;quot;RTFM&amp;quot;. But I think that
people who say this are either stuck in an inferiority complex or are just
coping.&lt;/p&gt;
&lt;p&gt;Imagine if any other business sold stuff like AWS does. Let&amp;#39;s take a restaurant
as an example.&lt;/p&gt;
&lt;p&gt;You go to a restaurant and want to order a pizza. The waiter walks up to you and
tells you what pizzas they have. There are a few you like so you ask for the
price and the waiter brings you the menu. You see that the pizza you like costs
15€ for a large so you order it. Then a short while later the waiter brings you
a single slice of the pizza. You ask him where&amp;#39;s the rest of the pizza and he
points out that on the back of the menu it says that some pizzas come with
limitations mentioned in the &amp;quot;limitation list&amp;quot;. Then he brings you the
limitation list which states that the pizza you ordered is served per slice.&lt;/p&gt;
&lt;p&gt;You&amp;#39;d probably never go to that place ever again.&lt;/p&gt;
&lt;h3 id="sometimes-it-can-feel-like-racketeering"&gt;Sometimes it can feel like racketeering&lt;/h3&gt;
&lt;p&gt;After using AWS for a while I&amp;#39;ve noticed that it isn&amp;#39;t as elastic as it claims
to be, and somehow that feels intentional.&lt;/p&gt;
&lt;p&gt;Let&amp;#39;s take the WebSocket disconnect incident again. We were using an instance
that has 2 vCPUs but can only use 10% consistently and we decided to upgrade to
an instance that can use 100% of the CPU available to it.&lt;/p&gt;
&lt;p&gt;That’s a 10x increase in compute, so we expected a 10x increase in price - and
that’s exactly what we got. The monthly price for our instance went up from
about 10€ to about 100€.&lt;/p&gt;
&lt;p&gt;But we soon learned that, while we did get 10x the CPU, we got only 1/5 of the
bandwidth.&lt;/p&gt;
&lt;p&gt;So we upgraded to the first node type that offered the same bandwidth as before
and our monthly cost rose to 200€ per month.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;That&amp;#39;s a 20x increase in price for 10x the compute.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Never mind the exorbitant price for what is essentially a managed EC2 instance
that otherwise costs 60€. What bugs me the most here is that we&amp;#39;re talking about
a virtual machine, not a bare-metal server. You can give it as much CPU, memory
and bandwidth as you want.&lt;/p&gt;
&lt;p&gt;Nearly all AWS services give you a predefined list of instance types to choose
from - this is not just an ElastiCache issue.&lt;/p&gt;
&lt;p&gt;I understand why smaller hosting providers make you choose from predefined
instance types - they simply don&amp;#39;t have the hardware to allow anybody to choose
what they need. But a hyperscaler like AWS? One that calls their VPS offering
Elastic Cloud Compute?&lt;/p&gt;
&lt;p&gt;Another thing I noticed is that some services have conveniently set, completely
arbitrary, limitations that seem to exist only to make AWS more money.&lt;/p&gt;
&lt;p&gt;For instance, API Gateway imposes a maximum duration on a WebSocket of 2h and
will close any idle connections after 10min. But at the same time AWS charges
you per WebSocket message in addition to each minute each client was connected,
and most applications will need at least 1 message after a reconnect.&lt;/p&gt;
&lt;p&gt;If you have 50k clients you&amp;#39;ll pay AWS 200€ every month just to keep them
connected to a WebSocket 24/7. For comparison, a 60€ EC2 instance can handle
more clients than that with no limitations.&lt;/p&gt;
&lt;p&gt;To go back to the restaurant analogy. Imagine if you bought a Pizza at a
restaurant and the waiter starts bringing you your pizza one slice at a time,
because they decided to do so for whatever reason. Then, at the end of the meal,
they charge you a delivery fee for every individual slice on top of the price of
the pizza.&lt;/p&gt;
&lt;p&gt;The pizza would have to be out of this world for me to come back to that
restaurant.&lt;/p&gt;
&lt;p&gt;When you run into such pricing practices AWS doesn&amp;#39;t feel like a partner helping
you build stuff anymore; it feels like an adversary trying to squeeze every
penny out of you.&lt;/p&gt;
&lt;h3 id="serfs-and-sovereigns"&gt;Serfs and sovereigns&lt;/h3&gt;
&lt;p&gt;My biggest gripe with AWS are its proprietary services. Some of them are great
and novel while others are OK equivalents of existing open-source services.&lt;/p&gt;
&lt;p&gt;When it comes to great and novel services, the first thing that comes to my mind
is S3.&lt;/p&gt;
&lt;p&gt;You can get S3-compatible object storage at a dozen places today - &lt;a href="https://www.digitalocean.com/products/spaces"&gt;DigitalOcean
Spaces&lt;/a&gt;, &lt;a href="https://www.backblaze.com/cloud-storage"&gt;Backblaze
B2&lt;/a&gt;, &lt;a href="https://www.cloudflare.com/developer-platform/products/r2/"&gt;Cloudflare
R2&lt;/a&gt;,
&lt;a href="https://www.hetzner.com/storage/object-storage/"&gt;Hetzner&amp;#39;s&lt;/a&gt;,
&lt;a href="https://www.ovhcloud.com/en/public-cloud/object-storage/"&gt;OVH&amp;#39;s&lt;/a&gt; and
&lt;a href="https://www.scaleway.com/en/object-storage/"&gt;Scaleway&amp;#39;s Object Storage&lt;/a&gt;, or
open-source &lt;a href="https://github.com/deuxfleurs-org/garage"&gt;Garage&lt;/a&gt; and
&lt;a href="https://github.com/minio/minio"&gt;MinIO&lt;/a&gt; just to name a few - but that wasn&amp;#39;t
always the case.&lt;/p&gt;
&lt;p&gt;A little over 10 years ago S3 reigned supreme when it came to storage. The only
real alternative to it was to either store the files locally on your server or
using a network attached drive, both of which required some skill to scale and
manage - at least compared to S3.&lt;/p&gt;
&lt;p&gt;Back then, it was common for people to e.g. use a managed host but still have an
AWS account just to get access to S3. Problem was, we were completely dependent
on AWS for object storage. If they raised prices we had no real option but to
pay. There was no contingency if S3 had an incident you just had to deal with
the customer backlash.&lt;/p&gt;
&lt;p&gt;When other S3-compatible object storage providers started popping up not only
did prices come down but you could suddenly choose a provider that suits your
need - no ingress fees, no egress fees, cheaper long-term storage, faster
storage, HIPPAA-compliant storage, etc. You could mix and match providers to
suit your need, or use multiple as a contingency.&lt;/p&gt;
&lt;p&gt;But most importantly, any code I write today that fetches or uploads files to S3
will work just as well anywhere. This means that, if I don&amp;#39;t like a price hike
or a term change from my current object storage provider, I can just pick up my
stuff and move to another provider that offers more favorable terms.&lt;/p&gt;
&lt;p&gt;When it comes to S3 - 10 years ago we were basically serfs, today we are
sovereign and I don&amp;#39;t miss the old days one bit.&lt;/p&gt;
&lt;p&gt;There are other great AWS services - like DynamoDB - but I avoid them in favor
of open-source alternatives just because I don&amp;#39;t want to find myself locked in
and having to accept any term change to avoid spending days switching to
something else.&lt;/p&gt;
&lt;h3 id="competence"&gt;Competence&lt;/h3&gt;
&lt;p&gt;The incident that happened at my first job wasn&amp;#39;t caused by our hosting
provider, it was caused by us. Our hosting provider at the time gave us all the
tools and services we needed to prevent that incident from happening, we just
didn&amp;#39;t know how to use them. It was our incompetence that caused the incident.&lt;/p&gt;
&lt;p&gt;As a knee-jerk reaction we migrated to AWS chasing a promise, or rather a dream,
that it will somehow save us from our incompetence - it didn&amp;#39;t. All it did was
mask that incompetence by making things easy to set up, but our old problems all
eventually reoccurred.&lt;/p&gt;
&lt;p&gt;Only by learning to run my own server through learning Linux, Docker,
networking, monitoring, backups and server security did I manage to avoid making
the same mistakes - not just with AWS but at any host.&lt;/p&gt;
&lt;p&gt;And that&amp;#39;s the take-away here - AWS, or any other managed service provider,
won&amp;#39;t save you from having to learn how to manage that service. They will give
you monitoring, but you still have to know what the metrics mean and what&amp;#39;s
acceptable for you. They will give you hardware, but you have to know what&amp;#39;s
enough for you. They will give you firewalls and other networking tools, but you
still have to configure them for your use-case.&lt;/p&gt;
&lt;p&gt;All you need to learn this stuff is a Virtual Machine or a Raspberry Pi and some
time. Set up a small server and try to configure a firewall, install a database,
run an app, upgrade the database. If you fail, and you probably will, just
re-install and try again.&lt;/p&gt;
&lt;p&gt;One of the best decisions I ever made was learning how to self-host the services
I use at work. It was difficult at first - as going from 0 to 1 always is - but
it made me much more confident and better at my job. I now understand not only
how to operate these services but also how to optimize my code to make the most
of them. And best of all is that these skills carry over to any hosting
provider, project and job.&lt;/p&gt;
&lt;p&gt;If you are willing to learn, I think you will be better off renting a few VPS
instances or bare-metal machines from the likes of
&lt;a href="https://www.digitalocean.com/"&gt;DigitalOcean&lt;/a&gt;,
&lt;a href="https://www.hetzner.com/"&gt;Hetzner&lt;/a&gt;, &lt;a href="https://www.ovhcloud.com/en-gb/"&gt;OVH&lt;/a&gt;, and
&lt;a href="https://www.scaleway.com/en/"&gt;Scaleway&lt;/a&gt;. The predictable pricing, and lack of
AWS BS, will save you time and money. Or if you don&amp;#39;t want to learn all this,
then go with a hosting platform like &lt;a href="https://www.heroku.com/"&gt;Heroku&lt;/a&gt; or
&lt;a href="https://fly.io/"&gt;Fly&lt;/a&gt;.&lt;/p&gt;&lt;/div&gt;
</content>
    <summary type="html">My first job was at a digital agency. In addition to app development we also hosted the apps that we'd develop for our clients. This allowed us to avoid fiddly managed hosts, and since we ran everything on a beefy bare-metal server we didn't have to manage dozens of machines at different...</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Article/40</id>
    <published>2025-01-27T10:26:00Z</published>
    <updated>2026-02-15T09:37:07Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/monitoring-actioncable-GdeaeHfIU4Yk"/>
    <title>Monitoring ActionCable</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;p&gt;A few weeks ago I was woken up with the following message &amp;quot;Hey, devices are
randomly disconnecting from WebSockets can you come online and help?&amp;quot;.&lt;/p&gt;
&lt;p&gt;Usually, when a widespread problem like this occurs out of the blue, my first
goal is to make the app mostly operational again and my second goal is to fix
the cause of the problem. To do that I usually go over each component that could
be causing this to check if it&amp;#39;s functioning correctly and then drill down on
any components that aren&amp;#39;t.&lt;/p&gt;
&lt;h3 id="step-one-identify-which-components-could-be-at-fault"&gt;Step one, identify which components could be at fault.&lt;/h3&gt;
&lt;p&gt;We use Rails&amp;#39; ActionCable for WebSockets. I&amp;#39;ve done a deep dive into ActionCable
&lt;a href="https://stanko.io/deconstructing-action-cable-DC7F33OsjGmK"&gt;in a previous
article&lt;/a&gt; if you&amp;#39;d
like to read more about it.&lt;/p&gt;
&lt;p&gt;ActionCable, except for the Ruby process itself, depends on Redis (in my case)
and on the database(s) that the app uses.&lt;/p&gt;
&lt;h3 id="step-two-prioritize"&gt;Step two, prioritize.&lt;/h3&gt;
&lt;p&gt;Since I knew that the devices were randomly disconnecting I assumed that either
app containers were restarting for whatever reason, or that something was
causing ActionCable&amp;#39;s worker pool to lag which then caused missed heartbeats.&lt;/p&gt;
&lt;p&gt;This means that the Ruby process is the most likely culprit, then the database,
and finally Redis.&lt;/p&gt;
&lt;h3 id="step-three-check-each-components"&gt;Step three, check each components.&lt;/h3&gt;
&lt;p&gt;I checked Grafana to see what&amp;#39;s going on with the app containers and saw that
CPU was elevated but wasn&amp;#39;t exceeding 80%, while memory didn&amp;#39;t go above 70%, and
the auto-scaler was adding more containers as needed. Everything was in order
given that a reconnect storm was going on.&lt;/p&gt;
&lt;p&gt;Then I checked Elastic APM to see what was going on with /cable. But the metrics
were phenomenal - sub-millisecond response times and 100% successful responses.
Fantastic numbers for a reconnect storm.&lt;/p&gt;
&lt;p&gt;Next I checked the database. CPU and memory were at average levels, there were
no slow queries. Everything was fine.&lt;/p&gt;
&lt;p&gt;Lastly, I checked Redis. To my surprise, here too, everything was fine. The CPU
never exceeded 10% and memory was at 5%.&lt;/p&gt;
&lt;p&gt;What was going on?&lt;/p&gt;
&lt;h3 id="step-four-stabilize-the-app"&gt;Step four, stabilize the app.&lt;/h3&gt;
&lt;p&gt;Since Redis and the database were fine, in an Hail Mary attempt, I scaled-out
the number of app containers to stop the disconnects. Surprisingly, this
worked... This pointed a spotlight at the application code and ActionCable. They
must have been the faulty component since the scale-out fixed the issue - but
how?&lt;/p&gt;
&lt;h3 id="step-five-find-the-root-cause"&gt;Step five, find the root cause.&lt;/h3&gt;
&lt;p&gt;At that point I realized that I had no clue what was going on with our
WebSockets after they were established. Elastic APM only captures metrics about
the HTTP part of the WebSocket connection, but once it was established I had
absolutely no insight into what&amp;#39;s going on - how many clients there are, what
the latency was like, how many messages were being sent...&lt;/p&gt;
&lt;p&gt;The best way to figure that out was to introduce some kind of monitoring. But
what should I monitor and how should I surface those metrics?&lt;/p&gt;
&lt;p&gt;I&amp;#39;m not one to reinvent the wheel, so I checked if some tool for monitoring
ActionCable already exists. I found out that Sentry&amp;#39;s and AppSignal&amp;#39;s APM tools
monitor ActionCable&amp;#39;s action execution times.&lt;/p&gt;
&lt;h3 id="action-execution-time"&gt;Action Execution Time&lt;/h3&gt;
&lt;p&gt;Action execution time measures how long it takes for a Channel to process a
received message. This metric is important because it can show if you have a
slow action which binds up the worker pool for too long and thus lowers
throughput and increases latency. It&amp;#39;s a nice way to quickly see if everything
is alright with ActionCable.&lt;/p&gt;
&lt;p&gt;I quickly exposed that metric to Elastic APM using the following code.&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# config/initializers/elastic_apm.rb&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;ActiveSupport&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Notifications&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;subscribe&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;perform_action.action_cable&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; |&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;event&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;|&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  transaction &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; ElasticAPM&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;start_transaction&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;#{event&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;payload&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:channel_class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;]}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;#&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;#{event&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;payload&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:action&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;]}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  transaction&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;start&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(event&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;time&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; *&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 1000.0&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;  ElasticAPM&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;end_transaction&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(event&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;payload&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:exception&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;]&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;blank?&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;That worked!&lt;/p&gt;
&lt;h3 id="pubsub-latency"&gt;PubSub latency&lt;/h3&gt;
&lt;p&gt;I soon noticed that some extra metrics, like the PubSub latency, would be
helpful. Knowing the PubSub latency would help me identify problems with the
PubSub component of ActionCable. If the latency - the time it takes for a
message to be sent to clients since it was broadcast from the app - goes up that
means that the PubSub service, in my case Redis, is probably the cause of a
problem.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pubsub_latency.png/793w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 793; --attachment-height: 255; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%205%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRjgAAABXRUJQVlA4ICwAAACQAQCdASoQAAUABABoJZwAApz%2BdxAA%2FvB8mpyMqPZf8CwBTwMS6wBmudfoAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #ece5e8;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pubsub_latency.png/480w.webp 480w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pubsub_latency.png/793w.webp 793w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Diagram that shows what PubSub latency measures" loading="lazy" decoding="async" class="attachment__image" width="793" height="255" src="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pubsub_latency.png/793w.png" srcset="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pubsub_latency.png/480w.png 480w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pubsub_latency.png/793w.png 793w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Diagram that shows what PubSub latency measures&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;To measure that latency I have to know how much time has passed since a message
was broadcast to when it was received in the ActionCable server. The easiest way
to do that is to broadcast a message with the current time in it, then receive
it in some channel and calculate the time difference between the time I received
the message and the timestamp in the message.&lt;/p&gt;
&lt;p&gt;To send the a message with the current timestamp I can do the following and run
it from a cron job every minute&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;measurement_payload &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;  sent_at:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Time&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;now&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;to_f&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;}&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;ActionCable&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;server&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;broadcast&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;metrics&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; measurement_payload)&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;To receive the message and calculate the latency I can do this&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;stream_from(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;metrics&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; proc &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;do&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; |&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;json&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;|&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;  payload &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; ActiveSupport&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;JSON&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;decode(json)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  sent_at &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; payload[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:sent_at&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  latency &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Time&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;now&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;to_f&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; -&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; sent_at&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;  Rails&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;logger&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;debug&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;PubSub Latency: &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;#{latency}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;But where can I put this code and how do I expose the latency?&lt;/p&gt;
&lt;p&gt;After some experimentation I decided to create a stream from every open channel
in ActionCable. This sounds pretty bad, and I&amp;#39;d like to find a better way to do
this, but it has some upsides and the downsides can be minimized. The upside is
that no measurements are performed if there are no clients. The downside is that
each client has its own metrics subscription even though only one subscription
per server is needed.&lt;/p&gt;
&lt;p&gt;There are many ways to limit the subscription to be one per server and I opted
for the simplest one - a mutex. A mutex, aka mutually exclusive lock, allows me
to ensure that just one thread is running a piece of code at a time. In Ruby,
you&amp;#39;d usually use
&lt;a href="https://rubyapi.org/3.4/o/thread/mutex#method-i-synchronize"&gt;Mutex#synchronize&lt;/a&gt;
like so:&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# Create the mutex and a counter&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;mutex &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Mutex&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;new&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;count &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# Defines a proc that prints the value of the counter&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# and increments it by 1&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;work &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; proc&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;  10&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;times&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    mutex&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;synchronize&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;      sleep&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.1&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;      puts&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;count: &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;#{count}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      count &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;+=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 1&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# Spawns 50 threads that all call the proc we just defined&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# and then waits for all the threads to finish&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;50&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;times&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;map&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;  Thread&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;new&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; { work&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;call&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;to_a&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;each&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;amp;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:join&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# Prints the final count after all the threads are done&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;puts&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;Final count: &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;#{count}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;If you now run this code from IRB and look at the output you&amp;#39;d see that the
count always goes up by one and that the final count is 500&lt;/p&gt;
&lt;pre class&gt;&lt;span class="line"&gt;&lt;span&gt;...&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;count: 487&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;count: 488&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;count: 489&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;count: 490&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;count: 491&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;count: 492&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;count: 493&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;count: 494&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;count: 495&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;count: 496&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;count: 497&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;count: 498&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;count: 499&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;Final count: 500&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;But if you remove the mutex, first you&amp;#39;d notice that the code runs much faster,
second thing you&amp;#39;d notice is that the output is completely random, sometimes
repeats, and change each time you run this&lt;/p&gt;
&lt;pre class&gt;&lt;span class="line"&gt;&lt;span&gt;...&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;count: 487&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;count: 488&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;count: 489&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;count: 490&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;count: 491&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;count: 492&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;count: 493&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;count: 493&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;count: 495&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;count: 496&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;count: 495&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;count: 496&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;count: 494&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;Final count: 497&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;This is because, without the mutex, all the threads try to read and update the
counter at the same time. Sometimes two threads read the value at the same
time - like 493 in the output above - which results in a value being skipped -
because both read 493 and updated it to 494.&lt;/p&gt;
&lt;p&gt;With the mutex, one thread locks the mutex using the synchronize method, updates
the counter ten times, and unlocks the mutex, then another thread lock it again,
etc.&lt;/p&gt;
&lt;p&gt;That&amp;#39;s why with the mutex the code takes longer to complete, each thread runs
after the other so the total time is 50 times 10 times 100ms which is 50
seconds. While without the mutex all threads run at the same time so the total
time it takes to execute the code is 10 x 100ms or 1 second.&lt;/p&gt;
&lt;p&gt;To guarantee that just 1 subscription will measure the latency I can use
&lt;a href="https://rubyapi.org/3.4/o/thread/mutex#method-i-try_lock"&gt;Mutex#try_lock&lt;/a&gt; and
&lt;a href="https://rubyapi.org/3.4/o/thread/mutex#method-i-unlock"&gt;Mutex#unlock&lt;/a&gt; instead
of Mutex#synchronize. Unlike synchronize which blocks and waits until the mutex
unlock, try_lock doesn&amp;#39;t block and instead returns true if it acquired the lock
or false if it didn&amp;#39;t. With try_lock I can tell all threads to try an measure
the latency, the thread that locks the mutex then performs the measurement and
all others return.&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;MUTEX&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Mutex&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;new&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; run_unless_already_running&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;amp;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-parameter)"&gt;block&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  lock_acquired &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; MUTEX&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;try_lock&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  return&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; unless&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; lock_acquired&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  block&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;call&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;ensure&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;  MUTEX&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;unlock&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; if&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; lock_acquired&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; collect&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-parameter)"&gt;payload&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  run_unless_already_running &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    latency &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Time&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;now&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;to_f&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; -&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; sent_at&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;    Rails&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;logger&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;debug&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;PubSub Latency: &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;#{latency}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;stream_from(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;metrics&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; proc &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;do&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; |&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;json&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;|&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;  payload &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; ActiveSupport&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;JSON&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;decode(json)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;  collect(payload)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;To make things prettier I put all code related to metrics collection into a
module&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;module&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ActionCable&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  module&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; MetricsCollector&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;    MUTEX&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Mutex&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;new&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;    STREAM_NAME&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;internal.action_cable.metrics&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;    COLLECTION_TRESHOLD&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 60&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;seconds&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    cattr_accessor &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:last_collected_at&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; &amp;lt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; self&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; measure&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;        ActionCable&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;server&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;broadcast&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(stream_name&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; measurement_payload)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; stream_name&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-parameter)"&gt; STREAM_NAME&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; measurement_payload&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;        {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;          sent_at:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Time&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;now&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;to_f&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;        }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; collect&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-parameter)"&gt;payload&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;        run_unless_already_running &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;          next&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; if&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; measurement_already_collected?&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;          self&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;last_collected_at&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Time&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;now&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;          collect_measurement(payload)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;        end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; measurement_already_collected?&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-parameter)"&gt; last_collected_at&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;amp;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;after?&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(collection_trashold&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;ago&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; collection_trashold&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-parameter)"&gt; COLLECTION_TRESHOLD&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      private&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; run_unless_already_running&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;amp;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-parameter)"&gt;block&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;        lock_acquired &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; MUTEX&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;try_lock&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;        return&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; unless&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; lock_acquired&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;        block&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;call&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      ensure&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;        MUTEX&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;unlock&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; if&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; lock_acquired&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; collect_measurement&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-parameter)"&gt;payload&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;        pub_sub_latency &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; [&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;0&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Time&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;now&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;to_f&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; -&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; payload&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;fetch&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;sent_at&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; -&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Float&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;INFINITY&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)]&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;max&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;        Rails&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;logger&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;debug&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;PubSub latency: &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;#{pub_sub_latency}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;Then I changed my cron job to run&lt;/p&gt;
&lt;pre class="highlight" data-language="shell"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;bin/rails&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; runner&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;ActionCable::MetricsCollector.measure&amp;quot;&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;And added the following to &lt;em&gt;app/channels/application_cable/channel.rb&lt;/em&gt;&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;module&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ApplicationCable&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; Channel&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ActionCable::Channel&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;Base&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    after_subscribe &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;      stream_from(&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;        ::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;ActionCable&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;MetricsCollector&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;stream_name&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;        proc &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;do&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; |&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;json&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;|&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;          payload &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; ActiveSupport&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;JSON&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;decode(json)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;          ::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;ActionCable&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;MetricsCollector&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;collect&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(payload)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;        rescue&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&amp;gt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; e&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;          Rails&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;logger&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;error&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;Failed to collect metrics: &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;#{e}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;        end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      )&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;How do I expose this metric to Elastic APM?&lt;/p&gt;
&lt;p&gt;After some thought I decided I wouldn&amp;#39;t. I could hack something together, but
I&amp;#39;m shoehorning this metric in and later metrics I flat out don&amp;#39;t know how to
expose in Elastic AMP. So I decided not to bother with this and figure it out
later.&lt;/p&gt;
&lt;h3 id="number-of-connections"&gt;Number of connections&lt;/h3&gt;
&lt;p&gt;Since I&amp;#39;m running an IoT platform I have a somewhat constant number of clients
connected at all times. This means that by knowing the number of clients at any
given moment I can easily see if there is a problem or if I need to scale out.&lt;/p&gt;
&lt;p&gt;Luckily, with the module that I&amp;#39;ve added to measure the PubSub latency it&amp;#39;s easy
to expose the number of client. All I have to do is change the
&lt;em&gt;collect_measurement&lt;/em&gt; method like so&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; collect_measurement&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-parameter)"&gt;payload&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  pub_sub_latency &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; [&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;0&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Time&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;now&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;to_f&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; -&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; payload&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;fetch&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;sent_at&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; -&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Float&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;INFINITY&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)]&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;max&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;  Rails&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;logger&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;debug&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;PubSub latency: &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;#{pub_sub_latency}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  connection_count &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; ActionCable&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;server&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;connections&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;length&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;  Rails&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;logger&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;debug&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;Connection count: &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;#{connection_count}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;Without that module I&amp;#39;d probably setup a periodic task like so&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;module&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ApplicationCable&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; Channel&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ActionCable::Channel&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;Base&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    periodically &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;every:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 1&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;minute&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	  connection_count &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; ActionCable&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;server&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;connections&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;length&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;      Rails&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;logger&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;debug&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;Connection count: &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;#{connection_count}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;The trick to getting the count is to call
&lt;em&gt;ActionCable.server.connections.length&lt;/em&gt; from within the server process. If you
call it from the Rails console or from a rails runner the count will always
return zero as the connections only exist in the server&amp;#39;s memory.&lt;/p&gt;
&lt;h3 id="client-server-latency"&gt;Client-Server Latency&lt;/h3&gt;
&lt;p&gt;Since I&amp;#39;m running &lt;a href="https://stanko.io/tracking-online-presence-with-actioncable-ukEi6sUZ98Bz"&gt;my own patch of ActionCable that adds PONG
messages&lt;/a&gt;
in addition to the standard heartbeat PING message, I can easily expose the time
it takes for a message to travel from the server over the Internet to a client.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/client_server_latency.png/1365w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 1365; --attachment-height: 400; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%205%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRjgAAABXRUJQVlA4ICwAAADQAQCdASoQAAUABABoJaQAAp3InkS1gAD%2B8HxCO6UZvG17%2BSRY7P4QoW6AAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #e8e6e8;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/client_server_latency.png/480w.webp 480w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/client_server_latency.png/800w.webp 800w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/client_server_latency.png/1200w.webp 1200w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/client_server_latency.png/1365w.webp 1365w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Diagram that shows what Client-Server latency measures" loading="lazy" decoding="async" class="attachment__image" width="1365" height="400" src="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/client_server_latency.png/1200w.png" srcset="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/client_server_latency.png/480w.png 480w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/client_server_latency.png/800w.png 800w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/client_server_latency.png/1200w.png 1200w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/client_server_latency.png/1365w.png 1365w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Diagram that shows what Client-Server latency measures&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;This metric isn&amp;#39;t useful for regular web apps as it&amp;#39;s non actionable - you can&amp;#39;t
fix your client&amp;#39;s Internet. But it&amp;#39;s useful for apps, like mine, where you have
control over clients and want to have them connected 24/7.&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# Stock Rails 8 doesn&amp;#39;t have this notificaiton&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;ActiveSupport&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Notifications&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;subscribe&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;client_latency.action_cable&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; |&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;_name&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; _start&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; _finish&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; _id&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; payload&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;|&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;  Rails&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;logger&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;debug&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;Client latency: &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;#{payload[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:value&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;]}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;Clients will start disconnecting if this latency exceeds six seconds. If the
median latency exceeds a few seconds this indicates a widespread Internet issue
&lt;em&gt;(which is more common in the US than I though it would be)&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Now that I have all these metrics I have to expose them somehow.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;I decided to talk to a coworker from the DevOps team about how they collect and
expose metrics from our machines and he told me that they use
&lt;a href="https://prometheus.io/docs/introduction/overview/"&gt;Prometheus&lt;/a&gt; and that he
could configure it to collect metrics from ActionCable.&lt;/p&gt;
&lt;p&gt;But what is Prometheus?&lt;/p&gt;
&lt;p&gt;Prometheus is an open-source monitoring and alerting system - it&amp;#39;s a service
that collects metrics, allows you to query them, and to configure alerts when
they exceed a threshold. It&amp;#39;s a bit different from the SaaS monitoring solutions
because it scrapes metrics from your application instead of having your
application push metrics to it.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/prometheus.png/1420w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 1420; --attachment-height: 610; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%207%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRkwAAABXRUJQVlA4IEAAAADwAQCdASoQAAcABABoJZQC7AEQ%2FBDW0gAA%2Fu2qAdDl7F4oQ3mvVyo8%2BwlVwr%2FuEwPWJdGyd9t3EG3QUX2CWgAA%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #dedede;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/prometheus.png/480w.webp 480w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/prometheus.png/800w.webp 800w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/prometheus.png/1200w.webp 1200w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/prometheus.png/1420w.webp 1420w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Diagram that explains the difference between Prometheus and other APM services" loading="lazy" decoding="async" class="attachment__image" width="1420" height="610" src="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/prometheus.png/1200w.png" srcset="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/prometheus.png/480w.png 480w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/prometheus.png/800w.png 800w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/prometheus.png/1200w.png 1200w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/prometheus.png/1420w.png 1420w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Diagram that explains the difference between Prometheus and other APM services&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;Usually, you&amp;#39;d expose a /metrics endpoint that renders all the metrics the
application has collected in a format that Prometheus can parse.&lt;/p&gt;
&lt;p&gt;I wasn&amp;#39;t sure how to do that at first, but I remember reading &lt;a href="https://dev.37signals.com/kamal-prometheus/"&gt;an article on
Basecamp&amp;#39;s dev blog and it mentioned a gem called
Yabeda&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://github.com/yabeda-rb/yabeda"&gt;Yabeda&lt;/a&gt; is a framework for monitoring Ruby
applications. Using it you can measure, or report measurements, to various
monitoring services like Datadog, NewRelic, Honeybadger, AWS CloudWatch,
Prometheus, and many others.&lt;/p&gt;
&lt;p&gt;In a nutshell, I can report my measurements to Yabeda and then it handles
exposing those metrics to various different monitoring services - like
Prometheus - for me.&lt;/p&gt;
&lt;p&gt;According to the documentation, before I can use Yabeda I have to configure it
which means that I have to define all the metrics that I&amp;#39;m going to expose.
Yabeda supports a handful of types of metrics - counters, gauges, and
histograms - so let me explain those first.&lt;/p&gt;
&lt;p&gt;A counter is a number that can only ever go up by some amount. E.g. the number
of ActiveJob jobs processed is a counter because the metric is a number that can
only ever increase. But the number of connections to ActionCable isn&amp;#39;t a counter
since the number goes up when more clients connect and goes down when clients
disconnect.&lt;/p&gt;
&lt;p&gt;To represent numbers that can go both up and down as a metric we need a gauge.
Gauges represent a metric that can be any number at any time.&lt;/p&gt;
&lt;p&gt;Finally, a histogram represents a distribution of values. It tells you how many
measurements you&amp;#39;ve got in a certain range called a bucket. Histograms are
usually represented by a set of counters and gauges - one counter for each
bucket, one counter for the number of measurements, and one gauge for the sum of
all measurements. This sounds a bit complicated, but it allows you to calculate
the average, median, and other distribution metrics quickly and without having
to store every single measurement somewhere. Usually you&amp;#39;d use a histogram to
represent time measurements like response times.&lt;/p&gt;
&lt;p&gt;Defining these metrics is fairly straight forward&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# config/initializers/yabeda.rb&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Yabeda&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;configure&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  group &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:action_cable&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    histogram &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:client_server_latency&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      comment &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;The time it takes for a message to travel via the Internet to a client&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      unit &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:seconds&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      buckets [&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;0.005&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.01&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.025&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.05&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.1&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.25&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.5&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 1&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 2.5&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 5&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 10&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    histogram &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:pubsub_latency&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      comment &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;The time it takes for a message to travel through the PubSub service&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      unit &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:seconds&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      buckets [&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;0.005&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.01&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.025&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.05&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.1&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.25&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.5&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 1&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 2.5&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 5&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 10&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    histogram &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:action_execution_duration&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      comment &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;The time it takes to perform an invoked action&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      unit &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:seconds&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      buckets [&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;0.005&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.01&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.025&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.05&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.1&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.25&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.5&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 1&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 2.5&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 5&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 10&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    gauge &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:connection_count&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; comment:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;Number of open WebSocket connections&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;To report a metric I have to build a chain of method calls consisting of the
group names of the metric I want to report, then the metric&amp;#39;s name, and finally
call &lt;em&gt;measure&lt;/em&gt; on that. The measure method accepts either one argument and a
block or two arguments. The first argument is a Hash of tags to add to the
measurement, I&amp;#39;ll explain those later. The second is the measurement itself.
Without the second argument, Yabeda will execute the block, measure it&amp;#39;s
execution time, and report that.&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Yabeda&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;action_cable&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;client_server_latency&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;measure&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;({}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.123&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# or&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Yabeda&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;action_cable&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;client_server_latency&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;measure&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;({}) &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;  sleep&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.123&lt;/span&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt; # do some work&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;I can now update all ActiveSupport Notification subscriptions to report to
Yabeda, and I can move them all to Yabeda&amp;#39;s initializer.&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# config/initializers/yabeda.rb&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Yabeda&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;configure&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  group &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:action_cable&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    histogram &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:client_server_latency&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      comment &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;The time it takes for a message to travel via the Internet to a client&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      unit &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:seconds&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      buckets [&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;0.005&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.01&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.025&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.05&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.1&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.25&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.5&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 1&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 2.5&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 5&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 10&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    histogram &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:pub_sub_latency&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      comment &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;The time it takes for a message to travel through the PubSub service&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      unit &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:seconds&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      buckets [&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;0.005&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.01&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.025&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.05&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.1&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.25&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.5&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 1&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 2.5&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 5&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 10&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    histogram &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:action_execution_duration&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      comment &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;The time it takes to perform an invoked action&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      unit &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:seconds&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      buckets [&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;0.005&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.01&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.025&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.05&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.1&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.25&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.5&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 1&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 2.5&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 5&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 10&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    gauge &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:connection_count&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; comment:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;Number of open WebSocket connections&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;ActiveSupport&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Notifications&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;subscribe&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;perform_action.action_cable&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; |&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;event&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;|&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  name &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;#{event&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;payload&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:channel_class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;]}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;#&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;#{event&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;payload&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:action&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;]}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  duration &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; event&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;duration&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; /&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 1000.0&lt;/span&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt; # convert ms to seconds&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;  Yabeda&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;    .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;action_cable&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;    .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;action_execution_duration&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;    .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;measure&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;({ &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;action:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; action }&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; duration) &lt;/span&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# I&amp;#39;ve used a tag here&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;ActiveSupport&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Notifications&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;subscribe&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;client_latency.action_cable&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; |&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;_name&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; _start&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; _finish&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; _id&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; payload&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;|&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  client_server_latency &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; payload[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:value&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;  Yabeda&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;    .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;action_cable&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;    .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;client_server_latency&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;    .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;measure&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;({}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; client_server_latency)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;You might have noticed that I&amp;#39;ve used a tag when reporting the action execution
time. You can think of tags as groups of measurements. Instead of collecting the
action execution time of all actions in ActionCable, using tags I&amp;#39;ll collect the
action execution time of each individual action. That way I can easily pin point
which action has the highest duration, but I can still get the average execution
time by finding the average of all the tagged measurements.&lt;/p&gt;
&lt;p&gt;Without tags I&amp;#39;d have to define dozens of histograms - one for each action. But
with tags I can define one histogram and let Yabeda group my measurements for
me.&lt;/p&gt;
&lt;p&gt;I have to update the metrics collector too&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;module&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ActionCable&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  module&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; MetricsCollector&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;    MUTEX&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Mutex&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;new&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;    STREAM_NAME&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;internal.action_cable.metrics&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;    COLLECTION_TRESHOLD&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 60&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;seconds&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    cattr_accessor &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:last_collected_at&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; &amp;lt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; self&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; measure&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;        ActionCable&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;server&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;broadcast&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(stream_name&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; measurement_payload)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; stream_name&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-parameter)"&gt; STREAM_NAME&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; measurement_payload&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;        {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;          sent_at:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Time&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;now&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;to_f&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;        }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; collect&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-parameter)"&gt;payload&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;        run_unless_already_running &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;          next&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; if&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; measurement_already_collected?&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;          self&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;last_collected_at&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Time&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;now&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;          collect_measurement(payload)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;        end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; measurement_already_collected?&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-parameter)"&gt; last_collected_at&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;amp;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;after?&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(collection_trashold&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;ago&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; collection_trashold&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-parameter)"&gt; COLLECTION_TRESHOLD&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      private&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; run_unless_already_running&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;amp;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-parameter)"&gt;block&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;        lock_acquired &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; MUTEX&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;try_lock&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;        return&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; unless&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; lock_acquired&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;        block&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;call&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      ensure&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;        MUTEX&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;unlock&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; if&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; lock_acquired&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; collect_measurement&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-parameter)"&gt;payload&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;        pub_sub_latency &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; [&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;0&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Time&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;now&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;to_f&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; -&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; payload&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;fetch&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;sent_at&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; -&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Float&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;INFINITY&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)]&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;max&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;        Yabeda&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;action_cable&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;pub_sub_latency&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;measure&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;({}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; pub_sub_latency)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;		connection_count &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; ActionCable&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;server&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;connections&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;length&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;		Yabeda&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;action_cable&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;connection_count&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;set&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;({}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; connection_count)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;Now that my metrics are in Yabeda, I have to somehow expose them to Prometheus.
For that I&amp;#39;ve added the &lt;a href="https://github.com/yabeda-rb/yabeda-prometheus-mmap"&gt;yabeda-prometheus-mmap
gem&lt;/a&gt; and mounted its route&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# config/routes.rb&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Rails&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;application&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;routes&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;draw&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  mount &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Yabeda&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Prometheus&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Exporter&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; at:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;/metrics&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;  # ...&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;There is also
&lt;a href="https://github.com/yabeda-rb/yabeda-prometheus"&gt;yabeda-prometheus&lt;/a&gt;, if you are
running a single worker server these two are effectively the same. But if you
are running Puma with multiple workers, or SolidQueue with multiple workers then
the mmap version of the gem will be much faster.&lt;/p&gt;
&lt;p&gt;Additionally, when running multiple workers, you probably also want to add
&lt;em&gt;&amp;quot;aggregation: :sum&amp;quot;&lt;/em&gt; to all gauges. Without that option, if a worker fails and
gets restarted, you&amp;#39;ll get very wonky metrics because the gauges will get
counted twice with the old worker&amp;#39;s gauge being stuck at whatever value it was
before the worker crashed.&lt;/p&gt;
&lt;p&gt;Now, with the route mounted I can connect to my ActionCable server and then open
&lt;em&gt;/metrics&lt;/em&gt; in a separate browser window to see the output&lt;/p&gt;
&lt;pre class&gt;&lt;span class="line"&gt;&lt;span&gt;...&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;# HELP action_cable_action_execution_duration_seconds Multiprocess metric&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;# TYPE action_cable_action_execution_duration_seconds histogram&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;action_cable_action_execution_duration_seconds_bucket{environment=&amp;quot;development&amp;quot;,action=&amp;quot;ChatChannel#post_message&amp;quot;,le=&amp;quot;+Inf&amp;quot;} 2189&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;action_cable_action_execution_duration_seconds_bucket{environment=&amp;quot;development&amp;quot;,action=&amp;quot;ChatChannel#post_message&amp;quot;,le=&amp;quot;0.005&amp;quot;} 101&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;action_cable_action_execution_duration_seconds_bucket{environment=&amp;quot;development&amp;quot;,action=&amp;quot;ChatChannel#post_message&amp;quot;,le=&amp;quot;0.01&amp;quot;} 559&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;action_cable_action_execution_duration_seconds_bucket{environment=&amp;quot;development&amp;quot;,action=&amp;quot;ChatChannel#post_message&amp;quot;,le=&amp;quot;0.025&amp;quot;} 2044&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;action_cable_action_execution_duration_seconds_bucket{environment=&amp;quot;development&amp;quot;,action=&amp;quot;ChatChannel#post_message&amp;quot;,le=&amp;quot;0.05&amp;quot;} 2174&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;action_cable_action_execution_duration_seconds_bucket{environment=&amp;quot;development&amp;quot;,action=&amp;quot;ChatChannel#post_message&amp;quot;,le=&amp;quot;0.1&amp;quot;} 2181&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;action_cable_action_execution_duration_seconds_bucket{environment=&amp;quot;development&amp;quot;,action=&amp;quot;ChatChannel#post_message&amp;quot;,le=&amp;quot;0.25&amp;quot;} 2189&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;action_cable_action_execution_duration_seconds_bucket{environment=&amp;quot;development&amp;quot;,action=&amp;quot;ChatChannel#post_message&amp;quot;,le=&amp;quot;0.5&amp;quot;} 2189&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;action_cable_action_execution_duration_seconds_bucket{environment=&amp;quot;development&amp;quot;,action=&amp;quot;ChatChannel#post_message&amp;quot;,le=&amp;quot;1&amp;quot;} 2189&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;action_cable_action_execution_duration_seconds_bucket{environment=&amp;quot;development&amp;quot;,action=&amp;quot;ChatChannel#post_message&amp;quot;,le=&amp;quot;10&amp;quot;} 2189&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;action_cable_action_execution_duration_seconds_bucket{environment=&amp;quot;development&amp;quot;,action=&amp;quot;ChatChannel#post_message&amp;quot;,le=&amp;quot;2.5&amp;quot;} 2189&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;action_cable_action_execution_duration_seconds_bucket{environment=&amp;quot;development&amp;quot;,action=&amp;quot;ChatChannel#post_message&amp;quot;,le=&amp;quot;5&amp;quot;} 2189&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;action_cable_action_execution_duration_seconds_count{environment=&amp;quot;development&amp;quot;,action=&amp;quot;ChatChannel#post_message&amp;quot;} 2189&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;action_cable_action_execution_duration_seconds_sum{environment=&amp;quot;development&amp;quot;,action=&amp;quot;ChatChannel#post_message&amp;quot;} 34.04095268249512&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;...&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;That&amp;#39;s my action execution duration. It works!&lt;/p&gt;
&lt;p&gt;My coworker had already configured Prometheus to scrape that endpoint every
minute or so and the metrics appeared in Prometheus within a few minutes.&lt;/p&gt;
&lt;h3 id="analyzing-the-metrics"&gt;Analyzing the metrics&lt;/h3&gt;
&lt;p&gt;Now that I had all the metrics somewhere where I could query them I had to make
them easy to analyze. I&amp;#39;ve decided to go with &lt;a href="https://grafana.com/"&gt;Grafana&lt;/a&gt; as
we already had an instance to monitor our infrastructure.&lt;/p&gt;
&lt;p&gt;Grafana allowed me to take this set of metrics and turn them into graphs that
are easy to parse. First, I exposed the number of connections. The graph now
clearly shows how many clients were connected at each minute.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090243.png/2400w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 2755; --attachment-height: 1338; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%208%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRi4AAABXRUJQVlA4ICIAAACwAQCdASoQAAgABABoJZQAAud%2F2ziAAP7p%2BdzZ3pVrZeAA%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #293931;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090243.png/480w.webp 480w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090243.png/800w.webp 800w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090243.png/1200w.webp 1200w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090243.png/1600w.webp 1600w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090243.png/2400w.webp 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="The number of connections to ActionCable plotted over time" loading="lazy" decoding="async" class="attachment__image" width="2755" height="1338" src="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090243.png/1200w.png" srcset="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090243.png/480w.png 480w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090243.png/800w.png 800w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090243.png/1200w.png 1200w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090243.png/1600w.png 1600w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090243.png/2400w.png 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;The number of connections to ActionCable plotted over time&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;Next, I plotted the action execution latency. This is a bit more involved just
because I wanted to plot the average, median, 95th percentile and 99th
percentile.&lt;/p&gt;
&lt;p&gt;The average is important for scaling as it&amp;#39;s used in &lt;a href="https://en.wikipedia.org/wiki/Little%27s_law"&gt;Little&amp;#39;s
law&lt;/a&gt; to determine the number of
workers necessary to handle the load. The median is a better representation of
what most clients experience as it represents the latency of 50% of all clients.
The 95th and 99th percentile represent the worst cases - the latency experienced
by 95% and 99% of all clients.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090532.png/2400w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 2746; --attachment-height: 1453; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%208%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRioAAABXRUJQVlA4IB4AAAAwAQCdASoQAAgABABoJaQAA3AA%2FvCIDbiLQuX1YAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #212121;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090532.png/480w.webp 480w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090532.png/800w.webp 800w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090532.png/1200w.webp 1200w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090532.png/1600w.webp 1600w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090532.png/2400w.webp 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="The action execution latency of all actions plotted over time" loading="lazy" decoding="async" class="attachment__image" width="2746" height="1453" src="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090532.png/1200w.png" srcset="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090532.png/480w.png 480w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090532.png/800w.png 800w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090532.png/1200w.png 1200w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090532.png/1600w.png 1600w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090532.png/2400w.png 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;The action execution latency of all actions plotted over time&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;In addition to plotting the overall latency across all actions I&amp;#39;ve also plotted
the latency per action as a heat-map. Here each rectangle represents 1 minute,
red rectangles represent high latency and blue represents low latency. If I see
a bright red action here I know that it&amp;#39;s causing a problem.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090930.png/2400w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 2687; --attachment-height: 873; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%205%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRjQAAABXRUJQVlA4ICgAAADwAQCdASoQAAUABABoJZQC7AEUpTsN6eAA%2Fu8%2BVhGvzvnYcp9nAAAA%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #293638;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090930.png/480w.webp 480w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090930.png/800w.webp 800w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090930.png/1200w.webp 1200w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090930.png/1600w.webp 1600w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090930.png/2400w.webp 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="A heatmap that breaks down the action execution latency by action over time" loading="lazy" decoding="async" class="attachment__image" width="2687" height="873" src="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090930.png/1200w.png" srcset="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090930.png/480w.png 480w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090930.png/800w.png 800w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090930.png/1200w.png 1200w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090930.png/1600w.png 1600w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090930.png/2400w.png 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;A heatmap that breaks down the action execution latency by action over time&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;I&amp;#39;ve plotted the client-server latency and PubSub latency the same way as the
overall action execution duration.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090630.png/2400w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 2751; --attachment-height: 1460; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%208%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRiYAAABXRUJQVlA4IBoAAAAwAQCdASoQAAgABABoJaQAA3AA%2FvCcaHHAAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #1e1d1e;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090630.png/480w.webp 480w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090630.png/800w.webp 800w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090630.png/1200w.webp 1200w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090630.png/1600w.webp 1600w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090630.png/2400w.webp 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Client-Server latency plotted over time" loading="lazy" decoding="async" class="attachment__image" width="2751" height="1460" src="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090630.png/1200w.png" srcset="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090630.png/480w.png 480w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090630.png/800w.png 800w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090630.png/1200w.png 1200w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090630.png/1600w.png 1600w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090630.png/2400w.png 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Client-Server latency plotted over time&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;-&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090713.png/2400w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 2751; --attachment-height: 1468; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%209%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRiYAAABXRUJQVlA4IBoAAAAwAQCdASoQAAkABABoJaQAA3AA%2FvCcdqQcAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #1d1d1e;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090713.png/480w.webp 480w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090713.png/800w.webp 800w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090713.png/1200w.webp 1200w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090713.png/1600w.webp 1600w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090713.png/2400w.webp 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="PubSub latency plotted over time" loading="lazy" decoding="async" class="attachment__image" width="2751" height="1468" src="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090713.png/1200w.png" srcset="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090713.png/480w.png 480w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090713.png/800w.png 800w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090713.png/1200w.png 1200w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090713.png/1600w.png 1600w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090713.png/2400w.png 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;PubSub latency plotted over time&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;With all these metrics in Grafana I could now focus on finding and fixing the
root cause. As the incident had passed and I didn&amp;#39;t have any metrics from it so
I&amp;#39;d have to wait for another one.&lt;/p&gt;
&lt;h3 id="step-six-fixing-the-root-cause"&gt;Step six, fixing the root cause.&lt;/h3&gt;
&lt;p&gt;The app was now stable so I tried a few things that I guessed could be the cause
while I waited to see if this would occur again.&lt;/p&gt;
&lt;p&gt;Then, after two weeks, the incident happened again!&lt;/p&gt;
&lt;p&gt;I quickly opened Grafana and saw that the PubSub latency was extremely high.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png attachment--expandable" data-full-src="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127092424.png/767w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 767; --attachment-height: 809; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2015%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRiwAAABXRUJQVlA4ICAAAAAwAQCdASoPABAABABoJaQAA3AA%2FvBwsCJBcknZlKnCAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #1f2221;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127092424.png/480w.webp 480w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127092424.png/767w.webp 767w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="PubSub latency during the incident" loading="lazy" decoding="async" class="attachment__image" width="767" height="809" src="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127092424.png/767w.png" srcset="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127092424.png/480w.png 480w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127092424.png/767w.png 767w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;PubSub latency during the incident&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;&lt;em&gt;(the 95th and 99th percentile are capped at 10s because that&amp;#39;s the largest
bucket I&amp;#39;ve configured)&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;This means that Redis is the culprit. For some reason It takes ages to deliver
messages. As a quick fix, I opened the AWS console and scaled up our Redis
instance. Within a few minutes the incident cleared up.&lt;/p&gt;
&lt;p&gt;I spent the next week investigating ElastiCache Redis and confirmed that it was
the root cause of both incidents. I&amp;#39;ll go into more detail on this in a
subsequent post, but &lt;a href="https://stanko.io/adversarial-web-services-Vjfkr3DF9LfG"&gt;the gist of it is that AWS is a piece of
crap&lt;/a&gt;.&lt;/p&gt;
&lt;h3 id="looking-back"&gt;Looking back&lt;/h3&gt;
&lt;p&gt;I would never have found and fixed this without the monitoring that I&amp;#39;ve set up.&lt;/p&gt;
&lt;p&gt;In the following month I&amp;#39;ve exposed more metrics like the broadcast and
transmission latency, and the number of confirmed and rejected subscriptions.&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;ActiveSupport&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Notifications&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;subscribe&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;broadcast.action_cable&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; |&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;_name&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; start&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; finish&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; _id&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; _payload&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;|&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;  Yabeda&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;action_cable&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;broadcast_duration&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;measure&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;({}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; finish&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;to_f&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; -&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; start&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;to_f&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;ActiveSupport&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Notifications&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;subscribe&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;transmit.action_cable&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; |&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;_name&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; start&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; finish&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; _id&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; _payload&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;|&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;  Yabeda&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;action_cable&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;transmission_duration&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;measure&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;({}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; finish&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;to_f&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; -&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; start&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;to_f&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;ActiveSupport&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Notifications&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;subscribe&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;transmit_subscription_confirmation.action_cable&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; |&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;_name&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; start&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; finish&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; _id&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; _payload&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;|&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;  Yabeda&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;action_cable&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;transmission_duration&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;measure&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;({}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; finish&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;to_f&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; -&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; start&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;to_f&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;  Yabeda&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;action_cable&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;confirmed_subscription_count&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;increment&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;({}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; by:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 1&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;ActiveSupport&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Notifications&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;subscribe&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;transmit_subscription_rejection.action_cable&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; |&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;_name&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; start&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; finish&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; _id&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; _payload&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;|&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;  Yabeda&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;action_cable&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;transmission_duration&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;measure&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;({}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; finish&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;to_f&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; -&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; start&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;to_f&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;  Yabeda&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;action_cable&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;rejected_subscription_count&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;increment&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;({}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; by:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 1&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;I don&amp;#39;t really need those metrics now, but through this incident I&amp;#39;ve learned
that it&amp;#39;s better to have a metric before you need it so I exposed everything I
could expose with ActiveSupport Notifications.&lt;/p&gt;
&lt;p&gt;Additionally, I&amp;#39;ve added a few more gems - like
&lt;a href="https://github.com/yabeda-rb/yabeda-puma-plugin"&gt;yabeda-puma-plugin&lt;/a&gt;,
&lt;a href="https://github.com/yabeda-rb/yabeda-rails"&gt;yabeda-rails&lt;/a&gt; and
&lt;a href="https://github.com/basecamp/yabeda-activejob"&gt;yabeda-activejob&lt;/a&gt; - that expose
various metrics to help me pinpoint problems in the future.&lt;/p&gt;&lt;/div&gt;
</content>
    <summary type="html">A few weeks ago I was woken up with the following message "Hey, devices are randomly disconnecting from WebSockets can you come online and help?".

Usually, when a widespread problem like this occurs out of the blue, my first goal is to make the app mostly operational again and my second goal is...</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Article/39</id>
    <published>2024-06-03T05:05:00Z</published>
    <updated>2026-02-15T09:37:07Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/software-is-made-by-people-gWZri0iMla1q"/>
    <title>Software is made by people</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;p&gt;The &lt;a href="https://x.com/noahflk/status/1795758603577545035?s=46"&gt;recent Hotwire pop-up
drama&lt;/a&gt; has shown me that
some have forgotten that software is made by people - people with different
backgrounds, different values, different skills, and different flaws.&lt;/p&gt;
&lt;p&gt;The diversity in opinions and aproaches is what makes modern day software
development great. That’s what drives innovation.&lt;/p&gt;
&lt;p&gt;There are many tribes of people that like to build software in a certain way,
that like to use a certain language, that approach problems a certain way, that
prefere certan conventions over others. It’s easy to find a tribe and join it,
or to start your own.&lt;/p&gt;
&lt;p&gt;When I started programming professionally I’ve aleardy found my tribes and
thought that people in other tribes - &lt;em&gt;especially&lt;/em&gt; those with diametrically
opposite beliefs - were just doing software wrong.&lt;/p&gt;
&lt;p&gt;But I was wrong back then.&lt;/p&gt;
&lt;p&gt;Today I understand that all tribes in our community formed not because we
interpreted something differently, but because we have different values about
the same thing - software.&lt;/p&gt;
&lt;p&gt;We are like visitors at an art gallery looking at an abstract sculpture - each
of us sees something different even though we are looking at the same thing.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg" data-full-src="https://media.stanko.io/variants/articles/software-is-made-by-people-gWZri0iMla1q/img_7410.jpeg/1290w.jpg"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 1290; --attachment-height: 781; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2010%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRk4AAABXRUJQVlA4IEIAAAAQAgCdASoQAAoABABoJaQAD4APth8O97EAAP7YUE%2B0DJ8u%2F8GgfQlInitX3B4Dtk8%2BwmBCPWzSsfodu1434NAAAAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #d4d4d4;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/software-is-made-by-people-gWZri0iMla1q/img_7410.jpeg/480w.webp 480w, https://media.stanko.io/variants/articles/software-is-made-by-people-gWZri0iMla1q/img_7410.jpeg/800w.webp 800w, https://media.stanko.io/variants/articles/software-is-made-by-people-gWZri0iMla1q/img_7410.jpeg/1200w.webp 1200w, https://media.stanko.io/variants/articles/software-is-made-by-people-gWZri0iMla1q/img_7410.jpeg/1290w.webp 1290w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Optical illusion that sometimes looks like a rabbit and sometimes like a duck (well, bird)" loading="lazy" decoding="async" class="attachment__image" width="1290" height="781" src="https://media.stanko.io/variants/articles/software-is-made-by-people-gWZri0iMla1q/img_7410.jpeg/1200w.jpg" srcset="https://media.stanko.io/variants/articles/software-is-made-by-people-gWZri0iMla1q/img_7410.jpeg/480w.jpg 480w, https://media.stanko.io/variants/articles/software-is-made-by-people-gWZri0iMla1q/img_7410.jpeg/800w.jpg 800w, https://media.stanko.io/variants/articles/software-is-made-by-people-gWZri0iMla1q/img_7410.jpeg/1200w.jpg 1200w, https://media.stanko.io/variants/articles/software-is-made-by-people-gWZri0iMla1q/img_7410.jpeg/1290w.jpg 1290w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Optical illusion that sometimes looks like a rabbit and sometimes like a duck (well, bird)&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;Where one person sees a rabbit another will see a duck. There is no one right
answer. If it’s a rabbit, or a duck, or both, or neither is dependent on you.&lt;/p&gt;
&lt;p&gt;HTML-over-the-wire and SPAs, in this context, are two ways of doing the same
thing - rendering content in a reactive way. There is no one right answer as to
what the best way to do it is. Which one you prefere is up to you.&lt;/p&gt;&lt;/div&gt;
</content>
    <summary type="html">The recent Hotwire pop-up drama has shown me that some have forgotten that software is made by people - people with different backgrounds, different values, different skills, and different flaws.

The diversity in opinions and aproaches is what makes modern day software development great. That’s...</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Article/38</id>
    <published>2024-04-01T12:00:00Z</published>
    <updated>2026-02-15T09:37:07Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B"/>
    <title>Why does Rails put the type column first in an index for a polymorphic association?</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;p&gt;Last week, I had a discussion with a coworker about how Rails indexes columns
used in polymorphic associations. He thought that the order of columns in the
index should be flipped - instead of indexing by type and ID, it should index by
ID and type - as that way the most restrictive column is first, and therefore
the index is more efficient. While I argued that the way that Rails indexes
polymorphic associations is very pragmatic while also being efficient.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;First off, what are we talking about?&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;We have an Access Log model in our app. It holds a log of who granted access to
which device and when. But the thing granting access can be a Person, another
Device or a Schedule.&lt;/p&gt;
&lt;p&gt;In Rails, an association that can point to many different types of models is
called a &lt;a href="https://guides.rubyonrails.org/association_basics.html#polymorphic-associations"&gt;&amp;quot;polymorphic
associations&amp;quot;&lt;/a&gt;.&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; AccessLog&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ApplicationRecord&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  belongs_to &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:device&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  belongs_to &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:grantor&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; polymorphic:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; true&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# Since grantor is polymorphic we can assign any type of model to it&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;log &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; AccessLog&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;create!&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;device:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Device&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;all&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;sample&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; grantor:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Person&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;all&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;sample&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;log&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;grantor&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt; # =&amp;gt; Person&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;log&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;update!&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;grantor:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Device&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;all&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;sample&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;log&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;grantor&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt; # =&amp;gt; Device&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;log&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;update!&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;grantor:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Schedule&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;all&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;sample&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;log&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;grantor&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt; # =&amp;gt; Schedule&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# Since the device association isn&amp;#39;t polimorphic we can&amp;#39;t assign anything&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# but a device to it&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;log&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;update!&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;device:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Person&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;all&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;sample&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;) &lt;/span&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# =&amp;gt; ActiveRecord::AssociationTypeMismatch: Device expected, got Person&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;A polymorphic and a regular association are implemented differently in the
database.&lt;/p&gt;
&lt;p&gt;A regular association is just a column that holds an ID that points to a record
in the other model&amp;#39;s table; usually it also has a foreign key constraint, which
ensures that the assigned ID actually exists in the other table.&lt;/p&gt;
&lt;p&gt;While a polymorphic association uses two columns, one to hold the type of model
it&amp;#39;s for and another to hold the ID of the record it&amp;#39;s for.&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;create_table &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;access_logs&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; force:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :cascade&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; |&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;t&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;|&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;  # Regular association - holds the ID of the record from the devices table&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  t&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;bigint&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;device_id&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; null:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; false&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;  # Polymorphic association&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  t&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;string&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;grantor_type&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; null:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; false&lt;/span&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt; # holds the model name&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  t&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;bigint&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;grantor_id&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; null:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; false&lt;/span&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt; # holds the ID of the record&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  t&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;index&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; [&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;device_id&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;]&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; name:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;index_access_logs_on_device_id&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  t&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;index&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; [&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;grantor_type&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;grantor_id&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;]&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; name:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;index_access_logs_on_entry&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# ensures that every ID in device_id coresponds to an id in the devices table&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;add_foreign_key &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;access_logs&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;devices&amp;quot;&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;When you generate an association, Rails will automatically create an index for
it. In the case of a polymorphic association, it will create a composite index -
&lt;em&gt;that&amp;#39;s an index that indexes two or more columns simultaneously&lt;/em&gt; - in which it
will put the type column first and the ID column last.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Why does it matter which column is first in the index?&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;This has to do with how databases build indices. By default, MySQL, MariaDB and
PostgreSQL build &lt;a href="https://en.wikipedia.org/wiki/B-tree"&gt;b-tree&lt;/a&gt; indices.&lt;/p&gt;
&lt;p&gt;An index is similar to a regular book index, but instead of having chapter names
and page numbers, it has values from the indexed column and internal record IDs.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png attachment--expandable" data-full-src="https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240319080622.png/936w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 936; --attachment-height: 945; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRiwAAABXRUJQVlA4ICAAAAAwAQCdASoQABAABABoJZwAA3AA%2FvICN5kBFsDL6EAAAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #fafbf7;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240319080622.png/480w.webp 480w, https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240319080622.png/800w.webp 800w, https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240319080622.png/936w.webp 936w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Comparison between a book and a naive database index" loading="lazy" decoding="async" class="attachment__image" width="936" height="945" src="https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240319080622.png/936w.png" srcset="https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240319080622.png/480w.png 480w, https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240319080622.png/800w.png 800w, https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240319080622.png/936w.png 936w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Comparison between a book and a naive database index&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;The problem with this kind of index is that it will grow as the number of
distinct values in the indexed column grows.&lt;/p&gt;
&lt;p&gt;E.g. if you create an index on a column that holds a person&amp;#39;s email address,
this kind of index would have one entry for each email, and the database would
have to scan through each email until it finds the right one. To speed things
up, we can do what dictionaries usually do and create a multi-level index.&lt;/p&gt;
&lt;p&gt;In a dictionary, you&amp;#39;d usually have an index that points you to another index.
E.g. the main index would tell you that the index for all words starting with R
is on page 6. Then the R sub-index would tell you that words starting with RU
are on pages 342 to 361. Then you&amp;#39;d go to page 342 and search for the word
&lt;em&gt;&amp;quot;Ruby&amp;quot;&lt;/em&gt; until you reached page 361.&lt;/p&gt;
&lt;div class="attachment-gallery attachment-gallery--2"&gt; &lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240401104044.png/703w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 703; --attachment-height: 483; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2011%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRioAAABXRUJQVlA4IB4AAAAwAQCdASoQAAsABABoJaQAA3AA%2FvJKm047yOu5AgA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #fcfcfa;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240401104044.png/480w.webp 480w, https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240401104044.png/703w.webp 703w" sizes="(width &gt;= 768px) 520px, 50vw"&gt; &lt;img alt="A multi-level index from a dictionary" loading="lazy" decoding="async" class="attachment__image" width="703" height="483" src="https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240401104044.png/703w.png" srcset="https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240401104044.png/480w.png 480w, https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240401104044.png/703w.png 703w" sizes="(width &gt;= 768px) 520px, 50vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;A multi-level index from a dictionary&lt;/figcaption&gt; &lt;/figure&gt;&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240401104111.png/663w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 663; --attachment-height: 396; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2010%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRigAAABXRUJQVlA4IBwAAAAwAQCdASoQAAoABABoJZwAA3AA%2FvJJK3EQIAAA%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #fcfcfb;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240401104111.png/480w.webp 480w, https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240401104111.png/663w.webp 663w" sizes="(width &gt;= 768px) 520px, 50vw"&gt; &lt;img alt="A naive multi-level index in a database" loading="lazy" decoding="async" class="attachment__image" width="663" height="396" src="https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240401104111.png/663w.png" srcset="https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240401104111.png/480w.png 480w, https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240401104111.png/663w.png 663w" sizes="(width &gt;= 768px) 520px, 50vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;A naive multi-level index in a database&lt;/figcaption&gt; &lt;/figure&gt; &lt;/div&gt;
&lt;p&gt;B-trees are similar to multi-level indices in dictionaries, though they have
extra rules and nuances that aren&amp;#39;t important now.&lt;/p&gt;
&lt;p&gt;What&amp;#39;s important is that a b-tree index can have a sub-index, which in term can
have a sub-index, and so on. And the more items each sub-index has, the slower
it is to find something.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;These indices work for single values such as words and emails, but how would
you make an index of two words, or an email and a number?&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;To create a composite index, most databases just concatenate the values together
and build a regular b-tree index. This is a gross simplification of what
actually happens, but in essence, that&amp;#39;s what they do. Because of that, the
order of the columns becomes important.&lt;/p&gt;
&lt;p&gt;If you tell the database to build an index over &lt;em&gt;&lt;strong&gt;grantor_type&lt;/strong&gt;&lt;/em&gt; &lt;strong&gt;first and
then&lt;/strong&gt; &lt;em&gt;&lt;strong&gt;grantor_id&lt;/strong&gt;&lt;/em&gt; it would take the two and concatenate them together like
so &lt;em&gt;&amp;quot;Person|443&amp;quot;&lt;/em&gt;. But if you tell it to build an index over &lt;em&gt;&lt;strong&gt;grantor_id&lt;/strong&gt;&lt;/em&gt;
&lt;strong&gt;first and then&lt;/strong&gt; &lt;em&gt;&lt;strong&gt;grantor_type&lt;/strong&gt;&lt;/em&gt; it would concatenate them like so
&lt;em&gt;&amp;quot;443|Person&amp;quot;&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;The order of the columns in the index changes the structure of the index.&lt;/p&gt;
&lt;p&gt;When the ID column is first, the index points to fewer rows, so the database has
to scan through fewer rows, and therefore it can find a match quicker. That&amp;#39;s
why it&amp;#39;s a general recommendation to put the most specific column first in a
composite index - it narrows down the search a lot.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240401105701.png/1044w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 1044; --attachment-height: 590; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%209%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRiYAAABXRUJQVlA4IBoAAAAwAQCdASoQAAkABABoJaQAA3AA%2FvJXOwAAAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #fcfcfc;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240401105701.png/480w.webp 480w, https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240401105701.png/800w.webp 800w, https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240401105701.png/1044w.webp 1044w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Naive composite database index with the ID column first and type column second" loading="lazy" decoding="async" class="attachment__image" width="1044" height="590" src="https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240401105701.png/1044w.png" srcset="https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240401105701.png/480w.png 480w, https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240401105701.png/800w.png 800w, https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240401105701.png/1044w.png 1044w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Naive composite database index with the ID column first and type column second&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;When the type column is first, there are a lot more rows to scan through in the
end.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240401104647.png/1321w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 1321; --attachment-height: 716; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%209%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRioAAABXRUJQVlA4IB4AAAAwAQCdASoQAAkABABoJZwAA3AA%2FvJSOppV8SAIAAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #fbfcfa;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240401104647.png/480w.webp 480w, https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240401104647.png/800w.webp 800w, https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240401104647.png/1200w.webp 1200w, https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240401104647.png/1321w.webp 1321w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Naive composite database index with the type column first and ID column second" loading="lazy" decoding="async" class="attachment__image" width="1321" height="716" src="https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240401104647.png/1200w.png" srcset="https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240401104647.png/480w.png 480w, https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240401104647.png/800w.png 800w, https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240401104647.png/1200w.png 1200w, https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240401104647.png/1321w.png 1321w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Naive composite database index with the type column first and ID column second&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;&lt;em&gt;Why would anyone create an index and put the type column first then?&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;You don&amp;#39;t have to follow a multi-level index all the way to the last index entry
to find something. E.g. if you are searching for all words that start with R,
you can go from the main index to find all sub-indices of words starting with R.
Take the start page of the first index and the last page of the last index. Now
you know that all words between these two pages start with R, and you didn&amp;#39;t
have to scan through anything.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;In a real app, you&amp;#39;d sometimes want to filter all records that are associated
with another record of a certain type.&lt;/strong&gt; E.g. you might want to fetch all Access
Logs that were granted by a Schedule, or all People associated with a Property
through an Access Log.&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; Property&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ApplicationRecord&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  has_many &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:access_logs&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  has_many &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:schedule_activations&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;    -&amp;gt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; { &lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;where(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;grantor_type:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;Schedule&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;)&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; }&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;    class_name:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;AccessLog&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  has_many &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:active_people&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;    through:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :access_logs&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;    source:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :grantor&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;    source_type:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;Person&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# Calling&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Propert&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;find&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;42&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;schedule_activations&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;count&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# Executes the following SQL statement&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# ```sql&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# SELECT COUNT(*)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# FROM access_logs&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# WHERE property_id = 42 &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;#   AND grantor_type = &amp;#39;Schedule&amp;#39;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# ```&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# Calling&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Propert&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;find&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;42&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;active_people&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;count&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# Executes the following SQL statement&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# ```sql&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# SELECT COUNT(tenants.*)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# FROM tenants INNER JOIN access_logs&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;#   ON tenants.id = access_logs.grantor_id&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# WHERE access_logs.property_id = 42&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;#   AND access_logs.grantor_type = &amp;#39;Person&amp;#39;&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;In that case, the composite index over&lt;/strong&gt; &lt;em&gt;&lt;strong&gt;grantor_id&lt;/strong&gt;&lt;/em&gt; &lt;strong&gt;first and then&lt;/strong&gt;
&lt;em&gt;&lt;strong&gt;grantor_type&lt;/strong&gt;&lt;/em&gt; &lt;strong&gt;is basically useless.&lt;/strong&gt; The database would have to traverse
the whole index to aggregate a range that it then has to scan through.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;While the index over&lt;/strong&gt; &lt;em&gt;&lt;strong&gt;grantor_type&lt;/strong&gt;&lt;/em&gt; &lt;strong&gt;first and then&lt;/strong&gt; &lt;em&gt;&lt;strong&gt;grantor_id&lt;/strong&gt;&lt;/em&gt;
&lt;strong&gt;can quickly give you the range that you have to scan through by looking at the
first and last entry of a sub-index.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;We can test this out.&lt;/p&gt;
&lt;p&gt;I created a test MariaDB database, with an access_logs table that I&amp;#39;ve filled
with a million rows. Then I created a composite index over &lt;em&gt;grantor_type&lt;/em&gt; and
&lt;em&gt;grantor_id&lt;/em&gt; that I&amp;#39;ve named &lt;em&gt;test_index&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;When I ask MariaDB to explain how it would go about counting all rows where the
&lt;em&gt;grantor_type&lt;/em&gt; is &lt;em&gt;Person&lt;/em&gt;, &lt;strong&gt;it says that it would use&lt;/strong&gt; &lt;em&gt;&lt;strong&gt;test_index&lt;/strong&gt;&lt;/em&gt; &lt;strong&gt;to
generate the count&lt;/strong&gt;.&lt;/p&gt;
&lt;pre class&gt;&lt;span class="line"&gt;&lt;span&gt;MariaDB [test]&amp;gt; CREATE INDEX test_index ON access_logs (grantor_type, grantor_id);&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;Query OK, 0 rows affected (1.209 sec)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;Records: 0  Duplicates: 0  Warnings: 0&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;MariaDB [test]&amp;gt; EXPLAIN  SELECT COUNT(1) FROM access_logs WHERE grantor_type = &amp;#39;Person&amp;#39;;                                                 &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;+------+-------------+---------------+------+---------------+------------+---------+-------+--------+--------------------------+&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;| id   | select_type | table         | type | possible_keys | key        | key_len | ref   | rows   | Extra                    |&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;+------+-------------+---------------+------+---------------+------------+---------+-------+--------+--------------------------+&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;|    1 | SIMPLE      | access_logs   | ref  | test_index    | test_index | 183     | const | 498290 | Using where; Using index |&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;+------+-------------+---------------+------+---------------+------------+---------+-------+--------+--------------------------+&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;1 row in set (0.002 sec)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;MariaDB [test]&amp;gt; SELECT COUNT(1) FROM access_logs WHERE grantor_type = &amp;#39;Person&amp;#39;;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;+----------+&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;| COUNT(1) |&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;+----------+&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;|   333400 |&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;+----------+&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;1 row in set (0.042 sec)&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;If I ask the same question, but change the order of columns in the index, &lt;strong&gt;then
MariaDB says that it can&amp;#39;t use any index to generate the count but it still
tries to use test_index&lt;/strong&gt;, and it takes about twice as long to count the rows.&lt;/p&gt;
&lt;pre class&gt;&lt;span class="line"&gt;&lt;span&gt;MariaDB [test]&amp;gt; DROP INDEX test_index ON access_logs;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;Query OK, 0 rows affected (0.045 sec)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;Records: 0  Duplicates: 0  Warnings: 0&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;MariaDB [test]&amp;gt; CREATE INDEX test_index ON access_logs (grantor_id, grantor_type);&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;Query OK, 0 rows affected (1.146 sec)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;Records: 0  Duplicates: 0  Warnings: 0&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;MariaDB [test]&amp;gt; EXPLAIN  SELECT COUNT(1) FROM access_logs WHERE grantor_type = &amp;#39;Person&amp;#39;;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;+------+-------------+---------------+-------+---------------+------------+---------+------+--------+--------------------------+&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;| id   | select_type | table         | type  | possible_keys | key        | key_len | ref  | rows   | Extra                    |&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;+------+-------------+---------------+-------+---------------+------------+---------+------+--------+--------------------------+&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;|    1 | SIMPLE      | access_logs   | index | NULL          | test_index | 192     | NULL | 996580 | Using where; Using index |&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;+------+-------------+---------------+-------+---------------+------------+---------+------+--------+--------------------------+&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;1 row in set (0.000 sec)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;MariaDB [test]&amp;gt; SELECT COUNT(1) FROM access_logs WHERE grantor_type = &amp;#39;Person&amp;#39;;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;+----------+&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;| COUNT(1) |&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;+----------+&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;|   333400 |&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;+----------+&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;1 row in set (0.095 sec)&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;These results will vary from database to database, but the principle still
stands.&lt;/p&gt;
&lt;p&gt;For example, &lt;a href="https://www.postgresql.org/docs/current/indexes-multicolumn.html"&gt;PostgreSQL can sometimes utilize an index even when it isn&amp;#39;t
optimal&lt;/a&gt;. In
the case of an index for a polymorphic association, changing the order of the
columns has much less impact than in MariaDB. In both cases, PostgreSQL uses the
index and produces a result in about the same time.&lt;/p&gt;
&lt;pre class&gt;&lt;span class="line"&gt;&lt;span&gt;postgres=# CREATE INDEX test_index ON access_logs (grantor_type, grantor_id);&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;CREATE INDEX&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;postgres=# EXPLAIN ANALYZE SELECT COUNT(1) FROM access_logs WHERE grantor_type = &amp;#39;Person&amp;#39;;                                                                      &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;QUERY PLAN                                                                       -----------------------------------------------------------------------------------------------------------------------------------------------------------------------                                                                            Finalize Aggregate  (cost=6457.79..6457.80 rows=1 width=8) (actual time=12.408..13.780 rows=1 loops=1)                                                ​-&amp;gt;  Gather  (cost=6457.57..6457.78 rows=2 width=8) (actual time=12.403..13.777 rows=3 loops=1)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    Workers Planned: 2&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;	Workers Launched: 2&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;	-&amp;gt;  Partial Aggregate  (cost=5457.57..5457.58 rows=1 width=8) (actual time=10.314..10.314 rows=1 loops=3)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;	  -&amp;gt;  Parallel Index Only Scan using test_index on access_logs  (cost=0.42..5107.64 rows=139972 width=0) (actual time=0.027..6.566 rows=111133 loops=3)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;	    Index Cond: (grantor_type = &amp;#39;Person&amp;#39;::text)                                      Heap Fetches: 0&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;Planning Time: 0.145 ms&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;Execution Time: 13.801 ms&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;(10 rows)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;postgres=# DROP INDEX test_index;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;DROP INDEX&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;postgres=# CREATE INDEX test_index ON door_releases (grantor_id, grantor_type);&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;CREATE INDEX&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;postgres=# EXPLAIN ANALYZE SELECT COUNT(1) FROM door_releases WHERE grantor_type = &amp;#39;Person&amp;#39;;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;QUERY PLAN&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;------------------------------------------------------------------------------------------------------------------------------------------------------------------------&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt; Finalize Aggregate  (cost=13778.29..13778.30 rows=1 width=8) (actual time=14.570..15.922 rows=1 loops=1)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;   -&amp;gt;  Gather  (cost=13778.08..13778.29 rows=2 width=8) (actual time=14.566..15.919 rows=3 loops=1)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;         Workers Planned: 2&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;         Workers Launched: 2&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;         -&amp;gt;  Partial Aggregate  (cost=12778.08..12778.09 rows=1 width=8) (actual time=12.210..12.210 rows=1 loops=3)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;               -&amp;gt;  Parallel Index Only Scan using test_index on door_releases  (cost=0.42..12428.15 rows=139972 width=0) (actual time=0.037..8.808 rows=111133 loops=3)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;                     Index Cond: (grantor_type = &amp;#39;Person&amp;#39;::text)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;                     Heap Fetches: 0&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt; Planning Time: 0.079 ms&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt; Execution Time: 15.944 ms&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;(10 rows)&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;Simply put.&lt;/p&gt;
&lt;p&gt;The type column is first in an index for a polymorphic association because then
the same index can be used to speed up association look-ups (querying by both
type and ID columns) and to filter by the type of the associated record
(querying by just the type column).&lt;/p&gt;
&lt;p&gt;These are the two most common use cases that you&amp;#39;ll encounter in a Rails app.
And also the use-cases for which Rails has built-in interfaces for (e.g. the
&lt;em&gt;source_type&lt;/em&gt; option).&lt;/p&gt;
&lt;p&gt;This is more efficient in terms of speed (index creation and updating) and space
(the size of the index on disk) than having two or more separate indices, which
would be required in some databases if the columns were flipped.&lt;/p&gt;&lt;/div&gt;
</content>
    <summary type="html">Last week, I had a discussion with a coworker about how Rails indexes columns used in polymorphic associations. He thought that the order of columns in the index should be flipped - instead of indexing by type and ID, it should index by ID and type - as that way the most restrictive column is...</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Article/37</id>
    <published>2024-02-06T07:10:00Z</published>
    <updated>2026-02-15T09:37:07Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/running-campfire-behind-traefik-LrGQM0GE0FVd"/>
    <title>Running Campfire behind traefik</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;p&gt;I rent a bare metal server from &lt;a href="https://www.scaleway.com/en/"&gt;Scaleway&lt;/a&gt; where I
host all my apps. For me, that&amp;#39;s much more economical than running multiple
small VPS boxes. 50€ a month buys me 2TB of disk space, 64GB of RAM and an 4
core / 8 thread CPU on which I can host two or three dozen apps. Compare that to
AWS or DigitalOcean where for the same money I get about one quarter of those
resources.&lt;/p&gt;
&lt;p&gt;All my apps are deployed as Docker containers, most of them using
&lt;a href="https://kamal-deploy.org/"&gt;Kamal&lt;/a&gt;, in front of which I put
&lt;a href="https://traefik.io/traefik/"&gt;traefik&lt;/a&gt; as a reverse proxy to direct traffic and
provide HTTPS via &lt;a href="https://letsencrypt.org/"&gt;Let&amp;#39;s Encrypt&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Last weekend I wanted to setup a Campfire instance for Ruby Zagreb - my local
Ruby user-group - on that machine.&lt;/p&gt;
&lt;p&gt;So I ran the official installer and got an error.&lt;/p&gt;
&lt;pre class="highlight" data-language="bash"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;#!/usr/bin/bash&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;sudo&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; once&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; setup&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; 1111-1111-1111-1111&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ██████╗&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; ███╗&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;   ██╗&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; ██████╗███████╗&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;██╔═══██╗████╗&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;  ██║██╔════╝██╔════╝&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;██║&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;   ██║██╔██╗&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; ██║██║&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;     █████╗&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;██║&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;   ██║██║╚██╗██║██║&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;     ██╔══╝&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;╚██████╔╝██║&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; ╚████║╚██████╗███████╗&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ╚═════╝&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; ╚═╝&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;  ╚═══╝&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; ╚═════╝╚══════╝&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;                            &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;Failed&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; to&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; start&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; the&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; software&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;                            &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;                                                                                                                                                                                                                                              &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;The&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; software&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; couldn&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;#39;t be started. Please try again. &lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;I expected the installer to fail since it can&amp;#39;t know my setup, and it didn&amp;#39;t ask
me anything besides the domain of the instance.&lt;/p&gt;
&lt;p&gt;Sadly, the email you get with your purchase doesn&amp;#39;t explain what the installer
does, and I can&amp;#39;t check what it&amp;#39;s doing since the installer is a binary blob. So
I had to snoop around for a bit.&lt;/p&gt;
&lt;p&gt;The first thing I found was the installer&amp;#39;s error log which said that the
container couldn&amp;#39;t bind to port 443.&lt;/p&gt;
&lt;pre class="highlight" data-language="bash"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;cat&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; .once-error.log&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;2024/01/27&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; 19:33:11&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; Error&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; response&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; from&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; daemon:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; driver&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; failed&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; programming&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; external&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; connectivity&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; on&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; endpoint&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; campfire&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; (e32f1a334f8ac25b9f3df0c283adb3eb3e8a554074f0827d17f8b566d03940a5): Bind &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;for&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; 0.0.0.0:443 failed: port is already allocated&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;Since traefik is bound to port 80 and 443 this makes perfect sense.&lt;/p&gt;
&lt;p&gt;The error looks like it came from Docker so I checked my images, and sure enough
there was a new &lt;em&gt;&amp;quot;registry.once.com/campfire:latest&amp;quot;&lt;/em&gt; image and a stopped
container which I promptly inspected.&lt;/p&gt;
&lt;p&gt;Within the container runs a custom proxy called
&lt;a href="https://github.com/rails/rails/issues/50479"&gt;thruster&lt;/a&gt; that terminates SSL and
handles the &lt;a href="https://docs.nginx.com/nginx/admin-guide/web-server/serving-static-content/#enabling-sendfile"&gt;sendfile
protocol&lt;/a&gt;.
That&amp;#39;s what&amp;#39;s bound to port 443 and 80 of the container.&lt;/p&gt;
&lt;p&gt;Inside the container is also a Redis instance, which implies that the installer
sets at least &lt;em&gt;&amp;quot;sysctl vm.overcommit_memory=1&amp;quot;&lt;/em&gt; else Redis wouldn&amp;#39;t start.&lt;/p&gt;
&lt;p&gt;I also saw that there were ENV variables injected into the container so I looked
where those came from and found a config file in
&lt;em&gt;&amp;quot;/root/.config/once/config.json&amp;quot;&lt;/em&gt;.&lt;/p&gt;
&lt;pre class="highlight" data-language="bash"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;sudo&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; cat&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; /root/.config/once/config.json&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; |&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; jq&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;  &amp;quot;token&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;1111-1111-1111-1111&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;  &amp;quot;product&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;campfire&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;  &amp;quot;product_name&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;Campfire&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;  &amp;quot;email_address&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;phony.email@example.com&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;  &amp;quot;ssl_domain&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;chat.rubyzg.org&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;  &amp;quot;validation_token&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;PHONY_VALIDATION_KEY&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;  &amp;quot;secret_key_base&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;PHONY_SECRET_KEY_BASE&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;  &amp;quot;vapid_private_key&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;PHONY_PRIVATE_KEY&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;  &amp;quot;vapid_public_key&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;PHONY_PUBLIC_KEY&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;  &amp;quot;storage_location&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;/var/once/campfire&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;  &amp;quot;cron_hour&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; 2,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;  &amp;quot;once_binary_etag&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;Seems pretty straight forward. This is roughly what the installer does&lt;/p&gt;
&lt;pre class="highlight" data-language="bash"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;sudo&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; sysctl&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; vm.overcommit_memory=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;1&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;docker&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; run&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;	-d&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;	--name&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; campfire&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;	--restart&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; unless-stopped&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;	--env-file&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; campfire/.env&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;	-p&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 443&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;	-p&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 80&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;	-v&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; campfire/storage:/rails/storage&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;	registry.once.com/campfire:latest&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;	bin/boot&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;;;&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;Where &lt;em&gt;&amp;quot;campfire/.env&amp;quot;&lt;/em&gt; contains the values from
&lt;em&gt;&amp;quot;/root/.config/once/config.json&amp;quot;&lt;/em&gt; plus a few extras.&lt;/p&gt;
&lt;pre class="highlight" data-language="bash"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# campfire/.env &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;SECRET_KEY_BASE&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;PHONY_SECRET_KEY_BASE&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;VAPID_PRIVATE_KEY&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;PHONY_PRIVATE_KEY&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;VAPID_PUBLIC_KEY&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;PHONY_PUBLIC_KEY&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;SSL_DOMAIN&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;chat.rubyzg.org&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;DISABLE_SSL&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;YES&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; &lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;To run Campfire without the installer, behind traefik, all I had to do is&lt;/strong&gt;&lt;/p&gt;
&lt;pre class="highlight" data-language="bash"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;sudo&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; sysctl&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; vm.overcommit_memory=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;1&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;docker&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; run&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;	-d&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;	--name&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; campfire&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;	--network&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; apps&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;	--restart&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; unless-stopped&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;	--env-file&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; campfire/.env&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;	--label-file&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; campfire/labels&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;	-v&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; campfire/Procfile:/rails/Procfile&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;	-v&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; campfire/production.rb:/rails/config/environments/production.rb&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;	-v&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; campfire/storage:/rails/storage&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;	registry.once.com/campfire:latest&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;	bin/boot&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;;;&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;Put this into &lt;em&gt;&amp;quot;campfire/labels&amp;quot;&lt;/em&gt;&lt;/p&gt;
&lt;pre class="highlight" data-language="plain"&gt;&lt;span class="line"&gt;&lt;span&gt;# Enable traefik for the container &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;traefik.enable=true &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;# Convert HTTP traffic to HTTPS &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;traefik.http.routers.campfire.entrypoints=websecure&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;traefik.http.routers.campfire.tls.certresolver=letsencrypt&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;# Register the container to a domain - CHANGE THIS TO YOUR DOMAIN &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;traefik.http.routers.campfire.rule=Host(`chat.rubyzg.org`) &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;# Return a service not available screen if Campfire isn&amp;#39;t running &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;traefik.http.middlewares.campfire.retry.attempts=10 &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;traefik.http.middlewares.campfire.retry.initialinterval=1s &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;traefik.http.services.campfire.loadbalancer.healthcheck.path=/up &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;traefik.http.services.campfire.loadbalancer.server.port=80 &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;traefik.http.services.campfire.loadbalancer.server.scheme=http &lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;Finally, I had to disable thruster and bind Puma to port 80 by updating the
&amp;quot;web&amp;quot; entry in the Procfile (you get the source files in the purchase email)&lt;/p&gt;
&lt;pre class="highlight" data-language="plain"&gt;&lt;span class="line"&gt;&lt;span&gt;# campfire/Procfile &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;web: bin/rails server -b 0.0.0.0 -p 80 &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;# ... &lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;This will boot the server, it will allow you to setup an admin account, create
rooms, add a logo, &lt;strong&gt;but you won&amp;#39;t be able to post messages because Action Cable
can&amp;#39;t connect to the server&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Traefik has &lt;a href="https://github.com/traefik/traefik/issues/6076"&gt;a bug where it doesn&amp;#39;t forward x-forwarded-for headers for
WebSocket handshakes&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;To fix that I had to update &lt;em&gt;&amp;quot;production.rb&amp;quot;&lt;/em&gt; with the exact URL that Action
Cable would get requests from&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# production.rb&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# ...&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;config&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;action_cable&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;url&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;wss://&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;#{&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;ENV&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;fetch&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;SSL_DOMAIN&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;)}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;/cable&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;config&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;action_cable&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;allowed_request_origins&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; [&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;https://&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;#{&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;ENV&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;fetch&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;SSL_DOMAIN&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;)}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# ...&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;That&amp;#39;s it.&lt;/p&gt;
&lt;p&gt;It&amp;#39;s isn&amp;#39;t as straight forward as I&amp;#39;d like it to be, and it would be nice if
Kamal and Campfire could play along nicely out of the box. I&amp;#39;d bet there is a
large overlap between those communities, and both are 37signals projects.&lt;/p&gt;&lt;/div&gt;
</content>
    <summary type="html">I rent a bare metal server from Scaleway where I host all my apps. For me, that's much more economical than running multiple small VPS boxes. 50€ a month buys me 2TB of disk space, 64GB of RAM and an 4 core / 8 thread CPU on which I can host two or three dozen apps. Compare that to AWS or...</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Article/36</id>
    <published>2024-02-05T13:00:00Z</published>
    <updated>2026-02-15T09:37:07Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/deconstructing-action-cable-DC7F33OsjGmK"/>
    <title>Deconstructing Action Cable</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/00011-2829697632.png/904w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 904; --attachment-height: 512; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%209%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRmoAAABXRUJQVlA4IF4AAAAQAgCdASoQAAkABABoJYgC7AYrdUZ4nvgAAP7nrnz6oGPqMF5jwaNzNO8sd9e2vSYneASJNOjxHKqRQQAtsJW%2B3rlKKs2AyrzfwZlC86e8TxSHy3v67ejznKx3ngAA%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #d8c6c4;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/00011-2829697632.png/480w.webp 480w, https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/00011-2829697632.png/800w.webp 800w, https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/00011-2829697632.png/904w.webp 904w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Deconstructing Action Cable" loading="lazy" decoding="async" class="attachment__image" width="904" height="512" src="https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/00011-2829697632.png/904w.png" srcset="https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/00011-2829697632.png/480w.png 480w, https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/00011-2829697632.png/800w.png 800w, https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/00011-2829697632.png/904w.png 904w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Deconstructing Action Cable&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;I knew what Action Cable was about and roughly how it worked since it was
released. But lately I&amp;#39;ve developed a much deeper understanding of it because I
had to extend it and explain its internals to others.&lt;/p&gt;
&lt;p&gt;The following is how I explained Action Cable to myself. I took a top-down
approach, starting at what I could observe Action Cable doing in my browser and
following the code until I found the implementation, which I then deconstructed
to understand why it does what it does.&lt;/p&gt;
&lt;p&gt;This was a along journey down a deep rabbit hole, but it was worth it. At the
end I now understand how and why Action Cable does what it does, and appreciate
how simple and elegant it actually is.&lt;/p&gt;
&lt;h2 id="from-http-to-websocket"&gt;From HTTP to WebSocket&lt;/h2&gt;
&lt;p&gt;Action Cable is a framework that allows your server to push messages to the
browser, or any other client.&lt;/p&gt;
&lt;p&gt;Usually your server can only respond to requests that a client, like your
browser, makes to it. But Action Cable opens a WebSocket between the client and
the server through which the server can push messages to the client at any time.&lt;/p&gt;
&lt;p&gt;The client can also push messages to the server through that WebSocket. But
that&amp;#39;s in my opinion less interesting as we could do that without Action Cable.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;How does the client connect to Action Cable&amp;#39;s WebSocket?&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;When anyone or anything wants to connect to your Rails app via Action Cable they
first have to make a HTTP request to &amp;quot;/cable&amp;quot; and after a bit of back and forth
out pops a WebSocket.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;But how does a HTTP request become a Websocket?&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Back in 1997 HTTP version 1.1 was released. It&amp;#39;s very similar to HTTP 1.0 with a
few additions, the one that&amp;#39;s important for WebSockets is &lt;a href="https://datatracker.ietf.org/doc/html/rfc2616#section-10.1.2"&gt;the addition of
status 101 aka. Switching protocols and the &lt;em&gt;&amp;quot;Upgrade&amp;quot;&lt;/em&gt;
header&lt;/a&gt;. The
intent of status 101 was to enable browsers to gracefully transition from HTTP
1.x to an eventual HTTP 2 or some other protocol that would be incompatible with
HTTP 1.&lt;/p&gt;
&lt;p&gt;And that&amp;#39;s exactly what a browser does when it connects to a WebSocket. It first
makes a request with the &lt;em&gt;&amp;quot;Upgrade&amp;quot;&lt;/em&gt; header set to &amp;quot;websocket&amp;quot; which tells the
server &amp;quot;hey, I want to switch to using WebSockets for this request&amp;quot;. If the
server supports WebSockets it responds with status 101 indicating that the
upgrade was accepted and that, from now on, it will communicate using the
WebSocket protocol on that request&amp;#39;s connection.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/pasted-image-20240124151038.png/771w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 771; --attachment-height: 756; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRioAAABXRUJQVlA4IB4AAACwAQCdASoQABAABABoJaQAAudGNfQAAP7w1Qf6AAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #202020;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/pasted-image-20240124151038.png/480w.webp 480w, https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/pasted-image-20240124151038.png/771w.webp 771w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="A WebSocket upgrade request" loading="lazy" decoding="async" class="attachment__image" width="771" height="756" src="https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/pasted-image-20240124151038.png/771w.png" srcset="https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/pasted-image-20240124151038.png/480w.png 480w, https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/pasted-image-20240124151038.png/771w.png 771w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;A WebSocket upgrade request&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;&lt;em&gt;But why do we need the HTTP request in the first place? Can&amp;#39;t we just connect
directly via the WebSocket protocol since we already know that the server
supports it?&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;The genius of WebSockets lies in the initial HTTP request. Because of that
initial HTTP request we have access to all the browser&amp;#39;s cookies that are sent
over, so we can use them to figure out who wants to open a WebSocket, if they
are allowed to do that, and we can remember who the socket is for.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;So Action Cable converts HTTP requests to WebSockets?&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Yes! It&amp;#39;s a WebSocket server, but it also does so much more.&lt;/strong&gt;&lt;/p&gt;
&lt;h2 id="connections"&gt;Connections&lt;/h2&gt;
&lt;p&gt;&lt;em&gt;So someone&amp;#39;s connected to our WebSocket, but how do we know who we are talking
to and how can we send messages to them?&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;In Action Cable, each open WebSocket has a corresponding Connection object. The
connection object is responsible for keeping track of who the WebSocket is for
and figuring out if the client is allowed to open a WebSocket.&lt;/p&gt;
&lt;p&gt;When a client makes the initial HTTP request to &lt;em&gt;&amp;quot;/cable&amp;quot;&lt;/em&gt;, Action Cable will
take the request&amp;#39;s headers, cookies, URL and create a Connection object from it.&lt;/p&gt;
&lt;p&gt;If it exists, the Connection object will be an instance of
&lt;em&gt;&amp;quot;ApplicationCable::Connection&amp;quot;&lt;/em&gt;. If that class doesn&amp;#39;t exist it will be an
instance of
&lt;a href="https://github.com/rails/rails/blob/cc633481602daf004eaab845c3780c6d885f882c/actioncable/lib/action_cable/connection/base.rb"&gt;&lt;em&gt;&amp;quot;ActionCable::Connection::Base&amp;quot;&lt;/em&gt;&lt;/a&gt;.
You can change the class of the connection by setting
&lt;em&gt;&amp;quot;config.action_cable.connection_class = -&amp;gt;{ WhateverClassIWant }&amp;quot;&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;When a connection is established the
&lt;a href="https://github.com/rails/rails/blob/cc633481602daf004eaab845c3780c6d885f882c/actioncable/lib/action_cable/connection/base.rb#L186"&gt;&lt;em&gt;&amp;quot;#connect&amp;quot;&lt;/em&gt;&lt;/a&gt;
method on our &lt;em&gt;&amp;quot;ApplicationCable::Connection&amp;quot;&lt;/em&gt; object will get called. In that
method you&amp;#39;d usually want to figure out who opened the connection, and reject it
if they aren&amp;#39;t allowed to open it.&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;module&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ApplicationCable&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; Connection&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ActionCable::Connection&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;Base&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    attr_accessor&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :current_person&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; connect&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;	  Rails&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;logger&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;debug&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;Someone wants to connect via WebSocket!&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	  set_current_person&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	  &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;	  unless&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; current_person&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;	    Rails&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;logger&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;debug&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;I don&amp;#39;t know who this is. Closing the WebSocket.&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	    reject_unauthorized_connection&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;	  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	  &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;	  Rails&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;logger&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;debug&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;It&amp;#39;s, &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;#{current_person&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;name&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    private&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; set_current_person&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      person &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;	    # We can access the param of the HTTP request&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;	    Person&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;find_by&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;token:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; request&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;params&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:token&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;]) &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;||&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;	    # We can access the session of the HTTP request&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;        Person&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;find_by&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;token:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; request&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;session&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:person_id&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;])&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;        &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      unless&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; person&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;        # We can access the cookies of the HTTP request&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;        session &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Session&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;find_by&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;id:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; cookies&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;encrypted&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:session_id&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;])&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;        person &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; session&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;amp;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;person&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	  &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	  self&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;current_person&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; person&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;There is also a
&lt;a href="https://github.com/rails/rails/blob/cc633481602daf004eaab845c3780c6d885f882c/actioncable/lib/action_cable/connection/base.rb#L204"&gt;&lt;em&gt;&amp;quot;#disconnect&amp;quot;&lt;/em&gt;&lt;/a&gt;
method that gets called when a WebSocket is closed. It&amp;#39;s useful if you want to
set something up in &lt;em&gt;&amp;quot;#connect&amp;quot;&lt;/em&gt; and then tear it down when the WebSocket
closes. E.g. if you&amp;#39;d want to count the number of open connections that a person
has, you could do something like this.&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;module&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ApplicationCable&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; Connection&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ActionCable::Connection&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;Base&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    attr_accessor&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :current_person&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; connect&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	  set_current_person&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	  reject_unauthorized_connection &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;unless&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; current_person&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	  current_person&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;increment!&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:connection_count&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; disconnect&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      current_person&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;amp;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;decrement!&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:connection_count&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    private&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;    # ...&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;Now you know who the WebSocket connection is for, but Rails doesn&amp;#39;t. To tell
Rails which attr_accessor(s) we&amp;#39;ll use for identification we have to change our
&lt;em&gt;&amp;quot;attr_accessor&amp;quot;&lt;/em&gt; to
&lt;a href="https://github.com/rails/rails/blob/cc633481602daf004eaab845c3780c6d885f882c/actioncable/lib/action_cable/connection/identification.rb#L20"&gt;&lt;em&gt;&amp;quot;identified_by&amp;quot;&lt;/em&gt;&lt;/a&gt;,
which is a class method that Action Cable provides.&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;module&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ApplicationCable&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; Connection&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ActionCable::Connection&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;Base&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;    #attr_accessor :current_person # CHANGE THIS&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    identified_by &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:current_person&lt;/span&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt; # TO THIS&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; connect&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	  set_current_person&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	  reject_unauthorized_connection &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;unless&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; current_person&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    private&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;    # ...&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;&lt;em&gt;Ok, now we&amp;#39;ve got a connection and we know who it&amp;#39;s for, how do we send a
message?&lt;/em&gt; Do we have to send some kind of JSON or XML or whatnot?&lt;/p&gt;
&lt;h2 id="the-wild-websocket-west"&gt;The Wild WebSocket West&lt;/h2&gt;
&lt;p&gt;We are used to HTTP in our Rails apps. With it we can transfer text, images,
audio, video and more from our server to the browser. But WebSockets are the
wild west of the Web.&lt;/p&gt;
&lt;p&gt;As the &amp;quot;socket&amp;quot; part implies, a WebSocket is similar to a Unix, TCP or UDP
socket in that you can transfer whatever you like over it and in any fashion
you&amp;#39;d like. It&amp;#39;s up to you to implement a protocol between your server and the
browser on top of your WebSocket.&lt;/p&gt;
&lt;p&gt;Luckily we don&amp;#39;t have to implement anything as &lt;strong&gt;Action Cable is not only a
WebSocket server but also a protocol.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;When you send a message to a client - or vice versa - the the object you are
sending will first be turned into a JSON-compatible object like a String, a
Number, a Hash, or an Array. Then it will be wrapped inside a Hash under a key
called &amp;quot;message&amp;quot; together with some additional things I&amp;#39;ll explain in a bit.
Then that Hash will be dumped as JSON and sent as an UTF-8 encoded String, byte
for byte through the WebSocket. On the other end the message is assembled into a
String again and parsed into an object.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/pasted-image-20240124152110.png/775w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 775; --attachment-height: 232; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%205%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRjIAAABXRUJQVlA4ICYAAABQAQCdASoQAAUABABoJQBOgCgAAP7vWp0RdhKYLb5VzuWlPsd0AA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #1f2d32;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/pasted-image-20240124152110.png/480w.webp 480w, https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/pasted-image-20240124152110.png/775w.webp 775w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Raw message sent via ActionCable to the browser" loading="lazy" decoding="async" class="attachment__image" width="775" height="232" src="https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/pasted-image-20240124152110.png/775w.png" srcset="https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/pasted-image-20240124152110.png/480w.png 480w, https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/pasted-image-20240124152110.png/775w.png 775w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Raw message sent via ActionCable to the browser&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;&lt;em&gt;Why do we need the wrapper Hash?&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;The wrapper Hash allows us to have different types of messages. The protocol has
three types of messages that a client can send to the server:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;subscribe&lt;/li&gt;
&lt;li&gt;unsubscribe&lt;/li&gt;
&lt;li&gt;message&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;And six kinds of messages that the server can send to the client:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;welcome&lt;/li&gt;
&lt;li&gt;disconnect&lt;/li&gt;
&lt;li&gt;ping&lt;/li&gt;
&lt;li&gt;confirm_subscription&lt;/li&gt;
&lt;li&gt;reject_subscription&lt;/li&gt;
&lt;li&gt;message&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;a href="https://github.com/rails/rails/blob/cc633481602daf004eaab845c3780c6d885f882c/actioncable/lib/action_cable/connection/base.rb#L211"&gt;&lt;strong&gt;The &amp;quot;welcome&amp;quot;
message&lt;/strong&gt;&lt;/a&gt;
is the simplest of them all. It&amp;#39;s just a Hash with a key &amp;quot;type&amp;quot; that holds the
value &amp;quot;welcome&amp;quot;. All messages sent from the server to a client have a &amp;quot;type&amp;quot;
field. This is always the first message that a server sends to any client that
connects to it. It&amp;#39;s used to indicate to the client that the WebSocket is
functional.&lt;/p&gt;
&lt;pre class="highlight" data-language="json"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;type&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;welcome&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;&lt;a href="https://github.com/rails/rails/blob/cc633481602daf004eaab845c3780c6d885f882c/actioncable/lib/action_cable/connection/base.rb#L135"&gt;&lt;strong&gt;The &amp;quot;ping&amp;quot;
message&lt;/strong&gt;&lt;/a&gt;
is used to keep a heartbeat. It&amp;#39;s sent to all connected clients every 3 seconds
so that the connection doesn&amp;#39;t become stale and therefore terminated by some
load balance in the middle. And it enables clients to detect when they have
disconnected from the server. If the client doesn&amp;#39;t receive a heartbeat message
within 6 seconds - that&amp;#39;s 2 heartbeats - it will try to reconnect. In addition
to the &amp;quot;type&amp;quot; field, a ping message will also have a &amp;quot;message&amp;quot; field which holds
the Unix timestamp of the moment the message was sent. This timestamp isn&amp;#39;t used
in the official client implementation.&lt;/p&gt;
&lt;pre class="highlight" data-language="json"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;type&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;ping&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;message&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 1705848059&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;&lt;a href="https://github.com/rails/rails/blob/cc633481602daf004eaab845c3780c6d885f882c/actioncable/lib/action_cable/connection/base.rb#L110"&gt;&lt;strong&gt;The &amp;quot;disconnect&amp;quot;
message&lt;/strong&gt;&lt;/a&gt;
instructs a client to disconnect from the server. In addition to the &amp;quot;type&amp;quot;
field it has a &amp;quot;reconnect&amp;quot; field which holds a Boolean. If the value of this
field is true, the client should immediately try to reconnect after it
disconnects. This is useful in case an error occurs and the connection has to be
restarted. And in addition to the &amp;quot;reconnect&amp;quot; field there is a &amp;quot;reason&amp;quot; field
which holds a String that gives you a brief explanation why your client is being
disconnected.&lt;/p&gt;
&lt;p&gt;There are four possible disconnect reasons:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;em&gt;unauthorized&lt;/em&gt; - sent when &amp;quot;reject_unauthorized_connection&amp;quot; is called&lt;/li&gt;
&lt;li&gt;&lt;em&gt;invalid_request&lt;/em&gt; - sent when the initial HTTP request was malformed&lt;/li&gt;
&lt;li&gt;&lt;em&gt;server_restart&lt;/em&gt; - sent when the server is about to restart&lt;/li&gt;
&lt;li&gt;&lt;em&gt;remote&lt;/em&gt; - send when the client is kicked for whatever reason&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class="highlight" data-language="json"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;type&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;disconnect&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;reconnect&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; true&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;reason&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;server_restart&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;To explain the &lt;em&gt;&amp;quot;message&amp;quot;&lt;/em&gt;, &lt;em&gt;&amp;quot;subscribe&amp;quot;&lt;/em&gt;, &lt;em&gt;&amp;quot;confirm_subscription&amp;quot;&lt;/em&gt;,
&lt;em&gt;&amp;quot;reject_subscription&amp;quot;&lt;/em&gt; &amp;amp; &lt;em&gt;&amp;quot;unsubscribe&amp;quot;&lt;/em&gt; messages I first have to explain
Channels.&lt;/p&gt;
&lt;h2 id="channels"&gt;Channels&lt;/h2&gt;
&lt;p&gt;&lt;em&gt;What are Channels?&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Channels are Action Cable&amp;#39;s controllers.&lt;/p&gt;
&lt;p&gt;In a regular HTTP request you&amp;#39;d specify which method you want to call on which
Controller by setting the request&amp;#39;s path and method. E.g. if you&amp;#39;d want to call
the &lt;em&gt;&amp;quot;create&amp;quot;&lt;/em&gt; method of the &lt;em&gt;&amp;quot;ArticlesController&amp;quot;&lt;/em&gt; you&amp;#39;d make a POST request to
&amp;quot;/articles&amp;quot; and pass any parameters you&amp;#39;d like the new Article object to have.&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ArticlesController&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;  # POST /articles&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; create&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    if&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Article&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;create&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(params&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;require&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:article&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;permit&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:title&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :content&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;))&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      redirect_to &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;action:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :show&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; status:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :see_other&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    else&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      render &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:new&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; status:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :unprocesssable_entity&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;In Action Cable you specify which method you want to call on which Channel. To
do that, you first have to subscribe to to a Channel and then you have to send a
message to it.&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ChatChannel&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ApplicationCable&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;Channel&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; post_message&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-parameter)"&gt;data&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;	  Chat&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Message&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;create&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;	    content:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; data[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:content&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;]&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;	    poster:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; current_person&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	  )&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;(Note that we get access to &lt;em&gt;&amp;quot;current_person&amp;quot;&lt;/em&gt; because we used &lt;em&gt;&amp;quot;identified_by&amp;quot;&lt;/em&gt;
in our Connection object)&lt;/p&gt;
&lt;p&gt;Now if you want to invoke &lt;em&gt;&amp;quot;ChatChannel#post_message&amp;quot;&lt;/em&gt; you first have to
subscribe to the &lt;em&gt;&amp;quot;ChatChannel&amp;quot;&lt;/em&gt; with a &lt;em&gt;&amp;quot;subscribe&amp;quot;&lt;/em&gt; message.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://github.com/rails/rails/blob/c7551d08fc572655105da38394d8672b8259c22f/actioncable/app/javascript/action_cable/subscriptions.js#L88"&gt;&lt;strong&gt;The &amp;quot;subscribe&amp;quot;
message&lt;/strong&gt;&lt;/a&gt;
has a &lt;em&gt;&amp;quot;command&amp;quot;&lt;/em&gt; field instead of a &lt;em&gt;&amp;quot;type&amp;quot;&lt;/em&gt; field. All messages sent from the
client to the server have a &lt;em&gt;&amp;quot;command&amp;quot;&lt;/em&gt; field. In addition to that it also has
an &lt;em&gt;&amp;quot;identifier&amp;quot;&lt;/em&gt; field which holds a JSON encoded Hash. The identifier Hash has
at least a &lt;em&gt;&amp;quot;channel&amp;quot;&lt;/em&gt; field which is the class name of the channel. But it can
also have other, user defined, values which will become available in the Channel
object through the &lt;em&gt;&amp;quot;params&amp;quot;&lt;/em&gt; method.&lt;/p&gt;
&lt;pre class="highlight" data-language="json"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;command&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;subscribe&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;identifier&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;{ &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;    \&amp;quot;channel\&amp;quot;: \&amp;quot;ChatChannel\&amp;quot;, &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;    \&amp;quot;room_name\&amp;quot;: \&amp;quot;Ruby Zagreb\&amp;quot; &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;  }&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;These params can be used however you&amp;#39;d like. E.g.&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ChatChannel&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ApplicationCable&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;Channel&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; post_message&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-parameter)"&gt;data&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	  current_person&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;	    .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;chat_rooms&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;	    .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;find_by&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;name:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; params[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:room_name&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;]) &lt;/span&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# PARAMS FROM THE SUBSCRIBE MESSAGE&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;	    &amp;amp;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;messages&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;	    &amp;amp;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;create&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;content:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; data[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:content&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;])&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;When someone subscribes to a Channel the &lt;em&gt;&amp;quot;subscribed&amp;quot;&lt;/em&gt; method on the channel
will be called. In it you can do various things, one of which is to authorize
the subscription. Let&amp;#39;s say that you want to reject subscriptions for chat rooms
that a person isn&amp;#39;t a member of, you could do something like this&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ChatChannel&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ApplicationCable&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;Channel&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; subscribed&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    @chat_room &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; current_person&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;	  .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;chat_rooms&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;	  .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;find_by&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;name:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; params[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:room_name&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;])&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	  &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	reject &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;unless&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; @chat_room&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; post_message&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-parameter)"&gt;data&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	@chat_room&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;messages&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;create&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;content:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; data[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:content&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;])&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;If the &lt;em&gt;&amp;quot;subscribed&amp;quot;&lt;/em&gt; method doesn&amp;#39;t call &lt;em&gt;&amp;quot;reject&amp;quot;&lt;/em&gt; the server will respond
with a &lt;em&gt;&amp;quot;confirm_subscription&amp;quot;&lt;/em&gt; message.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://github.com/rails/rails/blob/cc633481602daf004eaab845c3780c6d885f882c/actioncable/lib/action_cable/channel/base.rb#L301"&gt;&lt;strong&gt;The&lt;/strong&gt; &lt;em&gt;&lt;code&gt;&amp;quot;confirm_subscription&amp;quot;&lt;/code&gt;&lt;/em&gt;
&lt;code&gt;message&lt;/code&gt;&lt;/a&gt;
has a &lt;em&gt;&amp;quot;type&amp;quot;&lt;/em&gt;, and an &lt;em&gt;&amp;quot;identifier&amp;quot;&lt;/em&gt; field. The identifier holds the same value
that was sent in the original &lt;em&gt;&amp;quot;subscribe&amp;quot;&lt;/em&gt; message.&lt;/p&gt;
&lt;pre class="highlight" data-language="json"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;type&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;confirm_subscription&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;identifier&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;{ &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;    \&amp;quot;channel\&amp;quot;: \&amp;quot;ChatChannel\&amp;quot;, &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;    \&amp;quot;room_name\&amp;quot;: \&amp;quot;Ruby Zagreb\&amp;quot; &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;  }&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;If &lt;em&gt;&amp;quot;reject&amp;quot;&lt;/em&gt; was called then the server will respond with a
&lt;em&gt;&amp;quot;reject_subscription&amp;quot;&lt;/em&gt; message.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://github.com/rails/rails/blob/main/actioncable/lib/action_cable/channel/base.rb#L316"&gt;&lt;strong&gt;The &amp;quot;reject_subscription&amp;quot;
message&lt;/strong&gt;&lt;/a&gt;
has the same format as the &amp;quot;confirm_subscription&amp;quot; message.&lt;/p&gt;
&lt;pre class="highlight" data-language="json"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;type&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;reject_subscription&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;identifier&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;{ &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;    \&amp;quot;channel\&amp;quot;: \&amp;quot;ChatChannel\&amp;quot;, &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;    \&amp;quot;room_name\&amp;quot;: \&amp;quot;Ruby Zagreb\&amp;quot; &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;  }&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;Now that the client is subscribed they can invoke an action on the Channel using
the regular message type.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The &amp;quot;message&amp;quot; type messages&lt;/strong&gt; has two variants - one for messages sent from
the server to the client, and another for messages sent from the client to the
server.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://github.com/rails/rails/blob/c7551d08fc572655105da38394d8672b8259c22f/actioncable/app/javascript/action_cable/subscription.js#L83"&gt;&lt;strong&gt;If the client is sending the
message&lt;/strong&gt;&lt;/a&gt;,
then the message will contain a &lt;em&gt;&amp;quot;command&amp;quot;&lt;/em&gt; field with the value &amp;quot;message&amp;quot;, an
&lt;em&gt;&amp;quot;identifier&amp;quot;&lt;/em&gt; and a &lt;em&gt;&amp;quot;data&amp;quot;&lt;/em&gt; field. Again, the identifier holds the same value
that was sent in the original &lt;em&gt;&amp;quot;subscribe&amp;quot;&lt;/em&gt; message. While data can be anything
depending on what the client has sent.&lt;/p&gt;
&lt;pre class="highlight" data-language="json"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;command&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;message&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;identifier&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;{ &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;    \&amp;quot;channel\&amp;quot;: \&amp;quot;ChatChannel\&amp;quot;, &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;    \&amp;quot;room_name\&amp;quot;: \&amp;quot;Ruby Zagreb\&amp;quot; &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;  }&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;data&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;Hello, Zagreb!&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;&lt;a href="https://github.com/rails/rails/blob/cc633481602daf004eaab845c3780c6d885f882c/actioncable/lib/action_cable/channel/base.rb#L167C11-L167C25"&gt;When the channel receives a
message&lt;/a&gt;,
it will check if it implements a &lt;em&gt;&amp;quot;receive&amp;quot;&lt;/em&gt; method and if it does it will
invoke it and pass the message&amp;#39;s data to it.&lt;/p&gt;
&lt;p&gt;Though, if the client sent over a Hash, then the Channel will check if the data
Hash contains the key &amp;quot;action&amp;quot; with a String value. If it does, and the Channel
implements a method with the same name, then it will be invoked to process the
message&amp;#39;s data.&lt;/p&gt;
&lt;pre class="highlight" data-language="json"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;command&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;message&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;identifier&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;{ &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;    \&amp;quot;channel\&amp;quot;: \&amp;quot;ChatChannel\&amp;quot;, &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;    \&amp;quot;room_name\&amp;quot;: \&amp;quot;Ruby Zagreb\&amp;quot; &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;  }&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;data&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    &amp;quot;action&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;post_message&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    &amp;quot;content&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;Hello, Zagreb!&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;I&amp;#39;ll explain the server-to-client message format in a moment.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Now we know how to send messages from the client to Action Cable. But how do we
send messages from Action Cable to the client?&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;This is in my opinion the more interesting part. To send something we first have
to create a stream to which we can publish messages to.&lt;/p&gt;
&lt;p&gt;A stream is a PubSub channel. I &lt;em&gt;guess&lt;/em&gt; it&amp;#39;s called a stream in Action Cable
because, semantically, this PubSub channel acts like a stream of messages for
the client.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;What is a PubSub channel?&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href="https://en.wikipedia.org/wiki/Publish%E2%80%93subscribe_pattern"&gt;PubSub (short for
Publish-subscribe)&lt;/a&gt;
is a common programming pattern for sending messages between objects. An object
can subscribe to messages from, or publish messages to, a channel (sometimes
called a topic). When one object publishes a message to a channel, all objects
subscribed to that channel receive it. The message can be anything - a String, a
Hash, a Number, or another Object.&lt;/p&gt;
&lt;p&gt;In Action Cable, our Channel object is the subscriber and our application
(controllers, models, jobs, ...) is the publisher.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/pasted-image-20240128081709.png/979w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 979; --attachment-height: 274; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%204%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRigAAABXRUJQVlA4IBwAAAAwAQCdASoQAAQABABoJZwAA3AA%2FvF2g0MecCAA%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #f2f9f7;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/pasted-image-20240128081709.png/480w.webp 480w, https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/pasted-image-20240128081709.png/800w.webp 800w, https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/pasted-image-20240128081709.png/979w.webp 979w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Illustration of how PubSub works" loading="lazy" decoding="async" class="attachment__image" width="979" height="274" src="https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/pasted-image-20240128081709.png/979w.png" srcset="https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/pasted-image-20240128081709.png/480w.png 480w, https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/pasted-image-20240128081709.png/800w.png 800w, https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/pasted-image-20240128081709.png/979w.png 979w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Illustration of how PubSub works&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;To create a stream we can use either
&lt;a href="https://github.com/rails/rails/blob/cc633481602daf004eaab845c3780c6d885f882c/actioncable/lib/action_cable/channel/streams.rb#L78"&gt;&lt;em&gt;&amp;quot;stream_from&amp;quot;&lt;/em&gt;&lt;/a&gt;
which creates a PubSub channel using a String identifier that we pass to it
&lt;em&gt;(e.g. &amp;quot;chat:1337&amp;quot;)&lt;/em&gt;. Or
&lt;a href="https://github.com/rails/rails/blob/cc633481602daf004eaab845c3780c6d885f882c/actioncable/lib/action_cable/channel/streams.rb#L103"&gt;&lt;em&gt;“stream_for”&lt;/em&gt;&lt;/a&gt;
which accepts an object &lt;em&gt;(e.g. &amp;quot;Chat.find(1337)&amp;quot;)&lt;/em&gt;. &lt;em&gt;&amp;quot;stream_for&amp;quot;&lt;/em&gt; internally
generates a String identifier for the given object and calls &lt;em&gt;&amp;quot;stream_from&amp;quot;&lt;/em&gt;
with it.&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ChatChannel&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ApplicationCable&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;Channel&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; subscribed&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    @chat_room &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; current_person&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;	  .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;chat_rooms&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;	  .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;find_by&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;name:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; params[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:room_name&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;])&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	  &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	reject &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;unless&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; @chat_room&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;    # creates a PubSub channel to which we can publish messages to from anywhere&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    stream_for @chat_room&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;    # the above is basicaly the same as&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;    # stream_from &amp;quot;chat_room:#{@chat_room.id}&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;Now that we have a stream we can publish messages to it from anywhere using
&lt;em&gt;&amp;quot;broadcast_to&amp;quot;&lt;/em&gt; which accepts two arguments - the object we are broadcasting
to, and the messages to broadcast.&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;ChatChannel&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;broacast_to&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;  ChatRoom&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;find&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(params[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:id&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;])&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt; # The same record that we gave to stream_for&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;  &amp;quot;Hello, Zagreb!&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt; # The message I want to send&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;If you used &lt;em&gt;&amp;quot;stream_from&amp;quot;&lt;/em&gt; to create your stream, then you have to use
&lt;em&gt;&amp;quot;ActionCable.server.broadcast&amp;quot;&lt;/em&gt; instead of &lt;em&gt;&amp;quot;broadcast_to&amp;quot;&lt;/em&gt;. It also expects
two arguments - the stream we are broadcasting to, and the message we are
broadcasting.&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;ActionCable&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;server&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;broadcast&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;chat_room:123&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;Hello, Zagreb!&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;You can also send a message directly from the Channel object using the
&lt;a href="https://github.com/rails/rails/blob/3d132b855541c35236e93b3a676a3ee80d02a38a/actioncable/lib/action_cable/channel/base.rb#L214"&gt;&lt;em&gt;&amp;quot;transmit&amp;quot;&lt;/em&gt;
method&lt;/a&gt;.&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ChatChannel&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ApplicationCable&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;Channel&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; subscribed&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    @chat_room &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; current_person&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;	  .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;chat_rooms&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;	  .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;find_by&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;name:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; params[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:room_name&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;])&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	  &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	reject &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;unless&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; @chat_room&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    transmit &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;Welcome back, &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;#{current_person&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;first_name&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;!&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;&lt;em&gt;Ok, but what happens when we broadcast or transmit a message?&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Well, &lt;a href="https://github.com/rails/rails/blob/cc633481602daf004eaab845c3780c6d885f882c/actioncable/lib/action_cable/channel/base.rb#L223"&gt;it&amp;#39;s wrapped in a hash containing two
keys&lt;/a&gt; -
&lt;em&gt;&amp;quot;identifier&amp;quot;&lt;/em&gt; and &lt;em&gt;&amp;quot;message&amp;quot;&lt;/em&gt;. The identifier holds the same value that was
sent in the original &lt;em&gt;&amp;quot;subscribe&amp;quot;&lt;/em&gt; message. While the message holds whatever you
are sending to the client.&lt;/p&gt;
&lt;pre class="highlight" data-language="javascript"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;  &amp;quot;identifier&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;: &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;{ &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    \&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;channel\&amp;quot;: \&amp;quot;ChatChannel\&amp;quot;, &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    \&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;room_name\&amp;quot;: \&amp;quot;Ruby Zagreb\&amp;quot; &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  }&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;  &amp;quot;message&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;: &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;Hello, Zagreb!&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;Then that Hash is turned into JSON and sent via the WebSocket.&lt;/p&gt;
&lt;p&gt;When the client gets that message, it can figure out for which of its
subscriptions it&amp;#39;s for based on the &lt;em&gt;&amp;quot;identifier&amp;quot;&lt;/em&gt;, and then it can process the
&lt;em&gt;&amp;quot;message&amp;quot;&lt;/em&gt; however it likes.&lt;/p&gt;
&lt;h2 id="in-the-browser"&gt;In the browser&lt;/h2&gt;
&lt;p&gt;&lt;em&gt;Now we have sent a message from the server, but how can we respond to it in a
browser?&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href="https://www.npmjs.com/package/@rails/actioncable"&gt;Action Cable ships with an official JavaScript
client&lt;/a&gt; that enables you to
connect to a server via Action Cable, subscribe to any channel, and receive
messages. Just like its server counterpart, it hides the protocol from you and
allows you to focus only on the messages.&lt;/p&gt;
&lt;p&gt;To connect to a server you have to create a &lt;em&gt;consumer&lt;/em&gt;, which is a wrapper
around a WebSocket connection to our server.&lt;/p&gt;
&lt;p&gt;You can create a consumer that will connect to &amp;quot;/cable&amp;quot; without any extra params
just by calling &lt;em&gt;&amp;quot;createConsumer&amp;quot;&lt;/em&gt;.&lt;/p&gt;
&lt;pre class="highlight" data-language="javascript"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;import&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; { createConsumer } &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;from&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;@rails/actioncable&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; consumer&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; createConsumer&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;()&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;If you want to pass extra params to the server&amp;#39;s Connection object, e.g. for
authentication, you&amp;#39;ll have to pass the exact URL with params and all to
&lt;em&gt;&amp;quot;createConsumer&amp;quot;&lt;/em&gt;.&lt;/p&gt;
&lt;pre class="highlight" data-language="javascript"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;import&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; { createConsumer } &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;from&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;@rails/actioncable&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;// Fetches the user&amp;#39;s auth token from a meta tag in the DOM&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; token&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; document&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.querySelector&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;meta[name=auth_token]&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)?.content&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;// Takes the current URL,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;// changes it&amp;#39;s path to &amp;quot;/ws&amp;quot;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;// and adds &amp;quot;?token=#{token}&amp;quot; as the query params.&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;// The result looks something like &amp;quot;https://example.com/ws?token=123&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; webSocketURL&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; new&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; URL&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;window&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;location&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.href)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;webSocketURL&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.pathname &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;/ws&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt; // usually it&amp;#39;s &amp;quot;/cable&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;webSocketURL&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.search &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; `?token=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;${&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;token&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;`&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; consumer&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; createConsumer&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;webSocketURL&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.toString&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;())&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;Now that you have a consumer, you can subscribe with it to any channel you&amp;#39;d
like. To subscribe, you have to create a subscription with an identifier.&lt;/p&gt;
&lt;p&gt;If you remember from before, an identifier has to have a &lt;em&gt;&amp;quot;channel&amp;quot;&lt;/em&gt; field, but
can also have any additional fields you want and you&amp;#39;ll have access to these
fields as &lt;em&gt;&amp;quot;params&amp;quot;&lt;/em&gt; in your Channel object.&lt;/p&gt;
&lt;pre class="highlight" data-language="javascript"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;import&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; { createConsumer } &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;from&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;@rails/actioncable&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; consumer&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; createConsumer&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;()&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;// Subscribe to the ChatChannel &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;// and pass { room_name: &amp;quot;Ruby Zagreb&amp;quot; } as params&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;consumer&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;subscriptions&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.create&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  { channel&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;ChatChannel&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; room_name&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;Ruby Zagreb&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;&lt;em&gt;How do I process an incoming message with this?&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Well, &lt;a href="https://github.com/rails/rails/blob/c7551d08fc572655105da38394d8672b8259c22f/actioncable/app/javascript/action_cable/connection.js#L154"&gt;when your subscription receives a message it will try to call a
&lt;em&gt;&amp;quot;received&amp;quot;&lt;/em&gt; method of the subscription
object&lt;/a&gt;
to process the message.&lt;/p&gt;
&lt;p&gt;You can create a received method on the subscription yourself, like so&lt;/p&gt;
&lt;pre class="highlight" data-language="javascript"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;import&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; { createConsumer } &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;from&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;@rails/actioncable&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; consumer&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; createConsumer&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;()&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;// Subscribe to the ChatChannel &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;// and pass { room_name: &amp;quot;Ruby Zagreb&amp;quot; } as params&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; subscription&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; consumer&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;subscriptions&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.create&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  { channel&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;ChatChannel&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; room_name&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;Ruby Zagreb&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;subscription&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;received&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; function&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(message) {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  document&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;    .querySelector&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;#messages&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;    ?.insertAdjacentHTML&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;beforeend&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; `&amp;lt;div&amp;gt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;${&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;message&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;lt;/div&amp;gt;`&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;Though this approach has a problem - if you get a message immediately as you
subscribe, but before you create your received method, you&amp;#39;ll miss that message.&lt;/p&gt;
&lt;p&gt;Action Cable allows you to extend the Subscription object as you create it to
avoid this problem. You can pass a second argument while creating a
subscription. That argument has to be an object with which the newly created
Subscription object will be extended. Extending an object in JavaScript is
similar to including a mixin in a class in Ruby.&lt;/p&gt;
&lt;pre class="highlight" data-language="javascript"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;import&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; { createConsumer } &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;from&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;@rails/actioncable&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; consumer&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; createConsumer&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;()&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;// Subscribe to the ChatChannel &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;// and pass { room_name: &amp;quot;Ruby Zagreb&amp;quot; } as params&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; subscription&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; consumer&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;subscriptions&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.create&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  { channel&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;ChatChannel&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; room_name&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;Ruby Zagreb&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; }&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;    received&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(message) {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;	  this&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.appendMessage&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(message)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;    appendMessage&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(message) {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;	  this&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.messageContainer&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;()&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;?.insertAdjacentHTML&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;		&amp;quot;beforeend&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;		`&amp;lt;div&amp;gt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;${&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;message&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;lt;/div&amp;gt;`&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	  )&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;    messageContainer&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;() {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;	  document&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.querySelector&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;#messages&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;But there are other events that you can process in your Subscription object
besides receiving a message.&lt;/p&gt;
&lt;p&gt;You can process &lt;a href="https://github.com/rails/rails/blob/c7551d08fc572655105da38394d8672b8259c22f/actioncable/app/javascript/action_cable/subscriptions.js#L34"&gt;initialization
events&lt;/a&gt;,
&lt;a href="https://github.com/rails/rails/blob/c7551d08fc572655105da38394d8672b8259c22f/actioncable/app/javascript/action_cable/connection.js#L147"&gt;connect&lt;/a&gt;
and
&lt;a href="https://github.com/rails/rails/blob/c7551d08fc572655105da38394d8672b8259c22f/actioncable/app/javascript/action_cable/connection.js#L172"&gt;disconnect&lt;/a&gt;
events, as well as subscription
&lt;a href="https://github.com/rails/rails/blob/c7551d08fc572655105da38394d8672b8259c22f/actioncable/app/javascript/action_cable/subscriptions.js#L50"&gt;rejections&lt;/a&gt;.
Each event requires you to define a method to process it. Initialization
requires an &lt;em&gt;&amp;quot;initialized&amp;quot;&lt;/em&gt; method, rejection a &lt;em&gt;&amp;quot;rejected&amp;quot;&lt;/em&gt; method, connect and
disconnect a &lt;em&gt;&amp;quot;connected&amp;quot;&lt;/em&gt; and &lt;em&gt;&amp;quot;disconnected&amp;quot;&lt;/em&gt; method.&lt;/p&gt;
&lt;pre class="highlight" data-language="javascript"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;import&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; { createConsumer } &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;from&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;@rails/actioncable&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; consumer&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; createConsumer&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;()&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; subscription&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; consumer&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;subscriptions&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.create&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  { channel&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;ChatChannel&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; room_name&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;Ruby Zagreb&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; }&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;    // Called right after the Subscription object is created&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;    initialized&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;() {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;      console&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.log&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;`A subscription to &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;${&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;this&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.identifier&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; was created`&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;    // Called if the subscription was rejected by the server&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;	rejected&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;() {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;	  console&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.log&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;`The server rejected the subscription to &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;${&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;this&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.identifier&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;`&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	}&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;    // Called when the subscription gets confirmed by the server&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;    connected&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(data) {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;	  // The data object has a single property `reconnected`&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;	  // which indicates if this was a resubscribe after a disconnect&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;      console&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.log&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;`The server confirmed the subscription to &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;${&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;this&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.identifier&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;! Reconnected: &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;${&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;data&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.reconnected&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;`&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;    // Called when the WebSocket closes&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;	disconnected&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(data) {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;	  // The data object has a single propert `willAttemptReconnect`&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;	  // which indicates if a reconnect attempt will be made or not&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;	  console&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.log&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;`WebSocket to &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;${&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;this&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;consumer&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.url&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; closed! Will attempt reconnect: &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;${&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;data&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.willAttemptReconnect&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;`&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	}&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;    // Called when a message is sent from the server&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;    received&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(message) {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;	  console&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.log&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;`Received message: &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;${&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;message&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;`&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;There are also two actions that you can perform from your subscription - send
messages to the server, and unsubscribe.&lt;/p&gt;
&lt;p&gt;To unsubscribe just call
&lt;a href="https://github.com/rails/rails/blob/c7551d08fc572655105da38394d8672b8259c22f/actioncable/app/javascript/action_cable/subscription.js#L86"&gt;&lt;em&gt;&amp;quot;unsubscribe&amp;quot;&lt;/em&gt;&lt;/a&gt;.&lt;/p&gt;
&lt;pre class="highlight" data-language="javascript"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;import&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; { createConsumer } &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;from&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;@rails/actioncable&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; consumer&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; createConsumer&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;()&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; subscription&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; consumer&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;subscriptions&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.create&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  { channel&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;ChatChannel&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; room_name&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;Ruby Zagreb&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; }&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;    received&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(message) {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;      console&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.log&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;`Received message: &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;${&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;message&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;`&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      if&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; (&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;message&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.toLowerCase&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;() &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;===&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;avada kedavra&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;) &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;this&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.unsubscribe&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;()&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;And to send a message call the &lt;a href="https://github.com/rails/rails/blob/c7551d08fc572655105da38394d8672b8259c22f/actioncable/app/javascript/action_cable/subscription.js#L82"&gt;&lt;em&gt;&amp;quot;send&amp;quot;&lt;/em&gt;
method&lt;/a&gt;
with the message you want to send.&lt;/p&gt;
&lt;pre class="highlight" data-language="javascript"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;import&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; { createConsumer } &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;from&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;@rails/actioncable&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; consumer&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; createConsumer&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;()&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; subscription&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; consumer&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;subscriptions&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.create&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  { channel&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;ChatChannel&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; room_name&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;Ruby Zagreb&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;subscription&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.send&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;({ action&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;post_message&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; content&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;Hello, Zagreb!&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; })&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;h2 id="thousands-of-connections-one-server"&gt;Thousands of connections, one server&lt;/h2&gt;
&lt;p&gt;Now you know how to connect to Action Cable and send messages back and forth.
But if you have ever tuned a Rails application you probably know that there is a
&lt;a href="https://en.wikipedia.org/wiki/Thread_pool"&gt;thread pool&lt;/a&gt; that determines how
many simulations requests your server can process.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Does that mean that there is a maximum number of connections that Action Cable
can handle?&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Well, no. As long as your server has memory available it will be able to accept
and serve more Action Cable connections. Granted, it will process them slower
and slower, but it will work.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;How does that work?&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href="https://github.com/puma/puma"&gt;Puma&lt;/a&gt;, currently the most popular Ruby/Rack
application server, is a multi-threaded server. This means that when you make a
request to it, your request will be assigned to one thread that will process it
and generate a response.&lt;/p&gt;
&lt;p&gt;But, you don&amp;#39;t want to have too many threads. Each thread running on your server
gets a very short slice of time to run its code on the processor. The more
threads you have, the more any one thread has to wait to get to run its code on
the processor.&lt;/p&gt;
&lt;p&gt;So the smarter thing to do is to have a preset number of threads - a thread
pool - that can process requests. If a request comes in, and all threads in the
pool are busy, the request goes into a queue. It will wait there until a thread
becomes available. That&amp;#39;s why you want to take as little time as possible to
generate a response for a request. The more a request takes to process, the
higher the chance that some requests will end up in the queue which means that
your app feels slow.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;But Action Cable turns requests into WebSockets, which can stay open for days,
how don&amp;#39;t we run out of threads in the pool?&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;That&amp;#39;s the interesting part. &lt;strong&gt;Action Cable processes only the initial HTTP
request in Puma&amp;#39;s thread pool.&lt;/strong&gt; After you get a 101 response from the server
the request is &amp;quot;hijacked&amp;quot; and put into an event loop. &lt;strong&gt;So Puma&amp;#39;s thread pool
determines the maximum number of WebSocket connections that can be opened at
once.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Hijack? Event loop? What?&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Rails doesn&amp;#39;t integrate directly with an application server &lt;em&gt;(like Puma)&lt;/em&gt;.
Instead it implements Rack&amp;#39;s protocol through which it gets requests from the
application server and returns responses to it. This enables applications and
frameworks like Rails to work with any application server like Puma,
&lt;a href="https://github.com/ruby/webrick"&gt;WebBrick&lt;/a&gt; or
&lt;a href="https://github.com/socketry/falcon"&gt;Falcon&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Rack is great for request-response interactions like HTTP, but a WebSocket isn&amp;#39;t
request-response like. It&amp;#39;s a bi-directional stream of data.&lt;/p&gt;
&lt;p&gt;To support WebSockets and similar stream-like protocols, Rack offers a way to
read and write bytes directly to and from a connection initiated by a request -
it&amp;#39;s called hijacking the socket.&lt;/p&gt;
&lt;p&gt;When you hijack a Rack request you get it&amp;#39;s underlying socket. You can read and
write bytes to the sockets freely. But this means that it&amp;#39;s your responsibility
to implement whatever protocol you want over that socket.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://github.com/rails/rails/blob/main/actioncable/lib/action_cable/connection/stream.rb#L96"&gt;Action Cable hijacks the
socket&lt;/a&gt;
and passes it to &lt;a href="https://github.com/faye/websocket-driver-ruby/tree/main"&gt;Faye&amp;#39;s WebSocket
Driver&lt;/a&gt; library which
implements the WebSocket protocol and it puts that socket into an &lt;a href="https://en.wikipedia.org/wiki/Event_loop"&gt;event
loop&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;An event loop is a pattern for responding to events. You start an infinite loop
in which you wait for some event, when it occurs you process it and the loop
starts again.&lt;/p&gt;
&lt;p&gt;In Action Cable&amp;#39;s case it&amp;#39;s waiting for bytes to read from the socket it
hijacked. When they are available, it reads them and passes them on to the
WebSocket driver.&lt;/p&gt;
&lt;p&gt;That&amp;#39;s easy enough for one socket. But to wait for any of an infinite number of
sockets to have some bytes ready to be read would require an infinite number of
threads - one for each socket.&lt;/p&gt;
&lt;p&gt;To avoid spawning a thread for each WebSocket, Action Cable uses
&lt;a href="https://github.com/socketry/nio4r"&gt;nio4r&lt;/a&gt; &lt;a href="https://github.com/rails/rails/blob/main/actioncable/lib/action_cable/connection/stream_event_loop.rb#L95"&gt;which notifies it when a socket has
some bytes ready to be
read&lt;/a&gt;,
without spawning any threads. It does so using different functions of the
server&amp;#39;s operating system&amp;#39;s kernel - such as
&lt;a href="https://ruby-doc.org/3.2.2/IO.html#method-c-select"&gt;select&lt;/a&gt;,
&lt;a href="https://en.wikipedia.org/wiki/Epoll"&gt;epoll&lt;/a&gt; and
&lt;a href="https://en.wikipedia.org/wiki/Kqueue"&gt;kqueue&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Once it&amp;#39;s notified that &lt;a href="https://github.com/rails/rails/blob/3d132b855541c35236e93b3a676a3ee80d02a38a/actioncable/lib/action_cable/connection/stream_event_loop.rb#L95"&gt;a socket is
ready&lt;/a&gt;,
&lt;a href="https://github.com/rails/rails/blob/3d132b855541c35236e93b3a676a3ee80d02a38a/actioncable/lib/action_cable/connection/stream_event_loop.rb#L109"&gt;it reads its
bytes&lt;/a&gt;,
parses them into a message, &lt;a href="https://github.com/rails/rails/blob/3d132b855541c35236e93b3a676a3ee80d02a38a/actioncable/lib/action_cable/connection/stream_event_loop.rb#L116"&gt;and then passes the message to the Connection
object&lt;/a&gt;,
&lt;a href="https://github.com/rails/rails/blob/3d132b855541c35236e93b3a676a3ee80d02a38a/actioncable/lib/action_cable/connection/base.rb#L87"&gt;which then puts in in a thread pool to be
processed&lt;/a&gt; -
&lt;a href="https://github.com/rails/rails/blob/3d132b855541c35236e93b3a676a3ee80d02a38a/actioncable/lib/action_cable/server/worker.rb#L10"&gt;called the worker
pool&lt;/a&gt; -
to process that message.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/pasted-image-20240204081845.png/1107w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 1107; --attachment-height: 256; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%204%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRioAAABXRUJQVlA4IB4AAAAwAQCdASoQAAQABABoJZwAA3AA%2FvGIa9dymOSAAAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #f7f7f7;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/pasted-image-20240204081845.png/480w.webp 480w, https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/pasted-image-20240204081845.png/800w.webp 800w, https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/pasted-image-20240204081845.png/1107w.webp 1107w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Illustration of all the pools that a request goes through" loading="lazy" decoding="async" class="attachment__image" width="1107" height="256" src="https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/pasted-image-20240204081845.png/1107w.png" srcset="https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/pasted-image-20240204081845.png/480w.png 480w, https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/pasted-image-20240204081845.png/800w.png 800w, https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/pasted-image-20240204081845.png/1107w.png 1107w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Illustration of all the pools that a request goes through&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;&lt;em&gt;Another thread pool?&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Yes. &lt;strong&gt;This worker pool allows Action Cable to process multiple incoming
messages simultaneously.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;You can tweak the size of this worker pool by setting
&lt;em&gt;&amp;quot;config.action_cable.worker_pool_size&amp;quot;&lt;/em&gt; to the number of threads you&amp;#39;d like to
have in that pool.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The more threads in this pool the higher the number of incoming messages that
you can process concurrently&lt;/strong&gt;. More threads also means that your latency will
go up (as one threads will have to wait for longer to get access to the
processor), your database connection count will go up (as more threads can
access the database simultaneously), and your memory consumption will go up as
more messages and objects are in memory at the same time.&lt;/p&gt;
&lt;p&gt;But there is one more thread pool in Action Cable - the event loop thread pool.&lt;/p&gt;
&lt;p&gt;This one is used to dispatch events like &lt;a href="https://github.com/rails/rails/blob/3d132b855541c35236e93b3a676a3ee80d02a38a/actioncable/lib/action_cable/server/connections.rb#L29"&gt;sending heart beat
messages&lt;/a&gt;,
&lt;a href="https://github.com/rails/rails/blob/3d132b855541c35236e93b3a676a3ee80d02a38a/actioncable/lib/action_cable/connection/internal_channel.rb#L22"&gt;subscribing
to&lt;/a&gt;
and &lt;a href="https://github.com/rails/rails/blob/3d132b855541c35236e93b3a676a3ee80d02a38a/actioncable/lib/action_cable/connection/internal_channel.rb#L29"&gt;unsubscribing
from&lt;/a&gt;
PubSub channels created by stream_from,
&lt;a href="https://github.com/rails/rails/blob/3d132b855541c35236e93b3a676a3ee80d02a38a/actioncable/lib/action_cable/connection/stream.rb#L104"&gt;attaching&lt;/a&gt;
and
&lt;a href="https://github.com/rails/rails/blob/3d132b855541c35236e93b3a676a3ee80d02a38a/actioncable/lib/action_cable/connection/stream.rb#L110"&gt;detaching&lt;/a&gt;
hijacked sockets, and &lt;a href="https://github.com/rails/rails/blob/3d132b855541c35236e93b3a676a3ee80d02a38a/actioncable/lib/action_cable/channel/periodic_timers.rb#L67"&gt;triggering periodic
timers&lt;/a&gt;
(to which I&amp;#39;ll get to in just a bit).&lt;/p&gt;
&lt;p&gt;This thread pool has a fixed size as it&amp;#39;s intended to be an event dispatch of
sorts where you just schedule a task which the pool only trigger, the execution
of the task is mostly done in the worker pool.&lt;/p&gt;
&lt;h2 id="things-i-wish-i-knew-right-away"&gt;Things I wish I knew right away&lt;/h2&gt;
&lt;p&gt;There are a few things that I learned about Action Cable that I wish I knew
earlier.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;You can trigger actions and send messages periodically&lt;/strong&gt;, like every few
seconds. This is extremely useful if you have some state that you want to
synchronize periodically, or some house keeping you want to do.&lt;/p&gt;
&lt;p&gt;In your Channel object you can call a &lt;a href="https://github.com/rails/rails/blob/main/actioncable/lib/action_cable/channel/periodic_timers.rb#L31"&gt;&lt;em&gt;&amp;quot;periodically&amp;quot;&lt;/em&gt; class
method&lt;/a&gt;,
give it a method name or a block, and an interval. It will then run that
method/block for you with whatever interval you specified.&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; StockTickerChannel&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ApplicationCable&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;Channel&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;  # this will send a message to the client every 2 seconds &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;  # as long as they are subscribed&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  periodically &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;every:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 2&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;seconds&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    transmit &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;value:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; @stock&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;value&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; timestamp:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Time&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;now&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;to_i&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; subscribed&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    @stock &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Stock&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;find_by&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;symbol:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; params[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:symbol&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;])&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	  &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	reject &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;unless&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; @stock&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Some error trackers won&amp;#39;t catch errors from your Channel objects unless you
explicitly send them.&lt;/strong&gt; You can do that in your Connection using
&lt;em&gt;&amp;quot;rescue_from&amp;quot;&lt;/em&gt;, just like you would in a Controller.&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;module&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ApplicationCable&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; Connection&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ActionCable::Connection&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;Base&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    rescue_from &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Exception&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; |&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;error&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;|&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;      MyErrorTracker&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;capture_exception&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(error)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;    # ...&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;There is a callback on the Connection object for when an action gets invoked
on your Channel object.&lt;/strong&gt; You can register such a callback using
&lt;em&gt;&amp;quot;before_command&amp;quot;&lt;/em&gt;, &lt;em&gt;&amp;quot;after_command&amp;quot;&lt;/em&gt; and &amp;quot;around_command&amp;quot;, which is extremely
useful if you use &lt;a href="https://api.rubyonrails.org/classes/ActiveSupport/CurrentAttributes.html"&gt;Current
attributes&lt;/a&gt;.&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;module&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ApplicationCable&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; Connection&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ActionCable::Connection&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;Base&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    around_command &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;	  Current&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;set&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;person:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; current_person) { &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;yield&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;    # ...&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;There are callbacks on the Channel object for when someone subscribes or
unsubscribes.&lt;/strong&gt; There is &amp;quot;&lt;em&gt;before_subscribe&lt;/em&gt;&amp;quot;, &lt;em&gt;&amp;quot;after_subscribe&amp;quot;&lt;/em&gt;,
&lt;em&gt;&amp;quot;around_subscribe&amp;quot;&lt;/em&gt;, and &amp;quot;&lt;em&gt;before_unsubscribe&lt;/em&gt;&amp;quot;, &lt;em&gt;&amp;quot;after_unsubscribe&amp;quot;&lt;/em&gt;,
&lt;em&gt;&amp;quot;around_unsubscribe&amp;quot;&lt;/em&gt;. These are useful for implementing common behavior
through inheritance or mixins without having to call &amp;quot;super&amp;quot; form the
&lt;em&gt;&amp;quot;subscribed&amp;quot;&lt;/em&gt; or &lt;em&gt;&amp;quot;unsubscribed&amp;quot;&lt;/em&gt; method.&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;module&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; AppearanceTrackable&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  extend&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; ActiveSupport&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Concern&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  included &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	after_subscribe &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;unless:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :subscription_rejected?&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;	  Current&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;person&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;came_online!&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;	end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	after_unsubscribe &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;	  Current&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;person&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;went_offline!&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ChatChannel&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ApplicationCable&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;Channel&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  include&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; AppearanceTrackable&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; subscribed&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    @chat_room &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; current_person&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;	  .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;chat_rooms&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;	  .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;find_by&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;name:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; params[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:room_name&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;])&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	  &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	reject &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;unless&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; @chat_room&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    stream_for @chat_room&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;You probably want to bump Puma&amp;#39;s worker timeout if you are debugging your
Connection object.&lt;/strong&gt; Puma will kill any worker that doesn&amp;#39;t generate a response
within 60 seconds.&lt;/p&gt;
&lt;p&gt;This can be annoying if you are trying to debug something with &lt;em&gt;&amp;quot;debugger&amp;quot;&lt;/em&gt;,
&lt;em&gt;&amp;quot;binding.irb&amp;quot;&lt;/em&gt; or &lt;em&gt;&amp;quot;binding.pry&amp;quot;&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;But you can raise that timeout using the &lt;em&gt;&amp;quot;worker_timeout&amp;quot;&lt;/em&gt; method in
&lt;em&gt;&amp;quot;puma.rb&amp;quot;&lt;/em&gt;.&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# config/puma.rb&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;require&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; File&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;expand_path&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;../config/environment&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; File&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;dirname&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(__FILE__))&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# ...&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# Kill a worker thread if it didn&amp;#39;t generate a responsw in 8 hours&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;worker_timeout &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;8&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; *&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 3600&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; if&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; ENV&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;fetch&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;RAILS_ENV&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;development&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;) &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;==&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;development&amp;quot;&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;You have to tweak both Puma&amp;#39;s thread and Action Cable&amp;#39;s worker counts.&lt;/strong&gt;
Puma&amp;#39;s thread count controls how many new WebSocket connections can be created
simultaneously. Action Cable&amp;#39;s worker pool size determines how many WebSocket
messages you can process simultaneously. Both pools will create database
connections!&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# config/puma.rb&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;max_threads_count &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; ENV&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;fetch&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;RAILS_MAX_THREADS&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;) { &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;5&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;min_threads_count &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; ENV&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;fetch&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;RAILS_MIN_THREADS&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;) { max_threads_count }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;threads min_threads_count&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; max_threads_count&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# config/application.rb&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;config&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;action_cable&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;worker_pool_size&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; ENV&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;fetch&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;RAILS_MAX_THREADS&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;) { &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;5&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; }&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;In development, sometimes the server can behave a bit wonky.&lt;/strong&gt; I&amp;#39;m not a 100%
sure what&amp;#39;s going on but it seems like code reloading can cause rogue threads
with stale code. If that happens just run &amp;quot;touch tmp/restart.txt&amp;quot; to quickly
restart the server and reset everything.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;When a client looses it&amp;#39;s Internet connection it will take Action Cable up to
20 min to notice that, close the connection, and trigger callbacks.&lt;/strong&gt; I wrote
about that in my &lt;a href="https://stanko.io/tracking-online-presence-with-actioncable-ukEi6sUZ98Bz"&gt;previous
article&lt;/a&gt;.
This can cause you headaches if you want to allow only a certain number of
connections per client.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;You can use the official JS client outside of the browser, like in Node or
Bun.&lt;/strong&gt; The official client doesn&amp;#39;t use &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/WebSocket"&gt;the browser&amp;#39;s WebSocket
object&lt;/a&gt; directly. It
exports an &lt;em&gt;&amp;quot;adapters&amp;quot;&lt;/em&gt; object which has a property called &lt;em&gt;&amp;quot;WebSocket&amp;quot;&lt;/em&gt; which
holds the object that will be used to establish WebSocket connections. So you
can drop-in your own WebSocket object and use it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;You can remotely disconnect any client.&lt;/strong&gt; There is a
&lt;em&gt;&amp;quot;ActionCable::RemoteConnections&amp;quot;&lt;/em&gt; class that acts like an Active Record
relation. You can query it using its &lt;a href="https://github.com/rails/rails/blob/main/actioncable/lib/action_cable/remote_connections.rb#L36"&gt;&lt;em&gt;&amp;quot;where&amp;quot;&lt;/em&gt;
method&lt;/a&gt;
to get a connection. The where method searches for a connection by its
&lt;em&gt;&amp;quot;identified_by&amp;quot;&lt;/em&gt; fields. If it finds such a connection it gives you a proxy for
that connection on which you can only call
&lt;a href="https://github.com/rails/rails/blob/main/actioncable/lib/action_cable/remote_connections.rb#L56"&gt;&lt;em&gt;&amp;quot;disconnect&amp;quot;&lt;/em&gt;&lt;/a&gt;
which then disconnects the client.&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; Person&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; Applicationrecord&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ban!&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;    update!(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;banned:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; true&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;    ActionCable&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;RemoteConnections&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;      .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;where&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;current_person:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; self)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      &amp;amp;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;disconnect&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;reconnect:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; false&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;There are multiple PubSub adapters available besides Redis.&lt;/strong&gt; You can use
Postgres instead of Redis as a backend (&lt;a href="https://www.postgresql.org/docs/current/sql-notify.html"&gt;it has some
limitations&lt;/a&gt;). And you
can provide your own if you need.&lt;/p&gt;
&lt;pre class="highlight" data-language="plain"&gt;&lt;span class="line"&gt;&lt;span&gt;# config/cable.yml&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;production:&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;  adapter: postgres&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;h2 id="final-thought"&gt;Final thought&lt;/h2&gt;
&lt;p&gt;Action Cable is an amazing piece of software. It can seem complex, but that’s
because it does a lot. When you look at each piece individually the complexity
goes away.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png attachment--expandable" data-full-src="https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/pasted-image-20240205082517.png/537w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 537; --attachment-height: 955; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%209%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRjQAAABXRUJQVlA4ICgAAAAwAQCdASoJABAABABoJZQAA3AA%2FvDi%2BOqfhhQ9wKuSbMi1tuHkAAAA%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #f7e9ee;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/pasted-image-20240205082517.png/480w.webp 480w, https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/pasted-image-20240205082517.png/537w.webp 537w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Block overview of everything discussed" loading="lazy" decoding="async" class="attachment__image" width="537" height="955" src="https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/pasted-image-20240205082517.png/537w.png" srcset="https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/pasted-image-20240205082517.png/480w.png 480w, https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/pasted-image-20240205082517.png/537w.png 537w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Block overview of everything discussed&lt;/figcaption&gt; &lt;/figure&gt;&lt;/div&gt;
</content>
    <summary type="html">I knew what Action Cable was about and roughly how it worked since it was released. But lately I've developed a much deeper understanding of it because I had to extend it and explain its internals to others.

The following is how I explained Action Cable to myself. I took a top-down approach,...</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
</feed>
