Logging Framework
Chain of Responsibility for handlers, Strategy for formatters, Singleton for the root logger. Three patterns solving three orthogonal concerns in one system.
Key Abstractions
Singleton root logger. Accepts log calls and dispatches to the handler chain.
Enum (DEBUG, INFO, WARN, ERROR) with ordinal comparison for filtering
Abstract handler in the chain. Each handler decides whether to process and/or pass along.
Writes formatted log records to stdout
Appends formatted log records to a file
Strategy interface for formatting. JSON, plain text, or custom formats.
Immutable value object carrying timestamp, level, message, and logger name
Class Diagram
How It Works
Every application needs logging, and every logging framework solves the same three problems: what severity does this message have, where should it go, and what should it look like when it gets there? Log levels handle severity. Handlers handle destination. Formatters handle presentation. Keep these concerns separate and the framework stays flexible.
The handler pipeline is where Chain of Responsibility earns its keep. A log record enters the chain, and each handler decides independently whether to process it based on its own minimum level. Console handler might accept DEBUG and above. File handler only cares about WARN and ERROR. Remote handler sends everything to an aggregation service. You add, remove, and reorder handlers without touching the logger itself.
Singleton for the root logger makes sense because logging is inherently global state. Every module, every thread, every corner of the application should funnel through the same configured pipeline. Multiple logger instances with different handler configurations would lead to inconsistent output and missed logs.
Requirements
Functional
- Support log levels: DEBUG, INFO, WARN, ERROR with ordinal comparison
debug(),info(),warn(),error()convenience methods on the logger- Multiple handlers (console, file) that can be attached independently
- Each handler has its own minimum level filter
- Handlers can be chained via Chain of Responsibility
- Formatters are swappable per handler (plain text, JSON)
Non-Functional
- Thread-safe. Multiple threads logging simultaneously should produce clean, non-interleaved output.
- Singleton logger instance across the application
- LogRecord should be immutable to avoid synchronization issues when handlers share it
Design Decisions
Why Chain of Responsibility over direct dispatch?
Direct dispatch means the logger has a list of handlers and loops through them. That works. But CoR adds something valuable: each handler can decide whether to pass the record further down the chain. Maybe your error handler wants to suppress certain errors from reaching the file handler. Maybe you want a handler that transforms the record before passing it along. With direct dispatch, you'd need the logger to understand all these cases. With CoR, each handler owns its own logic.
Why Singleton for the logger?
Logging is one of the rare cases where Singleton is the right call. You want every part of the application to use the same handler configuration. If modules create their own logger instances with different handlers, some logs disappear and debugging becomes a guessing game. The Singleton pattern with double-checked locking (Java) or a lock-guarded check (Python) keeps initialization thread-safe.
Why immutable LogRecord?
When a log record passes through multiple handlers, possibly on different threads, mutable state invites bugs. If one handler modifies the message (adding a prefix, for example), downstream handlers see the modified version. Immutable records eliminate this class of bug entirely. Each handler reads the same data. If a handler needs to transform the record, it creates a new one.
Why IntEnum for LogLevel in Python?
IntEnum gives you free comparison operators. LogLevel.WARN >= LogLevel.INFO just works because the underlying values are integers. Regular Enum would require you to implement __lt__, __le__, and friends manually. Small thing, but it removes boilerplate that adds nothing.
Interview Follow-ups
- "How would you add log rotation?" Wrap FileHandler with size or time-based rotation. When the current file exceeds a threshold, rename it (app.log.1, app.log.2) and open a fresh one. Most frameworks use a RotatingFileHandler subclass.
- "How would you support structured logging?" Swap the formatter to JsonFormatter. For richer structured data, extend LogRecord with a
fieldsdictionary for arbitrary key-value pairs alongside the message. - "What about async logging for high-throughput systems?" Put a bounded queue between the logger and handlers. A dedicated writer thread drains the queue and dispatches to handlers. This decouples log generation from I/O. If the queue fills up, you either block or drop, depending on your reliability requirements.
- "How would you handle sensitive data in logs?" Add a filtering handler in the chain that scrubs or masks patterns (credit card numbers, SSNs, tokens) before passing the record to downstream handlers. Regex-based scrubbing covers most cases.
Code Implementation
1 from __future__ import annotations
2 from abc import ABC, abstractmethod
3 from dataclasses import dataclass
4 from datetime import datetime, timezone
5 from enum import IntEnum
6 from threading import Lock
7 import json
8 import os
9
10
11 class LogLevel(IntEnum):
12 """IntEnum so we get free comparison operators for level filtering."""
13 DEBUG = 0
14 INFO = 1
15 WARN = 2
16 ERROR = 3
17
18
19 @dataclass(frozen=True)
20 class LogRecord:
21 """Immutable log entry. Safe to pass across threads without copying."""
22 timestamp: datetime
23 level: LogLevel
24 message: str
25 logger_name: str
26
27
28 class LogFormatter(ABC):
29 @abstractmethod
30 def format(self, record: LogRecord) -> str: ...
31
32
33 class SimpleFormatter(LogFormatter):
34 def format(self, record: LogRecord) -> str:
35 ts = record.timestamp.strftime("%Y-%m-%d %H:%M:%S")
36 return f"[{ts}] [{record.level.name:5s}] [{record.logger_name}] {record.message}"
37
38
39 class JsonFormatter(LogFormatter):
40 def format(self, record: LogRecord) -> str:
41 return json.dumps({
42 "timestamp": record.timestamp.isoformat(),
43 "level": record.level.name,
44 "logger": record.logger_name,
45 "message": record.message,
46 })
47
48
49 class LogHandler(ABC):
50 """
51 Chain of Responsibility base. Each handler can process the record
52 and optionally pass it to the next handler in the chain.
53 """
54
55 def __init__(self, min_level: LogLevel, formatter: LogFormatter | None = None):
56 self._min_level = min_level
57 self._formatter = formatter or SimpleFormatter()
58 self._next: "LogHandler | None" = None
59 self._lock = Lock()
60
61 def set_next(self, handler: "LogHandler") -> "LogHandler":
62 self._next = handler
63 return handler
64
65 def handle(self, record: LogRecord) -> None:
66 if record.level >= self._min_level:
67 self._emit(record)
68 if self._next is not None:
69 self._next.handle(record)
70
71 @abstractmethod
72 def _emit(self, record: LogRecord) -> None: ...
73
74
75 class ConsoleHandler(LogHandler):
76 def _emit(self, record: LogRecord) -> None:
77 with self._lock:
78 print(self._formatter.format(record))
79
80
81 class FileHandler(LogHandler):
82 def __init__(self, file_path: str, min_level: LogLevel, formatter: LogFormatter | None = None):
83 super().__init__(min_level, formatter)
84 self._file_path = file_path
85
86 def _emit(self, record: LogRecord) -> None:
87 with self._lock:
88 with open(self._file_path, "a", encoding="utf-8") as f:
89 f.write(self._formatter.format(record) + "\n")
90
91
92 class Logger:
93 """
94 Singleton logger. Thread-safe, dispatches to handler chain.
95 """
96
97 _instance: "Logger | None" = None
98 _init_lock = Lock()
99
100 def __new__(cls, name: str = "root") -> "Logger":
101 with cls._init_lock:
102 if cls._instance is None:
103 instance = super().__new__(cls)
104 instance._name = name
105 instance._handlers: list[LogHandler] = []
106 instance._lock = Lock()
107 cls._instance = instance
108 return cls._instance
109
110 @classmethod
111 def get_instance(cls, name: str = "root") -> "Logger":
112 return cls(name)
113
114 @classmethod
115 def reset(cls) -> None:
116 """For testing. Clears the singleton so a fresh instance can be created."""
117 with cls._init_lock:
118 cls._instance = None
119
120 def add_handler(self, handler: LogHandler) -> None:
121 with self._lock:
122 self._handlers.append(handler)
123
124 def _log(self, level: LogLevel, message: str) -> None:
125 record = LogRecord(
126 timestamp=datetime.now(timezone.utc),
127 level=level,
128 message=message,
129 logger_name=self._name,
130 )
131 for handler in self._handlers:
132 handler.handle(record)
133
134 def debug(self, message: str) -> None:
135 self._log(LogLevel.DEBUG, message)
136
137 def info(self, message: str) -> None:
138 self._log(LogLevel.INFO, message)
139
140 def warn(self, message: str) -> None:
141 self._log(LogLevel.WARN, message)
142
143 def error(self, message: str) -> None:
144 self._log(LogLevel.ERROR, message)
145
146
147 if __name__ == "__main__":
148 Logger.reset()
149 logger = Logger.get_instance("app")
150
151 # Console handler: shows everything from DEBUG up
152 console = ConsoleHandler(min_level=LogLevel.DEBUG, formatter=SimpleFormatter())
153
154 # File handler: only WARN and above, JSON format
155 log_file = "/tmp/app_log_demo.log"
156 file_handler = FileHandler(log_file, min_level=LogLevel.WARN, formatter=JsonFormatter())
157
158 logger.add_handler(console)
159 logger.add_handler(file_handler)
160
161 # Chain demonstration: console also chains to a second handler
162 error_console = ConsoleHandler(min_level=LogLevel.ERROR, formatter=JsonFormatter())
163 console.set_next(error_console)
164
165 print("=== Logging at various levels ===\n")
166 logger.debug("Initializing database connection pool")
167 logger.info("Server started on port 8080")
168 logger.warn("Connection pool nearing capacity: 45/50")
169 logger.error("Failed to connect to replica database at 10.0.1.5")
170
171 # Verify file output
172 print(f"\n=== File handler output ({log_file}) ===")
173 if os.path.exists(log_file):
174 with open(log_file, "r") as f:
175 for line in f:
176 print(f" {line.rstrip()}")
177 os.remove(log_file)
178
179 # Singleton verification
180 logger2 = Logger.get_instance("different_name")
181 print(f"\nSingleton check: same instance = {logger is logger2}")
182
183 print("\nAll operations completed successfully.")Common Mistakes
- ✗Making the logger non-thread-safe. Multiple threads writing to the same handler without synchronization produces garbled output.
- ✗Formatting inside the logger instead of delegating to the handler's formatter. That couples the logger to a specific output format.
- ✗Forgetting to flush or close file handlers. Buffered writes get lost on crash if you never flush.
- ✗Using string concatenation for log messages even when the level is filtered out. Build the message lazily or check the level first.
Key Points
- ✓Chain of Responsibility lets you stack handlers. A log record flows through console, file, and remote handlers without the logger knowing how many exist.
- ✓Each handler has its own minimum level. Console might show DEBUG, while file only captures WARN and above.
- ✓Singleton ensures one logger instance across the entire application. All modules log through the same configured pipeline.
- ✓LogRecord is immutable. Once created, handlers can read it freely without synchronization concerns.