skip to content

How do you enrich structured logs in Spring Boot 3.4 with custom fields (like traceId or a service name) without writing a full custom formatter?

level: middleimportance: should knowfreq 35%

answer

  1. MDC.putCloseable -> per-request fields, clear in finally
  2. Micrometer Tracing auto-adds traceId/spanId to MDC
  3. logging.structured.json.add/rename/exclude/include
  4. service metadata: logging.structured.ecs.service.name/.version
  5. StructuredLoggingJsonMembersCustomizer via spring.factories

basics

~10 s

Put per-request values in SLF4J's MDC — they appear as fields automatically. Use logging.structured.json.add/.rename/.exclude to shape static fields, and the format's service properties (e.g. logging.structured.ecs.service.name) for service metadata.

solid answer

~40 s

Three layers. **(1) MDC** — anything you put in SLF4J's `MDC` (e.g. `traceId`, `userId`) is emitted as a structured field on every log line while it's set; Micrometer Tracing already populates `traceId`/`spanId` this way. **(2) Static JSON customization** — Boot 3.4 adds `logging.structured.json.add` (add a constant field), `.rename` (rename an existing field), `.exclude`/`.include` (drop or whitelist fields), letting you reshape a built-in format without code. **(3) Service metadata** — per-format properties like `logging.structured.ecs.service.name`, `.version`, `.environment`, `.node-name` (or `logging.structured.gelf.host`) populate the format's service fields. For programmatic control over which members appear, implement a `StructuredLoggingJsonMembersCustomizer` registered via `spring.factories`. Reserve a full `StructuredLogFormatter` for cases where the whole serialization shape is wrong.

code

java · 20 lines
java
import org.slf4j.MDC;
import org.slf4j.MDC.MDCCloseable;

// Dynamic, request-scoped field -> becomes a JSON member on every log line inside the block.
public void handle(long orderId) {
    try (MDCCloseable ignored = MDC.putCloseable("orderId", Long.toString(orderId))) {
        log.info("validating order");   // {... "orderId":"42" ...}
        log.info("order accepted");
    } // MDC.remove happens automatically -> no leak to the next request on this pooled thread
}

/*
 application.properties -- static shaping, no code:
   logging.structured.format.console=ecs
   logging.structured.ecs.service.name=order-service
   logging.structured.ecs.service.environment=prod
   logging.structured.json.add.region=eu-west-1
   logging.structured.json.rename.message=msg
   logging.structured.json.exclude=ecs.version
*/

go deeper

for a junior

Know MDC adds fields and that service name is a property.

for a middle

Distinguish MDC (dynamic) from json.add/rename/exclude (static) and service.* metadata.

for a senior

Explain MDC leakage, Micrometer trace correlation, and StructuredLoggingJsonMembersCustomizer vs full formatter.

for a principal

Design a fleet-wide field taxonomy: which fields are MDC vs static, contract stability, context propagation in reactive/async.

Enriching structured logs usually does **not** require a custom formatter. Boot 3.4 gives four escalating mechanisms. **1) MDC (Mapped Diagnostic Context) — dynamic, per-thread/per-request fields.** MDC is an SLF4J/Logback feature: a thread-local (and, with context propagation, task-local) map of key/values. Whatever you `MDC.put("orderId", id)` becomes a top-level field in the structured output for every log statement until you `MDC.remove`/`clear`. Always use try/finally so the value doesn't leak to other requests: ```java try (MDCCloseable c = MDC.putCloseable("orderId", String.valueOf(id))) { log.info("processing"); // JSON line includes "orderId" } ``` With **Micrometer Tracing**, `traceId`/`spanId` are placed in MDC automatically, so structured logs are correlatable to traces out of the box. This is the right tool for **request-scoped, changing** values. **2) Static JSON customization properties (Boot 3.4).** For **constant** shaping of a built-in format, without code: - `logging.structured.json.add.<name>=<value>` — add a constant member (e.g. `logging.structured.json.add.env=prod`). - `logging.structured.json.rename.<from>=<to>` — rename a member (e.g. map `message`→`msg`). - `logging.structured.json.exclude=<name>,<name>` — drop members you don't want. - `logging.structured.json.include=...` — whitelist members. These apply on top of `ecs`/`logstash`/`gelf`, so you can align a built-in format to a downstream schema with a couple of properties. **3) Service metadata properties.** Each format exposes service fields: - ECS: `logging.structured.ecs.service.name`, `.version`, `.environment`, `.node-name` → `service.name`, `service.version`, etc. - GELF: `logging.structured.gelf.host`, `logging.structured.gelf.service.version`. Set these once so every record identifies the emitting service — essential when many services share an index. **4) `StructuredLoggingJsonMembersCustomizer` — programmatic member control.** When property-based add/rename/exclude isn't expressive enough (conditional fields, computed values, reordering) but you still want to keep the built-in format's core, implement `org.springframework.boot.logging.structured.StructuredLoggingJsonMembersCustomizer<T>` and register it in `META-INF/spring.factories` (again, not as a bean, due to early init). It receives the `JsonWriter.Members` and can add/remove/transform members centrally. **Decision ladder:** dynamic per-request → MDC; a few constant fields/renames → `logging.structured.json.*` properties; identity of the service → `service.*` properties; conditional/computed members on a built-in format → `StructuredLoggingJsonMembersCustomizer`; entirely different schema → full `StructuredLogFormatter`. **Gotchas:** - **MDC leakage:** thread pools reuse threads; always clear MDC (try-with-resources `MDC.putCloseable`) or async tasks inherit stale context. Reactive apps need Micrometer context-propagation to carry MDC across threads. - **Renaming breaks dashboards:** `json.rename` changes the field contract with your aggregator — coordinate with dashboard/alert queries. - **Ordering/precedence:** MDC keys can collide with built-in field names; choose distinct keys.

  • Why must you clear MDC values, and what's the idiomatic way?
    Threads in a pool are reused, so a value left in MDC leaks into unrelated later requests on that thread. Use `MDC.putCloseable(...)` in a try-with-resources (or clear in a finally / a servlet filter) so it's removed deterministically.
  • How do traceId and spanId end up in the JSON without any code?
    Micrometer Tracing (Micrometer Observation + a tracer bridge) puts the current `traceId`/`spanId` into MDC for the active observation, and structured logging emits MDC entries as fields — giving log-to-trace correlation for free.

saying these in an interview costs you the question

  • Writing a full custom formatter just to add one constant field
  • Forgetting to clear MDC and causing cross-request field leakage in pooled threads
  • Assuming MDC propagates automatically across async/reactive boundaries without context propagation
  • Believing you must edit logback-spring.xml to add fields in Boot 3.4

context