Skip to content
Engineering · 4 min read

Structured logs with a request ID, or none at all

The claim Free-text log lines without a correlation identifier are not diagnostic data. They are a diary. During an incident you cannot answer the only question that matters — what...

A Written by Administrator
Structured logs with a request ID, or none at all

The claim

Free-text log lines without a correlation identifier are not diagnostic data. They are a diary. During an incident you cannot answer the only question that matters — what happened to this request — because the twelve lines belonging to it are interleaved with four thousand lines from every other request being served at the same moment. Adding a request ID and switching to JSON takes an afternoon and changes what your logs are for.

What the diary looks like

[2026-08-24 10:31:02] Processing order
[2026-08-24 10:31:02] Payment gateway call
[2026-08-24 10:31:03] User not found
[2026-08-24 10:31:03] Processing order

Which order? Which user? Was the "user not found" related to the payment call above it, or to a different request that happened to be running concurrently? On a server handling 40 requests per second, adjacency in the log file means nothing at all.

What it should look like

{"ts":"2026-08-24T10:31:02.418Z","level":"info","req_id":"01J2K9X4","route":"POST /orders","user_id":8812,"order_id":4821,"msg":"order created","duration_ms":86}
{"ts":"2026-08-24T10:31:02.902Z","level":"error","req_id":"01J2K9X4","route":"POST /orders","order_id":4821,"msg":"gateway timeout","provider":"moneris","attempt":2,"duration_ms":30011}

Now one filter reconstructs the entire life of a request:

jq -c 'select(.req_id=="01J2K9X4")' app.log

And one aggregation answers a question you previously had to guess at:

jq -r 'select(.level=="error") | .provider' app.log | sort | uniq -c | sort -rn

Generating and propagating the ID

Accept an inbound X-Request-Id if the load balancer supplies one; otherwise generate a ULID or UUID at the very edge of the request. Store it in a context or thread-local so every log call picks it up without being passed the value explicitly. In Nginx:

proxy_set_header X-Request-Id $request_id;
log_format json escape=json '{"ts":"$time_iso8601","req_id":"$request_id",'
    '"status":$status,"rt":$request_time,"uri":"$request_uri"}';

The critical step is propagation. Pass the same ID to your background jobs, your outbound API calls, and your error tracker. When a customer emails about a failed order, you want to paste one identifier and see the web request, the queue job it enqueued, and the third-party call that failed — in one query, across three systems.

Surface it to the customer too. Putting the request ID on your error page turns "the site broke this morning" into an exact lookup.

The fields worth standardising

Agree on names once and enforce them in review, because user_id, userId and uid in three services means your aggregation queries silently miss two-thirds of the data.

  • ts in ISO 8601 with milliseconds and an explicit UTC offset. Never local time — the March and November clock changes produce an hour of duplicated or missing timestamps that will confuse you exactly once, memorably.
  • level, msg, req_id, route, duration_ms, status.
  • Domain identifiers as their own fields, not interpolated into the message. "order_id": 4821 is queryable; "failed to process order 4821" is not.

What must never appear

Logs get shipped to third-party services, replicated into backups, and read by contractors. Keep out full card numbers, passwords, session tokens, authorisation headers, and any complete personal record. The common accident is logging an entire request body on error, which captures a password on the login route.

Log identifiers and last-four digits, not values. If you must log an email address for support purposes, decide that deliberately and write it into your retention policy rather than discovering it during a privacy review.

Log levels people will actually respect

Four levels is enough. Error means a human should look at it, which implies that an error you have decided to tolerate is not an error — downgrade it or the level stops meaning anything. Warn means the system handled something unexpected and carried on: a retry succeeded, a fallback was used. Info records the shape of normal operation, roughly one line per meaningful state change. Debug is off in production and switchable per request when you need it.

The test for whether your levels are calibrated is simple: if your error stream contains anything that happens more than a handful of times a day and nobody investigates, the calibration is wrong and you have rebuilt the diary in a new format.

Retention and volume

Structured logs are larger than text — roughly two to three times per line. For a site serving one million requests a month at four log lines each, expect 8 to 15 GB. That is trivial on disk and expensive on a per-gigabyte ingestion plan, so decide the split deliberately: 30 days searchable in a hosted tool, 12 months compressed in object storage at a few dollars per month.

gzip -9 app-2026-07.log   # typically 12:1 on JSON logs

The test

Pick a real request from yesterday. Time yourself reconstructing everything that happened to it. Under thirty seconds means your logging works. If it takes ten minutes of grep and inference, you are not going to do it at 02:00 when it matters, and the logs are functioning as reassurance rather than as instrumentation.

#logging #observability #operations #debugging

Keep reading