Skip to content

Logger Framework#

A logger framework LLD turns a single log.info("order placed") call into a tree of pluggable components: named hierarchical loggers, a configured level threshold, a formatter, a filter chain, and one or more appenders writing to console, files, syslog, or Kafka. The design problem is to keep the hot path cheap when disabled, lossless when enabled, thread-safe under burst, and reconfigurable at runtime. Log4j 2, Logback, and SLF4J are the canonical references.

Problem statement (interviewer prompt)

Design a logging framework (Log4j / SLF4J-style): named loggers per class, levels (TRACE → ERROR), pluggable appenders (console, file, syslog, kafka), formatters (text / JSON), async logging without blocking the caller, and thread-safety. Cover the class hierarchy + factory pattern.

classDiagram
  direction TB

  class LoggerFactory {
    -registry : Map~String,Logger~
    +getLogger(name) Logger
    +configure(Config)
  }

  class Logger {
    -name : String
    -level : Level
    -parent : Logger
    -additivity : bool
    -appenders : List~Appender~
    -filters : List~Filter~
    +trace(msg)
    +debug(msg)
    +info(msg)
    +warn(msg)
    +error(msg)
    +fatal(msg)
    +log(level, msg, ctx)
  }

  class Level {
    <<enumeration>>
    TRACE
    DEBUG
    INFO
    WARN
    ERROR
    FATAL
  }

  class LogRecord {
    +ts : long
    +level : Level
    +loggerName : String
    +thread : String
    +message : String
    +mdc : Map
    +throwable : Throwable
  }

  class Appender {
    <<interface>>
    +append(LogRecord)
    +close()
  }
  class ConsoleAppender
  class FileAppender
  class RollingFileAppender {
    -maxBytes : long
    -backupCount : int
    -rotate()
  }
  class SyslogAppender
  class KafkaAppender {
    -producer : KafkaProducer
    -topic : String
  }
  class AsyncAppenderWrapper {
    -delegate : Appender
    -queue : BlockingQueue
    -drainThread : Thread
  }

  class Layout {
    <<interface>>
    +format(LogRecord) String
  }
  class PatternLayout
  class JsonLayout
  class PlainTextLayout

  class Filter {
    <<interface>>
    +decide(LogRecord) Decision
  }
  class LevelFilter
  class RegexFilter
  class RateLimitFilter

  LoggerFactory "1" --> "*" Logger : registry
  Logger "1" --> "0..1" Logger : parent
  Logger "1" --> "*" Appender : appenders
  Logger "1" --> "*" Filter : filterChain
  Logger ..> LogRecord : creates
  Appender "1" --> "1" Layout : formats with
  Appender "1" --> "*" Filter : appenderFilters
  Appender <|.. ConsoleAppender
  Appender <|.. FileAppender
  Appender <|.. RollingFileAppender
  Appender <|.. SyslogAppender
  Appender <|.. KafkaAppender
  Appender <|.. AsyncAppenderWrapper
  AsyncAppenderWrapper o-- Appender : wraps
  Layout <|.. PatternLayout
  Layout <|.. JsonLayout
  Layout <|.. PlainTextLayout
  Filter <|.. LevelFilter
  Filter <|.. RegexFilter
  Filter <|.. RateLimitFilter

Logger names form a dotted hierarchy: com, com.app, com.app.service, com.app.service.OrderService. A child without an explicit level inherits from the nearest configured ancestor by walking parent references. The same walk drives appender lookup when additivity = true, bubbling each record up to ancestor appenders. This is the Chain of Responsibility at the heart of every Log4j-style framework.

Logger lookup and log() flow#

public final class LoggerFactory {
    private static final Map<String, Logger> REGISTRY = new ConcurrentHashMap<>();
    private static final Logger ROOT = new Logger("", Level.INFO, null);

    public static Logger getLogger(String name) {
        return REGISTRY.computeIfAbsent(name, n -> {
            int dot = n.lastIndexOf('.');
            Logger parent = dot < 0 ? ROOT : getLogger(n.substring(0, dot));
            return new Logger(n, null, parent); // null level: inherits
        });
    }
}

public class Logger {
    private final String name;
    private volatile Level level;        // null means inherit from parent
    private final Logger parent;
    private final List<Appender> appenders = new CopyOnWriteArrayList<>();
    private final List<Filter> filters = new CopyOnWriteArrayList<>();
    private boolean additivity = true;

    public Level effectiveLevel() {
        for (Logger l = this; l != null; l = l.parent)
            if (l.level != null) return l.level;
        return Level.INFO;
    }

    public void log(Level lvl, String msg, Throwable t) {
        if (lvl.ordinal() < effectiveLevel().ordinal()) return; // hot path
        LogRecord r = new LogRecord(System.currentTimeMillis(), lvl,
                                    name, Thread.currentThread().getName(),
                                    msg, MDC.snapshot(), t);
        for (Filter f : filters)
            if (f.decide(r) == Filter.Decision.DENY) return;
        for (Logger l = this; l != null; l = l.parent) {
            for (Appender a : l.appenders) a.append(r);
            if (!l.additivity) break;
        }
    }
}

public final class AsyncAppenderWrapper implements Appender {
    private final Appender delegate;
    private final BlockingQueue<LogRecord> queue = new ArrayBlockingQueue<>(8192);
    private final Thread drain;
    private volatile boolean running = true;

    public AsyncAppenderWrapper(Appender delegate) {
        this.delegate = delegate;
        this.drain = new Thread(this::drainLoop, "log-async");
        this.drain.setDaemon(true);
        this.drain.start();
    }

    public void append(LogRecord r) {
        if (!queue.offer(r)) { /* policy: drop, block, or write inline */ }
    }

    private void drainLoop() {
        while (running || !queue.isEmpty()) {
            try { delegate.append(queue.take()); }
            catch (InterruptedException e) { Thread.currentThread().interrupt(); }
        }
    }

    public void close() { running = false; drain.interrupt(); }
}

The effectiveLevel() short-circuit keeps disabled debug() calls cheap: no allocation, no formatting, no I/O. Once a record is built it flows through the filter chain (deny-on-first), then up the logger chain firing each appender unless additivity is off.

Design patterns used#

  • Chain of Responsibility: the logger hierarchy. effectiveLevel() walks parents until a configured level is found; appender dispatch walks until additivity = false halts it.
  • Strategy: Layout swaps PatternLayout, JsonLayout, or PlainTextLayout without touching the appender. Same Appender, different output shape.
  • Decorator: AsyncAppenderWrapper wraps any Appender and adds a BlockingQueue plus drain thread. The wrapped appender is unaware it is asynchronous.
  • Singleton / Factory: LoggerFactory owns the single per-process registry; getLogger(name) is idempotent so multiple call sites share the same Logger instance.
  • Observer: RollingFileAppender observes its own file size or wall-clock; on threshold cross it triggers a rotate (close, rename, reopen).
  • Filter chain: an ordered list of Filter strategies returning ACCEPT / NEUTRAL / DENY, applied per logger and again per appender for fine-grained control.

Common variants and follow-ups#

  • Structured logging (JSON): emit a record as JSON with stable field names so log aggregators (ELK, Datadog, Loki) parse without regex. The JsonLayout replaces PatternLayout and adds mdc, traceId, spanId as first-class fields.
  • MDC / NDC (mapped or nested diagnostic context): per-thread (or per-coroutine) key-value bag attached to every record. Used to carry userId, requestId, tenantId without changing every call site.
  • Correlation and trace IDs: an MDC entry populated at the request boundary (HTTP filter, message consumer) so every log line within a request is grep-able. The same ID feeds OpenTelemetry spans.
  • Sampling at high volume: instead of logging every event, sample one in N at INFO and always log on ERROR. A RateLimitFilter token-bucket keeps disk and network bounded.
  • Hot-reload config without restart: a watcher polls the YAML/XML config (or subscribes to a control plane) and atomically swaps levels and appenders. Avoids redeploys to debug production.
  • Dynamic level change at runtime: a JMX bean or admin endpoint flips Logger.level from INFO to DEBUG for one logger, scoped by name prefix, for a fixed window.
  • Async logging performance: a ring-buffer (LMAX Disruptor) outperforms ArrayBlockingQueue on contended write paths; one producer slot per thread eliminates the lock entirely.
  • OpenTelemetry-style export: an OtlpAppender ships records as OTLP-Logs with trace correlation baked in, removing the agent-side parsing step.

Edge cases#

  • Appender failure: file system full, syslog UDP socket down, Kafka producer blocked. The appender must catch and log to a fallback (stderr) rather than throw and kill user code. Per-appender errorHandler strategy.
  • Log loss versus blocking under back-pressure: when the async queue is full the policy is one of drop-oldest, drop-new, block the caller, or write inline. HFT picks drop, billing picks block.
  • Recursive logging: an appender or formatter triggers another log call. Detect via a thread-local inLogging flag and short-circuit to avoid stack overflow.
  • Shutdown hook to flush async queue: a JVM shutdown hook calls close() on every appender so the drain thread runs to completion. Without it the last records on the queue die with kill.
  • Log rotation while writing: RollingFileAppender must rotate atomically: close, rename to app.log.1, reopen at app.log. POSIX is forgiving (open inodes survive renames); Windows requires close-before-rename.
  • Very large messages: a 50 MB stack trace can OOM the queue. Cap message length and stack-trace depth before enqueueing.
  • GC pause from LogRecord allocation: high-volume INFO logging can dominate allocation. Object-pool LogRecord or use a flyweight backed by a ring-buffer slot.

Glossary and fundamentals#

Concepts referenced in this design. Each row links to its canonical page; the tag column shows whether it is a high-level (HLD) or low-level (LLD) concept.

Tag Concept What it is Page
LLD Creational patterns Singleton, Factory, Builder, Prototype creational-patterns
LLD Structural patterns Adapter, Decorator, Facade, Proxy, Composite structural-patterns
LLD Behavioural patterns Strategy, Observer, State, Command, Chain behavioral-patterns

FAQ#

How is log-level filtering implemented?#

Each Logger has a configured level threshold; calls below the threshold short-circuit before formatting, which keeps disabled log calls effectively free.

What does an appender do?#

An Appender writes formatted log events to a destination (console, file, syslog, Kafka). Loggers fan out to one or more appenders, decoupling capture from delivery.

How do you make logging asynchronous?#

Producers push events onto a lock-free ring buffer; a single consumer thread drains it and runs appenders, so the caller never blocks on I/O.

Which design patterns appear in logger frameworks?#

Singleton or factory for logger lookup, Chain of Responsibility for hierarchical level inheritance, Strategy for formatters, and Decorator for filters and async wrappers.

How are loggers organised hierarchically?#

By dotted name like com.example.api so configuration on a parent applies to all children unless explicitly overridden. This is the model Log4j and SLF4J use.

Quick reference#

Functional#

  • Hierarchical loggers (com.foo.bar).
  • Levels per logger.
  • Multiple appenders + formatters.
  • Filters.
  • MDC / structured fields.
  • Async + sync delivery.

Non-functional#

  • Sub-µs hot path when level disabled.
  • No log loss under burst (lossless async + bounded queue).
  • No deadlocks (re-entrant logging).

Trade-offs#

  • Sync is simple but blocks on slow appender; async decouples but loses on crash unless flushed.
  • JSON vs text: JSON wins for aggregation; text wins for greppable local files.
  • Per-thread context (MDC) invaluable for request tracing.

Refs#

  • Log4j 2, Logback, SLF4J architecture docs.
  • "Logger frameworks comparison" articles.