On-call does not fail because the system produced zero logs. It fails because five minutes of scrolling cannot answer: which tenant, which request, which dependency, how long did we wait? Structured logging is not a fashion choice. It is a contract between the process that emits lines and the human who will grep them at 3 a.m.
This article locks a minimal field set for an HTTP service, shows a bad line next to a useful one, and includes a tiny demo you can run from the repo.
The noisy default
Many services still emit lines like:
[INFO] handling request for user and order stuff status=503 took a while
Problems stacked in one sentence:
- No request id → cannot join with gateway or worker logs.
- No tenant / order id → cannot scope blast radius.
- No latency breakdown → “took a while” is not actionable.
- Free text shape → every dashboard regex is a science project.
- Status buried in prose → hard to alert on
http_status >= 500.
When the database pool times out, that line tells you almost nothing you did not already know from the user report.
A minimal field contract
For request-scoped logs on a backend API, keep a stable JSON object:
| Field | Why it exists |
|---|---|
ts | Order events without relying on ingest order |
level | info / warn / error for paging rules |
msg | Stable event name (http_request), not a novel |
service | Multi-service greps |
request_id | Join across hops |
route | Low-cardinality path template, not raw URLs with ids |
http_status | Outcome |
latency_ms | User-visible cost |
tenant_id | Blast radius |
order_id (or resource id) | Entity under repair |
error_code | Machine-stable reason when failing |
db_wait_ms (optional) | Proves pool/query delay vs pure CPU |
Cardinality rules:
- High cardinality (ids, emails) belongs in fields you filter occasionally—not in
msg. - Route templates stay low cardinality (
POST /v1/orders/{id}/pay), so you can chart error rates by route.
Bad vs good (runnable)
node content/labs/structured-logging-on-call/demo.mjs
The demo prints the same two logical events twice: once as noisy text, once as JSON. The failure case carries error_code: "db_pool_timeout" and db_wait_ms: 1180.
Useful on-call query sketch:
level:error AND error_code:db_pool_timeout AND tenant_id:t_42
Or, when you only have a request id from the client:
request_id:req_01JDEF
That second query should pull gateway, API, and worker lines if every hop forwards the same id.
Incident walkthrough with the good shape
Synthetic timeline:
- Page: elevated 503 on
orders-api. - Filter
service:orders-api AND level:errorfor the last 15 minutes. - Facet on
error_code→ dominantdb_pool_timeout. - Facet on
tenant_id→ mostlyt_42or evenly distributed (local vs global). - Check
db_wait_msvslatency_ms→ wait dominates, so look at pool/DB, not JSON serialization. - Grab one
request_idand confirm the same code on dependencies.
None of those steps work cleanly if the only log says “took a while.”
What to stop logging
Structure is not “log everything as JSON.” Volume and privacy still matter.
Stop or sample:
- Successful high-QPS health checks at
info(use metrics). - Full request/response bodies on hot paths.
- The same warning every loop iteration without backoff.
Never log:
- Passwords, session tokens, raw card data, password reset links.
- Unbounded free-text exception strings that may embed PII from upstream.
Prefer metrics for:
- QPS, saturation, pool active count—time series beats log counting for capacity.
Logs answer which entity and which code path. Metrics answer how bad and is it still rising.
Error logging rules that prevent spam
- One error log per failed request at the edge of the service (plus optional debug context), not one per layer with different formats.
- Stable
error_codevalues (db_pool_timeout,payment_declined), not interpolated English sentences as the only key. - Attach the ids you already have; do not require a second query to the database just to log.
- Downgrade expected client errors (validation 400) to
info/warnso pages stay reserved for 5xx and SLO burn.
Implementation sketch
Pseudocode for a request logger:
function logRequest(ctx, result) {
logger.write({
ts: new Date().toISOString(),
level: result.httpStatus >= 500 ? "error" : "info",
msg: "http_request",
service: "orders-api",
request_id: ctx.requestId,
route: ctx.routeTemplate,
http_status: result.httpStatus,
latency_ms: result.latencyMs,
tenant_id: ctx.tenantId,
order_id: ctx.orderId,
error_code: result.errorCode,
db_wait_ms: result.dbWaitMs,
});
}
Enforce the schema in code review or with a lightweight test that parses a sample line and asserts required keys exist on error paths.
Summary
| Practice | Purpose |
|---|---|
| Stable JSON fields | Query without poetry |
request_id everywhere | Join hops |
Low-cardinality route | Chart and alert |
error_code + timing fields | Jump to the subsystem |
| Drop bodies / secrets / health spam | Keep signal affordable |
Structured logging shortens on-call when every critical failure line can answer who, what resource, which request, which dependency, how long. The demo in this repository is small on purpose: if your production lines cannot support the same query, fix the contract before buying another dashboard.