Skip to content
ExponaBLOG
JAVA, SPRING FRAMEWORK & SPRING BOOT · DAY 033 / 100

Logging in Spring Boot with SLF4J in Java & Spring Boot

Good logs answer questions during incidents.

Expona Admin6 min read

Introduction

We've all been paged at 3 AM to investigate a production issue, only to find logs that tell us nothing about what actually went wrong. Good logs answer questions during incidents: Who made the request? What customer was affected? Where did the flow fail? Logging in Spring Boot with SLF4J in Java & Spring Boot is one of the highest-leverage skills you can develop—it transforms logs from noise into a diagnostic tool that saves your team hours of debugging.

In this article, we'll walk through production-grade logging patterns we've learned the hard way in e-commerce, SaaS, and trading systems. We'll cover parameterized messages, structured JSON output, request tracing with MDC, and what you absolutely must never log.

Use Parameterized Messages, Not String Concatenation

When you're new to logging, the instinct is to build the log message inline using string concatenation or string formatting. This approach has two problems: it's wasteful, and it makes your logs harder to parse and alert on.

String concatenation builds the message every time, even if the log level is disabled. If you have thousands of requests per second and DEBUG logging is off, you're still burning CPU concatenating strings that nobody will read.

java
// Bad: String concatenation, wasteful
logger.debug("User " + userId + " purchased item " + itemId + " for " + price);

// Better: String format, still evaluates args
logger.debug(String.format("User %d purchased item %d for %.2f", userId, itemId, price));

// Best: Parameterized messages, lazy evaluation
logger.debug("User {} purchased item {} for {}", userId, itemId, price);

Parameterized messages with SLF4J are evaluated only if the log level is enabled. The logger itself does the string substitution, not your code. This is cheap and it keeps your logging calls readable.

When you have many fields to log—like in microservices where context matters—parameterized messages keep the call clean:

java
logger.info("Order processed: orderId={}, customerId={}, amount={}, currency={}, status={}",
    orderId, customerId, amount, currency, status);

Use positional placeholders {} for every value you want to include. SLF4J will match them in order and substitute them only when needed.

Log Structured JSON in Production

Human-readable logs are great during local development. In production, you need structured logs that your observability stack can parse, index, and alert on. JSON is the standard.

We use Logback with Logstash encoder to output structured JSON. When your logs are JSON, tools like Datadog, ELK, Splunk, or even CloudWatch can automatically extract fields and let you filter, graph, and alert on them.

First, add the dependency to your pom.xml:

java
<dependency>
    <groupId>net.logstash.logback</groupId>
    <artifactId>logstash-logback-encoder</artifactId>
    <version>7.4</version>
</dependency>

Then configure Logback in application.yml or create a logback-spring.xml file:

java
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <springProperty name="app_name" source="spring.application.name"/>
    <springProperty name="app_env" source="app.env"/>

    <appender name="STDOUT_JSON" class="ch.qos.logback.core.ConsoleAppender">
        <encoder class="net.logstash.logback.encoder.LogstashEncoder">
            <customFields>{"app":"${app_name}","env":"${app_env}"}</customFields>
        </encoder>
    </appender>

    <root level="INFO">
        <appender-ref ref="STDOUT_JSON"/>
    </root>
</configuration>

Now every log line is valid JSON with timestamp, level, message, and custom fields:

java
{"@timestamp":"2025-01-15T10:23:45.123Z","level":"INFO","logger":"com.myapp.OrderService","message":"Order created","app":"order-api","env":"prod","orderId":"ORD-12345"}

Your observability tool can now extract orderId and create dashboards or alerts. This is non-negotiable in production systems.

Add a Trace ID with MDC for Every Request

In microservices, a single user action triggers calls across multiple services. Without a trace ID, logs from different services are disconnected noise. You need a thread-local way to attach context to every log line.

MDC (Mapped Diagnostic Context) is SLF4J's feature for this. We generate a unique trace ID when a request arrives, store it in MDC, and it automatically appears in every log from that thread.

Create a Spring Filter to inject the trace ID:

java
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.UUID;

@Component
public class TraceIdFilter extends OncePerRequestFilter {

    private static final String TRACE_ID_HEADER = "X-Trace-Id";
    private static final String TRACE_ID_MDC_KEY = "traceId";

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        
        String traceId = request.getHeader(TRACE_ID_HEADER);
        if (traceId == null || traceId.isEmpty()) {
            traceId = UUID.randomUUID().toString();
        }
        
        MDC.put(TRACE_ID_MDC_KEY, traceId);
        response.addHeader(TRACE_ID_HEADER, traceId);
        
        try {
            filterChain.doFilter(request, response);
        } finally {
            MDC.remove(TRACE_ID_MDC_KEY);
        }
    }
}

Configure Logback to include the trace ID in every log:

java
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
    <customFields>{"app":"${app_name}","traceId":"${traceId}"}</customFields>
</encoder>

Now when you query logs for a specific order, you can filter by trace ID and see the entire flow across all services:

java
logger.info("Payment initiated for order {}", orderId);
// Output: {"traceId":"550e8400-e29b-41d4-a716-446655440000","orderId":"ORD-12345","message":"Payment initiated for order ORD-12345"}

If that same trace ID appears in your payment service, inventory service, and email service logs, you can reconstruct the entire request flow in seconds. This is critical for diagnosing distributed issues.

Never Log Passwords, Tokens, or Full Card Numbers

This one sounds obvious, but we've seen it happen. A developer logs the entire request or response to debug an issue, forgets to remove it, and suddenly your logs contain customer payment data. This is both a security violation and a compliance nightmare.

Create a data masking utility:

java
public class SensitiveDataMasker {
    
    public static String maskPassword(String password) {
        return password != null && password.length() > 0 ? "***" : "";
    }
    
    public static String maskCardNumber(String cardNumber) {
        if (cardNumber == null || cardNumber.length() < 4) return "****";
        return "****" + cardNumber.substring(cardNumber.length() - 4);
    }
    
    public static String maskToken(String token) {
        if (token == null || token.length() < 10) return "***";
        return token.substring(0, 10) + "...";
    }
}

Use it in your business logic before logging:

java
LoginRequest request = parseRequest();
logger.info("Login attempt: username={}, passwordLength={}", 
    request.getUsername(), 
    request.getPassword().length());

// When logging user data:
logger.info("User payment method: last4={}", 
    SensitiveDataMasker.maskCardNumber(cardNumber));

// When passing tokens to other services, log only the prefix:
logger.debug("Calling downstream service with token: {}", 
    SensitiveDataMasker.maskToken(apiToken));

Better yet, avoid logging sensitive data altogether. Log what you need for debugging (username, amount, timestamp) but never the secrets themselves.

Common Mistakes

  • Logging at INFO level for every method call: This creates noise and makes it hard to find real issues. Reserve INFO for significant events (logins, transactions, errors). Use DEBUG for detailed flow tracking.
  • Logging the same information twice: Don't log inside your service method and then again in the controller. Pick one layer and log there. Usually the service layer is the right place.
  • Using Logback configuration in code instead of XML: Configuration belongs in XML or YAML, not in Java. This makes it easy to change log levels in production without redeploying.
  • Forgetting to handle MDC cleanup in async code: If you use @Async or thread pools, MDC won't automatically propagate. You'll need to manually copy and restore it in async tasks.
  • Not testing your log output: Write unit tests that assert on log content. This catches accidental PII leaks and ensures your structured fields are present.

Key Takeaways

  • Always use parameterized messages with logger.info("message: {}", value), never string concatenation or formatting.
  • Output structured JSON in production so your observability tools can parse, index, and alert on logs.
  • Generate and propagate a trace ID through MDC so you can follow a request across all your microservices.
  • Never log passwords, tokens, API keys, or full payment card numbers. Use masking utilities and log only what you need for debugging.
  • Set log levels appropriately: INFO for business events, DEBUG for flow details, WARN for recoverable issues, ERROR for failures that need attention.
  • Configure logging in XML or YAML so you can change levels without redeploying.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles