Design a Logging Framework (LLD)
A tree of named loggers with inherited levels, appenders with their own thresholds and formats, and an async queue that forces a choice between dropping and blocking.
- 4 levelsDEBUG, INFO, WARN, ERROR as ordered values
- 1 recordbuilt once per call and shared by appenders
- drop or blockthe choice a bounded queue forces
Show what to say
“I'd make a tree of named loggers where a child inherits its level from the nearest ancestor. A call checks the level first, builds one record, and hands it to appenders up the tree, each with its own threshold and formatter. Slow I/O goes behind a bounded async queue, and I'd inject the manager instead of using a singleton.”
Trade-offs
- Drop vs block: dropping keeps the request fast and counts losses, blocking keeps every line but can stall callers.
- Copy-on-write vs a lock: no lock on the hot path, at the cost of copying the appender list on each rare add.
- Lazy message vs a plain string: no cost when the level is off, in exchange for a slightly clumsier call.
Bring it up before they ask
On shutdown the async queue must be flushed, or the last and most useful lines before a crash or restart are lost.
A logger tree keyed by dotted names lets a child inherit its level, and each logger fans one record out to a list of appenders, each with its own threshold and formatter. Check the level before building anything, and put slow I/O behind a bounded queue.
Functional
- Four levels: DEBUG, INFO, WARN, ERROR
- Named loggers in a tree: app.db inherits from app
- Many appenders per logger (console, file, memory), each with a threshold
- A formatter per appender: plain text or JSON
- Change a level or add an appender at runtime
- Optional async appender with a bounded queue and flush on shutdown
Non-functional
- A disabled call costs almost nothing
- Safe when many threads log at once, and lines never interleave
- A failing appender never breaks the application or the other appenders
- New formats and destinations plug in without editing Logger
- Deterministic tests: the clock is injected
Out of scope
- Log shipping and search (an HLD follow-up)
- File rotation details
- Structured fields and tracing context (a follow-up)
A low-level design (LLD) question where the patterns are easy to name and the real test is judgement: what to build, and what to leave out.
New to this? Read Swap behaviour and State machines and observers first.
Clarifying questions and interface
Read more · 180 words
- Do loggers form a hierarchy, or is there one global logger?
- Can levels and appenders change while the program runs?
- Must a log call ever block, or may it lose lines under load?
- Plain text only, or JSON for a log shipper too?
- Many threads? Does the order of lines matter?
| Call | Returns | Notes |
|---|---|---|
manager.getLogger(name) | A logger | Same name, same logger; parents are created on demand |
logger.log(level, message) | How many appenders wrote it | The message may be a function, called only if the level is on |
logger.level = ... | Unset means inherit from the parent | |
logger.addAppender(a) | Copy-on-write, safe while others log | |
appender.append(record) | true if it wrote | Applies its own threshold and formatter |
What it remembers
LogManager.loggers: name → logger, plus theroot, which always has a level.- Per logger: an optional level, its parent, and a list of appenders.
- Per appender: a threshold, a formatter, and a sink. An async appender also holds a bounded queue and a drop count.
A record is built once per call and shared, never copied per appender.
Build the design in stages
Step through: named loggers with a level, then appenders, then formatters, then a queue, then the clock.
Read more · 93 words
Green marks what the latest stage added.
Patterns, and when to skip them. Strategy for the formatter, Decorator for the async wrapper, and Observer in spirit for the appender list: the logger never knows who is listening. These three earn their place because real changes land on them.
Skip the rest. A LoggerFactoryBuilder, an abstract LogHandlerChain, or a level class per level add nothing. Levels are four ordered values.
Singleton is a smell. A global logger cannot be reset between tests. Inject the manager, and offer a module-level default as a convenience only.
LogManager hands out named Loggers in a tree. A Logger has an optional Level, creates a Record for each enabled call, and hands it to its Appenders and then to its parent's. Appenders turn a record into a line with a swappable Formatter, an AsyncAppender wraps any appender with a bounded queue, and the Clock is injected.
- ◆ composition (owns)
- ◇ aggregation
- △ inheritance / implements (dashed)
- → association · ⇢ dependency
A log call, step by step
Pick a branch: a call that is switched off, one that reaches two appenders through the tree, and one where an appender throws.
- Client
- Service
- Worker
- Object storage
Text description
- Caller → app.db: log(level, supplier)
- app.db replies to Caller: 0 appenders wrote
Async logging and thread safety
A slow disk must not slow the request.
Read more · 125 words
The async appender is a decorator: it puts records on a bounded queue, and one worker thread writes them in order.
The bound is the real decision. Drop keeps the app fast and counts what it lost. Block keeps every line but can stall callers. Choose drop for debug noise, block for audit lines, and always flush the queue on shutdown.
Thread safety
- Appenders list: copy-on-write. Adds are rare and log calls constant, so each call iterates a snapshot with no lock.
- Line order: each sink call holds a lock, so two threads never interleave half-lines.
- Level: one reference read, safe if it is volatile (Java) and read once per call.
- Listener failures: catch per appender, so one broken disk does not silence the console.
- Client
- Service
- Queue
- Worker
- Object storage
Text description
- Caller → AsyncAppender: append(record)
- AsyncAppender → Bounded queue: offer(record)
- AsyncAppender replies to Caller: true, returns at once
- Bounded queue sends (async) to Worker: take()
- Worker → File: append(record)
Step-by-step answer
Five moves: clarify (hierarchy, runtime changes, blocking, threads), name the nouns (Logger, Level, Record, Appender, Formatter), write the hot path (check level, build record, walk up, append), add the two real extension points (formatter and appender), then async and threads (bounded queue, copy-on-write, flush).
Read more · 158 words
The level check comes first
A disabled call must cost almost nothing. Check before building the record, and take the message as a function so the string is built only when needed.
log(level, message) { if (!this.isEnabled(level)) return 0; const text = typeof message === 'function' ? message() : message; /* build the record, walk up the tree */ }Inheritance is a walk up the tree
A null level means ask the parent. The root always has one, so the loop ends. Changing
appchanges every descendant that inherits from it.Parents come from the name
getLogger("app.db")cuts at the last dot and getsappfirst. No registry of parents to keep in step.Appenders are independent
Each has its own threshold and formatter. Return how many wrote, so callers and tests can see the effect.
Tests
Inherit, override, thresholds, a lazy message that is not built, an appender added mid-call, one that throws, and an async queue that drops.
Implementation and code it yourself
The same design in JavaScript, Java or Python. Java and Python add real threads for the async appender.
Read more · 44 words
AsyncAppender.close()puts a stop marker behind the last record, so everything queued is written first.- The JavaScript async appender uses a microtask as its worker, and
flush()makes tests deterministic.
Try the Logging Framework exercise: inherited levels, appenders with thresholds, and two formats.
export const Level = { DEBUG: 10, INFO: 20, WARN: 30, ERROR: 40 };
const nameOf = n => Object.keys(Level).find(k => Level[k] === n);
// Strategy: how a record becomes a line. A new format is a new function, and nothing else changes.
export const plain = r => `${r.time} ${nameOf(r.level)} ${r.logger} - ${r.message}`;
export const json = r => JSON.stringify({ time: r.time, level: nameOf(r.level), logger: r.logger, message: r.message });
// An appender is where lines go. The sink is a function, so a console, a file and a list all look alike.
export class Appender {
constructor(sink, { threshold = Level.DEBUG, format = plain } = {}) {
Object.assign(this, { sink, threshold, format });
}
append(record) {
if (record.level < this.threshold) return false; // each appender has its own bar
this.sink(this.format(record));
return true;
}
}
// Decorator: wraps any appender so the caller never waits for slow I/O. The queue is bounded on purpose.
export class AsyncAppender {
constructor(inner, { capacity = 1000 } = {}) {
Object.assign(this, { inner, capacity });
this.queue = [];
this.dropped = 0;
}
append(record) {
if (this.queue.length >= this.capacity) { this.dropped++; return false; } // full: drop the newest, never block
this.queue.push(record);
if (this.queue.length === 1) queueMicrotask(() => this.flush()); // JavaScript's "worker thread"
return true;
}
flush() { while (this.queue.length) this.inner.append(this.queue.shift()); }
}Failure modes and trade-offs
Read more · 156 words
| What breaks | Effect | Mitigation |
|---|---|---|
| Disk full or slow | Every request stalls | Async appender with a bounded queue |
| Queue grows without a limit | Out of memory | Bound it, drop and count |
| Process exits with a full queue | Lost last lines | Flush on shutdown |
| An appender throws | Other appenders silent | Catch per appender |
| Log call builds a big string | CPU cost even when off | Lazy message, level check first |
Trade-offs
| Choice | Pros | Cons |
|---|---|---|
| Drop vs block | Drop: fast. Block: complete | Drop loses lines. Block can stall |
| Hierarchy vs one logger | Per-area levels | Inheritance rules to explain |
| Copy-on-write vs lock | No lock on the hot path | A copy on each add |
| Lazy message vs string | No cost when off | Slightly clumsier calls |
Follow-up questions
How would you add structured fields like request id?
Give the record a map of fields, filled from a per-thread or per-request context. Formatters decide how to print them. The call sites do not change.
How would you rotate log files?
Make rotation part of the file sink: when the size or day changes, close the file and open the next. The logger and the formatter do not know.
How do you stop sensitive data from leaking?
Add a filter or a decorating formatter that masks known fields, so every appender is covered by one change.
How do logs from 1,000 servers reach one place?
Each server writes JSON lines to local disk, and an agent ships them to a queue and a search index. That is the HLD version of this appender.
How would you test it?
Inject the clock and a memory sink, then assert whole lines. Add a slow sink to test drop and block, and a throwing appender to test isolation.
Check yourself
Question 1 of 3Score 2 out of 3 or better to mark this page complete.