Intermediate

Java Logging with SLF4J and Logback: From Setup to Production

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.

LogFlow TeamAugust 15, 202615 minJavaSLF4JLogbackSpring Boot

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.

Prerequisites

  • Java 17+ and Maven or Gradle
  • A LogFlow account (sign up free)

Step 1 — Dependencies

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>

Step 2 — Configure Logback for JSON output

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
}

Step 3 — Request correlation with MDC

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.

Spring Boot filter:

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.

Step 4 — Structured logging in services

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.

Step 5 — Ship logs to LogFlow

Option A — HTTP appender in Logback

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.

Option B — Direct HTTP shipping from the application

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());
    }
}

Common Java Logging Mistakes

String concatenation in log calls

// 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());

Forgetting to clear MDC

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.

Catching and re-logging

// 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

Using System.out.println

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.

Next steps

Start monitoring your logs today

Free plan available. No credit card required. Up and running in 2 minutes.

Get started free