⚡ Section 7 · Question #18

How to properly log exceptions

Proper exception logging in enterprise Java relies on three core tenets:


🟢 Junior Level

Short Interview Answer (30 seconds)

Proper exception logging in enterprise Java relies on three core tenets:

  1. Use a logging facade (SLF4J) paired with a production engine (Logback or Log4j2), never raw e.printStackTrace() or System.err.
  2. Pass the exception as the unparameterized final argument (log.error("Failed for orderId={}", orderId, e);). In SLF4J, you do not provide a {} placeholder for the Throwable; the logging framework automatically discovers it as the trailing argument and formats the complete stack trace and causal chain.
  3. Follow the “Log OR Throw, Never Both” invariant: either handle and log the exception at the current layer, or wrap and rethrow it to the caller. Logging and rethrowing at every layer floods logging aggregators (ELK, Loki) with redundant, identical stack traces for a single failure.

Canonical SLF4J Example in Java 21

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

public class OrderProcessingService {
    private static final Logger log = LoggerFactory.getLogger(OrderProcessingService.class);

    public void processPayment(String orderId, double amount) {
        try {
            executeGatewayCharge(orderId, amount);
        } catch (PaymentGatewayException e) {
            // ✅ Pass exception as the trailing argument WITHOUT an additional {} placeholder:
            log.error("Failed to charge credit card for orderId={}, amount={}", orderId, amount, e);
            // ✅ Either handle and log, OR wrap and rethrow (never both in real production pipelines):
            throw new OrderBusinessException("Payment processing failed for order: " + orderId, e);
        }
    }

    private void executeGatewayCharge(String id, double amount) throws PaymentGatewayException {
        // Interacting with external payment gateway
    }
}

Exception Severity Logging Matrix

Level When to Use for Exceptions Production Example
ERROR Unexpected system crashes, infrastructure outages, data corruption risks, or unrecoverable bugs that require on-call engineer intervention. DatabaseConnectionException, NullPointerException, Kafka broker partition offline
WARN Recoverable transient failures, retried operations, circuit breaker tripping, fallback invocations, or rate-limit saturation. Third-party REST API timeout during retry 1 of 3; degraded cache fallback
INFO / DEBUG Anticipated client errors or normal domain control flow that are not application bugs. Invalid password submission (401), missing resource (404), client request cancelled

🟡 Middle Level

The SLF4J Formatting Trap: Placeholders vs. Trailing Throwables

A classic technical interview question explores how SLF4J inspects its argument array:

// ❌ DISASTROUS BUG: Stack trace is completely LOST!
// SLF4J binds 'e' to the single '{}' placeholder, invoking e.toString() -> prints only class name + message!
log.error("Error occurred: {}", e);

// ❌ CODE SMELL: Manually converts to string; drops stack trace, line numbers, and root causes!
log.error("Error: " + e.getMessage());

// ❌ REDUNDANT: Causes double-printing of the message and wasted string allocation
log.error("Error: " + e.getMessage(), e);

// ✅ CORRECT: SLF4J prints the message AND extracts the complete stack trace from 'e'
log.error("Error occurred during transaction execution", e);

// ✅ CORRECT WITH CONTEXT: Each {} consumes a domain parameter; trailing 'e' prints the stack trace
log.error("Failed processing orderId={} for userId={}", orderId, userId, e);

The “Log and Throw” Anti-Pattern

When multiple architectural layers catch, log, and rethrow the same exception, log aggregators are overwhelmed with cascading duplicate stack traces for a single root incident:

┌────────────────────────────────────────────────────────────────────────────────────────┐
│                        THE "LOG AND THROW" ANTI-PATTERN CASCADE                        │
├────────────────────────────────────────────────────────────────────────────────────────┤
│ 1. DAO Layer:        catch (SQLException e)        -> log.error(...) -> throw ReposEx │
│                                    ↓                                                   │
│ 2. Service Layer:    catch (RepositoryException e) -> log.error(...) -> throw ServEx  │
│                                    ↓                                                   │
│ 3. Controller Layer: catch (ServiceException e)    -> log.error(...) -> return 500    │
│                                    ↓                                                   │
│ Result: 3 identical 80-line stack traces generated for ONE database timeout!           │
└────────────────────────────────────────────────────────────────────────────────────────┘

Production Architecture Fix

  • Lower Layers (DAO / Client / Service): Only wrap and rethrow with domain-specific context (throw new OrderProcessingException("Failed order " + id, e);). Do not log!
  • Topmost Layer (Spring @RestControllerAdvice or Job Runner): Catch the exception once, log it with full MDC context, and map it to an RFC 9457 ProblemDetail or client error response.

MDC (Mapped Diagnostic Context) & Distributed Tracing

In modern distributed microservices, a stack trace is ineffective without correlation identifiers (traceId, spanId, tenantId, userId):

import org.slf4j.MDC;

public class TracingFilter {
    public void doFilter(HttpRequest request, HttpResponse response, FilterChain chain) {
        String traceId = request.getHeader("X-Trace-Id");
        if (traceId == null) {
            traceId = UUID.randomUUID().toString();
        }
        
        MDC.put("traceId", traceId);
        MDC.put("userId", request.getHeader("X-User-Id"));
        try {
            chain.doFilter(request, response);
        } finally {
            // ⚠️ CRITICAL: Web server threads (Tomcat/Jetty) are recycled from a pool!
            // Failing to clear MDC leaks user data and trace IDs to subsequent requests!
            MDC.clear();
        }
    }
}

Structured JSON Logging (ECS / Logstash Layout)

To enable fast filtering, indexing, and visualization in Elasticsearch, Datadog, or Grafana Loki, logs should be emitted in structured JSON format rather than plain strings:

{
  "@timestamp": "2026-09-27T08:33:00.123Z",
  "log.level": "ERROR",
  "message": "Failed to charge credit card for orderId=ORD-99182, amount=149.99",
  "service.name": "billing-service",
  "trace.id": "4bf92f3577b34da6a3ce929d0e0e4736",
  "transaction.id": "00f067aa0ba902b7",
  "error.type": "com.example.PaymentGatewayException",
  "error.message": "Connection timed out connecting to stripe-api.internal:443",
  "error.stack_trace": "com.example.PaymentGatewayException: Connection timed out...\n\tat com.example.StripeClient.charge(StripeClient.java:84)"
}

Structured JSON ensures that log search engines can filter directly on error.type or trace.id without requiring fragile regular expression parsing.


🔴 Senior Level

High-Throughput Highload Defense: Log Flooding Mitigation

In highload environments (e.g., 50,000 RPS), an infrastructure collapse (such as a database outage) can trigger thousands of error logs per second, exhausting disk I/O, filling container storage, and crashing the host (Disk Full Outage).

Engineering Mitigations:

  1. Asynchronous Ring Buffers (Log4j2 Disruptor / Logback AsyncAppender): Worker threads hand log events off to a lock-free LMAX Disruptor ring buffer. Logging I/O executes asynchronously on a dedicated background thread:
    <appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
        <appender-ref ref="JSON_CONSOLE" />
        <queueSize>8192</queueSize>
        <!-- Drop TRACE, DEBUG, and INFO if buffer is more than 80% full -->
        <discardingThreshold>20</discardingThreshold>
        <neverBlock>true</neverBlock>
    </appender>
    
  2. Duplicate Message Filtering: Logback’s DuplicateMessageFilter suppresses identical stack traces occurring in rapid succession, replacing them with a counter summary (e.g., “Repeated 452 times in the last 5 seconds”).

Security: Log Scrubbing and PII Protection (GDPR / PCI-DSS)

Logging unmasked credit card numbers, passwords, authorization tokens, or medical records violates international data protection regulations (PCI-DSS requirement 3.4, GDPR article 32, HIPAA).

// ❌ CRITICAL SECURITY VULNERABILITY: Leaking raw authentication credentials
log.error("Authentication failed for token: {}", bearerToken, ex);

// ✅ SAFE: Log public identifiers or truncated hashes
log.error("Authentication failed for principalId: {}", principalId, ex);

Enterprise logging configurations utilize custom Masking Message Converters or OpenTelemetry log processors that apply regex replacement pipelines before flushing bytes to disk:

<!-- Example pattern scrubbing 16-digit credit cards -->
<pattern>%replace(%msg){'(?:\d{4}[ -]?){3}\d{4}', 'XXXX-XXXX-XXXX-XXXX'}%n</pattern>

Virtual Threads (Java 21) & Logging Context

The traditional MDC architecture depends on ThreadLocal. With Java 21 Virtual Threads (Project Loom), where an application may spawn 500,000 concurrent virtual threads:

  • Heavy ThreadLocal allocations increase heap overhead and garbage collection cycles.
  • Child virtual threads do not automatically inherit parent MDC without explicit context-propagation wrappers.
  • Java 21 Scoped Values (ScopedValue<T> - JEP 446) provide an immutable, lightweight alternative designed specifically for virtual thread pools, allowing zero-cost context inheritance without ThreadLocal memory leaks.

4 Tricky Questions

1. In SLF4J, what happens if you write log.error("Failed for id: {}, cause: {}", id, e) vs log.error("Failed for id: {}", id, e)?

Answer:

  • In log.error("Failed for id: {}, cause: {}", id, e): There are two {} placeholders and two arguments (id and e). SLF4J binds id to the first placeholder and e to the second placeholder via e.toString(). Because e was consumed by a placeholder, SLF4J does NOT treat it as a trailing throwable, and the stack trace is NOT printed!
  • In log.error("Failed for id: {}", id, e): There is one placeholder and two arguments. id consumes the {}. The remaining argument e is an unconsumed Throwable at the end of the argument list. SLF4J extracts e and prints the full stack trace.

2. Why does MDC.clear() in a finally block fail to clean up context when delegating work to CompletableFuture.supplyAsync()?

Answer: CompletableFuture.supplyAsync() schedules work on the ForkJoinPool.commonPool() (or another executor). Because MDC relies on ThreadLocal, the worker thread in the ForkJoinPool starts with an empty MDC unless explicitly copied.

Furthermore, if a developer manually copies MDC into the worker thread, MDC.clear() executed in the parent request thread’s finally block runs immediately after dispatching the asynchronous task, while the background thread continues executing. If the background thread fails to invoke MDC.clear() in its own finally block, the worker thread in ForkJoinPool is returned to the pool polluted with the previous request’s context.


3. What is the difference between %ex and %rEx in Logback layout patterns?

Answer:

  • %ex (or %exception) prints the standard Java stack trace format: the outermost enclosing exception is printed first, followed by nested causes via Caused by: ....
  • %rEx (Root Cause First Exception Converter) inverts the causal order: it prints the root cause first, followed by the intermediate wrapping exceptions. In deep enterprise applications where an exception is wrapped through 5 layers, %rEx displays the actual root cause at the very top of the log entry, saving engineers from scrolling past hundreds of lines of wrapper frames.

4. What happens if an exception’s getMessage() throws an unchecked exception during logging?

Answer: If an exception overrides getMessage() or toString() with faulty logic that throws a NullPointerException or RuntimeException, the call to log.error(...) will fail abruptly with that new runtime exception.

This causes the catch block itself to crash, aborting any recovery logic or subsequent error responses. For this reason, custom exception classes must never perform complex logic, network I/O, or unsanitized dereferencing inside getMessage() or toString().


🎯 Interview Cheat Sheet

Core Concepts Matrix

┌───────────────────────────┬──────────────────────────────────────┬──────────────────────────────────────────┐
│ Principle                 │ ❌ Incorrect Practice                │ ✅ Correct Enterprise Practice           │
├───────────────────────────┼──────────────────────────────────────┼──────────────────────────────────────────┤
│ Logger Invocation         │ log.error("Err: {}", e)              │ log.error("Err for id={}", id, e)        │
│ Architectural Flow        │ Log in catch AND rethrow             │ Wrap & rethrow OR log & handle (once)    │
│ Correlation Metadata      │ Plain text logs                      │ MDC with traceId, cleared in finally     │
│ Highload Protection       │ Synchronous console logging          │ AsyncAppender / Disruptor RingBuffer     │
│ Security / Privacy        │ Logging raw request bodies/tokens    │ Scrubbed JSON with PII masking filters   │
│ Formatting Strategy       │ String concatenation ("Err: " + e)   │ SLF4J parameterized templates ({})       │
└───────────────────────────┴──────────────────────────────────────┴──────────────────────────────────────────┘

Key Takeaways

  1. Never place a {} placeholder for the trailing Throwable in SLF4J.
  2. Abide by “Log OR Throw”: logging at every layer produces cascading duplicate log entries.
  3. Centralize exception logging at the API edge (e.g., Spring @RestControllerAdvice).
  4. Always pair MDC.put() with MDC.clear() in a finally block to prevent thread pool context pollution.
  5. In highload systems, use asynchronous appenders and JSON formatting to maintain system throughput and observability.

Red Flags to Avoid

  • ❌ Writing log.error("Failed: " + e.getMessage()) — discards the class name, causal chain, and line numbers.
  • ❌ Claiming that log.error("Failed: {}", e) prints the stack trace — it only prints e.toString().
  • ❌ Logging passwords, API tokens, or credit cards in error messages (severe compliance violation).
  • ❌ Forgetting MDC.clear() in servlet filters or interceptors.