Skip to content

Distributed Logging in Depth (Part 1) | SLF4J, Logback, Log4j2, Levels, Parent Child Loggers

The Real World Analogy

Imagine you manage a large warehouse where hundreds of workers operate simultaneously. Packages arrive, are sorted, stored, picked, and shipped. Most days, everything runs smoothly. But one morning, a shipment arrives incomplete. A customer is missing three items. Without records, you have no way to determine: Was the shipment incomplete when it arrived? Was it lost during internal sorting? Was it dispatched but never reached the customer? You cannot debug what you cannot observe.

Logging is the warehouse's event recording system — a timestamped journal of every significant action: "Package P1001 arrived at 09:14", "Package P1001 moved to Shelf B7 at 09:22", "Package P1001 dispatched at 14:35". With these records, root cause analysis takes minutes instead of days.

In software, logging serves the exact same purpose. It records important application events — requests received, business operations completed, errors encountered — so that developers can monitor and debug what the application is actually doing at runtime without needing to reproduce the problem live.

What Is Logging?

Logging means recording important application events such as incoming requests, outgoing responses, errors, warnings, or informational events. It enables developers to:

  • Monitor application behavior and detect anomalies in production.
  • Debug problems without needing to reproduce them in a live environment.
  • Audit critical business operations.
  • Trace the execution path of a failing request.

A simple example: you own a User service with a POST /users endpoint. When a user is created, you log "User created: userId=42, email=john@example.com". Later, when a customer reports a problem, you check the logs to see exactly what happened at what time.

This chapter focuses on how logging works in a single Spring Boot application. Once this foundation is clear, achieving distributed logging across multiple microservices becomes straightforward.

The Logging Architecture: SLF4J, Logback, Log4j2

Understanding the high level architecture before writing any code prevents confusion later when configuration does not behave as expected.

Spring Boot Application


  SLF4J (interface / API only)


  Implementation: Logback (default) or Log4j2


  Appender (decides destination: console, file, DB, Kafka)


  Output (log file, console stream, DB table, etc.)


  Log Aggregation Tools (Datadog, Elasticsearch + Kibana, Prometheus + Loki)

SLF4J — The Interface

SLF4J stands for Simple Logging Facade for Java. The name itself reveals its purpose — it is a facade, providing only an API (interface declarations) with zero implementation. Think of it as the List interface in Java. You know the contract (add, remove, get) but the actual behavior depends on the implementation (ArrayList, LinkedList).

SLF4J exposes the Logger interface and the LoggerFactory utility class. Your application code always calls SLF4J APIs. The actual logging work is delegated to whichever implementation library is on the classpath.

Logback — The Default Implementation

Logback is the default logging implementation that Spring Boot includes automatically. When you create a Spring Boot project from Spring Initializr with the spring-boot-starter-web dependency, three libraries arrive transitively:

  • slf4j-api — the SLF4J interface
  • logback-classic — the core Logback implementation
  • logback-core — Logback's foundation

You do not need to add any logging dependency manually. The system is fully functional out of the box.

Log4j2 — Alternative Implementation

Log4j2 (Logging for Java version 2) is another SLF4J implementation. If you want to switch to it, add the dedicated starter and exclude the default logging:

xml
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    <exclusions>
        <!-- Exclude the default Logback implementation -->
        <exclusion>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-logging</artifactId>
        </exclusion>
    </exclusions>
</dependency>

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-log4j2</artifactId>
</dependency>

Critical: If both Logback and Log4j2 are present on the classpath, SLF4J's internal factory discovers both and picks the first one it finds — essentially a random choice. You will see a classpath warning at startup. Always exclude the default when adding a replacement. Never have two SLF4J implementations on the classpath at once.

Appender — The Destination Decider

An appender is the component that decides where log output goes. Console, file, rolling file, database, Kafka — the appender contains the logic to write log data to a specific destination. The implementation library (Logback or Log4j2) uses appenders to produce output. The default appender in Spring Boot is the console appender.

Your First Logger: Minimal Working Example

java
package com.example.payment.controller;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class PaymentController {

    // One logger per class — this is the production standard practice.
    // The logger name becomes the full class path: com.example.payment.controller.PaymentController
    private static final Logger log = LoggerFactory.getLogger(PaymentController.class);

    @GetMapping("/payments")
    public String fetchAllPayments() {
        // Log an informational event
        log.info("Fetching all payments successfully");
        return "successfully fetched all payments";
    }
}

No additional dependencies are needed. When you hit /payments, the console shows:

2025-12-19 14:23:01.123  INFO 12345 --- [nio-8080-exec-1] c.e.p.controller.PaymentController : Fetching all payments successfully

Date, level, logger name, message — all formatted by the default Logback console appender.

How LoggerFactory.getLogger() Works Internally

The internal architecture is worth understanding because it directly explains behavior like caching, hierarchy, and configuration inheritance.

Internal Flow

  1. LoggerFactory.getLogger(PaymentController.class) is called.
  2. LoggerFactory determines which SLF4J implementation is on the classpath (Logback or Log4j2) by scanning the classpath for SLF4J service providers.
  3. It calls ILoggerFactory.getLogger(name), where name is "com.example.payment.controller.PaymentController".
  4. The implementation's LoggerContext (Logback) or equivalent checks its internal cache: Map<String, Logger>.
  5. If a Logger object for this name already exists in the cache, it is reused. If not, a new one is created and put into the cache.
  6. The returned Logger is an implementation of the SLF4J Logger interface — either ch.qos.logback.classic.Logger (Logback) or org.apache.logging.log4j.core.Logger (Log4j2).

Key insight: For any given logger name, exactly one logger object exists and is reused throughout the application's lifetime. This is why getLogger(PaymentController.class) in a class that is instantiated thousands of times still only creates one logger object.

java
// Logback LoggerContext pseudo-implementation:
public Logger getLogger(String name) {
    Logger cached = loggerCache.get(name);
    if (cached != null) {
        return cached; // reuse existing
    }
    Logger newLogger = createLogger(name);
    loggerCache.put(name, newLogger);
    return newLogger;
}

Logger Levels

Every logger has a configured level. Every log statement also implicitly carries a level. The logger only emits a log statement if the statement's level is equal to or higher than the logger's configured level.

Level Priority (highest to lowest)

ERROR  >  WARN  >  INFO  >  DEBUG  >  TRACE

Default Level

If no level is configured for a logger, its default is INFO. This means INFO, WARN, and ERROR statements are emitted; DEBUG and TRACE are suppressed.

Demonstration

java
@GetMapping("/payments")
public String fetchAllPayments() {
    log.error("This is an ERROR level message");
    log.warn("This is a WARN level message");
    log.info("This is an INFO level message");
    log.debug("This is a DEBUG level message");   // suppressed by default
    log.trace("This is a TRACE level message");   // suppressed by default
    return "ok";
}

Output (default INFO level):

ERROR ... PaymentController : This is an ERROR level message
WARN  ... PaymentController : This is a WARN level message
INFO  ... PaymentController : This is an INFO level message

DEBUG and TRACE are suppressed because their priority is lower than INFO.

Changing the Level via application.properties

properties
# Format: logging.level.<logger-name>=<LEVEL>
# Logger name = the full class name or package name
logging.level.com.example.payment.controller.PaymentController=DEBUG

With this setting, DEBUG is now emitted but TRACE is still suppressed.

ERROR ...
WARN  ...
INFO  ...
DEBUG ...

The Parent Child Logger Hierarchy

This is the most architecturally important concept in the logging framework. Every logger exists in a tree rooted at a special logger named root.

How the Hierarchy Is Built

When you call LoggerFactory.getLogger(PaymentController.class), the name is "com.example.payment.controller.PaymentController". The framework does not create just one logger object. It creates a full hierarchy:

root
 └── com
      └── com.example
           └── com.example.payment
                └── com.example.payment.controller
                     └── com.example.payment.controller.PaymentController

Every intermediate package has its own logger object in the cache. The root logger is always present — the framework creates it automatically and it cannot be deleted.

Viewing the Hierarchy with a Debugger

Place a breakpoint inside LoggerFactory.getLogger() and inspect the returned Logback Logger object. You will see fields:

  • name: the full logger name
  • level: the configured level (or null if inherited)
  • parent: reference to the immediate parent logger
  • childrenList: references to child loggers

For PaymentController's logger:

name   = "com.example.payment.controller.PaymentController"
parent = Logger{ name="com.example.payment.controller" }
  parent = Logger{ name="com.example.payment" }
    parent = Logger{ name="com.example" }
      parent = Logger{ name="com" }
        parent = Logger{ name="ROOT" }

Advantage 1: Log Level Inheritance

You do not need to configure the level for every class individually. Configure it on a parent logger and all children inherit it automatically.

properties
# This sets the level for ALL classes under com.example to DEBUG
logging.level.com.example=DEBUG

Now every class in any package under com.example uses DEBUG level without individual configuration. You can still override a specific child:

properties
logging.level.com.example=DEBUG
# Override only PaymentController back to WARN
logging.level.com.example.payment.controller.PaymentController=WARN

Advantage 2: Appender Propagation (Additivity)

Accepted log events propagate upward through the hierarchy and trigger all parent appenders along the way. This is why logs appear on the console even when you have never configured any appender yourself: the root logger has a default console appender, and all accepted log events bubble up to it.

The additivity rule:

  • If a logger has no appender, propagation continues upward to find a parent with an appender.
  • If a logger has an appender AND additivity is true (the default), the event is sent to both the logger's appender AND all parent appenders.
  • If additivity is false, the event is sent only to this logger's appenders and propagation stops.
PaymentController logger (no appender, additivity=true)

        ▼ propagate upward
com.example logger (no appender, additivity=true)

        ▼ propagate upward
root logger (console appender ← default)


Log appears on console ✓

Important distinction: Level inheritance is unconditional — even if additivity=false, if a logger is missing a level configuration, it always inherits from its parent. Additivity only controls appender propagation. Level and additivity are independent concerns.

Best Practices for Log Statements

Use Parameterized Logging (Never String Concatenation)

java
// WRONG: String concatenation always executes, even if DEBUG is suppressed.
// The string "User " + userId + " created with id " + id is built in memory
// regardless of whether the log statement will be emitted.
log.debug("User " + userId + " created with id " + id);

// CORRECT: Placeholders are only resolved if the log statement is accepted.
// If the logger level is WARN, this incurs zero string building cost.
log.debug("User {} created with id {}", userId, id);

Exception Logging: Always Put Throwable Last

java
try {
    processPayment(orderId);
} catch (Exception e) {
    // CORRECT: Throwable is always the last argument.
    // The full stack trace is automatically appended to the log event.
    log.error("Payment failed for orderId={}", orderId, e);

    // WRONG: This treats 'e' as a regular object and calls toString() on it.
    // No stack trace is logged.
    // log.error("Payment failed for orderId={} error={}", orderId, e);
}

Summary: How All the Pieces Fit Together

Your code calls: log.info("User created")


SLF4J Logger.info() is invoked


Implementation (Logback) converts to ILoggingEvent


Level check: Is this event's level >= logger's configured level?
   Yes → continue  |  No → DISCARD (stop here)


Appenders run: for each appender on this logger (if any)


Propagate upward (if additivity=true): run parent appenders too


Root logger: console appender by default → log appears on console

Interview Questions and Pitfalls

Q1: What is SLF4J and why does it exist as a separate library from Logback?

A: SLF4J is the Simple Logging Facade for Java — a pure interface library with no implementation. Its purpose is to decouple your application code from any specific logging implementation. By writing to SLF4J APIs, you can switch between Logback and Log4j2 (or any other SLF4J compatible implementation) without changing a single line of application code. You only change the implementation library on the classpath.

Q2: What happens if both Logback and Log4j2 are on the classpath simultaneously?

A: SLF4J detects multiple providers and prints a warning at startup: "Class path contains multiple SLF4J providers." It then picks the first one it finds during classpath scanning, which is non deterministic and unreliable. In production, this is a serious misconfiguration. Always exclude spring-boot-starter-logging (which brings Logback) when adding Log4j2 explicitly.

Q3: Explain how log level inheritance works in the parent child hierarchy.

A: Every logger has a level configuration. If a logger's level is not explicitly set, it inherits the level from its closest ancestor that has a configured level. This propagates all the way to the root logger as a last resort. This means you can configure one parent package (e.g., com.example) to use DEBUG, and all child loggers automatically use DEBUG without individual configuration. Additivity (appender propagation) has no effect on level inheritance — level is always inherited regardless of additivity setting.

Q4: What is additivity in logging and what does setting it to false do?

A: Additivity controls whether an accepted log event propagates upward through the logger hierarchy to trigger parent logger appenders. By default, additivity is true, meaning log events run appenders on the logger, then bubble up to parent loggers and run their appenders, all the way to root. Setting additivity to false prevents this propagation — only the current logger's appenders are executed. If a logger with additivity=false has no appenders configured, log events are silently discarded with a warning about no appender found.

Q5: Why should you use parameterized logging ({} placeholders) instead of string concatenation?

A: String concatenation executes unconditionally every time the log statement line is reached, regardless of whether the log level allows the statement to be emitted. Parameterized placeholders are only resolved when the logging framework confirms the statement will actually be emitted. In high traffic applications, suppressed DEBUG statements with string concatenation still incur CPU and memory cost for building strings that are immediately discarded. Parameterized logging eliminates that waste.

Q6: What is the root logger and why does it always produce console output even without explicit configuration?

A: The root logger is a special logger that the framework creates automatically — it cannot be deleted or disabled. It is the ancestor of every logger in the hierarchy. By default, the root logger has level=INFO and a console appender configured. Because all accepted log events propagate upward (additivity defaults to true), they eventually reach the root logger and trigger its console appender. This is why you see log output on the console even when you have written zero logging configuration.

Q7: Pitfall — log level configured in both logback-spring.xml and application.properties.

A: When both sources configure a level for the same logger, application.properties takes higher priority. The logback-spring.xml setting is overridden. This can cause confusion when the XML file specifies one level but the actual observed behavior corresponds to a different level from application.properties. Always check both configuration sources when debugging unexpected log output.