Back to Logging & Monitoring
Q2Question 2 of 2

How does logging work in Spring Boot?

Key Concepts
  • SLF4J is the logging facade (API), Logback is the default implementation that does the actual writing
  • Parameterized messages with curly braces avoid string concatenation overhead when the log level is disabled
  • Exception must be the last argument to log.error() to preserve the full stack trace
  • MDC (Mapped Diagnostic Context) attaches a traceId to every log line for cross-service request tracing
  • AsyncAppender puts log events in a queue so the request thread never blocks on disk I/O
  • PII must be masked before logging to comply with data protection regulations
  • JSON-formatted logs enable structured ingestion into ELK or Loki for centralized log management

Definition

Spring Boot uses SLF4J as the logging facade with Logback as the default implementation, where parameterized messages avoid string concatenation, MDC attaches correlation IDs to every log line, and AsyncAppender prevents disk I/O from blocking request threads.
  • SLF4J – Simple Logging Facade for Java, a standard API that your code calls. It is not a logging library itself.
  • Logback – the default logging implementation in Spring Boot that sits behind the SLF4J facade and handles the actual writing to console, files, or remote systems.
  • Parameterized logging – using log.info("Transfer {} to {}", fromId, toId) with curly-brace placeholders so the string is built only when the level is enabled.
  • MDC – Mapped Diagnostic Context, a thread-local map that attaches key-value pairs (like traceId) to every log line produced by that thread.
  • AsyncAppender – wraps a file or network appender so log events are dropped into an in-memory queue and written by a background thread, eliminating I/O blocking.
  • PII masking – replacing sensitive data (account numbers, PAN, Aadhaar) with masked values before writing to logs.

Think of a National Bank of India branch. SLF4J is the standard memo format that every employee uses. Logback is the printer that actually prints the memos. MDC is the stamp that marks every memo with the customer's token number so you can trace all activity for a single customer visit. AsyncAppender is putting memos in an outbox tray instead of walking to the printer each time: the office assistant picks them up in batches.

Understanding the Concepts

What is SLF4J and why is it a facade?

SLF4J stands for Simple Logging Facade for Java, and the key word is facade. A facade is a standard set of method names that your code calls. Behind the facade, a real logging engine does the actual work of writing text to a file, console, or remote system. In Spring Boot, the real engine behind the facade is Logback.

Your code only ever talks to SLF4J, so you write LoggerFactory.getLogger(SettlementService.class) and then call log.info(...). Because your code depends only on the facade, you could swap Logback for Log4j2 without changing a single line in your business classes.

JAVA
package com.bank.service;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;

@Service
public class TransferService {

    // SLF4J facade: your code talks to this interface
    private static final Logger log = LoggerFactory.getLogger(TransferService.class);

    public void transfer(String fromAccount, String toAccount, BigDecimal amount) {
        log.info("Transfer {} from {} to {}", amount, fromAccount, toAccount);
        // Logback (the implementation) writes this to console and file
    }
}
TEXT
2024-01-15 10:30:45.123 INFO  [main] c.b.s.TransferService : Transfer 50000.00 from SAV2001 to SAV2002

What are the log levels and when do you use each?

Logging levels form a ladder from least serious to most serious: TRACE, DEBUG, INFO, WARN, ERROR. You configure a threshold, and only messages at that level or above are written.

| Level | Purpose | Example | Production Use |
|---|---|---|---|
| TRACE | Very fine-grained debugging | Method entry/exit | Never (too verbose) |
| DEBUG | Developer detail | Balance before and after debit | Only during incidents |
| INFO | Normal business events | Transfer completed successfully | Always |
| WARN | Concerning but survivable | Retry needed, low balance threshold | Always |
| ERROR | Actual failures | Transfer failed, exception caught | Always |

JAVA
package com.bank.service;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;

@Service
public class AccountService {

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

    public void debit(String accountId, BigDecimal amount) {
        BigDecimal balanceBefore = getBalance(accountId);
        log.debug("Balance before debit for {}: {}", accountId, balanceBefore);

        if (balanceBefore.compareTo(amount) < 0) {
            log.warn("Insufficient balance for account {}: has {}, needs {}",
                accountId, balanceBefore, amount);
            throw new InsufficientBalanceException(accountId);
        }

        performDebit(accountId, amount);
        log.info("Debited {} from account {}", amount, accountId);
    }
}
YAML
# application-prod.yml: only INFO and above in production
logging:
  level:
    root: WARN
    com.bank: INFO
    com.bank.service: INFO
TEXT
In production (level=INFO): only the info and warn lines appear
In development (level=DEBUG): debug line also appears
The log.debug() call costs nothing in production because Logback
skips the string building when DEBUG is disabled

Why must you use parameterized logging?

Parameterized logging with curly-brace placeholders is not just a style preference. It is a performance requirement. With the placeholder form, SLF4J only builds the final string if the log level is actually enabled. With string concatenation, Java builds the entire string every single time, even when the log line will be thrown away.

JAVA
package com.bank.service;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.math.BigDecimal;

public class LoggingDemo {

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

    public void processTransfer(String transferId, BigDecimal amount) {
        // WRONG: string concatenation -- built EVERY call even when DEBUG is disabled
        log.debug("Processing transfer " + transferId + " for amount " + amount);

        // CORRECT: parameterized -- string built ONLY when DEBUG is enabled
        log.debug("Processing transfer {} for amount {}", transferId, amount);
    }
}
TEXT
With 10,000 transfers per second and DEBUG disabled in production:
  - Concatenation: 10,000 unnecessary String objects created per second -> GC pressure
  - Parameterized: zero String objects created -> no overhead

How do you log exceptions correctly?

Exception logging is the single most common mistake in Java applications. The correct form passes the exception as the last argument to log.error() without a placeholder for it. SLF4J recognizes a trailing Throwable and prints the full stack trace.

JAVA
package com.bank.service;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.math.BigDecimal;

public class ExceptionLoggingDemo {

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

    public void transfer(String fromAccount, String toAccount, BigDecimal amount) {
        try {
            executeTransfer(fromAccount, toAccount, amount);
        } catch (Exception e) {
            // WRONG: loses the entire stack trace
            log.error(e.getMessage());

            // WRONG: bypasses logging framework, goes to stderr
            e.printStackTrace();

            // WRONG: converts stack trace to string manually
            log.error("Transfer failed: " + e.toString());

            // CORRECT: exception as last arg, no placeholder for it
            log.error("Transfer failed from {} to {} for amount {}",
                fromAccount, toAccount, amount, e);
            // SLF4J sees 3 placeholders and 4 args, recognizes the
            // last arg is Throwable, and prints the full stack trace
        }
    }
}
TEXT
WRONG output (getMessage only):
  ERROR Transfer failed: null
  -- No stack trace, no idea WHERE the failure happened

CORRECT output (exception as last arg):
  ERROR Transfer failed from SAV2001 to SAV2002 for amount 50000.00
  java.lang.NullPointerException: null
      at com.bank.service.TransferService.executeTransfer(TransferService.java:45)
      at com.bank.service.TransferService.transfer(TransferService.java:30)
  -- Full stack trace, exact line number, complete context

What is MDC and how does it work?

MDC stands for Mapped Diagnostic Context. It is a small map of key-value pairs attached to the current thread. You put a value in at the start of a request, and from that moment every log line produced by that thread automatically carries the value, as long as your Logback pattern references it.

The classic use is correlation. Rajesh needs to trace all log lines for a single customer request across multiple service classes. At the start of the request, a servlet filter sets MDC.put("traceId", traceId) and the Logback pattern includes %X{traceId}. Now every log line from TransferService, AccountRepository, and AuditService includes the same traceId.

JAVA
package com.bank.filter;

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

@Component
public class CorrelationIdFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain)
            throws ServletException, IOException {
        String traceId = request.getHeader("X-Trace-Id");
        if (traceId == null) {
            traceId = UUID.randomUUID().toString().substring(0, 8);
        }
        MDC.put("traceId", traceId);
        response.setHeader("X-Trace-Id", traceId);
        try {
            chain.doFilter(request, response);
        } finally {
            MDC.clear();  // CRITICAL: prevent leaking to the next request on this thread
        }
    }
}
XML
<!-- logback-spring.xml: include traceId in every log line --syn-kw">>
syn-kw"><configurationsyn-kw">>
    syn-kw"><appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender"syn-kw">>
        syn-kw"><encodersyn-kw">>
            syn-kw"><patternsyn-kw">>%d{HH:mm:ss.SSS} [%X{traceId}] %-5level %logger{36} - %msg%nsyn-kw"></patternsyn-kw">>
        syn-kw"></encodersyn-kw">>
    syn-kw"></appendersyn-kw">>
    syn-kw"><root level="INFO"syn-kw">>
        syn-kw"><appender-ref ref="CONSOLE" /syn-kw">>
    syn-kw"></rootsyn-kw">>
syn-kw"></configurationsyn-kw">>
TEXT
10:30:45.123 [a1b2c3d4] INFO  c.b.s.TransferService - Transfer 50000 from SAV2001 to SAV2002
10:30:45.125 [a1b2c3d4] INFO  c.b.r.AccountRepository - Debited SAV2001
10:30:45.127 [a1b2c3d4] INFO  c.b.r.AccountRepository - Credited SAV2002
10:30:45.130 [a1b2c3d4] INFO  c.b.s.AuditService - Audit entry created

All four lines share traceId=a1b2c3d4, easy to filter in ELK/Loki

Why is MDC.clear() critical and what happens without it?

MDC lives on a ThreadLocal, and servlet containers reuse threads from a pool. If you do not clear the MDC when the request finishes, the next unrelated request that reuses the same thread inherits the stale traceId. Priya's account inquiry would carry Rajesh's previous debugging traceId, making the logs impossible to trace correctly.

JAVA
// WRONG: no cleanup -- traceId leaks to next request
public void doFilter(HttpServletRequest req, HttpServletResponse res, FilterChain chain) {
    MDC.put("traceId", UUID.randomUUID().toString());
    chain.doFilter(req, res);
    // Thread goes back to pool with stale traceId
}

// CORRECT: always clear in finally block
public void doFilter(HttpServletRequest req, HttpServletResponse res, FilterChain chain) {
    MDC.put("traceId", UUID.randomUUID().toString());
    try {
        chain.doFilter(req, res);
    } finally {
        MDC.clear();
    }
}

Why does heavy logging cause latency and how does AsyncAppender fix it?

Writing a log line to disk is actual I/O. Each disk write takes 1 to 5 milliseconds depending on the storage. If the thread handling a transfer blocks for 2 milliseconds on every log line, and the transfer logs 50 lines, that is 100 milliseconds of pure waiting added to every transfer.

AsyncAppender fixes this by inserting a queue between the request thread and the file writer. When your code calls log.info(...), the event is dropped into an in-memory queue in microseconds and the thread returns immediately. A separate background thread drains the queue and performs the slow disk writes.

XML
<!-- logback-spring.xml: async appender configuration --syn-kw">>
syn-kw"><configurationsyn-kw">>
    syn-kw"><appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"syn-kw">>
        syn-kw"><filesyn-kw">>logs/national-bank.logsyn-kw"></filesyn-kw">>
        syn-kw"><rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"syn-kw">>
            syn-kw"><fileNamePatternsyn-kw">>logs/national-bank.%d{yyyy-MM-dd}.logsyn-kw"></fileNamePatternsyn-kw">>
            syn-kw"><maxHistorysyn-kw">>30syn-kw"></maxHistorysyn-kw">>
        syn-kw"></rollingPolicysyn-kw">>
        syn-kw"><encodersyn-kw">>
            syn-kw"><patternsyn-kw">>%d{HH:mm:ss.SSS} [%X{traceId}] %-5level %logger{36} - %msg%nsyn-kw"></patternsyn-kw">>
        syn-kw"></encodersyn-kw">>
    syn-kw"></appendersyn-kw">>

    syn-kw"><appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender"syn-kw">>
        syn-kw"><queueSizesyn-kw">>1024syn-kw"></queueSizesyn-kw">>
        syn-kw"><discardingThresholdsyn-kw">>0syn-kw"></discardingThresholdsyn-kw">>   <!-- 0 = never discard --syn-kw">>
        syn-kw"><appender-ref ref="FILE" /syn-kw">>
    syn-kw"></appendersyn-kw">>

    syn-kw"><root level="INFO"syn-kw">>
        syn-kw"><appender-ref ref="ASYNC" /syn-kw">>
    syn-kw"></rootsyn-kw">>
syn-kw"></configurationsyn-kw">>
TEXT
Without AsyncAppender:
  Request thread -> log.info() -> WAIT for disk write -> continue
  50 log lines x 2ms each = 100ms added to every transfer

With AsyncAppender:
  Request thread -> log.info() -> drop in queue (microseconds) -> continue
  Background thread -> drain queue -> write to disk
  Transfer latency reduced by ~100ms

How do you mask PII in logs?

Banking applications must never log sensitive data like full account numbers, PAN numbers, or Aadhaar numbers. Regulations like GDPR and RBI guidelines require PII masking in all log output.

JAVA
package com.bank.util;

public final class PiiMasker {

    private PiiMasker() {}

    public static String maskAccountNumber(String accountNumber) {
        if (accountNumber == null || accountNumber.length() < 4) {
            return "****";
        }
        return "****" + accountNumber.substring(accountNumber.length() - 4);
    }

    public static String maskPan(String pan) {
        if (pan == null || pan.length() < 4) {
            return "****";
        }
        return pan.substring(0, 4) + "****" + pan.substring(pan.length() - 2);
    }
}
JAVA
package com.bank.service;

import com.bank.util.PiiMasker;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.math.BigDecimal;

public class TransferLogger {

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

    public void logTransfer(String fromAccount, String toAccount, BigDecimal amount) {
        // WRONG: logs full account numbers
        log.info("Transfer from {} to {} amount {}", fromAccount, toAccount, amount);
        // Output: Transfer from SAV2001234567 to SAV2009876543 amount 50000

        // CORRECT: masks sensitive data
        log.info("Transfer from {} to {} amount {}",
            PiiMasker.maskAccountNumber(fromAccount),
            PiiMasker.maskAccountNumber(toAccount),
            amount);
        // Output: Transfer from ****4567 to ****6543 amount 50000
    }
}

What should a senior engineer know?

  • Use Lombok's @Slf4j annotation to generate the logger field automatically instead of writing the static final Logger line.
  • For centralized logging, output JSON-formatted logs using Logstash encoder. JSON logs are parsed easily by ELK (Elasticsearch, Logstash, Kibana) and Grafana Loki.
  • When using @Async methods or CompletableFuture, MDC values are not automatically propagated to the new thread. You must wrap the TaskExecutor with MDCTaskDecorator to copy MDC values.
  • In high-throughput systems, set discardingThreshold=0 on AsyncAppender to prevent discarding WARN and ERROR messages under load. The default discards DEBUG and TRACE when the queue is 80 percent full.
  • Pankaj configures structured JSON logging in production with fields for traceId, accountId, serviceName, and timestamp, making it possible to search and filter logs across all National Bank of India microservices.
JAVA
package com.bank.config;

import org.slf4j.MDC;
import org.springframework.core.task.TaskDecorator;
import java.util.Map;

public class MdcTaskDecorator implements TaskDecorator {

    @Override
    public Runnable decorate(Runnable runnable) {
        Map<String, String> contextMap = MDC.getCopyOfContextMap();
        return () -> {
            if (contextMap != null) {
                MDC.setContextMap(contextMap);
            }
            try {
                runnable.run();
            } finally {
                MDC.clear();
            }
        };
    }
}

What are common interview follow-up questions?

  • What is the difference between SLF4J and Logback? SLF4J is the API (facade) that your code calls. Logback is the implementation that does the actual writing. You can swap Logback for Log4j2 without changing business code.
  • Can you use Log4j2 with Spring Boot? Yes. Exclude spring-boot-starter-logging (which includes Logback) and add spring-boot-starter-log4j2.
  • How do you configure different log levels for different packages? Use logging.level.=LEVEL in application.yml. For example, logging.level.com.bank.service=DEBUG sets DEBUG for your service package while the rest stays at INFO.
  • What is structured logging? Instead of plain text lines, each log event is a JSON object with named fields. This makes logs machine-parseable for search, filtering, and alerting in centralized systems.

Visual Overview

DIAGRAM
  Logging Architecture:

  Your Code
    │
    │  log.info("Transfer {} from {}", amount, account)
    ▼
  ┌─────────────────┐
  │     SLF4J        │  Facade (API only)
  │  (interface)     │
  └────────┬────────┘
           ▼
  ┌─────────────────┐
  │    Logback       │  Implementation
  │  (engine)        │
  └────────┬────────┘
           ▼
  ┌─────────────────┐
  │  AsyncAppender   │  Queue (non-blocking)
  │  [queue: 1024]   │
  └────────┬────────┘
           │ background thread
           ▼
  ┌─────────────────┐     ┌─────────────────┐
  │  Console         │     │  File            │
  │  Appender        │     │  Appender        │
  └─────────────────┘     └─────────────────┘


  MDC Flow:

  HTTP Request arrives
       │
  ┌────┴────────────────┐
  │ CorrelationIdFilter  │
  │ MDC.put("traceId",   │
  │   "a1b2c3d4")        │
  └────┬────────────────┘
       ▼
  ┌──────────────┐   ┌──────────────┐   ┌──────────────┐
  │ Controller    │   │ Service      │   │ Repository   │
  │ [a1b2c3d4]   │──→│ [a1b2c3d4]   │──→│ [a1b2c3d4]   │
  └──────────────┘   └──────────────┘   └──────────────┘
       │
  ┌────┴────────────────┐
  │ CorrelationIdFilter  │
  │ MDC.clear()          │  ← CRITICAL: prevent leak
  └─────────────────────┘

Key Takeaway

Spring Boot uses SLF4J as the logging facade with Logback as the default implementation. The critical rules are: parameterized messages with curly braces so the string is built only when the level is enabled, exception as the last argument to preserve the full stack trace, and never getMessage() or printStackTrace(). These rules apply to every log statement in the National Bank of India codebase.

MDC attaches a correlation traceId to every log line for cross-service request tracing. A servlet filter sets the traceId from the request header at the start and clears it in a finally block to prevent leaking across pooled threads. In production, Rajesh uses AsyncAppender to eliminate the 100ms latency that synchronous disk I/O was adding per transfer. PII is masked before logging using a utility class, and logs are output in JSON format for structured ingestion into ELK or Grafana Loki.

The most dangerous logging mistake is losing the stack trace. When log.error(e.getMessage()) is used instead of log.error("msg", e), the entire stack trace disappears and debugging becomes guesswork. The second most dangerous mistake is not clearing MDC, which causes unrelated requests to inherit stale trace IDs on pooled threads.

Remember one sentence: SLF4J facade, Logback engine, parameterized messages, exception as last arg, MDC for tracing, AsyncAppender for performance, and always clear MDC in finally.

---

Share
Use ← → arrow keys to navigate