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 untiladditivity = falsehalts it. - Strategy:
LayoutswapsPatternLayout,JsonLayout, orPlainTextLayoutwithout touching the appender. SameAppender, different output shape. - Decorator:
AsyncAppenderWrapperwraps anyAppenderand adds aBlockingQueueplus drain thread. The wrapped appender is unaware it is asynchronous. - Singleton / Factory:
LoggerFactoryowns the single per-process registry;getLogger(name)is idempotent so multiple call sites share the sameLoggerinstance. - Observer:
RollingFileAppenderobserves its own file size or wall-clock; on threshold cross it triggers a rotate (close, rename, reopen). - Filter chain: an ordered list of
Filterstrategies returningACCEPT / 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
JsonLayoutreplacesPatternLayoutand addsmdc,traceId,spanIdas 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,tenantIdwithout 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
RateLimitFiltertoken-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.levelfromINFOtoDEBUGfor one logger, scoped by name prefix, for a fixed window. - Async logging performance: a ring-buffer (LMAX Disruptor) outperforms
ArrayBlockingQueueon contended write paths; one producer slot per thread eliminates the lock entirely. - OpenTelemetry-style export: an
OtlpAppenderships 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
errorHandlerstrategy. - 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
inLoggingflag 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 withkill. - Log rotation while writing:
RollingFileAppendermust rotate atomically: close, rename toapp.log.1, reopen atapp.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
LogRecordallocation: high-volume INFO logging can dominate allocation. Object-poolLogRecordor 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.
Related Topics#
- Concurrency Primitives: blocking queues and condition variables behind the async wrapper
- Producer Consumer LLD: the same bounded-queue pattern used by
AsyncAppenderWrapper - Distributed Logging: the HLD layer that aggregates these log streams across hosts
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.