console.log("payment failed for user " + id) is readable until you need every failed payment for one provider in the last ten minutes. At that point, the information you need is stuck inside prose.
Short answer: emit one JSON object per event with stable field names, an event name, a severity, and correlation identifiers. Keep the human message, but make the fields queryable.
{"level":"error","event":"payment.charge_failed","request_id":"r_82","provider":"stripe","amount_cents":1299,"error_code":"card_declined","msg":"charge declined"}
| Field | Purpose |
|---|---|
timestamp, level | Order and filter incidents |
event | Stable name for dashboards and alerts |
request_id, trace_id | Join work across services |
| Domain fields | Answer the question without parsing msg |
msg | Give the on-call human context |
Log events, not diary entries
Good event names use a domain and an outcome: invoice.payment_failed, cache.refresh_started, auth.token_rejected. Avoid making dynamic values part of the event name. user_42_failed_login produces unbounded cardinality and destroys aggregation.
Logging JSON does not license logging secrets. Redact authorization headers, cookies, passwords, access tokens, and sensitive personal data before the event reaches a collector.
The practical pay-off
When an alert says checkout errors increased, query event=payment.charge_failed grouped by provider and error_code. That is a two-minute diagnosis. With string logs, it is a regex archaeology project conducted while customers are waiting.
Start with the few events your team debugs most often. Structured logging earns its keep when the fields reflect the decisions you make during an incident.
Cover photo by Al Nahian on Pexels.
