InterviewKata
Low-level design · Problem P03 of 15 · Warm-up problems

Logging framework

A classic for testing whether you can find the right seams: what varies (where logs go, how they look) goes behind interfaces, and what doesn't (levels, the order of checks) stays simple.

1. The prompt

The prompt"Design a logging library. It should support log levels, and sending logs to several destinations (console, file, a remote service) in different formats."

2. Clarify the requirements

Spend the first five minutes here. Ask, then write the answers down where the interviewer can see them.

AskAssume (unless told otherwise)
Can one message go to several places?Yes: every appender of the logger receives it.
Different formats per destination?Yes: each appender has its own formatter.
Is the level per logger or per destination?Both: the logger filters first, then each appender's threshold.
Async?Synchronous for now; async is a follow-up.

Must do

  • Levels: DEBUG < INFO < WARN < ERROR
  • Named loggers with their own minimum level
  • Several destinations, each with its own format and threshold

Must be

  • Cheap when a message is filtered out
  • Safe from many threads
  • Easy to add a new destination

Out of scope

  • Log rotation and retention
  • Remote shipping and batching (mention as a follow-up)
  • Configuration files

3. The classes

The class diagram: arrows show who uses or holds whom.
ClassIts one job
LevelAn ordered enum; comparisons decide what passes
LoggerFilters by level, builds the record, hands it to every appender
AppenderA destination with a threshold and a formatter
FormatterTurns a record into a line: text or JSON

4. How it works

5. Patterns and why

PatternWhereWhy it's worth it here
StrategyFormatter implementationsChange how a line looks without touching where it goes
Template methodAppender.append() checks the threshold, then calls write()Every destination gets the same rule; subclasses only implement write()
Observer-like fan-outA logger sends each record to all its appendersAdd a destination without changing the logger
Memory hook: "Filter early, fan out, format at the edge"The logger drops what's below its level before doing any work; each appender decides and formats for itself.

6. The code

Each appender has its own threshold and formatter, so one record can reach several destinations in different shapes.

from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
from enum import IntEnum


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


@dataclass(frozen=True)
class Record:
    level: Level
    logger: str
    message: str


class Formatter(ABC):
    @abstractmethod
    def format(self, r: Record) -> str: ...


class TextFormatter(Formatter):
    def format(self, r: Record) -> str:
        return f"[{r.level.name}] {r.logger}: {r.message}"


class JsonFormatter(Formatter):
    def format(self, r: Record) -> str:
        msg = r.message.replace("\\", "\\\\").replace('"', '\\"')
        return f'{{"level":"{r.level.name}","logger":"{r.logger}","message":"{msg}"}}'


class Appender(ABC):
    """A destination. Each has its own threshold and formatter, so one record can go to many places differently."""

    def __init__(self, formatter: Formatter, threshold: Level = Level.DEBUG) -> None:
        self.formatter, self.threshold = formatter, threshold

    def append(self, r: Record) -> None:
        if r.level >= self.threshold:
            self.write(self.formatter.format(r))

    @abstractmethod
    def write(self, line: str) -> None: ...


class ConsoleAppender(Appender):
    def write(self, line: str) -> None:
        print(line)


class MemoryAppender(Appender):
    """Stands in for a file or a network sink; also handy in tests."""

    def __init__(self, formatter: Formatter, threshold: Level = Level.DEBUG) -> None:
        super().__init__(formatter, threshold)
        self.lines: list[str] = []

    def write(self, line: str) -> None:
        self.lines.append(line)


class Logger:
    def __init__(self, name: str, level: Level, appenders: list[Appender]) -> None:
        self.name, self.level, self.appenders = name, level, appenders

    def log(self, level: Level, message: str) -> None:
        if level < self.level:   # cheap check first: most debug calls stop here
            return
        record = Record(level, self.name, message)
        for a in self.appenders:
            a.append(record)

    def debug(self, m: str) -> None: self.log(Level.DEBUG, m)
    def info(self, m: str) -> None: self.log(Level.INFO, m)
    def warn(self, m: str) -> None: self.log(Level.WARN, m)
    def error(self, m: str) -> None: self.log(Level.ERROR, m)


if __name__ == "__main__":
    alerts = MemoryAppender(JsonFormatter(), threshold=Level.ERROR)
    log = Logger("payments", Level.INFO, [ConsoleAppender(TextFormatter()), alerts])
    log.debug("cache warm-up details")
    log.info("charge started")
    log.warn("retrying card network")
    log.error('card declined: "insufficient funds"')
    print("alerts sink:")
    for line in alerts.lines:
        print(line)
enum Level { DEBUG = 10, INFO = 20, WARN = 30, ERROR = 40 }

interface LogRecord { level: Level; logger: string; message: string; }

interface Formatter { format(r: LogRecord): string; }

class TextFormatter implements Formatter {
  format(r: LogRecord): string { return `[${Level[r.level]}] ${r.logger}: ${r.message}`; }
}

class JsonFormatter implements Formatter {
  format(r: LogRecord): string {
    return JSON.stringify({ level: Level[r.level], logger: r.logger, message: r.message });
  }
}

/** A destination. Each has its own threshold and formatter, so one record can go to many places differently. */
abstract class Appender {
  constructor(protected formatter: Formatter, protected threshold: Level = Level.DEBUG) {}
  append(r: LogRecord): void {
    if (r.level >= this.threshold) this.write(this.formatter.format(r));
  }
  protected abstract write(line: string): void;
}

class ConsoleAppender extends Appender {
  protected write(line: string): void { console.log(line); }
}

/** Stands in for a file or a network sink; also handy in tests. */
class MemoryAppender extends Appender {
  readonly lines: string[] = [];
  protected write(line: string): void { this.lines.push(line); }
}

class Logger {
  constructor(private name: string, private level: Level, private appenders: Appender[]) {}
  log(level: Level, message: string): void {
    if (level < this.level) return; // cheap check first: most debug calls stop here
    const record: LogRecord = { level, logger: this.name, message };
    for (const a of this.appenders) a.append(record);
  }
  debug(m: string): void { this.log(Level.DEBUG, m); }
  info(m: string): void { this.log(Level.INFO, m); }
  warn(m: string): void { this.log(Level.WARN, m); }
  error(m: string): void { this.log(Level.ERROR, m); }
}

const alerts = new MemoryAppender(new JsonFormatter(), Level.ERROR);
const log = new Logger("payments", Level.INFO, [new ConsoleAppender(new TextFormatter()), alerts]);
log.debug("cache warm-up details");
log.info("charge started");
log.warn("retrying card network");
log.error('card declined: "insufficient funds"');
console.log("alerts sink:");
for (const line of alerts.lines) console.log(line);
import java.util.ArrayList;
import java.util.List;

enum Level { DEBUG, INFO, WARN, ERROR }

record LogRecord(Level level, String logger, String message) {}

interface Formatter {
    String format(LogRecord r);
}

class TextFormatter implements Formatter {
    public String format(LogRecord r) {
        return "[" + r.level() + "] " + r.logger() + ": " + r.message();
    }
}

class JsonFormatter implements Formatter {
    public String format(LogRecord r) {
        String msg = r.message().replace("\\", "\\\\").replace("\"", "\\\"");
        return "{\"level\":\"" + r.level() + "\",\"logger\":\"" + r.logger() + "\",\"message\":\"" + msg + "\"}";
    }
}

/** A destination. Each has its own threshold and formatter, so one record can go to many places differently. */
abstract class Appender {
    private final Formatter formatter;
    private final Level threshold;

    Appender(Formatter formatter, Level threshold) {
        this.formatter = formatter;
        this.threshold = threshold;
    }

    void append(LogRecord r) {
        if (r.level().compareTo(threshold) >= 0) write(formatter.format(r));
    }

    protected abstract void write(String line);
}

class ConsoleAppender extends Appender {
    ConsoleAppender(Formatter f) {
        super(f, Level.DEBUG);
    }

    protected void write(String line) {
        System.out.println(line);
    }
}

/** Stands in for a file or a network sink; also handy in tests. */
class MemoryAppender extends Appender {
    final List<String> lines = new ArrayList<>();

    MemoryAppender(Formatter f, Level threshold) {
        super(f, threshold);
    }

    protected synchronized void write(String line) {
        lines.add(line);
    }
}

class Logger {
    private final String name;
    private final Level level;
    private final List<Appender> appenders;

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

    void log(Level l, String message) {
        if (l.compareTo(level) < 0) return; // cheap check first: most debug calls stop here
        LogRecord r = new LogRecord(l, name, message);
        for (Appender a : appenders) a.append(r);
    }

    void debug(String m) { log(Level.DEBUG, m); }
    void info(String m) { log(Level.INFO, m); }
    void warn(String m) { log(Level.WARN, m); }
    void error(String m) { log(Level.ERROR, m); }
}

public class Main {
    public static void main(String[] args) {
        MemoryAppender alerts = new MemoryAppender(new JsonFormatter(), Level.ERROR);
        Logger log = new Logger("payments", Level.INFO, List.of(new ConsoleAppender(new TextFormatter()), alerts));
        log.debug("cache warm-up details");
        log.info("charge started");
        log.warn("retrying card network");
        log.error("card declined: \"insufficient funds\"");
        System.out.println("alerts sink:");
        alerts.lines.forEach(System.out::println);
    }
}

Output (the same in all three languages)

[INFO] payments: charge started
[WARN] payments: retrying card network
[ERROR] payments: card declined: "insufficient funds"
alerts sink:
{"level":"ERROR","logger":"payments","message":"card declined: \"insufficient funds\""}

7. Concurrency and edge cases

8. Follow-up questions

What interviewers ask once the core works, and a short answer for each.

How would you make it asynchronous?
An AsyncAppender decorator: callers enqueue the record; a background thread writes to the wrapped appender. Bounded queue; flush on shutdown.
How do child loggers inherit levels?
Loggers form a tree by name (payments.card → payments); a logger without its own level uses its parent's, like Java and Python logging.
How would you add log rotation?
A FileAppender that checks size or date before writing, and renames the file when a limit is reached.
Why not one global logger?
Named loggers let you turn on DEBUG for one component without drowning in everyone else's output.

9. Common mistakes

Formatting before filteringBuilding strings for messages that are then dropped wastes most of the cost of logging.
Hard-coding destinationsA switch on 'console' or 'file' inside the logger. Put destinations behind an interface.
One threshold for everythingAlerts want only errors; the console wants everything. Give each appender its own.
Unescaped JSONA message with a quote produces invalid JSON lines.

10. Check yourself

Why check the level before building the record?

Filter early, format late.

Which pattern lets you add a JSON format without touching appenders?

Each appender holds a formatter it can call.

A network appender is slow. What's the usual fix?

Callers return immediately; the queue absorbs bursts.

Your turn

  1. Add a FileAppender (10 min). Write lines to a file; make the write thread-safe.
  2. Add an AsyncAppender (15 min). A decorator with a bounded queue and a background thread.