It’s 3 AM. A payment flow is failing for one customer out of thousands, and you’re grepping 40,000 lines of timestamp-soup for a single orderId — a value buried inside a formatted message, different on every line because someone wrote "order " + id + " failed" in one place and "failed order=" + id in another. This post kills that workflow. Structured logging turns every log line into a JSON document with named, typed fields your ELK stack can filter, aggregate, and alert on. Spring Boot has supported it natively since 3.4, and on Boot 4 it’s a one-property switch — no logstash-logback-encoder, no XML wrestling, no code changes.

The one property

logging:
  structured:
    format:
      console: ecs

That’s the whole migration for the default Logback setup. Boot supports three JSON formats out of the box: ecs (Elastic Common Schema), gelf (Graylog), and logstash (Logstash JSON). A log line goes from this:

2026-10-05T12:40:01.123 INFO  [http-nio-8080-exec-3] c.e.payments.OrderService : Order placed orderId=ORD-9918 amount=149.99

to this:

{"@timestamp":"2026-10-05T12:40:01.123Z","log":{"level":"INFO","logger":"com.example.payments.OrderService"},"process":{"pid":39599,"thread":{"name":"http-nio-8080-exec-3"}},"service":{"name":"payments-api"},"message":"Order placed orderId=ORD-9918 amount=149.99","ecs":{"version":"8.11"}}

Same line, now a document. Kibana can filter service.name:payments-api AND log.level:ERROR without a single regex.

One thing to notice: orderId and amount are still just message text here — ECS wraps the log event, it doesn’t parse fields out of the message. Structured fields only appear when they’re supplied as MDC or SLF4J key-value data, which is exactly what the next two sections set up. Don’t read this example as “ECS extracts my values automatically.” The service.name comes from spring.application.name for free (version defaults to spring.application.version); override either under logging.structured.ecs.service.

MDC rides along for free

The real win isn’t the JSON wrapper — it’s that every key-value pair in the SLF4J MDC is added to the JSON object automatically. One filter puts a request ID on every line of every request:

@Component
public class RequestIdFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain) throws ServletException, IOException {
        String requestId = Optional.ofNullable(request.getHeader("X-Request-Id"))
                .filter(s -> !s.isBlank())
                .orElseGet(() -> UUID.randomUUID().toString());
        MDC.put("requestId", requestId);
        response.setHeader("X-Request-Id", requestId);
        try {
            chain.doFilter(request, response);
        } finally {
            MDC.remove("requestId"); // remove only the key this filter added —
                                     // clear() would also wipe traceId/spanId and
                                     // any context other filters established
        }
    }
}

The finally block is the part people forget, and it’s the part that matters: Tomcat recycles threads, so the requestId you set would leak into the next request on the same thread unless you remove it at the same boundary where you set it. Note the deliberate MDC.remove("requestId") instead of MDC.clear() — this filter can run alongside other MDC context, including Micrometer Tracing’s traceId/spanId, and clearing the whole map would erase those too.

Two production caveats before you copy this. First, X-Request-Id is untrusted input: this example reflects the header value into the MDC and back out in the response header. In production, validate its length and character set — or generate a fresh ID when the incoming value doesn’t match your expected format — so arbitrary client input can’t pollute your logs or grow MDC values without bound. Second, “every log line in the request” means every log line emitted on the request thread. MDC is thread-local, so @Async methods, executor tasks, and reactive pipelines won’t see requestId unless you propagate the context (e.g. a TaskDecorator that copies the MDC map).

What belongs in MDC: request-scoped identifiers — requestId, userId, tenantId. What doesn’t: full domain objects, values that change on every log line (raw timestamps, per-event UUIDs), or anything sensitive (more on that below).

Per-event fields: stop burying data in messages

When values live only inside the message string — string concatenation like "order " + id + " failed", or inconsistent formats across call sites — they are hard to query. The SLF4J fluent API (SLF4J 2, which Boot 4 ships) puts them next to the message as named fields instead:

log.atInfo()
   .addKeyValue("event", "order-placed")
   .addKeyValue("orderId", order.id())
   .addKeyValue("amount", order.amount())
   .addKeyValue("backordered", false)
   .log("Order placed");

That yields "event":"order-placed", "orderId":"ORD-9918", "amount":149.99, "backordered":false as top-level JSON fields — with native types, not strings. amount > 100 now works as a numeric filter instead of a parsing exercise.

Two rules of thumb:

  • Give events stable names, as the example does with event="order-placed". The field survives message rewording, so dashboards don’t break when someone edits the prose.
  • Keep fields to durable facts about the event — business IDs, status values, attempt counts. Never serialize an entire domain object; it bloats entries and copies unrelated data into long-term storage.

Shaping the JSON without XML

Boot lets you tune the output with properties — no custom encoder needed:

logging:
  structured:
    format:
      console: ecs
    json:
      exclude: log.level            # drop fields you don't need
      rename:
        process.id: procid          # rename to match your ingestion schema —
                                   # note this departs from ECS naming, so do it
                                   # only when the downstream schema requires it
      add:
        environment: ${APP_ENV:local}  # add fixed fields
    ecs:
      service:
        name: payments-api
        environment: production

Stack traces deserve their own tuning — a full trace on every error can be expensive for your ingestion pipeline:

logging:
  structured:
    json:
      stacktrace:
        max-length: 4096
        include-common-frames: false
        include-hashes: true   # hash lets you group identical crashes

For deeper surgery, implement StructuredLoggingJsonMembersCustomizer and point logging.structured.json.customizer at it.

Your own format, when the org has a schema

If your platform team mandates a specific log schema, implement StructuredLogFormatter — note it doesn’t even have to return JSON:

import ch.qos.logback.classic.spi.ILoggingEvent;
import org.springframework.boot.logging.structured.StructuredLogFormatter;

public class OrgLogFormat implements StructuredLogFormatter<ILoggingEvent> {

    @Override
    public String format(ILoggingEvent event) {
        return "time=" + event.getInstant()
                + " level=" + event.getLevel()
                + " message=" + event.getMessage() + "\n";
    }
}
logging:
  structured:
    format:
      console: com.example.logging.OrgLogFormat

The generic type ties the implementation to a logging system (ILoggingEvent for Logback, LogEvent for Log4j2) — that’s the one coupling to be aware of. Supported constructor parameters are injected automatically — things like Environment, StructuredLoggingJsonMembersCustomizer, StackTracePrinter, or ContextPairs — but this isn’t general Spring bean injection; the API documents a fixed set of supported parameter types.

One caveat: if you use a custom logback-spring.xml, Boot is no longer in charge of the encoder, so the logging.structured.format.* properties no longer control it — that’s expected behavior for a custom configuration, not a silent failure. Update the encoder to respect the structured-format properties:

<encoder class="org.springframework.boot.logging.logback.StructuredLogEncoder">
    <format>${CONSOLE_LOG_STRUCTURED_FORMAT}</format>
    <charset>${CONSOLE_LOG_CHARSET}</charset>
</encoder>

Trace correlation and Boot 4’s OTel story

Structured logs get dramatically more useful when every line carries traceId/spanId. On Boot 4, the recommended OTel/OTLP path is the official starter — one dependency, version managed by the Boot BOM:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-opentelemetry</artifactId>
</dependency>

This wires the OTel SDK, the Micrometer Tracing bridge, and the OTLP exporters. Trace context then lands in the MDC automatically, so traceId/spanId flow into your JSON with zero code. (On the Brave/Zipkin path instead, there’s a dedicated spring-boot-starter-zipkin, and Boot 4 splits its tracing support into per-tracer modules — spring-boot-micrometer-tracing-opentelemetry on the OTel path — so pull the artifacts matching your tracer.)

Three property families to know when wiring OTLP export:

  • OTLP tracing export: management.opentelemetry.tracing.export.otlp.*
  • OTLP log-export tuning: management.opentelemetry.logging.export.otlp.*
  • Other tracing exporters follow management.tracing.export.{name}.* — e.g. management.tracing.export.zipkin.* for Zipkin

Those are the Boot 4 names as documented in the official tracing reference. The 3.x management.otlp.* properties were relocated under the new management.opentelemetry.* / management.tracing.export.* names, so if you’re migrating a 3.x app that shipped logs or traces to an OTel collector, check the exact old-to-new mapping in that reference and the Spring Boot 4.0 migration guide rather than trusting any single rename pair — then grep your config for the old prefixes.

One conceptual distinction worth keeping straight: logging.structured.format.console: ecs controls the rendered log output, while management.logging.export.otlp.* configures OTLP log export — the exporter sends OpenTelemetry’s log data model, not the ECS JSON string. Console/file JSON and OTLP export are separate paths.

What never goes in the logs

Structured fields make data easier to query — including by people who shouldn’t see it. Never log Authorization/Cookie headers, tokens, API keys, passwords, full PII, or card data. If you need a correlatable identifier, mask it (email=a***@example.com) or use a keyed hash/HMAC — a plain unsalted hash of a low-entropy value like an email address can be dictionary-attacked, so hashing alone isn’t anonymization. Add a CI grep gate that fails the build on log.*(password|secret|token|apiKey) as an additional defense-in-depth check — a regex over source can’t reliably catch dynamically constructed values, so treat it as a backstop, not a guarantee.

Prove it: test the shape

Your log schema is a contract with your observability stack. Test it like one — with a real Logback event, not a mock, so the test compiles and runs as shown:

import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.classic.spi.LoggingEvent;
import org.junit.jupiter.api.Test;
import org.slf4j.LoggerFactory;

import static org.junit.jupiter.api.Assertions.*;

class OrgLogFormatTest {

    private final OrgLogFormat format = new OrgLogFormat();

    private static ILoggingEvent event(Level level, String message) {
        Logger logger = (Logger) LoggerFactory.getLogger("test");
        return new LoggingEvent(
                OrgLogFormatTest.class.getName(), logger, level, message, null, null);
    }

    @Test
    void includesLevelAndMessage() {
        String out = format.format(event(Level.INFO, "Order placed"));
        assertTrue(out.contains("level=INFO"));
        assertTrue(out.contains("message=Order placed"));
        assertTrue(out.endsWith("\n"));
    }

    @Test
    void specialCharactersExposeEscapingLimits() {
        // A message containing '=' is ambiguous in this naive format:
        // "message=note=a=b" has no defined field boundaries. This test
        // documents the wire format as-is — a production schema needs real
        // escaping or structured serialization (which the ECS/JSON formats
        // give you for free).
        String out = format.format(event(Level.INFO, "note=a=b"));
        assertTrue(out.contains("message=note=a=b"));
    }
}

The 2-minute interview answer

“Text logs are write-only. Structured logging emits each event as a JSON document with named, typed fields — ECS, GELF, or Logstash out of the box in Spring Boot via logging.structured.format.console. Boot’s structured formats include MDC key/value pairs automatically, per-event fields come from the SLF4J fluent API’s addKeyValue, and the schema is tunable with logging.structured.json.* properties or a custom StructuredLogFormatter. With Micrometer Tracing configured, trace and span IDs land in the MDC, so a log line joins directly to its trace. And I never log secrets or PII — structure makes data easier to query, which cuts both ways.”

Cheat sheet

NeedHow
JSON console outputlogging.structured.format.console: ecs (ecs/gelf/logstash)
JSON file outputlogging.structured.format.file: ecs
Request ID on every lineMDC in a filter, remove the filter-owned key in finally
Per-event fields, native typeslog.atInfo().addKeyValue("k", v).log("...")
Rename/drop/add fieldslogging.structured.json.rename/exclude/add
Trim stack traceslogging.structured.json.stacktrace.max-length, include-hashes
Custom schemaStructuredLogFormatter<ILoggingEvent>, FQCN in format.console
Custom logback.xmlStructuredLogEncoder + ${CONSOLE_LOG_STRUCTURED_FORMAT}
Trace correlationMicrometer Tracing bridge → traceId/spanId in MDC
OTLP log export (Boot 4)management.opentelemetry.logging.export.otlp.*
Secrets in logsNever — hash or mask, plus a CI grep gate

Takeaway

Structured logging isn’t a logging upgrade — it’s an observability upgrade. One property gives you JSON, you put request context into the MDC and Boot carries those fields into the structured output, addKeyValue gives you typed per-event fields, and the logging.structured.* properties give you schema control without XML. The next time a payment fails at 3 AM, you’ll filter requestId in Kibana instead of grepping 40,000 lines — and go back to sleep.