Last updated: July 11, 2025

JSON Logging: A Quick Guide for Engineers

If you’ve ever had to ssh onto a production server and grep through gigabytes of unstructured, plain-text logs to debug a critical failure, you already know the pain.

The classic printf approach to logging is a relic from a simpler time. In today’s world of distributed microservices, ephemeral containers, and complex user interactions, it’s not just inefficient; it’s a liability.

The industry’s answer has been structured logging, and its most popular implementation is JSON logging.

In this guide, we’ll cover the benefits of logging in JSON, but also tackle the hard truths that are often glossed over: the performance tax, the very real financial costs, and the operational discipline required to do it right at scale.

Let’s get started!

The unavoidable case for structured logging

Before we dive into JSON specifically, let’s be crystal clear on why we’re doing this. The goal of modern logging is not to create a human-readable story; it’s to produce a machine-parsable stream of events that is a useful signal for observability.

An unstructured log entry like this is a nightmare to parse reliably:

1
[2025-07-06 16:50:54,123] ERROR in PaymentService: Transaction failed for user 12345, amount: 99.99 USD - card declined.

To analyze this across thousands of instances, you’d write fragile regular expressions that break the moment someone changes the wording.

Now, consider its structured equivalent:

json
1234567891011121314151617
{
"timestamp": "2025-07-06T16:50:54.123Z",
"level": "error",
"service": {
"name": "PaymentService",
"version": "1.4.2"
},
"message": "Transaction failed",
"user": {
"id": 12345
},
"payment": {
"amount": 99.99,
"currency": "USD",
"reason": "card_declined"
}
}

You can now easily and reliably query for:

  • All errors from PaymentService,
  • The total failed transaction amount in the last hour,
  • Or the failure rate for a specific service version.

This is only the baseline. Structured logging is not a “nice to have”; it’s a fundamental prerequisite for observing modern software in production.

Implementing JSON logging

Getting your logs into a structured JSON format generally involves one of the following three approaches. You’ll likely use a combination of all three in any reasonably complex environment.

1. Application-level: Using a JSON logging framework

Your application code has the most context about what’s happening, so it should be the source of your structured logs. You’ll need adopt a structured logging framework that supports JSON output.

Most language ecosystems offer robust options such as:

For example, in Go, you can use the now built-in log/slog package to output JSON logs natively:

go
12345678910111213141516171819202122232425262728
package main
import (
"log/slog"
"net/http"
"os"
)
func helloHandler(w http.ResponseWriter, r *http.Request) {
slog.Info("incoming request to /hello",
slog.String("path", r.URL.Path),
slog.String("method", r.Method),
slog.String("user_agent", r.UserAgent()),
)
w.Write([]byte("hello world!"))
}
func main() {
// Set up a global logger
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil)))
mux := http.NewServeMux()
mux.HandleFunc("/hello", helloHandler)
http.ListenAndServe(":8080", mux)
}
json
output
123456
{
"time": "2025-07-11T06:03:21.284934328+01:00",
"level": "INFO",
"msg": "Debug message",
"process_id": 45787
}

With your application now configured, the next step is to tackle the other major sources of log data: the infrastructure components your application relies on.

2. Infrastructure-level: Configuring your dependencies

Many modern infrastructure components (databases, web servers, proxies) can now emit JSON logs natively. This is far more efficient than parsing their plain-text logs later.

You often can flip a single switch (or two) in many popular services to have them emit ready-to-consume JSON instead of plaintext logs:

Configuring JSON logging in AWS Lambda
Configuring JSON logging in AWS Lambda

3. Agent-level: Parsing unstructured logs

For legacy applications or services that you can’t reconfigure, your last resort is to parse the unstructured logs as they’re collected. A tool like the OpenTelemetry Collector can perform these transformations on the fly using components like Filelog receiver or the Transform processor before sending it to your backend.

Enforcing a uniform schema with OpenTelemetry

Switching every service to emit JSON is a great first step, but it isn’t the finish line. You may find that your auth-service logs a user ID as user.id, your billing-service prefers userId, and your legacy monolith uses usr.

This is the problem of schema inconsistency, and it makes querying and correlation much more difficult. Consistent field names (and data types) are just as critical as structured output.

The solution is to adopt a logging schema across all your applications and infrastructure. While you could invent your own, the industry is rapidly standardizing on the OpenTelemetry Semantic Conventions.

It’s a standardized specification for the structure of logs, metrics, and traces that gets you a consistent field semantics across your entire stack.

Adopting these conventions provides several key benefits.

First, you get standard names like traceId, http.request.method, severityNumber, and service.name that have a specific semantic meaning. An OpenTelemetry-native backend will automatically recognize these fields for powerful grouping and correlation.

The standard also cleanly separates metadata about the entity producing the log (Resource) from metadata about the event itself (Attributes).

Finally, it ensures that logs from different services can be correlated together, even if those services are written in different languages or use different logging libraries.

The good news is, you don’t have to overhaul your existing logging instrumentation to get all these benefits. You only have to install the appropriate log bridge for your logging library to automatically translate the log records into the OpenTelemetry log data model, and then let your logging pipeline handle the rest.

Practical considerations for JSON logging at scale

JSON logging pays off in richer diagnostics and simpler parsing, but it does introduce trade-offs. Before you flip every service to structured output, its necessary to account for the impact on performance, cost, and data storage.

The performance overhead

Serializing to JSON does incur extra CPU cycles and a few heap allocations, but in most applications that cost is vastly dwarfed by network round-trips or database queries.

The picture changes for ultra-low-latency workloads where logging happens on the hottest path. In such cases, a chatty or allocation-heavy logger can become a bottleneck.

This is why you must not just pick the most popular logger. For any service where performance matters, you must choose a logger designed to be allocation-aware.

In Go, for instance, Zerolog and Zap consistently benchmark as much as 10× faster than the older Logrus library for identical workloads.

Go logging benchmarks

Your performance tuning doesn’t stop at the code itself. You need to configure your agent to batch logs aggressively before sending, and use compression to reduce network costs.

For extreme scenarios, consider logging in a binary format within your cluster and only converting to JSON at the edge collector before shipping to your backend. This is an advanced move, but it’s the kind of trade-off required for ultra-low-latency systems.

Understanding the financial implications

Many observability vendors bill by gigabytes ingested, meaning that a 50% increase in log size can directly translate to a 50% increase in your bill, and that’s before add-ons like extra seats, indexing, long-term retention, or egress.

This creates a fundamental conflict: JSON is perfect for adding rich, valuable context to log records, but doing so increases data volume and, therefore, your costs.

The solution isn’t to retreat to plaintext or reduce contextual fields. Rather, it’s to pick an observability tool whose pricing doesn’t punish you for sending the data you actually need.

Dash0, for example, prices purely per log event, not per gigabyte, so you can keep the full, structured context in every log entry without watching your bill climb alongside byte count.

Managing your log volume

There are two levers that you can employ to keep both performance and spend under control: log-level filtering and sampling.

1. Filtering with log levels

Every structured logging framework supports severity levels and using them effectively helps you control the verbosity of your logs in different situations.

Most importantly, your log levels should be configurable at runtime without a redeploy so that when an incident occurs, you have the ability to temporarily raise the level for a specific service to get more detailed information without flooding your system with unnecessary noise from healthy components.

2. Taming log volume with sampling

Even with disciplined log levels, high-traffic endpoints can still overwhelm budgets with repetitive “everything is fine” logs. Sampling lets you keep the signal while shedding repetitive noise.

A basic strategy is keeping all ERROR entries, while recording only a fixed fraction (e.g., 1%) of other entries or hashing on a stable key like trace_id so that entire request logs are saved or dropped together.

Pairing intelligent sampling with a per-event billing model lets you send the logs that matter while keeping both latency and cost in check.

Best practices for high-impact JSON logs

With the groundwork laid, follow these guidelines to squeeze maximum value from every log line.

1. Ensure your logs are highly dimensional

Enrich your logs with all the context needed to understand the event being recorded. Think about all the information you need to understand the operation if it goes sideways and add it to the log.

This deep context is what separates a generic log statement from a useful piece of observability data. Just be sure your logging backend doesn’t penalize you for adding those valuable details.

2. Structure your stack traces

The default behavior of logging an exception as a single, multi-line string can be improved by rendering them as an array of frames so that you can aggregate errors by file, method, or exception type:

json
1234567891011121314151617181920212223
{
"level": "error",
"stack": [
{
"func": "main",
"line": "19",
"source": "main.go"
},
{
"func": "main",
"line": "250",
"source": "proc.go"
},
{
"func": "goexit",
"line": "1594",
"source": "asm_amd64.s"
}
],
"error": "file open failed!",
"time": "2025-07-11T12:00:12+01:00",
"message": "failed to execute query"
}

3. Include units in field names

A field named duration: 200 is ambiguous. Is it seconds, milliseconds, or microseconds? Ensure to remove the ambiguity by including the unit in the field name:

  • duration_ms: 200
  • response_size_bytes: 5120
  • cache_ttl_secs: 3600

This simple convention prevents costly misinterpretations when the pressure is on.

Final thoughts

Structured logging with JSON is a massive leap forward from the printf days. But treating it as a silver bullet is a mistake.

Effective logging is an engineering discipline. It requires you to think critically about performance, cost, and usability. It demands a deliberate approach to schema management and a strategy for controlling volume.

By embracing standards like OpenTelemetry, understanding the trade-offs, and implementing best practices for context and structure, you can transform your logs from a messy, reactive troubleshooting tool into a proactive, powerful engine for true system observability. Stop just writing logs. Start engineering your observability data.

Authors
Ayooluwa Isaiah
Ayooluwa Isaiah