skip to content

How do you implement and register a custom structured logging format in Spring Boot 3.4 when none of the built-in ones fit?

level: seniorimportance: should knowfreq 30%

answer

  1. StructuredLogFormatter<E>, E = ILoggingEvent / LogEvent
  2. register by FQN in logging.structured.format.*, not a bean
  3. constructor can take Environment (early init, no app beans)
  4. use JsonWriter, return one line (Boot adds newline)
  5. small tweaks -> json.add/rename/exclude or JsonMembersCustomizer

basics

~10 s

Implement org.springframework.boot.logging.structured.StructuredLogFormatter<E> (E is the log-framework event type, e.g. Logback's ILoggingEvent), return a JSON string from format(event), then set logging.structured.format.console (or .file) to that class's fully-qualified name.

solid answer

~40 s

Boot 3.4 lets you plug in a custom format by implementing `StructuredLogFormatter<E>`, where `E` is the underlying logging framework's event type — `ch.qos.logback.classic.spi.ILoggingEvent` for Logback or `org.apache.logging.log4j.core.LogEvent` for Log4j2. You override `String format(E event)` to produce one line (newline handling is done by Boot's encoder). To build JSON cleanly, use Boot's own `JsonWriter` helper rather than string concatenation. You register the formatter not as a bean but by putting its **fully-qualified class name** in `logging.structured.format.console` or `.file`. The class must have a supported constructor: it may be no-arg, or accept specific injectable parameters such as `Environment`, ` StructuredLoggingJsonMembersCustomizer`, and other Boot-provided contributors, letting you read config like a service name. This runs inside the logging subsystem, which initializes very early — so you can't autowire arbitrary application beans.

code

java · 31 lines
java
package com.example.logging;

import ch.qos.logback.classic.spi.ILoggingEvent;
import org.springframework.boot.json.JsonWriter;
import org.springframework.boot.logging.structured.StructuredLogFormatter;
import org.springframework.core.env.Environment;

// Registered via: logging.structured.format.console=com.example.logging.MyStructuredLogFormatter
public class MyStructuredLogFormatter implements StructuredLogFormatter<ILoggingEvent> {

    private final String serviceName;
    private final JsonWriter<ILoggingEvent> writer;

    // Boot injects Environment during early logging init (NOT via the bean factory).
    public MyStructuredLogFormatter(Environment environment) {
        this.serviceName = environment.getProperty("spring.application.name", "unknown");
        this.writer = JsonWriter.<ILoggingEvent>of(members -> {
            members.add("ts", ILoggingEvent::getInstant);
            members.add("level", e -> e.getLevel().toString());
            members.add("logger", ILoggingEvent::getLoggerName);
            members.add("msg", ILoggingEvent::getFormattedMessage);
            members.add("service", e -> this.serviceName);
        });
    }

    @Override
    public String format(ILoggingEvent event) {
        // Return a single line; Boot's encoder appends the newline.
        return this.writer.writeToString(event);
    }
}

go deeper

for a junior

Know that a custom format is possible via an interface and a class-name property.

for a middle

Name StructuredLogFormatter, the FQN registration, and that it's not a bean.

for a senior

Explain the early-init constraint, supported constructor injection, JsonWriter, and the customize-vs-replace decision.

for a principal

Reason about hot-path cost, thread-safety, framework coupling via E, and standardizing a formatter across services.

When `ecs`/`logstash`/`gelf` don't match your sink's schema, Spring Boot 3.4 lets you supply a **custom structured format**. **The interface.** Implement `org.springframework.boot.logging.structured.StructuredLogFormatter<E>`: ```java public interface StructuredLogFormatter<E> { String format(E event); default byte[] formatAsBytes(E event, Charset charset) { ... } } ``` The generic `E` is the **native event type of the logging framework in use**, not an abstraction: - Logback (the Boot default): `ch.qos.logback.classic.spi.ILoggingEvent`. - Log4j2: `org.apache.logging.log4j.core.LogEvent`. Your `format` returns the serialized record as a single line; Boot's `StructuredLogEncoder` appends the newline and handles bytes, so you return newline-delimited JSON without adding your own `\n`. **Building the JSON.** Prefer Spring Boot's `org.springframework.boot.json.JsonWriter<T>` — a small, allocation-light JSON builder designed for exactly this — over Jackson (heavy to init this early) or manual string building (escaping bugs). `JsonWriter.of(members -> ...)` lets you declare members once and write per event. **Registration — not a bean.** You do **not** register the formatter as a Spring bean, because logging initializes long before the ApplicationContext. Instead put its fully-qualified class name where a format value goes: ```properties logging.structured.format.console=com.example.logging.MyStructuredLogFormatter ``` Boot instantiates it reflectively. **Supported constructor injection.** The class needs a constructor Boot can call. Besides a no-arg constructor, Boot can inject a fixed set of parameters, including: - `org.springframework.core.env.Environment` — to read properties (service name, region, etc.). - `StructuredLoggingJsonMembersCustomizer<?>` — to apply JSON member customizations consistently. - Other Boot logging contributors (e.g. objects that provide the throwable-proxy converter). Only these supported types work; you cannot inject arbitrary application services. **Why beans don't work / early-init constraint.** The logging system is bootstrapped by `LoggingApplicationListener` very early in startup, before most of the context. That's the deliberate reason the API uses reflective instantiation with a whitelist of injectable collaborators rather than the bean factory. Consequences: read configuration through the injected `Environment`, keep the formatter dependency-free and fast, and make it thread-safe (a single instance formats events from many threads). **Lighter alternative — customize instead of replace.** If you only need to add/rename/remove a few fields of a built-in format, you usually don't need a full custom formatter: use the `logging.structured.json.add`, `.exclude`, `.rename`, `.include` properties, or implement a `StructuredLoggingJsonMembersCustomizer` registered via `META-INF/spring.factories`. Write a full `StructuredLogFormatter` only when the whole serialization shape differs. **Gotchas:** - Return one line, no trailing newline — Boot adds it; adding your own yields blank lines. - Don't autowire app beans; use the injected `Environment`. - Must be thread-safe and cheap — it's on the hot path of every log statement. - The `E` type binds you to a logging framework; a formatter written for `ILoggingEvent` won't work if you switch to Log4j2.

  • Why can't you just annotate the formatter with @Component and autowire your services into it?
    The logging system is initialized by `LoggingApplicationListener` before the ApplicationContext is ready, so the bean factory doesn't exist yet. Boot instantiates the formatter reflectively and only injects a supported set of collaborators like `Environment`.
  • You only need to add a `traceId` field to the ECS format. Do you write a full StructuredLogFormatter?
    No — use `logging.structured.json.add` (or a `StructuredLoggingJsonMembersCustomizer`) to add/rename/exclude fields on a built-in format. A full formatter is only for a completely different serialization shape.

saying these in an interview costs you the question

  • Registering the formatter as a @Bean/@Component and expecting Boot to pick it up
  • Autowiring application services into the formatter
  • Adding a trailing newline inside format() (produces blank lines)
  • Assuming StructuredLogFormatter is framework-agnostic — E is Logback's or Log4j2's event type
  • Ignoring thread-safety on the logging hot path

context