skip to content

In a Rack app, how would you write a middleware that measures each request's duration and adds it as a response header?

level: middleimportance: must knowfreq 58%

answer

  1. initialize receives the inner app
  2. call(env) delegates to @app.call(env)
  3. monotonic clock, local variables only
  4. lower-case header name in Rack 3
  5. use it in config.ru; Rack::Runtime exists

basics

~20 s

A Rack middleware is a class whose initialize takes the inner app and whose call(env) reads a monotonic clock, calls @app.call(env), writes the elapsed time into the returned headers under a lower-case name, and returns the triple; config.ru adds it with use.

solid answer

~40 s

A middleware is itself a Rack app that wraps another one. `Rack::Builder` instantiates it once as `Timing.new(app, *args)`, so `initialize` stores `@app`. In `call(env)` I take `Process.clock_gettime(Process::CLOCK_MONOTONIC)`, call `@app.call(env)`, compute the difference and set a header such as `headers["server-timing"]` - lower-case, because Rack 3 forbids uppercase header names - then return `[status, headers, body]`. Per-request values stay in local variables, since one instance serves every request and thread. I register it with `use Timing` before `run` in `config.ru`. Rack already ships `Rack::Runtime`, which writes `x-runtime` the same way. Note the limit: this times the stack until the triple comes back, not the time spent streaming the body; for that, hook `close` with `Rack::BodyProxy` or push a callback onto `env["rack.response_finished"]`.

code

ruby · 14 lines
ruby
class RequestTiming
  def initialize(app, label: "app")
    @app = app
    @label = label
  end

  def call(env)
    started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
    status, headers, body = @app.call(env)
    ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000
    headers["server-timing"] = format("%s;dur=%.1f", @label, ms)
    [status, headers, body]
  end
end

go deeper

for a junior

Recall the two methods of a middleware, initialize(app) and call(env), and that config.ru adds it with use before run.

for a middle

Write the middleware from memory: monotonic clock, call inward, lower-case header, return the triple. Explain that one instance serves all requests, so state stays in locals.

for a senior

Explain what the number measures depending on stack position and why streaming bodies escape it; use Rack::BodyProxy or rack.response_finished for end-to-end timing, and reach for Rack::Runtime before writing your own.

for a principal

Decide where timing belongs: a header for quick debugging, logs or metrics for real latency data, and which layers must be inside the measurement for the number to answer the question the team is asking.

## What a middleware is A **Rack middleware** is a Rack application that holds a reference to another Rack application. The server only ever calls the outermost object; each middleware decides what to do before and after it passes the request inward. Structurally it needs two methods: - `initialize(app, *args)` - `Rack::Builder` calls this **once**, when it builds the stack, passing the next app inward as the first argument and any extra arguments from the `use` line after it. - `call(env)` - called on **every request**. It usually calls `@app.call(env)` and returns the triple it gets back, possibly edited. ## The timing middleware, step by step ```ruby class RequestTiming def initialize(app, label: "app") @app = app @label = label end def call(env) started = Process.clock_gettime(Process::CLOCK_MONOTONIC) status, headers, body = @app.call(env) ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000 headers["server-timing"] = format("%s;dur=%.1f", @label, ms) [status, headers, body] end end ``` 1. **Read a monotonic clock** before calling inward. `Time.now` is wall-clock time and can jump when the system clock is adjusted; `Process::CLOCK_MONOTONIC` only moves forward. `Rack::Utils.clock_time`, which `Rack::Runtime` uses, makes this same call wherever the platform supports it. 2. **Call the inner app** and destructure the triple. 3. **Write the header in lower case.** Rack 3's SPEC forbids uppercase characters in response header names, and `Rack::Lint` raises on `Server-Timing`. 4. **Return a triple.** Returning the same body object is correct: this middleware never iterates it. In `config.ru`: ```ruby require_relative "request_timing" require_relative "app" use RequestTiming, label: "app" run App.new ``` `Rack::Builder#use` forwards positional arguments, keyword arguments and a block to `new`. ## State: locals, not instance variables The Builder creates **one instance per `use` line** and reuses it for the life of the process. A multi-threaded server such as Puma calls that same instance from several threads at once. Storing `@started = ...` inside `call` lets one request overwrite another's start time. Per-request data belongs in **local variables** or in the `env` Hash (under a key with a dot and your own prefix); instance variables are for configuration set in `initialize`. ## What the number actually measures Where you put the `use` line decides what gets timed: | Position in config.ru | Includes | |---|---| | First `use` (outermost) | every other middleware plus the app | | Last `use`, just before `run` | only the endpoint app | Even outermost, the timer stops when `@app.call(env)` **returns the triple**. For an Array body that is nearly the whole job; for a streaming or lazily-enumerated body the server has not yet sent a byte. Two ways to time the full response: - Wrap the body in **`Rack::BodyProxy.new(body) { ... }`**. Its block runs once, when the server calls `close` on the body after sending it. You cannot put the result in a header then, because headers are already sent, so log it instead. - Push a callable onto **`env["rack.response_finished"]`** when the server provides that Array. The server calls each entry with `env, status, headers, error` after the response is finished, in reverse order, and they must not raise. ## Variations interviewers probe - **Exceptions.** If the inner app raises, `@app.call(env)` never returns and no header is set. To log the duration of failed requests too, measure in a `begin`/`ensure` block and write to `env["rack.errors"]` or a logger, then let the exception propagate. - **Skipping paths.** A health-check endpoint polled every second can drown the data; check `env["PATH_INFO"]` and call straight through for it. - **Sharing the start time.** Inner layers can read a value you store in `env` under your own dotted key, such as `"timing.started"`; the SPEC reserves undotted keys for CGI variables and the `rack.` prefix for Rack itself. - **Configuration.** Anything passed on the `use` line arrives in `initialize` once, which is where thresholds or labels belong. ## Rack already has one: Rack::Runtime `Rack::Runtime` is the shipped version of this middleware. It measures with `Utils.clock_time`, formats seconds with `"%0.6f"` and writes them to `x-runtime`; `Rack::Runtime.new(app, "db")` writes `x-runtime-db` instead, so several can be stacked. It **does not overwrite** a header that an inner layer already set. Its own source comment gives the placement rule from the table above: right before the app to time the app, or before all other middleware to include them. ## Testing it ```ruby require "rack" app = RequestTiming.new(->(env) { [200, {}, ["ok"]] }) res = Rack::MockRequest.new(app).get("/", lint: true) res.get_header("server-timing") # e.g. "app;dur=0.0" ``` `Rack::MockRequest#get` builds an env, calls the app (through `Rack::Lint` when `lint: true`), and closes the body for you.

  • Your timing middleware sets @started in call and the numbers look random under load. Why?
    `Rack::Builder` creates one middleware instance per `use` line and a threaded server calls it concurrently, so every request writes the same `@started`. One request reads another's start time. Keep per-request values in local variables inside `call`, or in `env` under your own dotted key, and reserve instance variables for configuration.
  • Why can't you put the full streaming time into a response header?
    Headers are sent before the body. When the timer is stopped in `Rack::BodyProxy`'s close block or in a `rack.response_finished` callback, the status line and headers have already gone out, so the value can only be logged or sent to metrics.
  • What happens if an inner layer already set x-runtime when Rack::Runtime runs?
    `Rack::Runtime` checks `headers.key?` first and leaves an existing value alone. To stack several timers, give each a name: `use Rack::Runtime, "db"` writes `x-runtime-db`, so the outer and inner measurements do not collide.

saying these in an interview costs you the question

  • Storing the request start time in an instance variable of the middleware
  • Measuring elapsed time with Time.now in a request timer
  • Believing the timer includes the time spent streaming the body
  • Calling body.each inside the middleware to measure the full response
  • Writing the header as X-Response-Time under Rack 3