Skip to main content

Logging: SLF4J, Logback, and Log4j2

Logging is how your application communicates what it is doing at runtime. A good logging setup helps you debug issues, monitor production health, and audit system behaviour.

The modern Java logging stack has two layers:

  1. Facade -- SLF4J (Simple Logging Facade for Java) -- your code depends on this
  2. Implementation -- Logback or Log4j2 -- the actual engine that writes logs

SLF4J basics

Getting a logger

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

public class UserService {
// Logger named after the class (convention)
private static final Logger log = LoggerFactory.getLogger(UserService.class);

public void createUser(String name) {
log.info("Creating user: {}", name);
try {
// ...
log.debug("User {} created successfully", name);
} catch (Exception e) {
log.error("Failed to create user {}", name, e);
}
}
}

Many projects use Lombok's @Slf4j annotation to generate the logger field automatically.

Log levels

LevelUse caseExample
TRACEVery detailed diagnostic infoMethod entry/exit, loop iterations
DEBUGDiagnostic info useful during developmentVariable values, query parameters
INFONormal operational eventsService started, user created, request handled
WARNUnexpected but recoverable conditionsRetry attempt, deprecated API usage, slow query
ERRORFailures that need attentionUnhandled exception, external service down
log.trace("Entering method with args: {}, {}", a, b);
log.debug("Query returned {} results", results.size());
log.info("User {} logged in from {}", userId, ipAddress);
log.warn("API rate limit approaching: {}/{}", current, max);
log.error("Failed to connect to database", exception);

Parameterised messages (important!)

// GOOD: parameterised -- string concatenation only happens if the level is enabled
log.debug("Processing order {} for user {}", orderId, userId);

// BAD: string concatenation happens regardless of log level
log.debug("Processing order " + orderId + " for user " + userId);

// GOOD: expensive computation guarded
if (log.isDebugEnabled()) {
log.debug("Current state: {}", computeExpensiveState());
}

// Java 8+: lazy evaluation with suppliers (SLF4J 2.0+)
log.atDebug().addArgument(() -> computeExpensiveState())
.log("Current state: {}");

Logback configuration

Logback is the default SLF4J implementation (written by the same author).

Maven dependencies

<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.5.6</version>
</dependency>
<!-- This transitively includes slf4j-api and logback-core -->

logback.xml (basic)

Place in src/main/resources/logback.xml:

<configuration>

<!-- Console appender -->
<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>

<!-- File appender with rolling -->
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>logs/app.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>logs/app.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
<maxFileSize>50MB</maxFileSize>
<maxHistory>30</maxHistory>
<totalSizeCap>1GB</totalSizeCap>
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>

<!-- Package-level log levels -->
<logger name="com.example.myapp" level="DEBUG" />
<logger name="org.hibernate" level="WARN" />
<logger name="org.apache" level="INFO" />

<!-- Root logger -->
<root level="INFO">
<appender-ref ref="CONSOLE" />
<appender-ref ref="FILE" />
</root>

</configuration>

Pattern format reference

TokenOutputExample
%d{pattern}Date/time2024-01-15 14:30:45.123
%threadThread namemain, http-nio-8080-exec-1
%-5levelLog level (left-padded)INFO , DEBUG
%logger{n}Logger name (abbreviated to n chars)c.e.m.UserService
%msgLog messageCreating user: Alice
%nNewline
%X{key}MDC valuesee MDC section below
%exException stack trace(auto-appended for error logs)

Environment-specific config

Use logback-spring.xml (Spring Boot) or system properties:

<!-- logback.xml with conditional -->
<configuration>
<if condition='property("ENV").equals("prod")'>
<then>
<root level="WARN">
<appender-ref ref="FILE" />
</root>
</then>
<else>
<root level="DEBUG">
<appender-ref ref="CONSOLE" />
</root>
</else>
</if>
</configuration>

Log4j2 configuration

Maven dependencies

<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-slf4j2-impl</artifactId>
<version>2.23.0</version>
</dependency>
<!-- Bridges SLF4J to Log4j2 -->

log4j2.xml (basic)

Place in src/main/resources/log4j2.xml:

<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="WARN">
<Appenders>
<Console name="Console" target="SYSTEM_OUT">
<PatternLayout pattern="%d{yyyy-MM-dd HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n"/>
</Console>

<RollingFile name="File" fileName="logs/app.log"
filePattern="logs/app-%d{yyyy-MM-dd}-%i.log.gz">
<PatternLayout pattern="%d{yyyy-MM-dd HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n"/>
<Policies>
<SizeBasedTriggeringPolicy size="50MB"/>
<TimeBasedTriggeringPolicy/>
</Policies>
<DefaultRolloverStrategy max="30"/>
</RollingFile>
</Appenders>

<Loggers>
<Logger name="com.example" level="debug"/>
<Root level="info">
<AppenderRef ref="Console"/>
<AppenderRef ref="File"/>
</Root>
</Loggers>
</Configuration>

Logback vs Log4j2

FeatureLogbackLog4j2
PerformanceGoodBetter (async logging, lock-free)
Config formatXML, GroovyXML, JSON, YAML, properties
Async loggingVia AsyncAppenderBuilt-in AsyncLogger (LMAX Disruptor)
Conditional configWith Janino libraryBuilt-in <Filters>
Garbage-freeNoYes (reduces GC pressure)
PopularitySpring Boot defaultCommon in high-performance apps
SLF4J integrationNative (same author)Via bridge (log4j-slf4j2-impl)

For most projects, Logback is fine. Choose Log4j2 if you need maximum throughput or garbage-free logging.


MDC (Mapped Diagnostic Context)

MDC lets you attach contextual data to every log statement within a thread (e.g., request ID, user ID, session ID):

import org.slf4j.MDC;

// Set in a filter or interceptor
public void handleRequest(HttpServletRequest req) {
MDC.put("requestId", UUID.randomUUID().toString());
MDC.put("userId", req.getHeader("X-User-Id"));

try {
// All log statements in this thread now include requestId and userId
log.info("Handling request");
service.process();
} finally {
MDC.clear(); // always clear in finally!
}
}

Include MDC in log pattern

<!-- Logback pattern -->
<pattern>%d [%thread] [%X{requestId}] [%X{userId}] %-5level %logger{36} - %msg%n</pattern>

Output:

2024-01-15 14:30:45.123 [http-8080-1] [abc-123-def] [user42] INFO  c.e.UserService - Processing order

MDC with virtual threads / async

MDC is thread-local, so it does not propagate to child threads automatically. Solutions:

// Manual propagation
Map<String, String> context = MDC.getCopyOfContextMap();

executor.submit(() -> {
MDC.setContextMap(context);
try {
// MDC available here
log.info("Async task running");
} finally {
MDC.clear();
}
});

Structured logging (JSON)

For production systems, JSON logs are easier to parse, search, and aggregate (ELK, Splunk, Datadog):

Logback JSON (logstash-logback-encoder)

<dependency>
<groupId>net.logstash.logback</groupId>
<artifactId>logstash-logback-encoder</artifactId>
<version>8.0</version>
</dependency>
<appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<includeMdc>true</includeMdc>
</encoder>
</appender>

Output:

{
"@timestamp": "2024-01-15T14:30:45.123Z",
"level": "INFO",
"logger_name": "com.example.UserService",
"thread_name": "http-8080-1",
"message": "Creating user: Alice",
"requestId": "abc-123-def",
"userId": "user42"
}

Log4j2 JSON

<Console name="Console">
<JsonLayout compact="true" eventEol="true" stacktraceAsString="true">
<KeyValuePair key="service" value="my-app"/>
</JsonLayout>
</Console>

Bridging legacy logging frameworks

Many libraries use different logging APIs. Bridge them all to SLF4J:

<!-- Bridge JCL to SLF4J -->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>jcl-over-slf4j</artifactId>
<version>2.0.13</version>
</dependency>

<!-- Bridge JUL to SLF4J -->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>jul-to-slf4j</artifactId>
<version>2.0.13</version>
</dependency>

<!-- Exclude the original implementations -->
<!-- (in the dependency that pulls them in) -->

Performance tips

TipExplanation
Use parameterised messageslog.debug("User {}", user) avoids string concatenation when DEBUG is off
Guard expensive computationsif (log.isDebugEnabled()) { log.debug("State: {}", computeState()); }
Use async appenders for I/OWriting to files blocks the calling thread; async decouples this
Avoid logging in tight loopsMillions of log calls per second overwhelm any appender
Set appropriate levels per packagecom.example=DEBUG, org.hibernate=WARN
Use MDC instead of string formattingStructured context is searchable; string concatenation is not
Rotate and compress log filesPrevent disk exhaustion

Common pitfalls

PitfallProblemFix
String concatenation in log messageslog.debug("x=" + x) always evaluates, even if DEBUG is offUse log.debug("x={}", x)
Logging sensitive dataPasswords, tokens, PII in logsMask or exclude sensitive fields; review log output
Multiple SLF4J bindings on classpathSLF4J: Class path contains multiple SLF4J bindingsExclude duplicate bindings (check mvn dependency:tree)
Not clearing MDCMDC leaks between requests in thread poolsAlways MDC.clear() in a finally block
Logging and rethrowingSame error appears multiple times in logsEither log OR rethrow -- not both
e.printStackTrace()Prints to stderr, not the logging frameworkUse log.error("message", e)
Catching Exception just to log itSwallows the exception silentlyLog and rethrow, or handle appropriately
Missing logback.xml / log4j2.xmlSLF4J falls back to NOP logger (no output)Include the config file in src/main/resources/

See also