Set up structured JSON logging in a Java application with SLF4J and Logback. Configure MDC for request context, ship logs to a centralized platform, and avoid common Java logging mistakes.
Java's logging ecosystem is famously fragmented — java.util.logging, Log4j, Log4j2, JCL, SLF4J, Logback, each with different APIs and configuration formats. The modern standard is SLF4J as the facade with Logback as the implementation. Spring Boot uses this combination by default.
This tutorial covers structured JSON logging with SLF4J + Logback, request correlation via MDC, and shipping logs to a centralized platform.
For a Spring Boot project, SLF4J and Logback are included. Add the JSON encoder:
Maven:
<dependency>
<groupId>net.logstash.logback</groupId>
<artifactId>logstash-logback-encoder</artifactId>
<version>7.4</version>
</dependency>
Gradle:
implementation 'net.logstash.logback:logstash-logback-encoder:7.4'
For non-Spring projects, also add:
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.5.6</version>
</dependency>
Create or update src/main/resources/logback-spring.xml:
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<!-- Development: pretty console output -->
<springProfile name="dev,local">
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} %highlight(%-5level) [%thread] %cyan(%logger{36}) - %msg%n</pattern>
</encoder>
</appender>
<root level="DEBUG">
<appender-ref ref="CONSOLE" />
</root>
</springProfile>
<!-- Production: structured JSON -->
<springProfile name="production,staging">
<appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<customFields>{"service":"my-api"}</customFields>
<includeMdcKeyName>traceId</includeMdcKeyName>
<includeMdcKeyName>userId</includeMdcKeyName>
<includeMdcKeyName>requestPath</includeMdcKeyName>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="JSON" />
</root>
</springProfile>
</configuration>
Production JSON output:
{
"@timestamp": "2026-08-15T09:23:15.442Z",
"level": "INFO",
"logger_name": "com.app.OrderService",
"message": "Order created",
"service": "my-api",
"traceId": "abc-123",
"userId": "4821",
"orderId": "ORD-9912",
"total": 149.99
}
MDC (Mapped Diagnostic Context) is SLF4J's mechanism for attaching contextual data to every log in a thread. Set it once at the start of a request, and every log from that thread includes it automatically.
import jakarta.servlet.*;
import jakarta.servlet.http.*;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.util.UUID;
@Component
public class RequestContextFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpReq = (HttpServletRequest) request;
try {
String traceId = httpReq.getHeader("X-Trace-ID");
if (traceId == null || traceId.isBlank()) {
traceId = UUID.randomUUID().toString().substring(0, 8);
}
MDC.put("traceId", traceId);
MDC.put("requestPath", httpReq.getMethod() + " " + httpReq.getRequestURI());
// Set trace ID in response header for client correlation
((HttpServletResponse) response).setHeader("X-Trace-ID", traceId);
chain.doFilter(request, response);
} finally {
MDC.clear(); // Always clean up — threads are reused
}
}
}
Now every log in the request lifecycle automatically includes traceId and requestPath — no need to pass them manually.
Use SLF4J's fluent API (Logback 1.3+) or structured arguments:
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import static net.logstash.logback.argument.StructuredArguments.*;
@Service
public class OrderService {
private static final Logger log = LoggerFactory.getLogger(OrderService.class);
public Order createOrder(CreateOrderRequest request) {
MDC.put("userId", request.getUserId());
log.info("Order creation started",
kv("itemCount", request.getItems().size()),
kv("total", request.getTotal()));
try {
Order order = orderRepository.save(mapToEntity(request));
log.info("Order saved to database",
kv("orderId", order.getId()),
kv("total", order.getTotal()));
paymentService.charge(order);
log.info("Order completed successfully",
kv("orderId", order.getId()));
return order;
} catch (PaymentException e) {
log.error("Payment failed",
kv("orderId", request.getItems().get(0).getProductId()),
kv("provider", "stripe"),
kv("errorCode", e.getCode()),
e); // Pass exception for stack trace
throw e;
} finally {
MDC.remove("userId");
}
}
}
The kv() helper from logstash-logback-encoder adds key-value pairs as structured JSON fields, not embedded in the message string.
Add a custom appender that batches and ships logs via HTTP:
<appender name="LOGFLOW" class="ch.qos.logback.core.rolling.RollingFileAppender">
<!-- Write to a buffer file, then ship with a sidecar -->
<file>/var/log/app/app.json</file>
<encoder class="net.logstash.logback.encoder.LogstashEncoder" />
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>/var/log/app/app.%d{yyyy-MM-dd}.%i.json</fileNamePattern>
<maxFileSize>50MB</maxFileSize>
<maxHistory>3</maxHistory>
</rollingPolicy>
</appender>
Then use Vector, Fluent Bit, or Filebeat to tail the JSON file and forward to LogFlow's HTTP API.
import java.net.URI;
import java.net.http.*;
import java.util.concurrent.*;
@Component
public class LogFlowShipper {
private final HttpClient client = HttpClient.newHttpClient();
private final BlockingQueue<String> buffer = new LinkedBlockingQueue<>(10000);
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
@Value("${logflow.api-key}")
private String apiKey;
@PostConstruct
public void start() {
scheduler.scheduleAtFixedRate(this::flush, 1, 1, TimeUnit.SECONDS);
}
public void send(Map<String, Object> logEntry) {
try {
buffer.offer(new ObjectMapper().writeValueAsString(logEntry));
} catch (Exception ignored) {}
}
private void flush() {
List<String> batch = new ArrayList<>();
buffer.drainTo(batch, 100);
if (batch.isEmpty()) return;
String payload = "[" + String.join(",", batch) + "]";
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.getlogflow.com/v1/logs"))
.header("Authorization", "Bearer " + apiKey)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(payload))
.build();
client.sendAsync(request, HttpResponse.BodyHandlers.discarding());
}
}
// Bad — string is constructed even if DEBUG is disabled
log.debug("Processing order " + orderId + " with " + items.size() + " items");
// Good — parameterized, only evaluated when level is enabled
log.debug("Processing order {} with {} items", orderId, items.size());
MDC uses ThreadLocal. In servlet containers and thread pools, threads are reused. If MDC isn't cleared in a finally block, the next request on the same thread inherits stale context — leading to logs with wrong trace IDs.
// Bad — the same error logged at every layer of the call stack
catch (Exception e) {
log.error("Error in service", e);
throw new ServiceException("Failed", e);
}
// The caller also logs it, and the controller, and the error handler...
// Good — log at the boundary, re-throw without logging
catch (Exception e) {
throw new ServiceException("Payment processing failed", e);
}
// Let the global error handler log it once with full context
System.out.println bypasses the logging framework entirely. It has no levels, no structure, no MDC context, and no way to configure output format. Replace every System.out.println with the appropriate log level.
Free plan available. No credit card required. Up and running in 2 minutes.
Get started freeGo Logging with Zerolog: Zero-Allocation Structured Logs
Set up zerolog in Go for fast structured JSON logging with request context and centralized shipping.
How to Ship Vercel and Next.js Logs to a Log Management Platform
Vercel logs disappear after 1 hour. Set up persistent logging for Next.js with a log drain or SDK.
How to Monitor Cron Jobs and Catch Silent Failures
Cron jobs fail silently by default. Set up logging and silence detection to catch failures before users do.