Adding custom fields to Honeycomb traces in Rails by writing a Rack middleware

In our Rails app, installing the Honeycomb beeline magically gave us traces of all HTTP requests, with dozens of useful fields like ‘request.path’ and ‘response.status_code’. I really wanted the request verb (GET or POST) and I couldn’t find that. (It’s there, in ‘request.method’, I missed it. But still, it’s useful to know how to a Rack middleware so here’s the post.)

TL;DR, here’s all the code:

# config/initializers/honeycomb_custom.rb
class HoneycombCustom
  def initialize(app)
    @app = app
  end

  def call(env)
    Honeycomb.add_field("request.verb", env["REQUEST_METHOD"])
    @app.call(env)
  end
end

Rails.application.config.middleware.use HoneycombCustom

Rack makes a stack of tiny “apps” that all get to see a request and response.

Rails apps use Rack to pass requests through layer upon layer of middleware on their way in and out. See how many in your project with rails middleware. Ours looks like:

use Webpacker::DevServerProxy
... ten more...
use Honeycomb::Rails::Middleware
... twelve more...
use HoneycombCustom
... another one ...
run Sixmilebridge::Application.routes

At the bottom of that pile of middleware is the real app. Requests start at the top, work their way down, and finally get handled by Application.routes.

Rack sets these up by telling each one about the next app down.

At initialization, Rack builds up a stack of middleware, starting at the bottom. Each higher one receives the next one down as a parameter to its initialize method, and saves it.

initializing the stack, populating the “app” instance variable

Rack sends each through the “call” method.

Then as requests come in, Rack passes them in to the top of the stack with .call, and each middleware has the job of:

the “env” comes in to the call method; we do something, pass it on to the next one with “@app.call”, and then do something with the response before passing it on.
  • optionally do something with the request
  • pass it down to the next app’s call method
  • optionally do something with the response
  • return the response.

Technically, a middleware can choose to respond to the request instead of passing it on. But I’m not trying to interfere, here. My middleware calls Honeycomb.add_field with the extra information I want to add to my trace, then lets the response from the next app down be returned:

def call(env)
Honeycomb.add_field("request.verb", env["REQUEST_METHOD"])
@app.call(env)
end

Give the middleware to Rails in its config.

We supplied our middleware in a new file in the config/initializers directory in our Rails app. At the bottom of the file, we tell Rails to use it. We pass it the class as a whole, not an instance; it’ll need to instantiate it while constructing the stack.

Rails.application.config.middleware.use HoneycombCustom

Order matters sometimes

After adding that file, we ran rails middleware and verified that requests will go through Honeycomb’s middleware before ours (Honeycomb::Rails::Middleware is higher in the stack). That means the Honeycomb trace will be initialized before my code adds a field to it.

use Webpacker::DevServerProxy
... ten more...
use Honeycomb::Rails::Middleware
... twelve more...
use HoneycombCustom
... another one ...
run Sixmilebridge::Application.routes

That’s it. Rack middleware makes it possible to do something with every request and response.

The magic of Honeycomb for me is that I can see that the world is different because my app exists; I have external evidence that requests were served, and details of how. With this tiny middleware, I can get evidence of particular things I care about.

Now you, too, know how to do another thing, and you can make your app influence the world in tiny new ways.

Bonus hint: how did I find where the verb was in the env? By poking around in the REPL. I stuck a call to byebug (like literally a line that says “byebug”) right where I wanted to access env. At the next request, Rails turned into a console. I typed stuff until I found what I wanted, then put that expression in the code and took out the byebug.