Logger LLD

Design a logger

In-process logging library LLD: LogRecord, per-destination level + format, Formatter × Sink composition, per-destination locks, sink-failure handling, then async queues and hierarchical named loggers as extensions.

What a logger is (and isn’t)

01In-process

Library, not aggregator

02Levels

DEBUG…FATAL enum

03Fan-out

Many destinations

04Safe

No byte interleave

A logger is the in-process library your app links against — logger.info("user signed in") — timestamps the message, attaches severity, and writes to console, file, or both. Think Log4j, SLF4J, or Python’s logging. This is not a distributed log pipeline.
Logging pipeline
Logger builds a record → each Destination filters, formats, locks, sinks.

Analogy: post office drop box

Async logger queue
API threads drop letters; clerk files them.

Sync logging is handwriting in the archive book yourself (slow). Async logging is dropping letters in a post-office slot: your request returns; a clerk files them in order. A full slot means drop or block — pick a policy.

Clarifying questions → locked requirements

  • Scope? → In-process library; remote shipping out of v1 but pluggable later.
  • Levels? → DEBUG < INFO < WARN < ERROR < FATAL — enum, not a class hierarchy.
  • Multiple destinations? → Yes; one call fans out to all.
  • Filter where? → Per destination min level (console DEBUG, file WARN).
  • Format? → Varies independently of destination type (JSON to console, plain to file).
  • Concurrency? → Thread-safe; a record’s bytes never interleave on one destination.
  • Config? → Static at startup; no hot-reload / async / rotation in v1.

Entities

Logger entities
Logger → Destinations; each Destination composes Formatter + Sink.
  • Logger — public API; immutable destination list; builds LogRecord.
  • LogRecord — immutable value (timestamp, level, message, thread).
  • Destination — min level, formatter, sink, per-destination lock.
  • Formatter — Strategy: record → string (plain / JSON).
  • Sink — write formatted bytes (console / file / future remote).
  • LogLevel — enum with ordering; not DebugLevel/InfoLevel classes.
Application and OS threads aren’t entities — capture thread name at the call site. Don’t invent a Sink in the entity brainstorm until you’ve weighed inheritance vs composition on Destination; that’s the design payoff.

Class design: compose Destination

Three Destination shapes show up in interviews. Pick composition and say why.
  1. Type switch — one class, if CONSOLE / FILE. Breaks as you add sinks and null fields pile up.
  2. Template Method inheritance — abstract Destination + doWrite. Shares filter/format once, but wrapping (buffer/retry) composes poorly.
  3. Composition (preferred) — concrete Destination owns filter + format + lock; delegates bytes to a Sink. New remote = new Sink. Matches Log4j layout/appender separation.
from enum import IntEnum
from dataclasses import dataclass
from datetime import datetime
from threading import Lock
from typing import Protocol


class LogLevel(IntEnum):
    DEBUG = 10
    INFO = 20
    WARN = 30
    ERROR = 40
    FATAL = 50


@dataclass(frozen=True)
class LogRecord:
    timestamp: datetime
    level: LogLevel
    message: str
    thread_name: str


class Formatter(Protocol):
    def format(self, record: LogRecord) -> str: ...


class Sink(Protocol):
    def write(self, formatted: str) -> None: ...


class Destination:
    def __init__(self, formatter: Formatter, min_level: LogLevel, sink: Sink):
        self._formatter = formatter
        self._min_level = min_level
        self._sink = sink
        self._lock = Lock()

    def write(self, record: LogRecord) -> None: ...


class Logger:
    def __init__(self, destinations: list[Destination]):
        self._destinations = tuple(destinations)  # immutable after start

    def log(self, level: LogLevel, message: str) -> None: ...
    def info(self, message: str) -> None:
        self.log(LogLevel.INFO, message)
    # debug / warn / error / fatal likewise
import java.time.Instant;
import java.util.*;
import java.util.concurrent.locks.ReentrantLock;

enum LogLevel {
    DEBUG(10), INFO(20), WARN(30), ERROR(40), FATAL(50);
    final int value;
    LogLevel(int value) { this.value = value; }
}

final class LogRecord {
    final Instant timestamp;
    final LogLevel level;
    final String message;
    final String threadName;

    LogRecord(Instant timestamp, LogLevel level, String message, String threadName) {
        this.timestamp = timestamp; this.level = level;
        this.message = message; this.threadName = threadName;
    }
}

interface Formatter {
    String format(LogRecord record);
}

interface Sink {
    void write(String formatted);
}

class Destination {
    private final Formatter formatter;
    private final LogLevel minLevel;
    private final Sink sink;
    private final ReentrantLock lock = new ReentrantLock();

    Destination(Formatter formatter, LogLevel minLevel, Sink sink) {
        this.formatter = formatter; this.minLevel = minLevel; this.sink = sink;
    }

    void write(LogRecord record) { /* ... */ }
}

class Logger {
    private final List<Destination> destinations;

    Logger(List<Destination> destinations) {
        this.destinations = List.copyOf(destinations); // immutable after start
    }

    void log(LogLevel level, String message) { /* ... */ }
    void info(String message) { log(LogLevel.INFO, message); }
    // debug / warn / error / fatal likewise
}

Implementation highlights

def log(self, level: LogLevel, message: str) -> None:
    record = LogRecord(
        timestamp=datetime.utcnow(),
        level=level,
        message=message,
        thread_name=threading.current_thread().name,
    )
    for dest in self._destinations:
        dest.write(record)  # Destination swallows sink errors
void log(LogLevel level, String message) {
    LogRecord record = new LogRecord(
        Instant.now(),
        level,
        message,
        Thread.currentThread().getName()
    );
    for (Destination dest : destinations) {
        dest.write(record); // Destination swallows sink errors
    }
}
Capture timestamp and thread once at the top of log so every destination sees the same record. Don’t filter at Logger — thresholds live on destinations. Don’t lock around the destination list — it’s immutable; locks live next to each sink.
def write(self, record: LogRecord) -> None:
    if record.level < self._min_level:
        return  # silent drop
    formatted = self._formatter.format(record)  # outside lock: pure + immutable
    with self._lock:
        try:
            self._sink.write(formatted)
        except Exception as e:
            # Don't crash the caller; prefer stderr diagnostic in production
            sys.stderr.write(f"logger: sink write failed: {e}\n")
void write(LogRecord record) {
    if (record.level.value < minLevel.value) {
        return; // silent drop
    }
    String formatted = formatter.format(record); // outside lock: pure + immutable
    lock.lock();
    try {
        sink.write(formatted);
    } catch (Exception e) {
        // Don't crash the caller; prefer stderr diagnostic in production
        System.err.println("logger: sink write failed: " + e);
    } finally {
        lock.unlock();
    }
}
  • Propagate sink errors — kills the fan-out loop and can crash payment code because logging failed. Bad.
  • Swallow silently — other destinations OK; file may be empty for days. OK for interview if named.
  • Swallow + stderr diagnostic — what Log4j/Python do; mention rate-limiting diagnostics in production.
class PlainTextFormatter:
    def format(self, record: LogRecord) -> str:
        return (
            f"{record.timestamp.isoformat()} [{record.level.name}] "
            f"[{record.thread_name}] {record.message}"
        )


class ConsoleSink:
    def write(self, formatted: str) -> None:
        print(formatted, flush=True)


class FileSink:
    def __init__(self, path: str):
        self._f = open(path, "a", encoding="utf-8")  # open once

    def write(self, formatted: str) -> None:
        self._f.write(formatted + "\n")
        self._f.flush()  # recent lines survive crashes better
class PlainTextFormatter implements Formatter {
    public String format(LogRecord record) {
        return record.timestamp + " [" + record.level.name() + "] "
                + "[" + record.threadName + "] " + record.message;
    }
}

class ConsoleSink implements Sink {
    public void write(String formatted) {
        System.out.println(formatted);
        System.out.flush();
    }
}

class FileSink implements Sink {
    private final java.io.PrintWriter out;

    FileSink(String path) throws java.io.IOException {
        out = new java.io.PrintWriter(new java.io.FileWriter(path, true)); // open once
    }

    public void write(String formatted) {
        out.println(formatted);
        out.flush(); // recent lines survive crashes better
    }
}

Verification

  • Different thresholds — console DEBUG, file WARN; info(...) hits console, file silent-drops.
  • Two threads, one file — both format in parallel; lock serializes sink writes; no interleaved bytes (order = lock order, not global call order).
  • File sink throws — console still writes; stderr diagnostic; caller never sees the exception.
  • Happy: logger.info("hi") → record → each destination formats and writes.
  • Failure: DEBUG when level=INFO → no format/write; sink throws → isolated per destination policy.
  • Concurrency: Two threads log — per-destination locks; AsyncAppender hands off to worker queue.

Extensibility

Async / non-blocking log(). Bounded queue per Destination + worker thread: write enqueues and returns; worker formats and sinks. Single consumer → often no lock on the sink. Call out worker shutdown drain, overflow policy (drop newest + diagnostic vs block vs throw), and harder stack traces. Lock = correctness; queue = coordination — they compose.
Hierarchical named loggers. LoggerFactory.get_logger("com.app.payments") with name + parent pointer; effective level/destinations walk parents. Cache effective config on the hot path; factory registry is intentional shared state.

Common interview pitfalls

These mistakes show up constantly on this prompt. Name the trap, then show the fix in your design — don’t wait for the interviewer to catch you.

  • Singleton Logger as the whole design — no Destination/Formatter seams.
  • Formatting inside every call site instead of Formatter.
  • One giant lock for all sinks — unnecessary contention.
  • Blocking I/O on request thread with no async discussion.
  • Log level filtered after expensive string format.
  • Named loggers hierarchy ignored when asked “like log4j”.

Interview script (say this)

Read this once out loud before a mock. It’s the spine of a strong answer — not a script to recite robotically.

  1. Logger accepts LogRecord; routes to Destinations = Formatter × Sink.
  2. Levels filter early; format only if enabled.
  3. Per-destination lock for thread safety; optional async queue.
  4. Trace: INFO to console+file; ERROR only to file; slow sink doesn’t need to block forever — mention async.
  5. Extension: new sink = new Sink impl; Logger wiring unchanged.

Extra verification traces

Walk these three traces on the board. If you can narrate them cleanly, your implementation section usually follows.

def log(self, level, msg, **ctx):
    if level < self.level:
        return
    record = LogRecord(level, msg, ctx, self.clock())
    for dest in self.destinations:
        dest.write(record)  # formats under dest.lock

class Destination:
    def write(self, record):
        with self.lock:
            self.sink.write(self.formatter.format(record))
void log(LogLevel level, String msg, Map<String, Object> ctx) {
    if (level.value < this.level.value) return;
    LogRecord record = new LogRecord(level, msg, ctx, clock());
    for (Destination dest : destinations) {
        dest.write(record); // formats under dest.lock
    }
}

class Destination {
    final ReentrantLock lock = new ReentrantLock();
    Formatter formatter;
    Sink sink;

    void write(LogRecord record) {
        lock.lock();
        try {
            sink.write(formatter.format(record));
        } finally {
            lock.unlock();
        }
    }
}

Staff-level follow-ups

At staff+, they twist the prompt. Answer in one sentence that names the seam — don’t redesign the whole board.

  • Sampling / rate limit logs? — Filter in Logger pipeline before destinations.
  • Structured JSON? — JsonFormatter; sinks unchanged.
  • Context propagation? — ContextAdapter merges request_id into record.
  • Fan-out to Kafka? — KafkaSink implementing Sink.

Complete solution: synced file sink + optional async

from enum import IntEnum
from threading import Lock
from queue import Queue, Full
from threading import Thread

class Level(IntEnum):
    DEBUG = 10
    INFO = 20
    WARN = 30
    ERROR = 40

class FileSink:
    def __init__(self, path: str):
        self._f = open(path, "a", encoding="utf-8")
        self._lock = Lock()

    def write(self, line: str) -> None:
        with self._lock:
            self._f.write(line + "\n")
            self._f.flush()

class Logger:
    def __init__(self, name: str, level: Level, sinks: list):
        self.name, self.level, self.sinks = name, level, sinks

    def log(self, level: Level, msg: str) -> None:
        if level < self.level:
            return
        line = f"{level.name} {self.name} {msg}"
        for sink in self.sinks:
            try:
                sink.write(line)
            except Exception:
                pass  # never break caller

# Async variant: Queue between Logger.log and FileSink — see Coordination post.
import java.io.*;
import java.util.*;
import java.util.concurrent.locks.ReentrantLock;

enum Level {
    DEBUG(10), INFO(20), WARN(30), ERROR(40);
    final int value;
    Level(int value) { this.value = value; }
}

class FileSink {
    private final PrintWriter out;
    private final ReentrantLock lock = new ReentrantLock();

    FileSink(String path) throws IOException {
        out = new PrintWriter(new FileWriter(path, true));
    }

    void write(String line) {
        lock.lock();
        try {
            out.println(line);
            out.flush();
        } finally {
            lock.unlock();
        }
    }
}

class Logger {
    String name;
    Level level;
    List<FileSink> sinks;

    Logger(String name, Level level, List<FileSink> sinks) {
        this.name = name; this.level = level; this.sinks = sinks;
    }

    void log(Level level, String msg) {
        if (level.value < this.level.value) return;
        String line = level.name() + " " + name + " " + msg;
        for (FileSink sink : sinks) {
            try {
                sink.write(line);
            } catch (Exception ignored) {
                // never break caller
            }
        }
    }
}

// Async variant: BlockingQueue between Logger.log and FileSink — see Coordination post.

Concurrency cases

  • Two threads, one file — without sink lock, interleaved bytes.
  • Async logger — Queue hands off; single worker may skip sink lock.
  • Per-destination locks — console and file don’t block each other.

← Lattice