---
title: How to Output Logs as JSON in Rust
summary: Output Rust logs as JSON using the tracing crate with the tracing-subscriber JSON layer for structured logging.
lang: en
slug: how-to-output-logs-as-json-in-rust
published: 2026-04-17T14:12:14+00:00
updated: 2026-04-25T22:00:13+00:00
canonical: https://www.rustfaq.org/en/how-to-output-logs-as-json-in-rust/
---

# How to Output Logs as JSON in Rust



## When text logs stop working

Your application is running in production. A user reports a crash. You pull the logs and stare at a wall of unstructured text. Grep returns four hundred lines of noise. You need to filter by `user_id`, aggregate by `request_id`, and pipe the output into a dashboard tool. Text logs fail here. Structured data wins. JSON is the lingua franca of modern observability. Rust gives you a clean way to emit structured logs without the pain of manual serialization.

## Structured logging in plain words

Traditional logging treats output like a diary entry: a string of characters meant for human eyes. JSON logging treats output like a database row: fields, types, and values that machines can parse instantly. The `tracing` crate is the standard for structured logging in Rust. It captures events with key-value pairs. The `tracing-subscriber` crate takes those events and decides where they go. By adding the JSON layer, you tell the subscriber to serialize every event into a JSON object instead of a text line. The result is a stream of parseable records.

## Minimal setup

Start with the dependencies. You need `tracing` for the macros and `tracing-subscriber` for the output layer. The JSON functionality lives behind a feature flag.

```toml
[dependencies]
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["json"] }
```

Initialize the subscriber in `main` before any logging happens. The `Layer` builder lets you configure the JSON output.

```rust
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, fmt::Layer};

fn main() {
    // Create a layer that formats events as JSON.
    // Disable extra fields like thread IDs to keep output clean.
    let json_layer = Layer::new()
        .json()
        .with_target(false)
        .with_thread_ids(false)
        .with_thread_names(false)
        .with_file(false)
        .with_line_number(false);

    // Build the subscriber: registry + level filter + json layer.
    // This initializes the global tracing system.
    tracing_subscriber::registry()
        .with(tracing_subscriber::filter::LevelFilter::INFO)
        .with(json_layer)
        .init();

    // Log an event with structured fields.
    // `message` is special; it becomes the "message" key in JSON.
    tracing::info!(message = "Application started", user_id = 123);
}
```

Run the code. You get a single line of JSON on stdout.

```json
{"message":"Application started","user_id":123}
```

Check stdout. You just emitted your first structured log.

## How the pipeline works

When `main` runs, the subscriber initializes globally. The `registry` acts as the base. The `LevelFilter::INFO` drops anything below INFO. The `json_layer` intercepts the remaining events. When `tracing::info!` fires, it captures `message` and `user_id`. The layer serializes these into a JSON object. The `message` field gets special treatment: it becomes the top-level `message` key. Other fields become keys in the same object. The result writes to stdout.

## Realistic logging with errors

Real applications have functions, errors, and context. You often want to log errors as structured data too. The `tracing` macros support formatting errors cleanly.

```rust
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, fmt::Layer};
use std::io;

fn process_request(id: u64) -> io::Result<()> {
    // Simulate work.
    if id % 2 == 0 {
        return Err(io::Error::new(io::ErrorKind::InvalidInput, "Even IDs are forbidden"));
    }
    Ok(())
}

fn main() {
    let json_layer = Layer::new().json();

    tracing_subscriber::registry()
        .with(tracing_subscriber::filter::LevelFilter::DEBUG)
        .with(json_layer)
        .init();

    // Log a request start.
    tracing::info!(request_id = 42, "Processing request");

    // Use `?` to attach the error to the log event if it fails.
    if let Err(e) = process_request(42) {
        // The % prefix formats the error using Display.
        // This ensures the JSON contains a readable string, not a raw struct dump.
        tracing::error!(error = %e, request_id = 42, "Request failed");
    }
}
```

The `%` prefix is a community convention. Without it, `tracing` might try to serialize the error struct directly, which can fail or produce verbose output. Use `%` for errors to get a clean string in the JSON.

Attach errors with `%`. Keep your JSON clean.

## Spans and context

Spans represent context. In JSON, they appear as nested objects. This helps correlate logs.

```rust
use tracing::span;

fn main() {
    let json_layer = Layer::new().json();

    tracing_subscriber::registry()
        .with(tracing_subscriber::filter::LevelFilter::INFO)
        .with(json_layer)
        .init();

    // Create a span with fields.
    let req_span = span!(tracing::Level::INFO, "request", id = 42);
    let _guard = req_span.enter();

    // Events inside the span inherit the span's fields.
    tracing::info!("Processing");
}
```

The output nests the span fields.

```json
{"message":"Processing","request":{"id":42}}
```

You can flatten this structure if your log aggregator prefers flat keys. Use `flatten_event(true)` on the layer. This merges span fields into the top level.

```rust
let json_layer = Layer::new()
    .json()
    .flatten_event(true);
```

Choose the shape that matches your tooling. Nested spans preserve hierarchy. Flattened events simplify querying.

## Filtering with environment variables

Production applications rarely hardcode log levels. The community convention is to expose `RUST_LOG` support. Users expect to control log levels via environment variables. Use `EnvFilter` to honor this expectation.

```rust
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, fmt::Layer};

fn main() {
    // EnvFilter reads RUST_LOG from the environment.
    // Falls back to INFO if the variable is missing.
    let filter = tracing_subscriber::EnvFilter::try_from_default_env()
        .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));

    let json_layer = Layer::new().json();

    tracing_subscriber::registry()
        .with(filter)
        .with(json_layer)
        .init();

    tracing::info!("Ready");
}
```

Run with `RUST_LOG=debug ./app` to see debug logs. Run with `RUST_LOG=error ./app` to see only errors.

Honor `RUST_LOG`. Your users will thank you.

## Pitfalls and errors

Missing the feature flag is the most common mistake. If you forget `features = ["json"]` in `Cargo.toml`, the compiler rejects the code. You'll see E0599 (no method named `json` found). The feature is gated because JSON serialization pulls in dependencies. Always check your `Cargo.toml` if the method is missing.

The `message` key is reserved. If you log `tracing::info!(message = "foo", message = "bar")`, the second value wins. Use `message` only for the human-readable summary. Put data in other keys.

JSON serialization allocates. In hot loops, this adds overhead. Profile before enabling JSON in tight paths. For most I/O bound services, the cost is negligible.

Check the feature flag before blaming the compiler.

## When to use JSON logging

Use JSON logging when you need machine-readable output for dashboards, aggregation tools, or log shippers like Fluentd and Vector. Use JSON logging when your application runs in a containerized environment where stdout is the standard log destination. Use plain text logging when you are debugging locally and want human-readable output in the terminal. Use `defmt` when you are targeting embedded devices with no standard library and need minimal binary size. Use the `log` crate facade when you are writing a library and want to let the binary decide how to handle logs.

Pick the format that matches your destination. Machines want JSON. Humans want text.

## Where to go next

- [How to Add Logging to a Rust Application](/en/how-to-add-logging-to-a-rust-application/)
- [How to Use defmt for Efficient Logging on Embedded Devices](/en/how-to-use-defmt-for-efficient-logging-on-embedded-devices/)
- [How to use log crate in Rust logging facade](/en/how-to-use-log-crate-in-rust-logging-facade/)