The design should support multiple log levels, multiple log destinations, configurable log formats, and high-throughput logging.
Requirements
A correct logging system should meet the following requirements.1. Support multiple log levels.
2. Allow logging to multiple destinations.
3. Support configurable log formats.
4. Filter logs by log level.
5. Support asynchronous logging.
6. Support thread-safe logging.
7. Allow adding new log destinations easily.
Design
The system consists of a Logger that receives log messages.Each log message has a severity level and is formatted by a Formatter before being written to one or more Appenders.
The logger filters messages based on the configured log level before submitting them for asynchronous processing.
The formatter controls the structure of the final log message, while appenders control where the message is written.
Java Implementation
The Appender interface defines a common contract for writing log messages to different destinations.public interface Appender {
void append(String message);
}
The ConsoleAppender writes log messages directly to the console.
public class ConsoleAppender implements Appender {
@Override
public void append(String message) {
System.out.println(message);
}
}
The FileAppender writes log messages to a file. Since it implements the Appender interface, it can be added without changing the Logger implementation.
public class FileAppender implements Appender {
private final String filePath;
public FileAppender(String filePath) {
this.filePath = filePath;
}
@Override
public void append(String message) {
try {
Files.writeString(
Path.of(filePath),
message + System.lineSeparator(),
StandardOpenOption.CREATE,
StandardOpenOption.APPEND
);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
The LogLevel enum defines the available logging levels in increasing order of severity.
public enum LogLevel {
TRACE(0),
DEBUG(1),
INFO(2),
WARN(3),
ERROR(4),
FATAL(5);
private final int value;
LogLevel(int value) {
this.value = value;
}
public boolean isEnabled(LogLevel configuredLevel) {
return this.value >= configuredLevel.value;
}
}
The Formatter interface defines how a log message is converted into its final string representation.
This keeps formatting logic separate from the Logger and allows different formatting strategies to be introduced without modifying the Logger.
public interface Formatter {
String format(LogLevel level, LocalTime timestamp, String threadName, String message);
}
The PatternFormatter uses a configurable pattern to construct the final log message.
public class PatternFormatter implements Formatter {
private final String pattern;
public PatternFormatter(String pattern) {
this.pattern = pattern;
}
@Override
public String format(LogLevel level, LocalTime timestamp, String threadName, String message) {
return MessageFormat.format(pattern, level, timestamp, threadName, message);
}
}
Before submitting the logging task to the executor, the Logger captures the caller thread and timestamp. This is important because the actual formatting and writing happen later on a worker thread.
Calling Thread.currentThread() inside the asynchronous task would return the worker thread instead of the thread that originally generated the log message.
public class Logger {
private final List<Appender> appenders;
private final LogLevel configuredLevel;
private final Formatter formatter;
private final ExecutorService executorService = Executors.newFixedThreadPool(3);
public Logger(List<Appender> appenders, LogLevel configuredLevel, Formatter formatter) {
this.appenders = appenders;
this.configuredLevel = configuredLevel;
this.formatter = formatter;
}
public void log(LogLevel level, String message) {
if (!level.isEnabled(configuredLevel)) {
return;
}
String threadName = Thread.currentThread().getName();
LocalTime timestamp = LocalTime.now();
executorService.execute(() -> {
String formattedMessage = formatter.format(
level,
timestamp,
threadName,
message
);
appenders.forEach(appender -> appender.append(formattedMessage));
});
}
}
The use of an ExecutorService allows the application thread to submit the logging task and continue execution without waiting for formatting and I/O operations to complete.
Example
This example configures the logger with the INFO log level, a configurable message pattern, and both a FileAppender and a ConsoleAppender.public static void main(String[] args) {
String logFilePath = "/Users/nageshkumar/Desktop/app.log";
String pattern = "{0}, {1}, {2}: {3}";
Formatter formatter = new PatternFormatter(pattern);
Logger logger = new Logger(
List.of(
new FileAppender(logFilePath),
new ConsoleAppender()
),
LogLevel.INFO,
formatter
);
logger.log(LogLevel.TRACE, "TRACE !!!");
logger.log(LogLevel.DEBUG, "DEBUG !!!");
logger.log(LogLevel.INFO, "INFO !!!");
logger.log(LogLevel.WARN, "WARN !!!");
logger.log(LogLevel.ERROR, "ERROR !!!");
logger.log(LogLevel.FATAL, "FATAL !!!");
}
With the INFO log level configured, TRACE and DEBUG messages are filtered out, while INFO, WARN, ERROR, and FATAL messages are submitted for asynchronous processing. The resulting output can look like:
INFO, 23:45:12, main: INFO !!!
WARN, 23:45:12, main: WARN !!!
ERROR, 23:45:12, main: ERROR !!!
FATAL, 23:45:12, main: FATAL !!!
The main thread in the output represents the thread that originally called logger.log(). The actual formatting and writing may be performed by a different worker thread from the executor service.
Complexity
Time ComplexityLog Message β O(1) for log-level filtering and task submission from the caller thread.
The asynchronous worker performs formatting in O(1) and iterates over N appenders, resulting in O(N) processing per accepted log message.
Space Complexity
The Logger maintains N appenders and a fixed-size executor, resulting in O(N) space for the configured appenders, excluding queued asynchronous logging tasks.