Skip to main content

Structured Logging in Spring Boot 4: SLF4J Fluent API, MDC, and Custom Formatters

A human-readable log line is great for a terminal and terrible for a log aggregator, which has to regex-scrape fields back out of it. Structured logging flips that: write one JSON object per line instead, with fields as real JSON members from the start. Spring Boot 4 ships this as a built-in feature — no Logstash encoder dependency, no hand-rolled JSON layout — and this post builds a small service around it: one line enabled via a property, SLF4J’s fluent API and MDC both landing as real JSON fields with zero extra code, and a custom formatter for when none of the built-in formats fit.

Everything below was run for real: a Spring Boot context actually bootstrapped with logging.structured.format.console=ecs in effect, its console output captured and asserted on line by line, not assumed from the reference docs.

Versions used in this post. Spring Boot 4.1.1 · Spring Framework 7.0.9 · Logback 1.5.38 · SLF4J 2.0.18 · JDK 25 (Temurin). Confirmed via mvn dependency:tree on spring-boot-starter alone — no web starter, no JSON library anywhere on the classpath.

This demo is small enough that it doesn’t warrant its own repository module — every source file is linked for download right where it’s discussed below.

Turning it on

One property is the entire opt-in. No encoder XML, no extra dependency — spring-boot-starter on its own already pulls in logback-classic, and Boot’s structured-logging formatters write JSON themselves:

spring.application.name=structured-logging-demo
logging.structured.format.console=ecs

ecs is one of three built-in formats — the others are gelf (Graylog) and logstash (the logstash-logback-encoder JSON shape). All three are selected the same way, by name, for console output and/or file output independently via logging.structured.format.file.

What actually comes out

A service logging one line with SLF4J’s fluent API and an MDC entry:

@Service
public class OrderService {

    private static final Logger log = LoggerFactory.getLogger(OrderService.class);

    public void placeOrder(String orderId, String tenant, BigDecimal amount) {
        MDC.put("tenant", tenant);
        try {
            log.atInfo()
                    .addKeyValue("orderId", orderId)
                    .addKeyValue("amount", amount)
                    .log("order placed");
        } finally {
            MDC.remove("tenant");
        }
    }
}

Download: OrderService.java · application entry point: StructuredLoggingDemoApplication.java · build file: pom.xml. The actual console line this call produced, captured mid-test and never retyped by hand:

{"@timestamp":"2026-10-15T02:48:17.635571457Z","log":{"level":"INFO","logger":"com.ankurm.structuredlogging.OrderService"},"process":{"pid":8108,"thread":{"name":"main"}},"service":{"name":"structured-logging-demo","node":{}},"message":"order placed","tenant":"acme-corp","orderId":"ord-101","amount":249.99,"ecs":{"version":"8.11"},"corpname":"ankurm"}

Transcript: 31-ecs-order-line.txt. Three things worth noticing in that one line, none of which needed a single line of serialization code: addKeyValue("orderId", ...) and addKeyValue("amount", ...) show up as top-level JSON members, tenant (set via plain MDC.put) shows up right alongside them, and corpname is there too — a fixed member added by one more property, not by the call site at all:

logging.structured.json.add.corpname=ankurm

Download: application.properties, with both of this demo’s structured-logging properties in one place. That last one is part of a small family of properties for reshaping the JSON without a custom formatter: logging.structured.json.exclude drops a path, logging.structured.json.rename.<path> renames a member, and logging.structured.json.customizer points at a StructuredLoggingJsonMembersCustomizer for anything those three can’t express.

Going deeper: MDC actually clears, verified rather than assumed

It’s easy to write MDC.put/MDC.remove in a try/finally and never check that the finally branch actually does what it’s supposed to. This post’s test does check it: it calls placeOrder("ord-202", "globex", ...), lets the method return (running its finally), then logs one more plain line and inspects that line for leftover state:

{"@timestamp":"2026-10-15T02:48:17.703392502Z","log":{"level":"INFO","logger":"com.ankurm.structuredlogging.EcsStructuredLoggingTest"},"process":{"pid":8108,"thread":{"name":"main"}},"service":{"name":"structured-logging-demo","node":{}},"message":"no tenant in scope here","ecs":{"version":"8.11"},"corpname":"ankurm"}

No tenant member, and no trace of "globex" anywhere in the line. Transcript: 31-ecs-mdc-scoping.txt. Because MDC is thread-local, this kind of leak would otherwise only show up under real concurrent load, long after the code that caused it shipped — exactly the kind of bug an assertion like this is worth having.

Testing it without a JSON library

mvn dependency:tree on this module has nothing matching jackson anywhere in it — not in compile scope, not even pulled in transitively by spring-boot-starter-test, because no web starter is on the classpath to bring one in. So the test doesn’t parse the captured line into an object; it asserts on the real string with String.contains():

@SpringBootTest(classes = StructuredLoggingDemoApplication.class)
class EcsStructuredLoggingTest {

    @Autowired
    OrderService orderService;

    @Test
    void placingAnOrderWritesOneEcsJsonLineWithTheFluentAndMdcFields() throws Exception {
        String orderLine = capture(() ->
                orderService.placeOrder("ord-101", "acme-corp", new BigDecimal("249.99")));

        assertThat(orderLine).startsWith("{").endsWith("}");
        assertThat(orderLine).contains("\"message\":\"order placed\"");
        assertThat(orderLine).contains("\"orderId\":\"ord-101\"");
        assertThat(orderLine).contains("\"amount\":249.99");
        assertThat(orderLine).contains("\"tenant\":\"acme-corp\"");
        assertThat(orderLine).contains("\"corpname\":\"ankurm\"");
    }

    // capture() swaps System.out for a ByteArrayOutputStream around one
    // log call, then restores it - see the full file for the helper.
}

Download: EcsStructuredLoggingTest.java. The capture happens around a single call so the assertion is about one specific JSON line, not whatever else the console printed around it — Spring’s own startup banner lines are structured JSON too once the property is set, which is obvious in hindsight but easy to get wrong the first time you try to grab “the” output.

A formatter that isn’t JSON at all

The built-in formats cover ECS, GELF and Logstash, but the extension point behind all three, StructuredLogFormatter<E>, doesn’t require JSON output at all — it’s one method, event in, string out:

public interface StructuredLogFormatter<E> {
    String format(E event);
    default byte[] formatAsBytes(E event, Charset charset) { ... }
}

Confirmed with javap -p against the real spring-boot-4.1.1.jar, not copied from a changelog. A formatter implementing it for Logback’s event type can emit anything:

public class PipeDelimitedLogFormatter implements StructuredLogFormatter<ILoggingEvent> {

    @Override
    public String format(ILoggingEvent event) {
        return event.getLevel() + "|" + event.getLoggerName() + "|" + event.getFormattedMessage() + "\n";
    }
}

Download: PipeDelimitedLogFormatter.java. Wired in by fully-qualified class name instead of a built-in format id:

logging.structured.format.console=com.ankurm.structuredlogging.PipeDelimitedLogFormatter

Because the interface’s only job is “turn one event into one string,” it’s unit-testable with no Spring context and no console-capturing trick — just a real Logback LoggingEvent, built directly:

@Test
void formatProducesAPipeDelimitedLineInsteadOfJson() {
    Logger logger = (Logger) LoggerFactory.getLogger("com.ankurm.structuredlogging.SomeClass");
    LoggingEvent event = new LoggingEvent();
    event.setLoggerName(logger.getName());
    event.setLevel(Level.WARN);
    event.setMessage("disk usage above threshold");

    String formatted = new PipeDelimitedLogFormatter().format(event);

    assertThat(formatted).isEqualTo("WARN|com.ankurm.structuredlogging.SomeClass|disk usage above threshold\n");
}

Download: PipeDelimitedLogFormatterTest.java. The real output:

WARN|com.ankurm.structuredlogging.SomeClass|disk usage above threshold

Transcript: 31-pipe-delimited-format.txt. A custom formatter is tied to one logging system — the type parameter is ILoggingEvent for Logback or LogEvent for Log4j2 — and constructor parameters on it are injected automatically, so a formatter that needs, say, an environment property can just declare a constructor argument for it.

Going deeper: why console capture, not a ListAppender

A more conventional way to test logging output is a Logback ListAppender attached programmatically to a logger, which captures ILoggingEvent objects directly. That approach works well for testing that something was logged, but it sidesteps the exact thing this post is actually about: what logging.structured.format.console=ecs does to the bytes that hit the console. The ILoggingEvent a ListAppender sees is pre-formatting — it would never catch a mistake in the format property itself, a formatter that throws, or the corpname property’s JSON-reshaping behavior, because none of those run until the real console encoder does. Capturing System.out around one call is cruder but tests the feature as configured, not as assumed.

log.atInfo().addKeyValue(…).log(…) + MDC.put(…) one ILoggingEvent: message, level, MDC map, key-value pairs exactly what a ListAppender would see – before any formatting logging.structured.format.console=ecs built-in formatter one JSON object per line @timestamp, log.level, service.name, ecs.version… logging.structured.format.console=PipeDelimitedLogFormatter custom StructuredLogFormatter<ILoggingEvent> LEVEL|logger|message not JSON – format() just returns a String

What this post didn’t cover

  • No log4j2 example. Everything here uses Logback, the default Boot pulls in; a Log4j2 custom formatter is the same interface with LogEvent in place of ILoggingEvent, not demonstrated here.
  • No stack-trace customization. logging.structured.json.stacktrace.* (max length, root-first-or-last, hashes) is real and documented but this demo never throws, so there was nothing to verify against.
  • No log aggregator on the other end. This post stops at “a correct JSON line hits stdout” — shipping it to Elasticsearch, Graylog, or a Logstash pipeline is a separate, deployment-specific concern.

When this is the right tool

NeedWhat to use
JSON logs matching Elastic Common Schema, no new dependencylogging.structured.format.console=ecs
Per-call fields on a structured log lineSLF4J fluent API’s addKeyValue()
A field that should ride along with every line for a request’s durationMDC.put/MDC.remove (in a try/finally)
A fixed field on every line, set once, not at any call sitelogging.structured.json.add.<name>
An output shape none of the built-in formats produceA custom StructuredLogFormatter<E>

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.