Skip to main content
Technical Systems

JSON Logging: Designing Logs as a Dependable Data Contract

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: Designing Logs as a Dependable Data Contract

JSON logging is often introduced as a formatting improvement. Instead of writing a line of free-form text such as Payment failed for order 4821, an application emits a JSON object with separate fields for the event, order, error, service, and request context.

That is useful, but it is not the real advantage.

The real value appears when logs are treated as operational data with a dependable schema. A log line should not merely be valid JSON; it should use stable field names, correct data types, predictable correlation fields, useful error structure, and clear rules for what must never be recorded.

Application event


Structured log event

       ├── stable schema
       ├── typed fields
       ├── readable message
       ├── trace/correlation context
       ├── safe error data
       └── controlled dimensions


Search / dashboards / alerts / investigations

Without those rules, JSON can make bad logging look more sophisticated without making it more reliable. A collection of inconsistent JSON objects is still inconsistent data, which is why the choice between structured logging and plain text logs is really a choice about operational contracts.

The better mental model is simple: a structured log schema is an API between application code and the people and systems that consume operational telemetry.

Valid JSON Is Only the Starting Point

Consider three services recording the same basic concept:

{"userId":"42","status":"success"}
{"user_id":42,"result":"ok"}
{"user":{"id":"42"},"outcome":true}

All three entries are valid JSON documents. They are also three different data contracts.

A query such as user_id = 42 will not reliably find the same information across them, while a dashboard expecting status cannot automatically interpret result or outcome. Even the identifier changes type between a string and a number.

This is the central problem that a good JSON logging design needs to solve.

Structured logging works well when an organization agrees that common concepts have common representations. A service name should use the same field wherever possible, HTTP status should not sometimes be called status, sometimes statusCode, and sometimes hidden inside the message, while an elapsed duration should have a known field and unit rather than relying on whoever wrote the log statement.

A shared schema does not mean every event must contain the same fields. Different events naturally carry different data, but common concepts should remain stable across those events, much like Elastic Common Schema defines reusable field meanings.

For example:

{
  "timestamp": "2026-09-03T10:42:18.214Z",
  "level": "error",
  "service": "payment-api",
  "event": "payment.authorization_failed",
  "message": "Payment authorization failed",
  "order_id": "ord_4821",
  "duration_ms": 318
}

The useful part is not the braces. It is that another engineer can reasonably expect level, service, event, order_id, and duration_ms to mean the same thing tomorrow, in another service, and in another query.

That stability is what turns individual log statements into a usable operational dataset.

Stable Fields and Correct Types Make Logs Queryable

Field names are part of the contract, but data types are equally important.

Suppose response time is logged like this:

{"duration":"318ms"}

It is human-readable, but poor for analysis because the numeric value and unit have been merged into a string. Sorting, aggregation, percentile calculations, and threshold queries now require parsing text that should have been structured at the source.

A better representation is:

{"duration_ms":318}

The unit is explicit in the field name and the value remains numeric. The logging backend can now calculate averages, compare values, build histograms, and find requests above a threshold without reparsing the field.

The same principle applies to booleans, counts, identifiers, timestamps, and status codes. A boolean should normally be a boolean rather than "true", a count should be numeric rather than "5 items", and a status code should not change from 200 to "200" depending on which code path emitted it.

Identifiers deserve a little care because numeric-looking identifiers are not necessarily quantities. An order ID such as "004281" should usually remain a string if arithmetic has no meaning and leading zeroes or formatting matter.

A practical schema might therefore distinguish the following types:

FieldExampleAppropriate type
service"checkout-api"string
http.status_code503number
duration_ms284number
retryabletrueboolean
order_id"ord_4821"string
timestamp"2026-09-03T10:42:18Z"timestamp/string representation

Consistency matters because operational tooling assumes that fields are comparable. If duration_ms is numeric in 95% of events and a string in the remaining 5%, the schema is no longer dependable even though every individual log entry may still be syntactically valid.

Naming conventions deserve the same discipline. Whether a team chooses snake_case, dotted names such as http.status_code, or another convention matters less than choosing deliberately and applying it consistently.

The goal is predictable meaning.

Keep a Readable Message, but Put Meaningful Data in Fields

Structured logs should not become unreadable simply because they are machine-queryable.

A useful event can contain both a concise human-readable message and structured fields that represent the facts behind it:

{
  "level": "warn",
  "event": "inventory.reservation_failed",
  "message": "Inventory reservation failed",
  "sku": "A184",
  "warehouse_id": "wh_07",
  "requested_quantity": 4,
  "available_quantity": 2
}

The message helps a human scanning a log stream understand the event quickly. The other fields make the same event searchable and aggregatable.

Problems appear when important data exists only inside the message:

{
  "message": "Inventory reservation failed for SKU A184 in warehouse wh_07 because 4 were requested but only 2 were available"
}

This sentence is perfectly readable, but finding all failures for warehouse_id = wh_07 now depends on text parsing. Aggregating by SKU or comparing requested quantities becomes unnecessarily difficult.

The opposite extreme is not ideal either. Logs consisting entirely of opaque fields can force engineers to mentally decode every event during an incident.

The strongest pattern is therefore readable message plus queryable fields. The message summarizes what happened, while the schema carries the data that operators are likely to filter, group, correlate, alert on, or inspect, especially during distributed tracing investigations.

An explicit event name can also be more dependable than treating the message itself as an identifier. Messages often change for clarity, but an event such as inventory.reservation_failed can remain stable and become the basis for dashboards and alerts.

That separation allows wording to evolve without silently breaking operational queries.

Correlation and Error Fields Should Explain the Failure Path

Logs become much more useful when an event can be connected to the wider execution that produced it.

In a distributed system, a single request may cross an API gateway, several services, a database, and an external provider. If each component logs independently with no shared context, an investigation becomes a timestamp-matching exercise.

Correlation and tracing fields provide that connection.

{
  "level": "error",
  "service": "payment-api",
  "event": "provider.timeout",
  "message": "Payment provider timed out",
  "trace_id": "8a71c32e...",
  "span_id": "91bf42...",
  "correlation_id": "order-4821",
  "provider": "example-payments",
  "timeout_ms": 3000
}

A trace_id can connect the event to one distributed execution, while a span_id identifies the particular traced operation. A broader [correlation_id]https://interwebicly.com/correlation-trace-id) may be useful when an application needs to connect activity belonging to a logical workflow that extends across multiple requests, asynchronous jobs, or traces.

The important design principle is consistency. If some services use traceId, others trace_id, and others place the identifier only inside a message, the ability to follow work across boundaries deteriorates quickly.

Error fields need the same thought.

Logging only "Something went wrong" provides almost no operational value, while dumping an entire exception object without structure can create noise and unpredictable schemas. Useful error events normally need enough information to distinguish the type of failure, understand where it happened, and determine whether investigation or retry is appropriate.

A structured event might include:

{
  "level": "error",
  "event": "database.query_failed",
  "message": "Order lookup failed",
  "error.type": "TimeoutError",
  "error.message": "Database request exceeded timeout",
  "error.code": "DB_TIMEOUT",
  "retryable": true,
  "order_id": "ord_4821",
  "trace_id": "8a71c32e..."
}

Stack traces can still be valuable, particularly for unexpected application failures, but they should complement the structured error fields rather than become the only useful information in the event.

Good error logging should make common investigative questions easy to answer: what failed, which operation was involved, what kind of error occurred, which request or workflow did it belong to, and what contextual identifiers are safe and useful for narrowing the problem?

That is much stronger than simply recording the exception text.

A Good Logging Schema Defines What Must Not Be Logged

Once logs become structured and searchable, they also become easier to retain, copy, index, export, and analyze. That increases their usefulness, but it increases the importance of deciding what data is allowed into them.

Logging an entire object is one of the easiest ways to break that boundary.

Consider application code that records an incoming request body for debugging. Today the object may contain only harmless fields, but a later release can add passwords, authentication tokens, addresses, health information, payment details, or other sensitive values without anyone updating the logging statement.

Structured logging therefore needs redaction rules, not just good intentions, because privacy principles such as data minimisation apply to operational telemetry too.

The safest approach is usually to define which fields are permitted rather than repeatedly trying to identify every dangerous value after it has been logged. Where sensitive objects must pass through generic logging infrastructure, known secrets and personal fields should be removed or masked before the event leaves the application.

Application data


Logging policy

      ├── include operational fields
      ├── redact secrets
      ├── omit sensitive payloads
      └── normalize approved identifiers


Log pipeline

Tokens, passwords, authorization headers, session credentials, private keys, and similar secrets should not reach general-purpose logs. Personal data requires its own deliberate treatment based on what the system is allowed to retain and why.

Redaction also needs testing. A rule that exists only as a convention is easy to bypass when another developer adds a new field or library.

The logging contract should make safe behaviour the default.

Cardinality Determines Whether Useful Logs Become Expensive Logs

Structured fields make logs easy to index, group, filter, and aggregate, but not every possible field is equally useful for those operations.

This is where cardinality matters.

A field such as environment might have only a handful of possible values:

production
staging
development

A field such as request_id may have millions of unique values.

Both can be useful, but they serve different purposes. Low-cardinality dimensions are often good candidates for dashboards, grouping, and indexes, while extremely high-cardinality values may be useful primarily when searching for a specific execution.

Carelessly attaching high-cardinality data to every event can increase indexing, storage, and query costs. URLs containing arbitrary IDs, complete query strings, user-generated text, large request bodies, and dynamically constructed field names can all make telemetry expensive without creating equivalent operational value.

The lesson is not to avoid unique identifiers. Trace IDs and request IDs are extremely useful during investigations, including cases where timeouts do not cancel work and abandoned activity must be found later.

The lesson is to distinguish fields needed to locate a specific event from fields intended for large-scale aggregation.

Logging volume matters too. A high-traffic service that records several large JSON events for every successful request can generate enormous amounts of data, even if each individual event appears reasonable.

Good logging design therefore asks whether the event will actually be used. Routine successful operations may need concise records, while unusual failures can justify richer context.

Operational data has a cost model. A dependable logging contract should account for it.

Validate the Schema Like Any Other Interface

Logging conventions tend to decay when they exist only in documentation.

One team uses request_id; another introduces requestId. A library emits duration_ms as a number, while a new service sends it as a string. Someone adds a sensitive field during debugging, and another service invents a different event name for an existing concept.

Individually, none of these changes may break the application. Collectively, they break the operational dataset.

That is why the final step in structured logging is schema validation.

The logging layer can normalize common fields before events are emitted, while tests can verify required fields, types, naming conventions, and redaction behaviour. Shared libraries can provide standard context such as service name, environment, timestamp, severity, trace identifiers, and application version rather than requiring every call site to recreate them.

A simplified flow looks like this:

Application event


Shared logger

       ├── add standard context
       ├── validate field types
       ├── redact prohibited data
       └── enforce schema rules


JSON log event


Collector / storage / analysis

Schema evolution should also be deliberate. Renaming a field used by production dashboards is not merely cosmetic, because downstream consumers may depend on it just as application code depends on an API response.

That is why treating the schema as a contract is such a useful model. It encourages teams to think about compatibility, ownership, validation, and changes before inconsistencies spread across millions of events.

JSON itself solves only the serialization problem.

A dependable logging system solves the harder problem of meaning. Stable field names make events comparable, correct types make them analyzable, readable messages keep them usable during incidents, trace and correlation IDs connect distributed work, structured error fields explain failures, redaction protects sensitive data, and cardinality controls keep the system economically sustainable, even when configuration drift makes the runtime environment harder to trust.

Schema validation then prevents those decisions from slowly eroding as the codebase grows.

Good JSON logging is therefore not about producing more structured log lines. It is about defining an operational data contract that applications can emit consistently and engineers can trust when something goes wrong.