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 afinally; Logback renders it with%X{correlationId}or includes the whole map via JSON encoders (logstash-logback-encoder does by default). It is a thread-boundThreadLocalmap — it silently evaporates on async hops (CompletableFuture, thread pools). Reactor needs thecontext-propagationlibrary (Hooks.enableAutomaticContextPropagation()), Kotlin coroutines needkotlinx-coroutines-slf4j'sMDCContext; Java 21+ScopedValueis the structured-concurrency-friendly direction the platform is moving toward. - Go:
log/slogis the standard —slog.Info("msg", "key", v)or typedslog.String(...)attrs. There is no global MDC; the idiom is to carry request-scoped attrs incontext.Contextand either derive a pre-bound logger (logger.With("correlation_id", id)stored in the context) or write aslog.HandlerwhoseHandle(ctx, r)extracts known context keys and appends them to every record. - Rust: the
tracingcrate — 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'sfmtlayer with.json()emits structured output. - Node:
AsyncLocalStoragegives request-scoped storage that survivesawait; middleware runs the request insideals.run(store, …)and the logger pulls from it — pino'smixin()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
- Observability & SRE practice — the strategic map this group details; logs are one of its three signals.
- Correlation IDs & context propagation — where the MDC values come from: the edge filter that extracts or generates them.
- OpenTelemetry — the logs signal bridges existing frameworks into
OTLP;
trace_idin MDC is the join key. - Health checks: liveness, readiness, startup — sibling concern; probe endpoints are the one traffic worth excluding from access logs.
- Frameworks × the stdlib contracts —
log/slogas the logging contract all Go web stacks converge on.
Citations
[1] SLF4J manual — MDC [2] Go blog — Structured Logging with slog [3] tracing — spans and fields [4] Node.js docs — AsyncLocalStorage