Why requests need an identity

A single page load in Heroku's Dashboard passes through three separate components before it returns an app list; a git push heroku master touches closer to six. That kind of service-oriented decomposition pays off logistically, but it makes production debugging considerably harder.

Request IDs are one of the cheapest tools for getting visibility back. The idea — shared with Twitter's Zipkin, though far lighter, and with the Route 53 troubleshooting technique — is to group everything belonging to a request as it threads through a distributed architecture. Two things come out of it:

  • Events can be tagged, so a complete report of what happened, and when, in every component touched can be assembled.
  • An identifier is exposed to users — internal or external — who can hand it back when reporting a specific problem.

The identifier itself is a UUID generated when the request begins and kept for its lifetime. In Rack this is a small middleware:

class Middleware::Instruments
  def initialize(app)
    @app = app
  end

  def call(env)
    env["REQUEST_ID"] = SecureRandom.uuid
    @app.call(env)
  end
end

Application logging includes that identifier alongside each event; in Sinatra a helper handles it:

def log(action, data={})
  data.merge!(request_id: request.env["REQUEST_ID"])
  ...
end

A correctly instrumented request then emits a stream where every line carries the request ID:

app=api authenticate elapsed=0.001 request_id=9d5ccdbe-6a5c-4da7-8762-8fb627a020a4
app=api rate_limit elapsed=0.001 request_id=9d5ccdbe-6a5c-4da7-8762-8fb627a020a4
app=api provision_token elapsed=0.003 request_id=9d5ccdbe-6a5c-4da7-8762-8fb627a020a4
app=api serialize elapsed=0.000 request_id=9d5ccdbe-6a5c-4da7-8762-8fb627a020a4
app=api response status=201 elapsed=0.005 request_id=9d5ccdbe-6a5c-4da7-8762-8fb627a020a4

Draining log streams into Splunk gives a central place to pull back everything belonging to one request ID:

9d5ccdbe-6a5c-4da7-8762-8fb627a020a4

Platform-generated and composed IDs

You don't necessarily have to mint the identifier yourself. Heroku's routing layer can generate a request ID, which means platform-generated log events can carry a tag too; the value arrives in an incoming header:

def log(action, data={})
  data.merge!(request_id: request.env["HTTP_HEROKU_REQUEST_ID"])
  ...
end

That covers a single app. The composed case — several apps calling one another — is where the concept gets extended: callers inject their own request ID through a request header.

api = Excon.new("https://api.heroku.com", headers: {
  "Request-ID" => request.env["REQUEST_ID"]
})
api.post("/oauth/tokens", expects: 201)

The callee validates the incoming ID and, when it looks legitimate, tags its requests with that value in addition to one it generates itself. A request spanning multiple apps is therefore traceable as a group, while every app retains an individual trace for each of its own requests.

def call(env)
  env["REQUEST_ID"] = SecureRandom.uuid
  if env["HTTP_REQUEST_ID"] =~ UUID_PATTERN
    env["REQUEST_ID"] += "," + env["HTTP_REQUEST_ID"]
  end
  @app.call(env)
end

Events from the composed apps are then tagged with all of the generated IDs:

app=id session_check elapsed=0.000 request_id=4edef22b...
app=api authenticate elapsed=0.001 request_id=9d5ccdbe...,4edef22b...
app=api rate_limit elapsed=0.001 request_id=9d5ccdbe...,4edef22b...
app=api provision_token elapsed=0.003 request_id=9d5ccdbe...,4edef22b...
app=api serialize elapsed=0.000 request_id=9d5ccdbe...,4edef22b...
app=api response status=201 elapsed=0.005 request_id=9d5ccdbe...,4edef22b...
app=id response status=200 elapsed=0.010 request_id=4edef22b...

Querying Splunk with the top-level request ID returns log events from every composed app. Splunk is not the only option here — Papertrail and similar services do the same job.

Getting ready to search Splunk for a request ID.
Getting ready to search Splunk for a request ID.

Adjustments worth making

The middleware pattern takes a minor modification so that arbitrarily many request IDs can be injected, which is what you need to follow a request across three or more composed services.

def call(env)
  env["REQUEST_ID"] = SecureRandom.uuid
  if env["HTTP_REQUEST_ID"]
    request_ids = env["HTTP_REQUEST_ID"].split(",").
      select { |id| id =~ UUID_PATTERN }
    env["REQUEST_ID"] = (env["REQUEST_ID"] + request_ids).join(",")
  end
  @app.call(env)
end

Emitting the request ID as a response header makes individual requests easier to pick out and debug later on:

def call(env)
  request_id = SecureRandom.uuid
  ...
  status, headers, response = @app.call(env)
  headers["Request-ID"] = request_id
  [status, headers, response]
end
curl -i https://api.example.com/hello
...
Request-ID: 9d5ccdbe-6a5c-4da7-8762-8fb627a020a4
...

Heroku's V3 platform API returns a request ID with every response.

Where a context-sensitive helper like Sinatra's is architecturally awkward — likely in a larger application — logging can pull from a thread-safe request store instead:

# request store that keys a hash to the current thread
module RequestStore
  def self.store
    Thread.current[:request_store] ||= {}
  end
end

# middleware that initializes a request store and and adds a request ID to it
class Middleware::Instruments
  ...

  def call(env)
    RequestStore.store.clear
    RequestStore.store[:request_id] = SecureRandom.uuid
    @app.call(env)
  end
end

# class method that can extract a request ID and tag logging events with it
module Log
  def self.log(action, data={})
    data.merge!(request_id: RequestStore.store[:request_id])
    ...
  end
end