Docker & ContainersXIX · ObservabilityCorrelation
End-to-end correlation — request IDs across signals
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
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 ID | Request ID | |
|---|---|---|
| Format | 32 hex characters (16 bytes) | anything; commonly a UUID |
| Created by | the instrumentation SDK at the first instrumented hop | the outermost proxy |
| Propagated by | the traceparent header, automatically | an X-Request-ID header, by your code |
| Exists when tracing is off | no | yes |
| Exists for a request the proxy rejected | no | yes |
| Good for | causation, timing, the span tree | support 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.
# 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;
}
}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_serversunique-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.
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)$ 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:
| Question | Signal |
|---|---|
| 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
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"
doneRead the output as a map of where the chain breaks:
- A service with
0lines 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
Q1. Why is adding a `request_id` label to `http_requests_total` — to make debugging easier — a serious mistake?
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?
Q3. Which of these break the correlation chain across containers? Select all that apply.
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.