BackendMedium35 min

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.

Say it in 30 seconds
  • 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.

0 of 0 sections ticked
30-second answer

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?
CallReturnsNotes
manager.getLogger(name)A loggerSame name, same logger; parents are created on demand
logger.log(level, message)How many appenders wrote itThe 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 wroteApplies its own threshold and formatter

What it remembers

  • LogManager.loggers: name → logger, plus the root, 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.

Stage 1 of 5

Named loggers with a level

A manager hands out one Logger per name. Each call becomes a Record, and a level decides whether the call matters at all.

Logging framework class diagramLogManager 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.LogManagerroot: Loggerloggers: Map<name, Logger>getLogger(name)LevelDEBUG < INFO < WARN < ERRORLoggername: 'app.db'level: Level or nullparent: Loggerappenders: copy-on-write listlog(level, message)isEnabled(level)addAppender(a)Recordtimelevellogger namemessageby namethresholdcreates

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.

Press Play to watch it run · 2 steps

The call first asks whether its level is on. If not, it returns at once with nothing built. If it is, the Logger builds one Record and walks up the tree: its own appenders first, then app, then the root. Each appender applies its own threshold and formatter.

logger.log(level, message) for the logger app.dbThe call first asks whether its level is on. If not, it returns at once with nothing built. If it is, the Logger builds one Record and walks up the tree: its own appenders first, then app, then the root. Each appender applies its own threshold and formatter.log(level, supplier)level null: ask app, which says WARNalt [level off]call was DEBUG, below WARN: the supplier never runs0 appenders wrote
  • Client
  • Service
  • Worker
  • Object storage
Text description
  1. Caller → app.db: log(level, supplier)
  2. 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.

Press Play to watch it run · 5 steps

The caller puts the record on a bounded queue and goes on. A single worker thread takes records in order and passes them to the real appender. When the queue is full the overflow policy decides: DROP loses the newest record and counts it, BLOCK makes the caller wait.

AsyncAppender: the caller and the workerThe caller puts the record on a bounded queue and goes on. A single worker thread takes records in order and passes them to the real appender. When the queue is full the overflow policy decides: DROP loses the newest record and counts it, BLOCK makes the caller wait.append(record)alt [room in the queue]offer(record)true, returns at oncetake()append(record)
  • Client
  • Service
  • Queue
  • Worker
  • Object storage
Text description
  1. Caller → AsyncAppender: append(record)
  2. AsyncAppender → Bounded queue: offer(record)
  3. AsyncAppender replies to Caller: true, returns at once
  4. Bounded queue sends (async) to Worker: take()
  5. 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
  1. 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 */
    }
  2. Inheritance is a walk up the tree

    A null level means ask the parent. The root always has one, so the loop ends. Changing app changes every descendant that inherits from it.

  3. Parents come from the name

    getLogger("app.db") cuts at the last dot and gets app first. No registry of parents to keep in step.

  4. Appenders are independent

    Each has its own threshold and formatter. Return how many wrote, so callers and tests can see the effect.

  5. 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 breaksEffectMitigation
Disk full or slowEvery request stallsAsync appender with a bounded queue
Queue grows without a limitOut of memoryBound it, drop and count
Process exits with a full queueLost last linesFlush on shutdown
An appender throwsOther appenders silentCatch per appender
Log call builds a big stringCPU cost even when offLazy message, level check first

Trade-offs

ChoiceProsCons
Drop vs blockDrop: fast. Block: completeDrop loses lines. Block can stall
Hierarchy vs one loggerPer-area levelsInheritance rules to explain
Copy-on-write vs lockNo lock on the hot pathA copy on each add
Lazy message vs stringNo cost when offSlightly 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 3

Score 2 out of 3 or better to mark this page complete.

1. The logger app.db has no level of its own, and app is set to WARN. A DEBUG call on app.db arrives. What happens?