Monitoring the health and performance of a live system is one of the most challenging task for its maintainers. Teams need a way to monitor everything that is going on and fix issues as soon as they arrive. This is exactly where a Logging Service comes to play. It acts as the eyes and ears of your system, recording crucial information about its operations.
Developers can scan through the generated logs and potentially find cause for bugs and failures. In today’s era, it is not possible to operate any large scale system without proper logging!
In this article, we’ll explore how to design a logging library tailored for high-volume, concurrent applications. We’ll take an iterative approach, gradually building and refining our solutions to make them more effective with each step. Ready to dive in? Let’s get started!
Let us first address the elephant of the room i.e.,
Why do we need a logging library? Isn’t logging something I can just handle with a few print statements or built-in functions?
While this indeed is the case for small projects, it really gets complex when developing for large, high-traffic applications. Here’s why a dedicated logging library really does pay off ~
In other words, while the basic logging may suffice for small projects or simple prototypes, there are much more compelling reasons to use a logging library in terms of functionality, performance, and manageability. This, then, makes it very worth your time for an application that needs a reliable and scalable logging solution.
In this first implementation of our logging library, we will consider one of the critical performance considerations ~
Logging, if done directly on an application’s main thread, can cause bottlenecks, which in turn slow down your application.
To resolve this, we would design the logging library so that it keeps a separate message queue for log messages. Then, we will have a different thread within the logging library process these messages and write them to the log, all while keeping the main thread of the application responsive.
Here is how such a library can be implemented ~
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
public class AsyncLogger {
private static final long CAPACITY = 1000;
private final BlockingQueue<String> logQueue = new LinkedBlockingQueue<>(CAPACITY);
private final LoggerThread loggerThread = new LoggerThread();
public AsyncLogger() {
loggerThread.start();
}
public void log(String message) {
try {
logQueue.put(message);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
System.err.println("Failed to log message: " + e.getMessage());
}
}
private class LoggerThread extends Thread {
@Override
public void run() {
while (true) {
try {
String message = logQueue.take();
System.out.println(message); // Replace with actual logging to file or other destinations
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
System.err.println("Logger thread interrupted: " + e.getMessage());
}
}
}
}
}
Voila! With this we have created the first versions of our logging library. But we are still not done, there are a number of improvements we can make on top of it.
First of all, we need a way to terminate the logger so that it does not prevent the JVM from shutting down in production. Stopping the logger thread is easy since our library makes use of BlockingQueue
which is responsive to interruptions. So, whenever an interruption is received, our logger will exit on the very next take
call.
Not familiar with interruptions?
Here is a blog that can help you get going!
There is, however, a problem with the way our logger currently handles interruption. There could still be messages inside the logQueue
when an interruption is received, and an abrupt shutdown would cause all of those messages to be lost even when the client would expect those messages to be already added to the log!
Additionally, it is possible that the client threads are blocked on the log
method call because there being CAPACITY
number of messages in the logQueue
. In such a case, interruption sent to logger would not cause the blocked client threads to resume!
Think of shutting down the logger like ending a typical Producer-Consumer process. You need to stop both the consumer and the producer. In our case, the logger thread acts as the consumer, while the clients that send log messages are the producers. Interrupting the logger thread stops the consumer, but because clients are not separate threads, managing their cancellation is more complex.
Another way of implementing shutdown is to keep a flag inside the logger library. Whenever a shutdown is needed, the flag would be set to true and clients should check the flag before trying to submit message to the queue. This is how the log
method might look if we modify it to use the flag.
public void log(String msg) throws InterruptedException {
if (!shutdownRequested)
queue.put(msg);
else
throw new IllegalStateException("logger is shut down");
}
Unfortunately, this approach introduces a potential race condition in our library. A client might check that a shutdown has not been requested, but by the time it attempts to submit a message to the queue, the logger thread could have already exited.
A way to fix the race condition is to make submission of log messages atomic. Note that we don’t want to hold a lock when enqueuing a message to logQueue
since put
can block. Instead, we can atomically check for shutdown and conditionally increment a counter to “reserve” the right to submit a message.
This is how the logger library would now look like ~
public class AsyncLogger {
private static final long CAPACITY = 1000;
private final BlockingQueue <String> logQueue = new LinkedBlockingQueue<>(CAPACITY);
private final LoggerThread loggerThread = new LoggerThread();
private final PrintWriter writer;
@GuardedBy("this") private boolean isShutdown;
@GuardedBy("this") private int reservations;
public AsyncLogger(final PrintWriter printWriter) {
this.printWriter = printWriter;
loggerThread.start();
}
// Method to be called when logger service need to be turned off
public void stop() {
synchronized(this) {
isShutdown = true;
}
loggerThread.interrupt();
}
public void log(String msg) throws InterruptedException {
synchronized(this) {
if (isShutdown)
throw new IllegalStateException(...);
++reservations; // Increasing reservations
}
queue.put(msg);
}
private class LoggerThread extends Thread {
public void run() {
try {
while (true) {
try {
synchronized(this) {
if (isShutdown && reservations == 0)
break;
}
String msg = queue.take();
synchronized(this) {
--reservations;
}
writer.println(msg);
} catch (InterruptedException e) { /* retry */ }
}
} finally {
writer.close();
}
}
}
}
In this way, most of the issues relating to logger shutdown are resolved.
Also, note that we have replaced System.out.println
calls with a call to PrintWriter#println
method. The PrintWriter
class abstracts out the actual logging logic. There could be several ways to implement this class and one of them could be to print it to standard output!
interface PrintWriter {
void println(String msg);
void close();
}
class StandardOutputWriter implements PrintWriter {
void println(String msg) {
System.out.println(msg);
}
void close() {}
}
public class FileWriter implements PrintWriter {
private final BufferedWriter writer;
public FileWriter(String filePath) throws IOException {
this.writer = new BufferedWriter(new ::java.io.FileWriter(filePath));
}
@Override
public void println(String msg) {
try {
writer.write(msg);
writer.newLine();
writer.flush(); // Ensure the message is written to the file immediately
} catch (IOException e) {
System.err.println("Failed to write message to file: " + e.getMessage());
}
}
public void close() throws IOException {
writer.close();
}
}
With this we reach the end of this blog. If you enjoy this read, subscribe and checkout my profile for more such interesting articles!