Structured JSON Logging: How to Debug Production API Errors and Search Logs at Scale
Structured JSON logs are queryable across millions of events in milliseconds; unstructured string logs require brittle grep. Here's why JSON logging matters, the OpenTelemetry log schema standard, structured logging libraries in Python/Node/Go, and how formatting API error responses reveals everything needed to debug a production incident.
By sadiqbd Β· June 16, 2026
JSON logs are machine-readable by design β but most developers treat them as unstructured text and miss most of the value
Structured logging β writing log output as JSON objects rather than formatted strings β transforms debugging from grep-and-hope into queryable analytics. A log line like {"level":"error","timestamp":"2024-11-15T14:23:01Z","service":"payments","trace_id":"a3f9b2","user_id":"usr_4821","amount":299.99,"error":"card_declined","code":"insufficient_funds"} contains everything needed to correlate this event with a specific user, transaction, and trace β and it's queryable without any string parsing.
Why structured logging beats string formatting
Unstructured log:
2024-11-15 14:23:01 ERROR Payment failed for user 4821: card declined (insufficient_funds), amount: $299.99
Structured JSON log:
{
"level": "error",
"timestamp": "2024-11-15T14:23:01.382Z",
"service": "payments",
"environment": "production",
"trace_id": "a3f9b2c1d4e5",
"span_id": "f8e7d6c5",
"user_id": "usr_4821",
"amount": 299.99,
"currency": "GBP",
"payment_method": "card",
"error_code": "insufficient_funds",
"error_category": "card_declined",
"message": "Payment failed"
}
Querying unstructured: grep "user 4821" app.log | grep "card declined" β brittle, depends on exact string format, breaks if the log message template changes.
Querying structured (Elasticsearch, Datadog, Loki):
user_id:usr_4821 AND error_category:card_declined AND amount:>200
Find all large declined payments for a specific user, with exact field matching, in milliseconds across millions of log events.
JSON log schemas and standard fields
A consistent schema across all services enables cross-service correlation. The OpenTelemetry log data model (increasingly the standard for cloud-native logging) recommends these base fields:
{
"Timestamp": "2024-11-15T14:23:01.382Z", // ISO 8601
"SeverityText": "ERROR", // TRACE, DEBUG, INFO, WARN, ERROR, FATAL
"SeverityNumber": 17, // Numeric severity (1=TRACE...24=FATAL)
"Body": "Payment failed", // Human-readable message
"Resource": { // Service-level context
"service.name": "payments",
"service.version": "2.4.1",
"deployment.environment": "production"
},
"Attributes": { // Event-specific context
"trace_id": "a3f9b2c1d4e5",
"user_id": "usr_4821",
"error.code": "insufficient_funds"
}
}
Standard severity levels and when to use them:
TRACE: very fine-grained, typically only in development (entering/exiting functions)DEBUG: diagnostic information useful during debugging; usually disabled in productionINFO: normal operational events (request received, payment processed successfully)WARN: unexpected but handled conditions that might need attentionERROR: failures that need investigation (payment declined, downstream timeout)FATAL: severe errors that cause the application to abort
Structured logging in different languages
Python (structlog):
import structlog
log = structlog.get_logger()
# Bind context that persists for this request
request_log = log.bind(
trace_id="a3f9b2c1d4e5",
user_id="usr_4821",
service="payments"
)
# Log events β all include bound context
request_log.info("payment_initiated", amount=299.99, currency="GBP")
request_log.error("payment_failed", error_code="insufficient_funds")
Output:
{"level": "info", "timestamp": "2024-11-15T14:23:01.100Z", "trace_id": "a3f9b2c1d4e5", "user_id": "usr_4821", "service": "payments", "amount": 299.99, "currency": "GBP", "event": "payment_initiated"}
{"level": "error", "timestamp": "2024-11-15T14:23:01.382Z", "trace_id": "a3f9b2c1d4e5", "user_id": "usr_4821", "service": "payments", "error_code": "insufficient_funds", "event": "payment_failed"}
Node.js (pino):
const pino = require('pino');
const log = pino();
const requestLog = log.child({
traceId: 'a3f9b2c1d4e5',
userId: 'usr_4821',
service: 'payments'
});
requestLog.info({ amount: 299.99, currency: 'GBP' }, 'payment_initiated');
requestLog.error({ errorCode: 'insufficient_funds' }, 'payment_failed');
Go (zerolog):
log.Error().
Str("trace_id", "a3f9b2c1d4e5").
Str("user_id", "usr_4821").
Float64("amount", 299.99).
Str("error_code", "insufficient_funds").
Msg("payment_failed")
Debugging production API errors with JSON
When an API returns an error, the response body is often JSON. Formatting it immediately reveals the structure:
Raw error response:
{"error":{"code":"RATE_LIMIT_EXCEEDED","message":"Too many requests","details":{"limit":100,"window":"1m","reset_at":"2024-11-15T14:24:00Z","retry_after":47},"request_id":"req_9x8y7z"}}
Formatted:
{
"error": {
"code": "RATE_LIMIT_EXCEEDED",
"message": "Too many requests",
"details": {
"limit": 100,
"window": "1m",
"reset_at": "2024-11-15T14:24:00Z",
"retry_after": 47
},
"request_id": "req_9x8y7z"
}
}
Key fields become immediately visible: you must wait 47 seconds before retrying, the rate limit window is 1 minute at 100 requests, and the request_id can be provided to the API vendor's support.
Log aggregation and querying
Elasticsearch + Kibana (ELK stack): ingest JSON logs, query with Kibana Query Language (KQL) or Elasticsearch DSL. Dominant in self-hosted environments.
Grafana Loki: log aggregation optimised for Kubernetes environments. Queries with LogQL, similar to PromQL. More cost-effective than Elasticsearch for log storage.
Datadog / New Relic / Splunk: commercial SaaS log aggregation with JSON parsing, dashboards, alerting.
The JSON advantage in all of these: parsers don't need to extract fields from strings; every JSON key becomes a queryable attribute automatically. String log lines require regex extraction rules; JSON logs index everything by default.
How to use the JSON Formatter on sadiqbd.com
- Paste raw API error responses β format instantly to identify the error structure
- Format log output β copy a minified JSON log line and format for readability during debugging
- Validate JSON structure β catch missing commas, unclosed brackets before the log schema goes to production
- Minify JSON β compact JSON before storing in a field or embedding in another system
Frequently Asked Questions
Should I format JSON logs with pretty-printing in production? No β pretty-printed JSON (with newlines and indentation) breaks single-line-per-log-event conventions that log shippers depend on. Log aggregators parse one JSON object per line; multi-line pretty-printed JSON requires special configuration. Always write minified (single-line) JSON to log output in production. Pretty-print only for human review (copy into the formatter).
How do I redact sensitive fields from JSON logs? Use a log processor middleware or hook that replaces sensitive values before they're written:
- Passwords, tokens, API keys: replace with
[REDACTED] - Credit card numbers: mask all but last 4 digits
- IP addresses: in some jurisdictions (GDPR), truncate the last octet Structlog and Pino both support processor pipelines for this purpose.
Is the JSON Formatter free? Yes β completely free, no sign-up required.
Try the JSON Formatter free at sadiqbd.com β format, validate, and minify any JSON instantly.