rgoussu@goussu: ~/library/platform/observability-and-sre
~/library/platform/observability-and-sre cat structured-logging.md

Structured logging & MDC

# Logs as machine-parseable events with per-request context — JSON output, level discipline, what belongs in a log vs a metric vs a span, and the MDC idiom across SLF4J, slog, tracing, and AsyncLocalStorage.

Conceptsaved 2026-08-11 #observability#logging#structured-logging#mdc#slf4j#slog#tracing#pino

Overview

Structured logging treats every log call as an event with fields, not a formatted sentence: a stable message plus typed key-value attributes, emitted as JSON (or logfmt) so the log pipeline can filter, group, and join without regexes. The second half of the practice is MDC (Mapped Diagnostic Context): a per-request key-value context that the logging backend stamps onto every line emitted while the request is in flight, so a single correlation_id=… filter reconstructs one request's story. Each platform has a different carrier for that context — thread-bound maps on the JVM, context.Context in Go, span fields in Rust, AsyncLocalStorage in Node — and knowing the carrier is knowing where the idiom breaks.

Key points

  • Events, not strings: keep the message template constant ("order rejected") and put the variability in attributes (order_id, reason). String-interpolated messages destroy grouping; attributes make logs queryable like rows.
  • Format: JSON Lines in production, pretty/colored rendering in dev — an encoder configuration, never a code change. logfmt (key=value …) is the lighter alternative where humans still tail raw output.
  • Level discipline: ERROR = someone should act (failed request, broken invariant); WARN = degraded or surprising but handled; INFO = state changes that narrate the system's life (startup, config, request outcomes); DEBUG/TRACE = diagnostic flow, off by default. Log a failure once, at the boundary where it's handled — never log-and-rethrow at every layer.
  • Log vs metric vs span: a metric answers "how many / how fast, in aggregate" — cheap, pre-aggregated, no per-request detail; a span answers "where did this request spend its time, across services" — causality and timing; a log answers "what exactly happened here" — discrete events with rich detail. Don't log request latencies (that's a histogram), don't log call trees (that's a trace); do log the one-off event with the payload-level context neither of the others carries.
  • MDC on the JVM: org.slf4j.MDC.put("correlationId", id) in a filter, MDC.clear() in a finally; Logback renders it with %X{correlationId} or includes the whole map via JSON encoders (logstash-logback-encoder does by default). It is a thread-bound ThreadLocal map — it silently evaporates on async hops (CompletableFuture, thread pools). Reactor needs the context-propagation library (Hooks.enableAutomaticContextPropagation()), Kotlin coroutines need kotlinx-coroutines-slf4j's MDCContext; Java 21+ ScopedValue is the structured-concurrency-friendly direction the platform is moving toward.
  • Go: log/slog is the standard — slog.Info("msg", "key", v) or typed slog.String(...) attrs. There is no global MDC; the idiom is to carry request-scoped attrs in context.Context and either derive a pre-bound logger (logger.With("correlation_id", id) stored in the context) or write a slog.Handler whose Handle(ctx, r) extracts known context keys and appends them to every record.
  • Rust: the tracing crate — spans carry fields (info_span!("request", correlation_id = %id)), and every event inside an entered span inherits them: the span context is the MDC. #[instrument] wraps a function in a span; tracing-subscriber's fmt layer with .json() emits structured output.
  • Node: AsyncLocalStorage gives request-scoped storage that survives await; middleware runs the request inside als.run(store, …) and the logger pulls from it — pino's mixin() hook merges the store into every line.
  • Cost model: logs are the most expensive signal per byte. Bound volume (sample or rate-limit DEBUG, drop health-check access logs), bound attribute cardinality, and redact PII at the encoder (masking rules in logstash-encoder, redact: [paths] in pino) — not by hoping call sites remember.

Details

What goes where

Question Signal Why
Error rate, p99 latency, queue depth Metric Aggregable, cheap, alertable — no per-request identity
Where did this request spend 800 ms? Span/trace Causality across services, timed tree
Why was order 4711 rejected? Log Discrete event, full detail, payload context
Which requests hit the slow path? Trace (+ exemplars) Per-request question at aggregate scale

The three meet through shared identifiers: the correlation id / trace id stamped in MDC ties a log line to its trace; exemplars tie a histogram bucket to a sample trace.

The MDC carrier per platform

Platform Carrier Enrichment point Async caveat
JVM (SLF4J/Logback) ThreadLocal map (MDC) Servlet/JAX-RS filter sets & clears Lost on executor hops; Reactor/coroutines need propagation libs; ScopedValue (Java 21+) is the future carrier
Go context.Context Middleware wraps ctx; custom slog.Handler extracts None — ctx is explicit; the discipline is passing ctx
Rust tracing span fields Middleware opens a span with fields Futures must be .instrument(span)-ed (Tower/axum layers do it)
Node AsyncLocalStorage Middleware als.run(); pino mixin() reads Survives await; lost only in queueing libs that break async context

In a hexagonal service all of these are enriched in one place — the driving adapter's filter/middleware — per the placement stated in the parent note; the domain logs through the facade (SLF4J, slog, tracing) and never sets context itself.

Examples

Logback pattern with MDC, and the JSON encoder that includes the whole map:

<pattern>%d %-5level [%X{correlationId}] %logger - %msg%n</pattern>
<!-- or: --> <encoder class="net.logstash.logback.encoder.LogstashEncoder"/>

A slog.Handler pulling request attrs from context:

func (h ctxHandler) Handle(ctx context.Context, r slog.Record) error {
    if id, ok := ctx.Value(correlationKey).(string); ok {
        r.AddAttrs(slog.String("correlation_id", id))
    }
    return h.inner.Handle(ctx, r)
}

pino fed by AsyncLocalStorage:

const logger = pino({ mixin: () => als.getStore() ?? {} });
// middleware: als.run({ correlation_id: id }, () => next());

Related

Citations

[1] SLF4J manual — MDC [2] Go blog — Structured Logging with slog [3] tracing — spans and fields [4] Node.js docs — AsyncLocalStorage