Skip to main content
Technical Systems

JSON Logging Best Practices: Designing Logs as Operational Data

A log line is useful when both humans and machines can trust its shape.

Practical JSON logging guidance for production services, including schema design, correlation IDs, redaction, log levels, cost control, validation, and JavaScript examples.

JSON Logging Best Practices: Designing Logs as Operational Data

JSON logging turns application events into records that can be searched, filtered, counted, alerted on, and correlated across systems. That is a major improvement over plain text logs, but it is not automatic. A service can emit valid JSON and still produce logs that are noisy, expensive, unsafe, or hard to query.

The difference is design. Useful JSON logs have stable fields, consistent names, correct types, clear messages, correlation identifiers, and enough context to explain what happened without dumping entire request bodies into storage. They are written for two audiences at once: people who need a readable event and systems that need predictable data.

This guide focuses on the practical rules that make JSON logs reliable in production.

Start With a Small Required Schema

Every service should emit a shared baseline of fields. The baseline does not need to be large, but it should be consistent. If one service writes requestId, another writes request_id, and a third writes req, searching across the fleet becomes unnecessarily painful.

A useful minimum looks like this:

{
  "timestamp": "2026-05-05T14:30:45Z",
  "level": "info",
  "service": "analytics-api",
  "environment": "production",
  "message": "Instagram insights collected",
  "request_id": "req_8f3a2b",
  "trace_id": "trace_12ab34"
}

The message field keeps the event readable. The other fields make it queryable. A schema like this lets dashboards, alerts, and incident queries work across many services without custom parsing rules for each one.

Use Stable Field Names

Field names are an interface. Once dashboards, alerts, saved searches, and incident playbooks depend on them, renaming a field becomes a breaking change. Choose names deliberately and document them.

Prefer one convention, such as snake case:

{
  "request_id": "req_123",
  "response_time_ms": 235,
  "status_code": 200
}

Avoid mixing conventions in the same log stream:

{
  "requestId": "req_123",
  "response_time_ms": 235,
  "StatusCode": 200
}

The second example is still JSON, but it is messier operational data. Consistency matters more than the specific naming style.

Keep Types Honest

One of the advantages of JSON logging is that values can keep their types. Do not turn everything into strings. Numbers should be numbers, booleans should be booleans, arrays should be arrays, and objects should be used carefully when nesting helps.

Good:

{
  "status_code": 429,
  "retryable": true,
  "duration_ms": 812,
  "metrics_requested": ["reach", "views", "profile_views"]
}

Less useful:

{
  "status_code": "429",
  "retryable": "true",
  "duration_ms": "812",
  "metrics_requested": "reach,views,profile_views"
}

Typed fields make aggregation and alerting less fragile. A query that asks for duration_ms > 1000 should not have to convert a string first.

Include Correlation IDs

A single user action may move through a frontend, API gateway, application service, background worker, queue, database, and third-party API. Without a shared identifier, reconstructing that path from logs is slow.

Include a request or correlation ID on every log produced during the same operation. If distributed tracing is available, include the trace ID too.

{
  "level": "error",
  "service": "billing-worker",
  "message": "Invoice capture failed",
  "request_id": "req_8f3a2b",
  "trace_id": "trace_12ab34",
  "job_id": "job_775",
  "invoice_id": "inv_991"
}

This is where JSON logging connects to broader observability. A trace can show the shape of a request, metrics can show the trend, and logs can explain the details of individual events.

Write Messages for Humans

Structured fields should carry context, but the message field still matters. It should summarize the event in plain language without embedding every variable.

Good:

{
  "message": "Payment provider returned rate limit",
  "provider": "stripe",
  "status_code": 429,
  "retry_after_seconds": 60
}

Less useful:

{
  "message": "Payment provider stripe returned status 429 with retryAfter 60 for request req_123"
}

The second message is readable, but the key facts are trapped in prose. The first message is easier to scan and easier to query.

Be Careful With Errors

Error logging should preserve enough information to debug without leaking sensitive details. Include a stable error code, error class or type, message, stack trace where appropriate, and operational context such as the step that failed.

{
  "level": "error",
  "service": "orders",
  "message": "Order reservation failed",
  "error_code": "INVENTORY_RESERVATION_FAILED",
  "error_type": "InventoryTimeoutError",
  "order_id": "ord_1048",
  "duration_ms": 3000,
  "retryable": true
}

Avoid logging raw exception objects without understanding how the logger serializes them. Some libraries include stack traces cleanly; others may drop fields or serialize more than expected. Test the emitted shape, not just the code path.

Redact Before Logs Leave the Service

Logs are often copied, indexed, retained, exported, and read by more people than primary application data. Treat them as sensitive. Do not log passwords, access tokens, API keys, private keys, raw authorization headers, full payment details, or unnecessary personal data.

Central redaction rules help:

authorization -> [REDACTED]
access_token -> [REDACTED]
password -> [REDACTED]
card_number -> [REDACTED]

Redaction in the log pipeline is useful as a second layer, but the safest place to prevent leaks is before the event leaves the application. Structured fields make redaction easier because sensitive values have names. Plain text redaction has to guess from patterns.

Avoid High-Cardinality Noise

High-cardinality fields have many unique values. Some are necessary, such as request IDs. Others can make log indexes expensive or less useful, such as full URLs with query strings, raw user agents, large payload hashes, or arbitrary search terms.

Before adding a field, ask whether it will be used for investigation, grouping, alerting, or audit. If the field is rarely useful and highly variable, consider omitting it, sampling it, hashing it, or storing it outside the log with a reference ID.

For example, this can be noisy:

{
  "url": "/search?q=every unique user query with filters"
}

This is often more useful:

{
  "route": "/search",
  "has_filters": true,
  "query_length": 34
}

Control Volume Deliberately

JSON logs can become expensive because they are easy to enrich. A busy service that logs every successful request with dozens of fields may create large ingestion and indexing bills.

Use log levels consistently. debug should be safe to reduce or disable in production. info should record meaningful lifecycle and business events. warn should indicate unusual but handled conditions. error should mean something failed and deserves attention or aggregation.

Sampling can help for high-volume success paths:

log all errors
log all warnings
sample successful health checks
sample repetitive debug events
retain audit events separately

Do not sample blindly. Some events, such as payment failures or security decisions, may need complete retention even if they are frequent.

Validate Log Shape

If logs are operational data, validate them like operational data. A lightweight schema or test can catch missing required fields, inconsistent types, and accidental renames before deployment.

For example, a test can assert that a request log includes:

timestamp
level
service
environment
message
request_id
status_code
duration_ms

Validation is especially useful during migrations. If teams are moving from plain text to JSON, or renaming requestId to request_id, tests and dashboards should catch drift early. This connects directly to the broader structured logging tradeoff covered in Structured Logs and Plain Text Logs.

JavaScript Example With Pino

The exact library matters less than the habits: create request context once, use child loggers, keep fields typed, and avoid leaking sensitive data.

import crypto from "node:crypto";
import pino from "pino";

const logger = pino({
  level: process.env.LOG_LEVEL || "info",
  redact: {
    paths: ["req.headers.authorization", "access_token", "password"],
    censor: "[REDACTED]"
  }
});

export function requestLogger(req, res, next) {
  const requestId = req.headers["x-request-id"] || crypto.randomUUID();
  const traceId = req.headers["traceparent"] || requestId;
  const startedAt = Date.now();

  req.log = logger.child({
    service: "analytics-api",
    environment: process.env.NODE_ENV || "development",
    request_id: requestId,
    trace_id: traceId
  });

  res.on("finish", () => {
    req.log.info({
      message: "HTTP request completed",
      method: req.method,
      route: req.route?.path || req.path,
      status_code: res.statusCode,
      duration_ms: Date.now() - startedAt
    });
  });

  next();
}

This pattern keeps request identifiers consistent across all logs produced during the request. Route handlers can add domain fields without rebuilding the base context each time.

API Collection Example

Suppose a service collects Instagram account insights on a schedule. A useful success log might look like this:

{
  "timestamp": "2026-05-05T14:30:45Z",
  "level": "info",
  "service": "analytics-api",
  "environment": "production",
  "message": "Instagram insights collection completed",
  "account_id": "ig_48291",
  "collection_run_id": "run_20260505_1430",
  "metrics_requested": ["reach", "views", "profile_views"],
  "status_code": 200,
  "duration_ms": 235,
  "rows_written": 18
}

A rate-limit log should expose the operational facts without hiding them in text:

{
  "timestamp": "2026-05-05T14:31:02Z",
  "level": "warn",
  "service": "analytics-api",
  "environment": "production",
  "message": "Instagram API rate limit encountered",
  "account_id": "ig_48291",
  "collection_run_id": "run_20260505_1430",
  "status_code": 429,
  "retryable": true,
  "retry_after_seconds": 60
}

Those fields support alerts, retries, dashboards, and support conversations. They also preserve the difference between a successful collection with zero rows and a failed collection with no data.

References

These references are useful for schema design and implementation behavior:

Conclusion

JSON logging is valuable because it turns runtime events into dependable operational data. The format alone is not enough. The logs need stable names, honest types, readable messages, correlation IDs, redaction, and cost controls.

Design logs as something your future incident response depends on, because it will. When the next production issue arrives, good JSON logs should make the important questions easier to ask and faster to answer.