Skip to main content

Design a Logging Library/Framework (Log4j/Logback style)

Problem Statement

Design a flexible, extensible, and thread-safe logging library (similar to Log4j or Logback). The library should allow applications to log messages at various severity levels. It should support writing log messages to multiple destinations (like the system console or a file) with custom formatting (like plain text or JSON). Additionally, it should support asynchronous logging to ensure that the logging framework does not block the application's critical execution path during heavy disk I/O.

Asked In Companies

Functional Requirements

  • Support log levels: DEBUG, INFO, WARN, and ERROR.
  • Allow filtering based on the configured log level (e.g., if the logger is set to INFO, DEBUG logs should be ignored).
  • Provide pluggable log destinations (Appenders) such as Console and File.
  • Support dynamic formatting of log messages (Layouts) such as Plain Text and JSON.
  • Ensure thread safety so that multiple threads can log concurrently without corrupting output or causing race conditions.
  • Support asynchronous logging using an in-memory queue to delegate I/O overhead to a background thread.

Objects Required

  • LogLevel (Enum)
  • LogMessage (Value Object)
  • Layout (Interface)
  • Appender (Interface)
  • Logger (Core Controller)
  • LogManager (Singleton Registry)

LogLevel Enum

We represent severity levels using an enum. The order is important here because we can assign integer values to evaluate log thresholds.


public enum LogLevel {
    DEBUG(1),
    INFO(2),
    WARN(3),
    ERROR(4);

    private final int priority;

    LogLevel(int priority) {
        this.priority = priority;
    }

    public int getPriority() {
        return priority;
    }
}

The priority value helps us run comparative checks dynamically. The getPriority() method returns this numerical value so we can check if a logged event meets the logger's minimum active threshold.


LogMessage Class

A LogMessage object encapsulates all the contextual metadata associated with a logging event, preventing us from passing raw strings around.


import java.time.Instant;

public class LogMessage {
    private final String message;
    private final LogLevel level;
    private final Instant timestamp;
    private final String threadName;

    public LogMessage(String message, LogLevel level) {
        this.message = message;
        this.level = level;
        this.timestamp = Instant.now();
        this.threadName = Thread.currentThread().getName();
    }

    public String getMessage() { return message; }
    public LogLevel getLevel() { return level; }
    public Instant getTimestamp() { return timestamp; }
    public String getThreadName() { return threadName; }
}

The constructor captures the log text, severity level, instant timestamp, and the name of the calling thread. The getter methods expose this metadata cleanly to formatters.


Layout Interface & Implementations

The Layout interface determines how a LogMessage is serialized into a string. Applying this interface decouples the output format from the destination itself.


public interface Layout {
    String format(LogMessage message);
}

The format() method accepts a message envelope and transforms it into a structured format. Let's write two implementations: PlainTextLayout and JsonLayout.


import java.time.ZoneId;
import java.time.format.DateTimeFormatter;

public class PlainTextLayout implements Layout {
    private static final DateTimeFormatter formatter = 
        DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(ZoneId.systemDefault());

    @Override
    public String format(LogMessage message) {
        return String.format("[%s] [%s] [%s] - %s",
                formatter.format(message.getTimestamp()),
                message.getLevel(),
                message.getThreadName(),
                message.getMessage());
    }
}

The overridden format() method converts the metadata into a human-readable, bracket-delimited string, formatted using local system time.


public class JsonLayout implements Layout {
    @Override
    public String format(LogMessage message) {
        return String.format("{\"timestamp\":\"%s\",\"level\":\"%s\",\"thread\":\"%s\",\"message\":\"%s\"}",
                message.getTimestamp().toString(),
                message.getLevel(),
                message.getThreadName(),
                message.getMessage().replace("\"", "\\\""));
    }
}

This version formats the payload as a JSON string, which is highly useful when piping logs to log aggregators like Elasticsearch or Splunk.


Appender Interface & Implementations

An Appender controls the write destination of the formatted logs. By creating a unified interface, we make it easy to write to standard out, local files, or network sockets.


public interface Appender {
    void append(LogMessage message);
}

The append() method receives the log envelope, formats it using an internal layout, and commits it to the destination. Here is the console implementation:


public class ConsoleAppender implements Appender {
    private final Layout layout;

    public ConsoleAppender(Layout layout) {
        this.layout = layout;
    }

    @Override
    public synchronized void append(LogMessage message) {
        System.out.println(layout.format(message));
    }
}

The constructor binds a layout format, and the synchronized append() method outputs to console while ensuring that log print lines from concurrent threads do not interleave.


import java.io.FileWriter;
import java.io.IOException;
import java.io.PrintWriter;

public class FileAppender implements Appender {
    private final Layout layout;
    private final String filePath;

    public FileAppender(Layout layout, String filePath) {
        this.layout = layout;
        this.filePath = filePath;
    }

    @Override
    public synchronized void append(LogMessage message) {
        try (PrintWriter writer = new PrintWriter(new FileWriter(filePath, true))) {
            writer.println(layout.format(message));
        } catch (IOException e) {
            System.err.println("Failed to write log to file: " + e.getMessage());
        }
    }
}

The FileAppender requires a file path. The synchronized append() method opens the file in append mode, formats the message, and flushes it, catching any file system exceptions safely.


Asynchronous Appender (Performance Optimization)

To prevent blocking application threads during slow file I/O operations, we can build a wrapper appender that uses an in-memory queue to delegate the write operations to a background daemon thread.


import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;

public class AsyncAppender implements Appender {
    private final Appender targetAppender;
    private final BlockingQueue<LogMessage> queue;
    private final Thread workerThread;

    public AsyncAppender(Appender targetAppender) {
        this.targetAppender = targetAppender;
        this.queue = new LinkedBlockingQueue<>(10000); // Set capacity limit
        this.workerThread = new Thread(this::consumeLogs);
        this.workerThread.setDaemon(true);
        this.workerThread.setName("Log-Async-Worker");
        this.workerThread.start();
    }

    @Override
    public void append(LogMessage message) {
        boolean offered = queue.offer(message);
        if (!offered) {
            System.err.println("Log queue full. Dropping log message: " + message.getMessage());
        }
    }

    private void consumeLogs() {
        try {
            while (true) {
                LogMessage message = queue.take(); // Blocks until log message is available
                targetAppender.append(message);
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

The constructor starts a daemon worker thread. The append() method adds the log message to a queue. Since it doesn't do any direct writing, this call returns instantly. The consumeLogs() method runs on a background loop, taking messages from the queue and writing them using the wrapped target appender.


Logger & LogManager (Configuration & Client Interface)

The Logger class is what applications call directly. It keeps track of the active log level and routes valid messages to all registered appenders.


import java.util.ArrayList;
import java.util.List;

public class Logger {
    private final String name;
    private LogLevel level;
    private final List<Appender> appenders;

    public Logger(String name, LogLevel level) {
        this.name = name;
        this.level = level;
        this.appenders = new ArrayList<>();
    }

    public synchronized void setLevel(LogLevel level) {
        this.level = level;
    }

    public synchronized void addAppender(Appender appender) {
        appenders.add(appender);
    }

    public void log(LogLevel level, String message) {
        if (level.getPriority() >= this.level.getPriority()) {
            LogMessage logMessage = new LogMessage(message, level);
            List<Appender> appendersCopy;
            synchronized (this) {
                appendersCopy = new ArrayList<>(appenders);
            }
            for (Appender appender : appendersCopy) {
                appender.append(logMessage);
            }
        }
    }

    public void debug(String msg) { log(LogLevel.DEBUG, msg); }
    public void info(String msg) { log(LogLevel.INFO, msg); }
    public void warn(String msg) { log(LogLevel.WARN, msg); }
    public void error(String msg) { log(LogLevel.ERROR, msg); }
}

The setLevel() and addAppender() methods dynamically modify the logger's rules. The primary logic lies in log(): it checks priority thresholds, snapshot-copies the appender list to allow thread-safe iterations, and calls append() on each registered appender. debug(), info(), warn(), and error() are convenient shortcuts.


import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

public class LogManager {
    private static final LogManager instance = new LogManager();
    private final Map<String, Logger> loggers;

    private LogManager() {
        loggers = new ConcurrentHashMap<>();
    }

    public static LogManager getInstance() {
        return instance;
    }

    public Logger getLogger(String name) {
        return loggers.computeIfAbsent(name, k -> new Logger(k, LogLevel.INFO));
    }
}

The LogManager uses the Singleton pattern to coordinate global configurations. The getLogger() method retrieves or initializes a Logger instance using a thread-safe ConcurrentHashMap.


Class Diagram

LogLevelDEBUGINFOWARNERRORpriority: intgetPriority(): intLogMessagemessage: Stringlevel: LogLeveltimestamp: InstantthreadName: StringgetMessage(): StringgetLevel(): LogLevelgetTimestamp(): InstantgetThreadName(): StringLayoutformat(message: LogMessage): StringPlainTextLayoutformat(message: LogMessage): StringJsonLayoutformat(message: LogMessage): StringAppenderappend(message: LogMessage): voidConsoleAppenderlayout: Layoutappend(message: LogMessage): voidFileAppenderlayout: LayoutfilePath: Stringappend(message: LogMessage): voidAsyncAppendertargetAppender: Appenderqueue: BlockingQueue<LogMessage>workerThread: Threadappend(message: LogMessage): voidconsumeLogs(): voidLoggername: Stringlevel: LogLevelappenders: List<Appender>setLevel(level: LogLevel): voidaddAppender(appender: Appender): voidlog(level: LogLevel, message: String): voiddebug(msg: String): voidinfo(msg: String): voidwarn(msg: String): voiderror(msg: String): voidLogManagerinstance: LogManagerloggers: Map<String, Logger>getInstance(): LogManagergetLogger(name: String): Logger111many1111111many

Also See

Comments

Popular posts from this blog

Designing a Parking Lot - Low Level Design

Problem Statement Design a parking lot that can handle vehicles entering and leaving while managing parking across multiple floors. Each vehicle should be assigned a suitable parking spot based on its type, and the spot should be freed once the vehicle exits. The design should also support generating a ticket at entry and optionally calculating the parking fee based on the duration of stay. Asked In Companies Amazon Google Microsoft Uber Walmart Flipkart Meta PayPal Oracle Salesforce Adobe Apple Intuit LinkedIn Atlassian Functional Requirements The design should support multiple vehicle types such as bikes, cars, and trucks A vehicle must be assigned a parking spot compatible with its type A parking spot cannot be assigned to more than one vehicle at a time The parking lot should support multiple levels (floors) The design should search and allocate an availa...

Most Frequently Asked Low Level Design(LLD) Interview Questions

Below are the curated list of most commonly asked Low Level Design (LLD) interview problems. Each problem includes a short description and a link to the complete solution with code and class diagrams. Design Parking Lot System The system should handle parking for different vehicle types such as bikes, cars, and trucks. It should manage slot allocation, availability tracking, and entry/exit flow. The design also ensures efficient usage of parking space under varying load conditions. View Solution Design Elevator / Lift System The system should support multiple elevators operating across floors with request handling logic. It focuses on scheduling algorithms to minimize wait time and optimize movement. It also manages direction control and concurrent floor requests. View Solution Design Movie Ticket Booking System The system should allow users to browse movies, select shows, and book seats. It handles seat ...

Software Design Patterns for LLD Interviews: A Complete Guide

Software Design Patterns for LLD Interviews: A Complete Guide In Software Development Engineer (SDE) interviews—especially for mid-level and senior roles—low-level design (LLD) rounds assess your ability to write clean, reusable, maintainable, and extensible code. The foundation of resolving these architectural challenges lies in the standard Gang of Four (GoF) Design Patterns. Rather than memorizing theoretical definitions, interviewers expect you to apply these patterns to real-world scenarios, identifying the trade-offs of each. Below is a comprehensive guide to the 12 most frequently asked design patterns in LLD interviews, categorized by their classification (Creational, Structural, and Behavioral). Each pattern contains a concrete, real-world Java implementation and a detailed breakdown of design decisions. Creational Design Patterns Creational design patterns deal with object creation mechanisms. They abstract the instantiation process, making a system independent of how...