Production bugs arrive with symptoms, not stack traces. A customer reports that their request failed at 14:37:22 UTC. You open your logs. There are ten thousand requests in that minute. Which one is theirs? If you don't have request IDs, you're correlating by timestamp and guessing.
Request IDs are the cheapest observability improvement you can make. Three lines of middleware and every log line in your system — across every service that touched that request — becomes linkable to a single root cause.
The Pattern
Every incoming HTTP request gets a unique identifier, typically a UUID v4 or a ULID. This ID is generated at the edge (or accepted from the client if one is provided), attached to every log line for the duration of the request, passed downstream to every service call the request triggers, and included in error responses returned to the client.
The standard header name is X-Request-Id. Nginx uses $request_id (set automatically). Cloudflare uses CF-Ray. AWS ALB uses X-Amzn-Trace-Id. The name matters less than using it consistently.
The Middleware
In Express (Node.js):
const { v4: uuidv4 } = require('uuid');
app.use((req, res, next) => {
req.requestId = req.headers['x-request-id'] || uuidv4();
res.set('X-Request-Id', req.requestId);
next();
});
In FastAPI (Python):
import uuid
from fastapi import Request
@app.middleware("http")
async def add_request_id(request: Request, call_next):
request_id = request.headers.get("x-request-id") or str(uuid.uuid4())
request.state.request_id = request_id
response = await call_next(request)
response.headers["X-Request-Id"] = request_id
return response
In Go:
func RequestIDMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestID := r.Header.Get("X-Request-Id")
if requestID == "" {
requestID = uuid.New().String()
}
ctx := context.WithValue(r.Context(), requestIDKey, requestID)
w.Header().Set("X-Request-Id", requestID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
Each of these is under ten lines. The pattern is always the same: check for an incoming ID, generate one if absent, attach it to the request context, set it in the response header.
Propagation to Downstream Services
The ID is useless if it doesn't follow the request. Every outbound HTTP call your service makes should include the request ID as a header:
fetch('https://api.payments.internal/charge', {
headers: {
'X-Request-Id': req.requestId,
'Content-Type': 'application/json',
},
body: JSON.stringify({ amount }),
});
Every service in your stack that receives that header should log it and include it in its own downstream calls. The result: a single root request ID propagates through every service that touched the original request, and you can reconstruct the full call chain from logs alone.
Structured Logging Correlation
The request ID is only useful if it appears in every log line. With structured logging (JSON logs), attach it once at the start of the request:
// Node.js - child logger with request ID
const reqLogger = logger.child({ requestId: req.requestId });
With Python's contextvars:
request_id_var = contextvars.ContextVar('request_id')
class RequestIdFilter(logging.Filter):
def filter(self, record):
record.request_id = request_id_var.get(None)
return True
Now every log line in the request's execution context carries the ID. When the production incident occurs, you search your log aggregator for the request ID and get the full story.
Nginx $request_id
Nginx generates its own request ID automatically as $request_id — a 32-character hex string unique per request. Use it directly in your proxy config:
proxy_set_header X-Request-Id $request_id;
add_header X-Request-Id $request_id;
log_format main '$remote_addr - $request_id - $request';
Your upstream services receive the Nginx-generated ID, and your access logs are keyed by the same ID. The two log sources become joinable without any application code change.
Including Request IDs in Error Responses
When a request fails, include the request ID in the error response body:
{
"error": "Internal Server Error",
"request_id": "550e8400-e29b-41d4-a716-446655440000",
"message": "An unexpected error occurred"
}
Now when a customer reports an error, they can give you the request ID directly. You search your logs for that ID and have the complete context immediately. No timestamp correlation, no guessing. This is especially valuable in B2B APIs where your customers have developers who can read error responses.
What Request IDs Do Not Replace
Request IDs are not distributed tracing. They let you correlate log lines across services, but they don't give you timing, span relationships, or the structured call graph that tools like Jaeger or Honeycomb provide. If you need to understand how long each step took or which service was the bottleneck, you need tracing — and you should use the same ID as the trace ID for consistency.
Request IDs also don't help with errors that happen before the request reaches your middleware (connection failures, TLS errors, upstream firewall drops). Those need different tooling.
But for the majority of production bugs — a request failed and you need to find why — request IDs are the single most impactful change you can make to your observability posture. The implementation is trivial. The payoff arrives the first time you need it.
More field notes from production infrastructure at builds.anethoth.com.