What a logger is (and isn’t)
Library, not aggregator
DEBUG…FATAL enum
Many destinations
No byte interleave
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.Analogy: post office drop box
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 — 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.
Class design: compose Destination
- Type switch — one class,
if CONSOLE / FILE. Breaks as you add sinks and null fields pile up. - Template Method inheritance — abstract Destination +
doWrite. Shares filter/format once, but wrapping (buffer/retry) composes poorly. - 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
}
}
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
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.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.
- Logger accepts LogRecord; routes to Destinations = Formatter × Sink.
- Levels filter early; format only if enabled.
- Per-destination lock for thread safety; optional async queue.
- Trace: INFO to console+file; ERROR only to file; slow sink doesn’t need to block forever — mention async.
- 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.