🧠 Logging Framework LLD — Thought Process Guide¶
Goal: Design a log4j / Python-
logging-style library and defend the three things that separate a good answer: correct hierarchy semantics, real thread-safety, and a non-blocking path with an explicit overflow policy.
📊 Class Diagram¶
classDiagram
class LogManager {
+root: Logger
+clock
+get_logger(name) Logger
+shutdown()
}
class Logger {
+name
+parent: Logger
+propagate: bool
-level: Level?
-handlers: tuple
+set_level(level)
+effective_level() Level
+add_handler(h)
+log(level, msg, *args, **extra)
+debug() info() warning() error() exception()
}
class LogRecord {
<<frozen>>
+logger_name
+level
+msg
+args
+created
+context
+extra
+exc_text
+message
}
class Handler {
<<abstract>>
+level
+formatter: Formatter
+filters: Filter[]
+handle(record)
+emit(text, record)*
+flush()
+close()
}
class Formatter {
<<abstract>>
+format(record) str
}
class Filter {
<<abstract>>
+filter(record) LogRecord?
}
class AsyncHandler {
+target: Handler
+overflow: OverflowPolicy
+dropped: int
-queue: Queue
-worker: Thread
}
class Level {
<<IntEnum>>
DEBUG INFO WARNING ERROR CRITICAL
}
class OverflowPolicy {
<<enum>>
DROP
BLOCK
}
LogManager --> "*" Logger
Logger --> Logger : parent
Logger --> "*" Handler
Logger ..> LogRecord : creates
Handler --> Formatter
Handler --> "*" Filter
Handler <|-- StreamHandler
StreamHandler <|-- ConsoleHandler
StreamHandler <|-- FileHandler
Handler <|-- InMemoryHandler
Handler <|-- AsyncHandler
AsyncHandler --> Handler : target
Formatter <|-- TextFormatter
Formatter <|-- JsonFormatter
Filter <|-- NameFilter
Filter <|-- RedactionFilter
Filter <|-- SamplingFilter
⏱️ How to run this in a 45–60 min interview¶
| Minutes | Do | Say out loud |
|---|---|---|
| 0–5 | Clarify (questions below). | "Library, not service. I'll design the in-process API first, then talk about the pipeline behind it." |
| 5–12 | Entities + interfaces: Level, LogRecord, Logger, Handler (Strategy for the sink), Formatter, Filter, LogManager. |
"Logger decides whether; handlers decide where; formatters decide how it looks; filters decide which and can transform." |
| 12–30 | Core code: get_logger building the dotted hierarchy; effective_level(); log() with the fast-path level check; propagation loop; Handler.handle template method; console + in-memory handlers; text formatter. |
"The disabled path must be one integer comparison: no record, no string formatting." |
| 30–42 | Thread-safety + async: per-handler lock around emit; copy-on-write handler tuple; AsyncHandler with bounded queue, worker, DROP vs BLOCK, flush/close. |
"An unbounded queue just moves the outage from latency to OOM." |
| 42–52 | Extension: JSON formatter, correlation ids via contextvars, redaction, sampling. |
"Context is captured at record creation, in the caller, because the worker thread has a different context." |
| 52–60 | Tests and the at-scale pipeline (HLD). |
Clarifying questions worth asking¶
- Which levels? Custom levels? (Five standard ones;
IntEnumleaves room.) - Hierarchy by dotted name with inheritance? (Yes, that is the classic ask.)
- Sinks: console, file, in-memory now; network later? (Handler interface.)
- Must logging ever block the caller? What happens when a sink is slower than the producer? (Drives
AsyncHandler+ overflow policy.) - Is losing logs acceptable? Which ones? (Usually: DEBUG/INFO yes under pressure, ERROR+ prefer not; audit logs are not "logs", they're data.)
- Structured output? (JSON for machines, text for humans.)
- Multi-threaded? asyncio? (Both, hence
contextvars, notthreading.local.) - Config at runtime (change a level without restart)? (Supported:
set_levelinvalidates the level cache.)
Phase 1: Separate the four decisions¶
| Question | Owner | Why separate |
|---|---|---|
| Is this level enabled here? | Logger.effective_level() |
Must be the cheapest check in the system |
| Where does it go? | Handler subclasses (Strategy) |
Sinks vary independently of format |
| What does it look like? | Formatter |
Same sink, different formats (text locally, JSON in prod) |
| Should this record go to this sink, and in what form? | Filter (drop or transform) |
Redaction and sampling are per-sink policies |
Phase 2: Hierarchy and inheritance¶
get_logger("a.b.c")createsaanda.bif missing, soparentis always the true nearest ancestor. (Python's stdlib uses placeholders and fixes up children later. Creating ancestors eagerly is simpler and costs a few objects.)level=Nonemeans "inherit".effective_level()walks up to the first explicit level; root always has one.- Walking on every call is O(depth). Cache it per logger, tagged with a manager-wide generation that any
set_levelbumps. Readers stay lock-free and stale caches self-invalidate.
Phase 3: Propagation is a Chain of Responsibility¶
node = self
while node:
for h in node.handlers: h.handle(record)
if not node.propagate: break
node = node.parent
The semantic interviewers probe: ancestor logger levels are not re-checked; ancestor handler levels are. So root.level=ERROR doesn't hide INFO from app.db (if app.db is INFO) at root's handlers. To restrict a sink, set the handler's level. This matches log4j and Python and is worth stating explicitly.
Phase 4: Thread-safety, made real¶
Handler.handle: level → filters → format outside the lock →emitinside the handler's lock. Lines never interleave; formatting parallelises.Logger._handlersis an immutable tuple replaced under the manager lock (copy-on-write): the hot path iterates it without locking.LogRecordis frozen, so it can cross to another thread safely.- Logging must never crash the app:
handle()catches everything, reports to stderr (handle_error), and moves on.
Phase 5: Non-blocking with a bounded queue¶
AsyncHandler(target, capacity, overflow):
- Caller thread: level + filters (cheap rejection), then enqueue the record (not formatted text).
- Worker thread:
target.handle(record)(format + I/O). - Queue full:
DROPdiscards the incoming record and counts it;BLOCKwaits (optionally up toblock_timeout, then drops). DROP protects latency; BLOCK protects completeness. Exposedroppedas a metric. flush()=queue.join()thentarget.flush().close()= stop accepting, enqueue a stop marker with a blocking put (it must get in even under DROP), join the worker, account for any stragglers, close the target.LogManager.shutdown()closes async handlers before plain ones, so a target shared by both is still open while the queue drains. The default manager registers it withatexit.
Phase 6: Context and structure¶
log_context(correlation_id=...)sets aContextVarholding an immutable mapping; nested scopes merge and restore via the reset token.ContextVaris per thread and per asyncio task;threading.localwould leak between tasks on one event-loop thread.- The record copies the context at creation, so async handlers see the caller's context, not the worker's.
JsonFormatterflattens context + extra, then writes reserved keys (ts,level,logger,message,template) last so a strayextracan't spoof them. Keeping the unformattedtemplategives a stable grouping key for log analytics.
Phase 7: Quick checklist¶
✅ Disabled levels cost one comparison; message interpolation is lazy
✅ Hierarchy with inheritance; cached effective level with generation invalidation
✅ Propagation semantics stated and tested
✅ Per-handler lock; lock-free reads of handler lists; frozen records
✅ Bounded async queue, DROP vs BLOCK(+timeout), drop counter, flush/close ordering
✅ contextvars correlation ids for threads and asyncio
✅ JSON, redaction (template, args and fields), request-consistent sampling
✅ Logging failures never propagate to the caller