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.
logging.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=ecsThe 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
MDCappear 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 afinallyblock, 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
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) {
}
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.
# 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.
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.
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