Structured JSON Logging — grep Is Not Enough
Production is on fire. Someone SSHs into a box and greps a 4GB text file for Error. The failing checkout is buried in a multi-line stack dump with no request id. Loki and Datadog can ingest that wall of text, but you cannot filter status=500 AND route=/checkout AND request_id=… efficiently unless you emit structured JSON with stable field names.
SSH-and-grep is fine for a single laptop process. Distributed systems need join keys.
One event, one JSON object
function log(level: string, msg: string, fields: Record<string, unknown>) {
const event = {
level,
msg,
time: new Date().toISOString(),
service: process.env.SERVICE_NAME,
env: process.env.APP_ENV,
...fields,
};
process.stdout.write(JSON.stringify(event) + "\n");
}
Pretty-printed JSON across multiple lines breaks most shippers. Use NDJSON in production; pretty-print only on a developer machine. Prefer a library (pino, winston JSON mode, zap, slog) over hand-rolled forever — wrappers still belong to you for redaction.
Fields that pay rent
| Field | Purpose |
|---|---|
trace_id / request_id | Join logs across services |
route / method / status | HTTP golden signals |
error.type / error.message | Alert without dumping stacks into msg |
duration_ms | Latency breadcrumbs |
user_id (internal opaque id) | Support — avoid raw email if policy forbids |
Propagate ids from the edge:
app.use((req, res, next) => {
const requestId = req.header("x-request-id") ?? crypto.randomUUID();
res.setHeader("x-request-id", requestId);
req.log = (level: string, msg: string, fields?: object) =>
log(level, msg, { request_id: requestId, route: req.path, ...fields });
next();
});
Without correlation, JSON is only prettier spaghetti.
PII and secret pitfalls
log("debug", "payload", { body: req.body }) will eventually capture passwords, tokens, or card data. Rules that survive audits:
- Allowlist fields; never dump entire bodies by default.
- Redact
authorization,cookie,password,otp, PANs. - Assume log storage has broader read access than the app DB.
- Stripe webhook bodies and auth headers do not belong in shared Slack log dumps.
Cardinality: labels vs fields
In Loki/Prometheus-style systems, labels are indexed and expensive. Keep labels low-cardinality: service, env, level. Put user_id and request_id in the JSON body (or structured metadata), not as labels — or the index explodes during a traffic spike.
Levels and noise control
| Level | Use |
|---|---|
| error | Needs human attention |
| warn | Degraded path / retries |
| info | Coarse lifecycle events |
| debug | Local or sampled in prod |
Logging every cache hit at info costs more than the cache saves. Sample debug; unlock verbose logging with a per-request header when debugging a single customer.
Query mental model
{service="api", env="prod"} | json | status >= 500 | line_format "{{.request_id}} {{.msg}}"
That workflow does not exist for free-form paragraphs. Teach on-call to query by request_id before downloading log files.
Migration without a big-bang rewrite
- Adopt one logger; configure JSON output in prod.
- Document a ten-field schema shared across services.
- Add request ids at the edge gateway or Worker.
- Ban new
console.login server PR review. - Delete noisy hot-path logs as you touch files.
Structured logging is not “use JSON because it is trendy.” It is how your future self finds one failing checkout without grepping the universe — with redaction so the search itself is not a breach.