Company questionsGroundworkOla

Low-level design · Libraries and infrastructure

Logging library with pluggable outputs

  • Reported once
  • SDE-2
  • Strategy
  • Observer
  • Producer consumer
  • Bounded queue
  • 1 source

One candidate write-up. Treat it as a single data point.

The prompt
Design and implement a logging library that application code calls to record messages. Messages can go to several outputs at once, such as a file, a network target or a database, and adding an output should be easy. The message format is configurable, the library should add little overhead for the caller, and the follow-up discussion covers how it handles concurrency.

Restated in our own words, not a quote from Ola or any page. The answer below is written for this site.

Try it first.The answer is covered until you pick. Talk it through against a 60 minute clock, or just read it.

Clarify first

Pin the scope down before you name a single class.

Questions to ask first: which severity levels are needed, is the threshold set per logger, per output or both, which formats and outputs are required, should a slow output ever block the caller, what happens to messages when the buffer is full, is the order of messages guaranteed, and does the library need structured fields and a flush on shutdown.

The report names a logging library with several appenders, a configurable message format and low overhead, with concurrency discussed in a later round; severity levels, the queue and the drop policy are assumptions here. Assumed scope: four levels (debug, info, warn, error) compared as numbers, a minimum level on each logger and a separate one on each output, a formatter chosen per output, two outputs (an in-memory one standing in for the console, and a file that rolls after a number of lines), structured key-value fields, child loggers with dotted names, and a bounded queue that drops new events when it is full and counts them. One drain worker keeps order. Out of scope: network outputs, configuration files, sampling and per-request context.

Entities and relationships

Who owns what, and who talks to whom.

  • Logger has a name, a minimum level, a dispatcher and a clock. log compares the level first, builds the event only if it is enabled, and accepts either a message or a function that produces it. child makes a logger with a dotted name that shares the dispatcher.
  • Event is a plain object with time, level, logger name, message and fields. The fields are copied when the event is created.
  • Dispatcher owns a bounded queue and the list of appenders. publish adds an event or counts a drop, and drain hands queued events to the appenders in order. It counts drops and appender failures.
  • Appender is the output interface with a level, a formatter and append(line). MemoryAppender and RollingAppender implement it. Each appender is a subscriber to every event.
  • Formatter is the format interface with format(event). TextFormatter and JsonFormatter implement it.
  • Counter is the injected clock that returns increasing numbers, so tests do not depend on real time.

The design

The class map, the main flow and the patterns that earn their place.

Logging class mapCallers only touch the logger. A queue separates them from the appenders and their formatters.
Eventtime, level, fieldsLoggerlevel check, lazy textCounternext() timestampDispatcherbounded queueAppenderMemory, RollingFormatterText, Jsoncreatespublishdrain to eachformats withtimestamps1234
  1. The logger builds an event only if the level is enabled, calling a lazy message function at that point.
  2. It publishes the event to the dispatcher, which queues it or counts a drop when full.
  3. A drain step takes events in order and offers each one to every appender whose level allows it.
  4. The appender's own formatter turns the event into a line before it is written.

Open on the whiteboardAll 2 diagrams on one tidy board, with a step-by-step walkthrough.

Cheap hot path. Logger.log compares the level before doing anything else. Below the threshold it returns false having built nothing, and a message supplied as a function is only called when the level passes, so expensive string building is skipped for disabled levels. That is what low overhead means at the call site. An unknown level throws a RangeError at the call, since that is a programming error. The event copies the caller's fields at that moment, because the queue formats them later and a caller that reuses its map would otherwise change lines already logged; the copy is shallow.

Strategy for format, pluggable outputs. The formatter belongs to the appender, so the console output can be plain text while another output writes JSON, and a new format is one class. JsonFormatter renames a field that clashes with a reserved key, such as level or msg, to a name with a field_ prefix so the envelope cannot be overwritten. Appender is the second seam, and each carries its own threshold, so one output can take everything while another only takes errors. The dispatcher treats appenders as subscribers, which is the Observer idea without a framework.

Concurrency. Callers must not wait on a slow disk or serialise on an output. publish only appends to a bounded queue and returns. A single drain worker, stepped explicitly here through drain(limit), takes events in order and gives each to the appenders. One consumer means outputs never see interleaved lines and the order of accepted events is kept without a lock per appender. The real version uses a multi-producer single-consumer queue, such as a ring buffer or a queue whose lock is held only for the push, and a worker thread that wakes on a signal and drains in batches. Shutdown must drain what is left.

Back-pressure. When the queue is full this version drops the new event and counts it, so the application is never blocked. Other policies are to drop the oldest, to block the caller, or to reserve room so errors are never dropped. The trade-off is lost lines versus stalled requests, and the drop counter makes the loss visible.

Failure isolation. The dispatcher wraps each appender call in a try and catch. A failing appender, such as a full disk, adds to the failure count and the other appenders and later events carry on.

Rolling output. RollingAppender starts a new file after a number of lines, so five lines with a limit of two make files of 2, 2 and 1 lines. A real rolling output rolls on size or time, renames the old file and deletes old ones beyond a retention count.

The code

Runnable JavaScript, with the checks it passes.

ola-logging-library.js
const LEVELS = { DEBUG: 10, INFO: 20, WARN: 30, ERROR: 40 };
const NAMES = { 10: "DEBUG", 20: "INFO", 30: "WARN", 40: "ERROR" };

class Counter {
  constructor() {
    this.n = 0;
  }
  next() {
    this.n += 1;
    return this.n;
  }
}

class TextFormatter {
  format(event) {
    const extra = Object.entries(event.fields).map(([k, v]) => " " + k + "=" + v);
    return event.time + " " + NAMES[event.level] + " " + event.name + ": " + event.message + extra.join("");
  }
}

class JsonFormatter {
  format(event) {
    const out = { time: event.time, level: NAMES[event.level], logger: event.name };
    for (const [key, value] of Object.entries(event.fields)) out[key in out || key === "msg" ? "field_" + key : key] = value;
    out.msg = event.message;
    return JSON.stringify(out);
  }
}

class MemoryAppender {
  constructor(level, formatter) {
    this.level = level;
    this.formatter = formatter;
    this.lines = [];
  }
  append(line) {
    this.lines.push(line);
  }
}

class RollingAppender {
  constructor(level, formatter, maxLines) {
    this.level = level;
    if (!Number.isInteger(maxLines) || maxLines <= 0) throw new RangeError("maxLines must be positive");
    this.formatter = formatter;
    this.maxLines = maxLines;
    this.files = [[]];
  }
  append(line) {
    if (this.files[this.files.length - 1].length >= this.maxLines) this.files.push([]);
    this.files[this.files.length - 1].push(line);
  }
}

class Dispatcher {
  constructor(appenders, capacity) {
    this.appenders = appenders;
    this.capacity = capacity;
    this.queue = [];
    this.dropped = 0;
    this.failures = 0;
  }
  publish(event) {
    if (this.queue.length >= this.capacity) {
      this.dropped += 1;
      return false;
    }
    this.queue.push(event);
    return true;
  }
  drain(limit = Infinity) {
    let handled = 0;
    while (this.queue.length > 0 && handled < limit) {
      const event = this.queue.shift();
      handled += 1;
      for (const appender of this.appenders) {
        if (event.level < appender.level) continue;
        try {
          appender.append(appender.formatter.format(event), event);
        } catch (error) {
          this.failures += 1;
        }
      }
    }
    return handled;
  }
}

class Logger {
  constructor(name, level, dispatcher, clock) {
    this.name = name;
    this.level = level;
    this.dispatcher = dispatcher;
    this.clock = clock;
  }
  log(level, message, fields = {}) {
    if (!NAMES[level]) throw new RangeError("unknown level " + level);
    if (level < this.level) return false;
    const text = typeof message === "function" ? message() : message;
    const event = { time: this.clock.next(), level, name: this.name, message: text, fields: { ...fields } };
    return this.dispatcher.publish(event);
  }
  debug(message, fields) {
    return this.log(LEVELS.DEBUG, message, fields);
  }
  info(message, fields) {
    return this.log(LEVELS.INFO, message, fields);
  }
  warn(message, fields) {
    return this.log(LEVELS.WARN, message, fields);
  }
  error(message, fields) {
    return this.log(LEVELS.ERROR, message, fields);
  }
  child(suffix) {
    return new Logger(this.name + "." + suffix, this.level, this.dispatcher, this.clock);
  }
}

Runs here in your browser, in a sandboxed worker. Nothing is sent anywhere.

Try your own input

Write JavaScript that calls the code above and prints with console.log. It runs after the solution.

Extend it

The change requests an interviewer throws next.

  1. How would you give different loggers different levels and change them at runtime without a restart?
  2. How would you guarantee that error events are never dropped when the queue is full?
  3. How would you attach a request id to every line written while handling one request?
  4. How would you flush and stop cleanly when the process shuts down?
  5. How would you add an output that sends batches over the network and retries when the network fails?

Where candidates slip

The mistakes this problem punishes.

Formatting the message before checking the level. Writing synchronously under one global lock, which makes every caller wait for the slowest output. An unbounded queue that grows until memory runs out. Letting an exception from one output stop logging or crash the application. Using several writers so lines from different events interleave. Hard-coding the format inside the logger. Taking timestamps from the system clock so tests are flaky. Not flushing the queue at shutdown, so the last lines are lost. Keeping a reference to the caller's mutable fields until the worker formats them.

Have a go before you read.

Interviews reward the sketch you make cold. Clarify out loud, draw it, then compare with the answer.

Now make it stick

Where this was reported

Public pages only. Companies do not confirm these, and they rotate their questions, so treat each link as one person's account or one list's tag.

Read these chapters first