Skip to main content
RunBook Academy

Docker & ContainersXIX · ObservabilityCorrelation

End-to-end correlation — request IDs across signals

Intermediate⏱ ~22 mindocker

What you'll learn

  • Distinguish a trace ID from a request ID and know which each is for
  • Mint a correlation ID at the outermost proxy, and know which proxies can
  • Emit structured logs carrying trace and span IDs from every container
  • Explain why a request ID in a metric label is a cardinality incident
  • Pivot from a metric alert to a trace to the logs of one request

Prerequisites

Verified against Docker Engine 29.x · Docker Engine 28.x · Docker Compose 2.x · containerd 2.x · runc 1.2.x · BuildKit 0.20+ · Linux kernel 5.15+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-12

Not yet marked complete on this device.

A request that crosses five containers produces five log streams, one trace and a great many metric samples. Without a shared identifier the only thing joining them is a timestamp, and joining by timestamp on a busy host means “these two things happened in the same 40 milliseconds as three thousand other things”.

Correlation is the shared identifier. It is a small amount of work, almost all of it done once, and it has to be in place before the incident — retrofitting it during one is not possible.

Two identifiers, and they are not interchangeable

Trace IDRequest ID
Format32 hex characters (16 bytes)anything; commonly a UUID
Created bythe instrumentation SDK at the first instrumented hopthe outermost proxy
Propagated bythe traceparent header, automaticallyan X-Request-ID header, by your code
Exists when tracing is offnoyes
Exists for a request the proxy rejectednoyes
Good forcausation, timing, the span treesupport tickets, sharing with a human

Most systems need both, and the cheapest way to have both is to make them the same value where possible: mint the request ID at the proxy, and let the trace ID be derived from it or logged alongside it. What you must not do is have two independent IDs that nobody ever writes in the same log line, because then correlating them is its own project.

The row that decides it is “a request the proxy rejected”. A request that was rate-limited, failed TLS, or hit a 413 never reached an instrumented application and therefore has no trace — but it is exactly the request a user is complaining about. Only an ID minted at the edge covers it.

Minting the ID at the true edge

The right place is the outermost thing that sees the request. Do it further in and every hop before that point is invisible.

Two of the four proxies in this course can generate an identifier themselves. Two cannot, and it is worth knowing which is which before you plan around it.

Configuration changenginx — $request_id
# Accept a caller-supplied ID from a trusted upstream, otherwise mint one.
map $http_x_request_id $correlation_id {
  default   $http_x_request_id;
  ""        $request_id;
}

log_format correlated escape=json
  '{"time":"$time_iso8601","request_id":"$correlation_id",'
  '"client":"$remote_addr","method":"$request_method","uri":"$request_uri",'
  '"status":$status,"upstream_time":"$upstream_response_time",'
  '"request_time":$request_time}';

server {
  access_log /var/log/nginx/access.log correlated;

  location / {
      proxy_set_header X-Request-ID $correlation_id;
      proxy_pass http://app_backend;

      # Return it to the client so a user can quote it in a ticket.
      add_header X-Request-ID $correlation_id always;
  }
}
Configuration changeHAProxy — unique-id-format
frontend https_in
  bind *:443 ssl crt /etc/haproxy/certs/app.example.com.pem

  # The uuid() sample fetch produces a fresh UUID per request.
  unique-id-format %[uuid()]
  unique-id-header X-Request-ID

  # Echo it back so the client can quote it.
  http-response set-header X-Request-ID %[unique-id]

  # And put it in the log line.
  log-format "%ci:%cp [%tr] %ft %b/%s %TR/%Tw/%Tc/%Tr/%Ta %ST %B %hrl %hsl %tsc %ac/%fc/%bc/%sc/%rc %sq/%bq %ID"

  default_backend web_servers

unique-id-format also accepts HAProxy’s older composite form — %{+X}o\ %ci:%cp_%fi:%fp_%Ts_%rt:%pid, which encodes the connection four-tuple, a timestamp and the process ID — which is harder to read but carries information a UUID does not.

Threading it through the application

Two things must happen in every service: read the incoming ID or mint one, and attach it to every log line the request produces.

import logging
import uuid
from contextvars import ContextVar

from flask import Flask, g, request

app = Flask(__name__)
_request_id: ContextVar[str] = ContextVar("request_id", default="-")


@app.before_request
def assign_request_id():
    # Trust the header only from the edge proxy; see the client-IP lesson.
    incoming = request.headers.get("X-Request-ID")
    g.request_id = incoming or str(uuid.uuid4())
    _request_id.set(g.request_id)


@app.after_request
def echo_request_id(response):
    response.headers["X-Request-ID"] = g.request_id
    return response


class CorrelationFilter(logging.Filter):
    """Puts the id on every record, including ones from libraries."""

    def filter(self, record):
        record.request_id = _request_id.get()
        return True

The ContextVar rather than g is deliberate. Flask’s g is not visible to logging calls made from a background thread or an async task, so library log lines would silently carry -. A context variable propagates where the runtime propagates context, which is the same boundary the tracing SDK uses.

And when an outbound call is made, forward it:

import requests


def call_inventory(item_id):
    return requests.get(
        f"http://inventory:9090/items/{item_id}",
        headers={"X-Request-ID": _request_id.get()},
        timeout=(2.0, 5.0),
    )

That one line is the container boundary. Omit it in one service and the chain breaks there, silently, and the logs of every service downstream of it become unfindable.

Configuration changestructured output
import logging
import sys

from pythonjsonlogger import json as jsonlogger

handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(
  jsonlogger.JsonFormatter(
      "%(asctime)s %(levelname)s %(name)s %(message)s "
      "%(request_id)s %(otelTraceID)s %(otelSpanID)s",
      rename_fields={"asctime": "time", "levelname": "level"},
  )
)
handler.addFilter(CorrelationFilter())

root = logging.getLogger()
root.handlers = [handler]
root.setLevel(logging.INFO)
Read-only / Safewhat a correlated log line looks like
$ docker compose logs --no-log-prefix --since 2m api | head -2
{"time":"2026-08-12T02:41:07.412Z","level":"INFO","name":"myapp.orders","message":"order.processed","request_id":"9f2c1e44-73a1-4b8e-9d0a-6c1f2e5b7a30","otelTraceID":"4bf92f3577b34da6a3ce929d0e0e4736","otelSpanID":"00f067aa0ba902b7","order_id":12345}
{"time":"2026-08-12T02:41:07.518Z","level":"ERROR","name":"myapp.inventory","message":"inventory.unavailable","request_id":"9f2c1e44-73a1-4b8e-9d0a-6c1f2e5b7a30","otelTraceID":"4bf92f3577b34da6a3ce929d0e0e4736","otelSpanID":"9d7f21ba03c4e618","sku":"AB-1174"}

Illustrative output

The structured form is not a style preference. docker logs gives you one interleaved stream per container; the only thing that makes those streams joinable across five containers is a field a log backend can index. The stdout/stderr contract and the collection pipeline are covered in the logging part of this course — correlation is what makes that pipeline worth having.

Never put a request ID in a metric label

The division of labour that works:

QuestionSignal
How many requests failed?metrics — aggregate, cheap, alertable
Which request failed, and why?logs — one entry per event, high cardinality is free
Where did that request spend its time?traces — one tree per sampled request

The bridge between the first and the others is an exemplar: a metric sample may carry a small annotation pointing at one trace ID that contributed to it. It does not become a label, so it does not multiply series, and it gives a histogram bucket a clickable example.

Prometheus stores exemplars behind --enable-feature=exemplar-storage, with the buffer size configured by the storage/exemplars block. They are introduced by the OpenMetrics exposition format, so the client library and the scrape must both support it.

The diagnostic flow

sequenceDiagram
  participant Alert
  participant Metrics
  participant Trace
  participant Logs
  Alert->>Metrics: p99 latency > 2s on /orders
  Metrics-->>Alert: exemplar attached to the slow bucket
  Alert->>Trace: open that trace ID
  Trace-->>Alert: 1.8s in the inventory CLIENT span
  Alert->>Logs: filter otelTraceID = that value
  Logs-->>Alert: inventory logged pool timeout, 4 retries

Each arrow is a pivot that only works because both ends carry the same identifier. Break any one of them and the step becomes “search by timestamp and hope”, which on a busy host is not a step at all.

Working it in the other direction is just as common and is the one users initiate: a customer quotes the X-Request-ID from an error page, you search logs for that value, find the trace ID on the same line, and open the trace. That flow is the reason the proxy echoes the header back to the client.

Verification that can fail

Read-only / Safefollow one request through every container
set -euo pipefail
HOST=app.example.com
RID="verify-$(date +%s)-$$"

# Send one request carrying an ID we chose, so it is easy to find.
curl -sS -o /dev/null -D - -H "X-Request-ID: $RID" "https://$HOST/orders/42" | grep -i '^x-request-id' || echo 'PROXY DID NOT ECHO THE HEADER'

sleep 3

# Every service that handled it should have logged it at least once.
for svc in proxy api inventory; do
n=$(docker compose logs --since 2m --no-log-prefix "$svc" 2>/dev/null | grep -c "$RID" || true)
printf '%-12s %s line(s)\n' "$svc" "$n"
done

Read the output as a map of where the chain breaks:

  • A service with 0 lines did not receive the header, or received it and did not log it. Either way the chain ends there and every service downstream is unfindable.
  • The proxy did not echo the header means a user cannot quote an ID from an error page, so the support-ticket direction of the flow does not work.
  • Every service has at least one line is the passing case, and it is the only evidence that correlation works. A configuration that looks correct is not evidence.

Run this after every change to the edge configuration. It takes five seconds and it is the only check that covers the whole path.

Knowledge check

Knowledge check · 4 questions

  1. Q1. Why is adding a `request_id` label to `http_requests_total` — to make debugging easier — a serious mistake?

  2. Q2. A user reports an error and quotes an ID from the error page. Why is that ID more useful than a trace ID for this particular case?

  3. Q3. Which of these break the correlation chain across containers? Select all that apply.

  4. Q4. Both Traefik and Caddy ship a built-in middleware that generates a correlation ID for each request.

Passing score: 75%. Answers are checked in this browser.

Where next

With a trace, a correlated log line and a metric, the next question is which of the three to reach for. The next lesson is the honest version of that comparison, including what each one costs to store.