Skip to main content

Logging Framework

A logger that can write to a console today and a file, or a network sink, tomorrow - without a single call site changing. The two decisions that matter are "should this line even be logged" and "where does it go once it is."

Requirements

Functional

  • Code logs a message at a level (DEBUG, INFO, WARN, ERROR).
  • A logger has a minimum level; messages below it are dropped before any formatting or writing happens.
  • A message can be written to more than one destination at once (console and file, say).
  • Each destination can format the same message differently (plain text on console, a structured line in a file).

Non-functional

  • Adding a new destination (a network sink, a metrics pipeline) must not touch Logger or any existing appender.
  • Filtering by level must happen before formatting, so a dropped DEBUG line never pays the cost of building its output string.

Design

Logger checks the level once, then hands the message to every registered Appender. Each appender owns its own Formatter and its own write target, so "log to two places in two formats" is just "register two appenders," not a special case.

ApplicationLoggerAppenderFormatterinfo("started")1isEnabled(INFO)2append(record)3format(record)4write(formatted)5
  1. 1The caller logs at a level without knowing where it will end up.
  2. 2The logger checks its threshold first - a level below it never reaches an appender.
  3. 3Every registered appender gets the same record, regardless of how many there are.
  4. 4The appender asks its own formatter for a string - console and file can format differently.
  5. 5Each appender writes to its own target: stdout, a file, or a network socket.

Splitting Formatter out of Appender means the same appender class can be reused with a different format (say, a FileAppender with a JSON formatter instead of a plain one) without writing a new appender at all.

Class diagram

«interface»Appender+ append(record): void«interface»Formatter+ format(record): stringLogger- minLevel: LogLevel- appenders: List<Appender>+ log(level, message): void+ debug/info/warn/error(message): voidLogRecord- level: LogLevel- message: string- timestamp: datetimeConsoleAppender+ append(record): voidFileAppender+ append(record): voidPlainTextFormatter+ format(record): stringJsonFormatter+ format(record): string
implementsusescreates
Logger filters by level once, then fans the log record out to every Appender, each pairing its own Formatter with its own write target.

Code

import java.time.Instant;
import java.util.*;
 
enum LogLevel { DEBUG, INFO, WARN, ERROR }
 
class LogRecord {
final LogLevel level;
final String message;
final Instant timestamp;
 
LogRecord(LogLevel level, String message) {
this.level = level;
this.message = message;
this.timestamp = Instant.now();
}
}
 
interface Formatter {
String format(LogRecord record);
}
 
class PlainTextFormatter implements Formatter {
public String format(LogRecord record) {
return "[" + record.timestamp + "] " + record.level + " - " + record.message;
}
}
 
class JsonFormatter implements Formatter {
public String format(LogRecord record) {
return "{\"level\":\"" + record.level + "\",\"message\":\"" + record.message + "\"}";
}
}
 
interface Appender {
void append(LogRecord record);
}
 
class ConsoleAppender implements Appender {
private final Formatter formatter;
 
ConsoleAppender(Formatter formatter) {
this.formatter = formatter;
}
 
public void append(LogRecord record) {
System.out.println(formatter.format(record));
}
}
 
class FileAppender implements Appender {
private final String path;
private final Formatter formatter;
 
FileAppender(String path, Formatter formatter) {
this.path = path;
this.formatter = formatter;
}
 
public void append(LogRecord record) {
System.out.println("(writing to " + path + ") " + formatter.format(record));
}
}
 
class Logger {
private final LogLevel minLevel;
private final List<Appender> appenders = new ArrayList<>();
 
Logger(LogLevel minLevel) {
this.minLevel = minLevel;
}
 
void addAppender(Appender appender) {
appenders.add(appender);
}
 
void log(LogLevel level, String message) {
if (level.ordinal() < minLevel.ordinal()) return;
LogRecord record = new LogRecord(level, message);
for (Appender appender : appenders) {
appender.append(record);
}
}
 
void debug(String message) { log(LogLevel.DEBUG, message); }
void info(String message) { log(LogLevel.INFO, message); }
void warn(String message) { log(LogLevel.WARN, message); }
void error(String message) { log(LogLevel.ERROR, message); }
}

Design decisions

  • Level filtering happens once in Logger, not once per appender. If every appender re-checked "is this level enabled," a DEBUG line dropped by the logger's threshold would still get formatted and passed around per destination. Checking it once at the entry point means a disabled level costs nothing beyond a single comparison.
  • Formatter is separate from Appender instead of baked into it. An appender's job is "where does this go"; a formatter's job is "what does it look like." Keeping them separate means a console appender and a file appender can share the same plain-text formatter, or diverge, without either concern leaking into the other's class.
  • Appenders are a list the logger fans out to, not a single configured destination. "Log to console and file" falls out of registering two appenders rather than requiring a MultiAppender wrapper class - the logger's fan-out loop already handles any number of them.
  • What's missing for a real system: this framework writes synchronously on the calling thread; a production logger would hand records to a bounded queue drained by a background writer so a slow file system or network sink can't add latency to the code path that's logging, and would support per-appender levels (route ERROR to a network sink, everything to a local file) rather than one global threshold.
0%0 of 122 pages studied