Structured Logging

Write logs as JSON with Spring Boot's built-in ECS, Logstash or GELF formats so log platforms can search and filter by field.

Syntaxlogging.structured.format.console=ecs logging.structured.ecs.service.name=orders log.atInfo().addKeyValue("orderId", id).log("Order placed"); MDC.put("tenant", tenant); try { ... } finally { MDC.remove("tenant"); }

A plain text log line is easy for a person and hard for a machine. To find every failed payment for one merchant in a log platform, you want fields, not a regular expression over free text. Structured logging writes each event as one JSON object per line, with the timestamp, level, logger, message, trace ID and your own key-value pairs as separate fields.

Built in formats

Spring Boot supports structured logging without extra libraries. Pick a format for the console, the file, or both:

logging.structured.format.console=ecs

The built-in format IDs are ecs (Elastic Common Schema), logstash and gelf (Graylog). Choose the one your log platform understands natively; most accept ECS. You can also give the fully qualified name of your own StructuredLogFormatter class. In containers, write JSON to the console and let the platform collect standard output; do not manage log files inside the container.

Adding context

Two ways to attach fields to a log event:

  • Key-value pairs with the SLF4J fluent API: log.atInfo().addKeyValue("orderId", id).log("Order placed"). The pair belongs to that one event.
  • MDC (mapped diagnostic context): values put in MDC appear on every event logged by that thread until removed. Good for a tenant or request ID set once in a filter. Always remove them in a finally block, or the value leaks into the next request served by the same pooled thread.

Prefer key-value pairs over string concatenation. "Order " + id + " placed" gives you one searchable message per order; a fixed message with an orderId field gives you one message and a field you can filter and group by.

Shaping the output

The logging.structured.json.* properties include, exclude, rename and add members without code, and logging.structured.ecs.service.* fills in service name, version and environment for ECS. For anything else implement StructuredLoggingJsonMembersCustomizer and register it with logging.structured.json.customizer.

Mistakes that cost money or trust

Structured logs are easy to search, which makes leaked data easy to find too. Never log passwords, tokens, full card numbers or request bodies wholesale. Watch volume as well: logging every request at INFO in JSON can cost more in log storage than the servers running the service. Keep development readable by using the plain format in a dev profile and JSON everywhere else.

Example

Example · java
import java.math.BigDecimal;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;

record Payment(String orderId, String merchantId, BigDecimal amount) {
}

@Service
public class PaymentLogger {

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

    public void declined(Payment payment, String reason) {
        // One fixed message, with the variable parts as separate JSON fields.
        log.atWarn()
                .addKeyValue("orderId", payment.orderId())
                .addKeyValue("merchantId", payment.merchantId())
                .addKeyValue("amount", payment.amount())
                .addKeyValue("reason", reason)
                .log("Payment declined");
    }
}

When to use it

  • Search the log platform for every declined payment of one merchant in the last hour by filtering on merchantId.
  • Tag all log lines of a request with the tenant so one customer's problem can be isolated in a multi-tenant API.
  • Feed ECS-formatted logs straight into Elasticsearch without a parsing pipeline.

More examples

JSON in production, text in development

Each event is now a single JSON line with <code>service.name</code>, <code>service.version</code> and the ECS fields log platforms index automatically. Leaving the format empty in a development profile turns structured logging off there, so the console stays readable while you work.

Example · properties
# application.properties
spring.application.name=orders
logging.structured.format.console=ecs
logging.structured.ecs.service.name=${spring.application.name}
logging.structured.ecs.service.version=1.4.2
logging.structured.ecs.service.environment=production

# Rename a member and add a fixed one to every event.
logging.structured.json.rename.process.thread.name=thread
logging.structured.json.add.team=checkout

# application-dev.properties would hold only:
# logging.structured.format.console=

Request context in MDC

Every log line written while this request is handled now carries a <code>tenant</code> field, including lines from libraries you do not control. The header is validated first so a client cannot inject arbitrary text into your logs, and the <code>finally</code> block clears it before the thread serves another request.

Example · java
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

@Component
public class TenantLoggingFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException {
        String tenant = request.getHeader("X-Tenant");
        if (tenant != null && tenant.matches("[a-z0-9-]{1,40}")) {
            MDC.put("tenant", tenant);
        }
        try {
            chain.doFilter(request, response);
        } finally {
            MDC.remove("tenant");
        }
    }
}

Customising the JSON members in code

A customizer can change names, filter members or add new ones for every event. It is created while the logging system starts, before the application context exists, so it cannot use dependency injection. Reach for it only when the properties cannot express what you need.

Example · java
import org.springframework.boot.json.JsonWriter;
import org.springframework.boot.logging.structured.StructuredLoggingJsonMembersCustomizer;

// Registered with logging.structured.json.customizer=<package>.LowerCaseNamesCustomizer
public class LowerCaseNamesCustomizer implements StructuredLoggingJsonMembersCustomizer<Object> {

    @Override
    public void customize(JsonWriter.Members<Object> members) {
        members.applyingNameProcessor(JsonWriter.NameProcessor.of(String::toLowerCase));
    }
}

Discussion

  • Be the first to comment on this lesson.