Engineering Atlas › Low-Level Design

Code a logging framework

12 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

Requirements

Functional:

Non-functional:

Out of scope: log shipping, indexing and search (that is a distributed logging system), configuration file parsing, and log rotation policies, which are a sink's internal concern.

Entities and relationships

LogManager owns the registry: a map from logger name to its configuration (level, sinks, propagate flag) and the injected clock. There is one manager per process in practice, but making it an ordinary object, not a global, keeps tests isolated.

Logger is a lightweight handle: a name, a reference to the manager and a set of bound fields. Two handles with the same name share configuration because the configuration lives in the manager, keyed by name. That is why bind() can return a new Logger cheaply: it copies the field map, not the configuration.

LogRecord is an immutable value: timestamp, level, logger name, message and fields. It is created once per enabled call and handed to every sink, so sinks must not mutate it.

Formatter is a strategy with one method, format(record) -> string. Sink is the output port with one method, emit(record). A sink owns a formatter and a minimum level. Concrete sinks include an in-memory sink for tests, a file sink, a stdout sink and an async sink that wraps another sink with a queue.

Relationships: the manager has many configurations; a configuration has many sinks; a sink has one formatter; loggers create records; records flow from a logger's name up through its ancestors.

Class diagram

classDiagram
  class LogManager {
    +clock
    +getLogger(name) Logger
    +effectiveLevel(name) int
    +dispatch(record)
    +errorCount() int
  }
  class LoggerConfig {
    +level
    +sinks
    +propagate
  }
  class Logger {
    +name
    +fields
    +log(level, msg, fields) bool
    +bind(fields) Logger
    +setLevel(level)
    +addSink(sink)
  }
  class LogRecord {
    +ts
    +level
    +logger
    +msg
    +fields
  }
  class Sink {
    <<interface>>
    +emit(record)
  }
  class Formatter {
    <<interface>>
    +format(record) string
  }
  LogManager "1" *-- "many" LoggerConfig : keyed by name
  Logger --> LogManager
  Logger ..> LogRecord : creates
  LoggerConfig o-- Sink
  Sink <|.. MemorySink
  Sink <|.. FileSink
  Sink <|.. AsyncSink
  AsyncSink --> Sink : wraps
  MemorySink --> Formatter
  FileSink --> Formatter
  Formatter <|.. TextFormatter
  Formatter <|.. JsonFormatter

Key flows

The main flow is one error() call on app.db, where app has a sink and the root has a sink that is currently failing.

sequenceDiagram
  participant App
  participant L as Logger app.db
  participant M as LogManager
  participant S1 as Sink on app
  participant S2 as Sink on root
  App->>L: error("timeout", ms=900)
  L->>M: effectiveLevel("app.db")
  M-->>L: INFO from app
  L->>L: merge bound fields, read clock
  L->>M: dispatch(record)
  M->>S1: emit(record)
  S1->>S1: level check, format, write
  M->>M: app.propagate is true
  M->>S2: emit(record)
  S2-->>M: raises disk full
  M->>M: errorCount += 1
  M-->>L: done
  L-->>App: true

The level lookup walks app.db, app, root and stops at the first name that has a level set. Note that application is not a child of app: the hierarchy is split on dots, never on string prefixes. If the call is disabled, the flow ends after the first arrow pair and nothing else happens. That early exit is the most important line in the framework.

Design patterns used and why

Concurrency and edge cases

Down the stack

Layer 1The contract

A call at level L on name N is enabled if L is at least the level of the nearest ancestor of N (including N) that has a level set. An enabled call produces exactly one record with a timestamp from the injected clock and the bound fields merged with call-site fields, call-site winning. The record is offered to every sink on N and its ancestors, in order from N up, stopping after the first name whose propagate is false. Each sink writes it if the level passes that sink's own threshold. The call never throws because of a sink. A disabled call has no observable effect and does not read the clock.

Layer 2The mechanism

The level lookup is a walk over dot-separated prefixes against a hash map, which costs one string slice and one map lookup per level of depth. Real frameworks cache the answer. Python's logging.Logger keeps a _cache dictionary from level to enabled flag and clears every logger's cache when any level changes. Log4j 2 precomputes each LoggerConfig and swaps in a new configuration object on reload. Go's log/slog puts the check behind Handler.Enabled(ctx, level) so a handler can decide before a slog.Record is built. Bound fields map to slog.Logger.With, which calls Handler.WithAttrs and lets a JSON handler pre-render the bound part once instead of on every line. Sorting keys at format time gives stable output; Go's encoding/json already sorts map keys when it marshals a map.

Layer 3The machine

A disabled call that hits a cached level check costs a few nanoseconds: a pointer load, an integer compare and a branch the CPU predicts correctly. An enabled call is far more expensive. Building the record allocates a field map and strings (a few hundred bytes), formatting a JSON line takes on the order of 1 to 5 microseconds, and the line, typically 150 to 300 bytes, is copied into a buffer. A write() system call into the page cache costs 1 to 5 microseconds, and fsync to make it durable costs from about 100 microseconds on a good NVMe drive to 10 milliseconds on a spinning disk. At 20,000 lines per second of 250 bytes, that is 5 MB/s, which is fine for the disk but costs a whole core if every line is formatted and written synchronously. In a container, stdout goes through a pipe to the runtime's log driver, and a full pipe buffer (64 KB on Linux by default) blocks the writer. That is how a slow log collector stalls an application.

Why it's built this way

  1. Most log calls are below the active level, so the framework is designed around a cheap early exit before any allocation.
  2. Operators reason about subsystems, not objects, so configuration is keyed by hierarchical names and inherits down the tree.
  3. One event has several audiences (a human at a terminal, a JSON pipeline, an alerting sink), so output is split into sinks, each with its own format and threshold.
  4. I/O is slow and fails, so sinks are isolated: errors are counted, slow sinks get queues, and the caller never pays for a broken disk with an exception.

Extensibility

Leadership lens

Check yourself

  1. Why is the level stored per name in the manager instead of on each Logger object?

    Show answer

    So every handle for a name, including bound copies and handles created before the change, sees the same level, and so a parent's change is inherited by children that never set their own.

  2. A child logger app.db has an ERROR sink and propagates to a root sink at INFO. app.db has level WARN. Which sinks get a WARN record?

    Show answer

    Only the root sink. The call is enabled by the logger level, the app.db sink filters it out at its own ERROR threshold, and the root sink accepts it.

  3. Why must the level check happen before reading the clock and merging fields?

    Show answer

    Because those steps allocate and cost time on every call, and most calls are disabled. Doing them first makes the disabled path as expensive as the enabled one.

  4. What is the risk of holding the manager's lock while calling emit?

    Show answer

    A slow sink blocks every logging thread, and a sink that logs from inside emit deadlocks. Snapshot the sinks under the lock, then emit without it.

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →