Introduction
In this post, we'll explore an approach to logging messages of your applications. I'll also conclude with my own package solution for logging messages in dart/flutter applications.
But first of all, what does logging mean?
In the state of art logging is
the act of keeping a log of events that occur in a computer system, such as problems, errors or just information on current operations ...
So it's a crucial activity in computing for monitoring and understanding how a system operates.
But how can we do it?
We can answer this question by answering the following points:
- "What should we log?";
- "When should we log?";
- "Where should we log?".
What should we log?
Even just from the previous explanation, we can find multiple information we should log:
- Messages useful for debugging our application;
- Error messages to alert us in case of abnormal behavior in our application;
- Information about the normal use of the app. This approach is very effective for system verification, especially when providing support to customers where the application isn't showing errors but is still not functioning correctly due to behavioral bugs.
We have seen various purposes for which logging information is essential, which suggests the need for severity levels. These could be, in a simple way: debug, info, and error.
when should we log
We have seen various reasons for logging messages. However, writing them down every time might not always be useful; in fact, it could create noise that would make (for example) error analysis more difficult.
At first, by generalizing the use cases, we might assume that debug messages might only be logged during development (obviously, the other types of errors are also useful during development). Errors should always be logged but in production they take a greater importance. Info messages are useful for system checks or customer support. There will likely be many informational messages in the life of an application, so logging them all the time in production could be counterproductive (due to noise and storage costs). Therefore, it would be better to have flags to enable or disable them as needed (like for the customer support).
Where should we log
The final question is where. During development, it is very convenient to write any type of message to the debug console. However, in production or staging environments, it is very useful to have online systems to send messages, such as Sentry or Grafana, or, where that's not possible, a mechanism for saving them to the file system (or both and other options!).
Combining the "when" with the "where" gives us a matrix of scenarios. Obviously, this matrix doesn’t cover all the real-world cases of an actual application, but it can be a good initial approximation from which to develop a solution. It’s also useful as an intermediate step before analyzing my dart package proposal.
Development | Production | |
---|---|---|
Debug | Console | X (Enabled with a flag?) |
Info | Console | X (Enabled with a flag?) |
Error | Console | Online tools, File system, ... |
However, only the where and when change; the logs themselves remain the same!
Let's dive into the analysis of my proposal.
It is important to differentiate between the system used to add a new message and the management of where and when these messages are handled (and any additional information).
where the log
method of the Logger
class will be:
class Logger {
void log(Severity severity, String message) {
// iterate through each handler that will manage the log.
for (final handler in handlers) {
handler.log(severity, message);
}
}
...
}
This approach allows us to have a Logger
instance that can be passed throughout the system, while we set up different handlers (implementation of Handler
) in a single location, such as during the application's bootstrap phase.
For example, referring to the class diagram, we can add a ConsoleHandler
only if we're in development mode, a SentryHandler
only in production (and in its log
method, we decide to log only if the severity is error
) and or a FileSystemHandler
that is activated only in production for applications that cannot communicate with external systems like Sentry.
class ConsoleHandler implements Handler {
void log(Severity severity, String message) {
developer.log(message)
}
}
class SentryHandler implements Handler {
void log(Severity severity, String message) {
if (severity != Severity.error) {
return
}
// log message
}
}
void bootstrap() {
...
final logger = Logger();
if (isDev()) {
logger.add(ConsoleHandler());
}
if (isProd()) {
logger.add(SentryHandler());
}
}
A system like this, which differentiates between loggers and handlers, makes the entire setup highly flexible. Another example, though perhaps less common but still effective, is having a handler within a UI component (AppPageViewHandler
) to listen to messages and transform them into interactive widgets. This approach allows for filtering, categorization, and other functionalities.
In Dart, I developed the library en_logger. This library applies the concepts mentioned above, with an extended severity level adhering to syslog levels and many additional features such as: instances, attachments and prefix.
Here’s a practical example (included in the library's example):
void main(List<String> args) async {
// a custom handler that under the hood use sentry
final sentry = await SentryHandler.init();
// default printer configured
final printer = PrinterHandler()
..configure({Severity.notice: PrinterColor.green()});
// an enLogger with a default prefix format
final logger = EnLogger(
defaultPrefixFormat: PrefixFormat(
startFormat: '[',
endFormat: ']',
style: PrefixStyle.uppercaseSnakeCase,
))
..addHandlers([
sentry,
printer,
]);
// debug log
logger.debug('a debug message');
// error with data
logger.error(
"error",
data: [
EnLoggerData(
name: "response",
content: jsonEncode("BE data"),
description: "serialized BE response",
),
],
);
// logger instance with prefix
final instLogger = logger.getConfiguredInstance(prefix: 'API Repository');
instLogger.debug('a debug message'); // [API Repository] a debug message
instLogger.error(
'error',
prefix: 'Custom prefix',
); // [Custom prefix] a debug message
}
class SentryHandler extends EnLoggerHandler {
SentryHandler._();
static Future<SentryHandler> init() async {
await Sentry.init(
(options) {
options.dsn = 'https://example@sentry.io/example';
},
);
return SentryHandler._();
}
@override
void write(
String message, {
required Severity severity,
String? prefix,
StackTrace? stackTrace,
List<EnLoggerData>? data,
}) {
// just a simple example
// fine tune your implementation...
if (severity.atLeastError) {
Sentry.captureException(message, stackTrace: stackTrace);
return;
}
Sentry.captureMessage(message);
}
}
Initially, we configure two handlers: PrinterHandler
and SentryHandler
. After setup, the only reference needed is the logger
, which allows us to write new messages. This decouples message handling from the actual logging operations.
With this system, we can decouple our application from external systems by wrapping them within handlers. This way, the application is not tied to external packages and can easily swap them out over time if needed.
Conclusion
I hope this post has been helpful to you. Leave a feedback, and if you're interested, give en_logger a try!
P.S. How to pass the logger instance throughout the system? Check out the post about dependency injection.
Top comments (2)
good article, earned a follow from me :)
Thank you for the kind words 🙏.