Build your own Logger in Dart and Flutter
Logging is a fundamental process of recording messages and state of a program. These records are built with the intent of better understanding a program's execution. Logging involves taking in a message, and outputting a log. These logs can take many different shapes, files, databases, but the most common is a console output. Dart, Flutter, provide three methods for console output: print
, debugPrint
and log
.
The print(Object?)
function is the standard method for printing text to a console in a Dart program. Takes in an Object
, and makes a call to the Object
's toString()
function. This converts the Object
into a String
and sends its to the console logs.
debugPrint
The debugPrint(String? int?)
function works exactly like the print(Object?)
(it actually makes a call to the print
function), except it throttles the output and auto-applies a new line if an int
is passed to the wrapWidth
argument.
debugPrint
throttles messages to the console to prevent the native platforms from dropping messages. This will ensure all messages make it to the console.
The wrapWidth
argument will configure the logs to be applied to multiple lines if the number of characters exceed the number from wrapWidth
.
log
log
is from the dart: developer
package, and allows for more structured logs. It comes with some nice features as well: Messages can be time stamped, message tagging via name
argument, there is a severity level
argument, and it allows for Errors and StackTraces to be passed as arguments. The message tagging is very helpful when filtering logs from the DevTools
application.
Creating a Logger Interface
First off, why create a Logger
interface? Why not just put print
, debugPrint
or log
where needed?
Logs should never almost never leak into production builds, especially console outputs. Recordings of what the app, or the user, is doing should never be openly available to be read by anyone. This could possibly expose the app, user, and a plethora sensitive information if you're not careful.
There might also be a requirement to send logs to multiple sources: files, remote apis, etc. Those sources could change depending on the build varient (release/debug).
Also, its not fun keeping track of if (kDebugMode) {
statements through an entire application's codebase.
Defining Levels
To define a Logger
interface, lets define the levels of our messages. Levels imply the severity of the log message. These levels can be interpreted however needed by the implementation of the Logger
. Lets start the standard levels: Verbose
, Info
, Debug
, Warning
, and Error
. Verbose
being the least sever level, and Error
the most sever.
abstract interface class Logger {
/// Verbose level logging
void v(String message, {
String? tag,
Exception? error,
StackTrace? stacktrace
});
/// Information level logging
void i(String message, {
String? tag,
Exception? error,
StackTrace? stacktrace
});
/// Debug level logging.
void d(String message, {
String? tag,
Exception? error,
StackTrace? stacktrace
});
/// Warning level logging
void w(String message, {
String? tag,
Exception? error,
StackTrace? stacktrace
});
/// Error level logging
void e(String message, {
String? tag,
Exception? error,
StackTrace? stacktrace
});
}
With the snippet above, we can see there is a method for each level: v
for Verbose
, i
for Info
, etc.
Building a Console Logger
With an interface defined, lets implement a console-based Logger
class!
import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'logger.dart';
const _debug = 'Debug';
const _error = 'Error';
const _info = 'Info';
const _verbose = 'Verbose';
const _warning = 'Warning';
final class ConsoleLogger implements Logger {
const ConsoleLogger({required this.defaultTag})
: assert(kDebugMode, "Console Logger shouldn't be used in release builds.");
final String defaultTag;
@override
void d(String message, {String? tag, Exception? error, StackTrace? stacktrace}) {
_log(
level: _debug,
message: message,
tag: tag,
error: error,
stacktrace: stacktrace,
);
}
@override
void e(String message, {String? tag, Exception? error, StackTrace? stacktrace}) {
_log(
level: _error,
message: message,
tag: tag,
error: error,
stacktrace: stacktrace,
);
}
@override
void i(String message, {String? tag, Exception? error, StackTrace? stacktrace}) {
_log(
level: _info,
message: message,
tag: tag,
error: error,
stacktrace: stacktrace,
);
}
@override
void v(String message, {String? tag, Exception? error, StackTrace? stacktrace}) {
_log(
level: _verbose,
message: message,
tag: tag,
error: error,
stacktrace: stacktrace,
);
}
@override
void w(String message, {String? tag, Exception? error, StackTrace? stacktrace}) {
_log(
level: _warning,
message: message,
tag: tag,
error: error,
stacktrace: stacktrace,
);
}
void _log({
required String level,
required String message,
String? tag,
Exception? error,
StackTrace? stacktrace
}) {
final time = DateTime.now();
final buffer = StringBuffer('[$level]')
..write(' ')
..write('${time.hour}:')
..write('${time.minute}:')
..write('${time.second}:')
..write('${time.millisecond}')
..write(' - ')
..write(message);
log(
buffer.toString(),
time: time,
name: tag ?? defaultTag,
error: error,
stackTrace: stacktrace,
);
}
}
Okay, lets break down the snippet above!
const _debug = 'Debug';
const _error = 'Error';
const _info = 'Info';
const _verbose = 'Verbose';
const _warning = 'Warning';
final class ConsoleLogger implements Logger {
...
The const
variables declared above are the values we will use to log each level.
...
import 'package:flutter/foundation.dart';
import 'logger.dart';
...
final class ConsoleLogger implements Logger {
const ConsoleLogger({required this.defaultTag})
: assert(kDebugMode, "Console Logger shouldn't be used in release builds.");
final String defaultTag;
...
The name of the new Logger
is called ConsoleLogger
- pretty appropriate for what its purpose is!
Inside the ConsoleLogger
class, you notice we have a defaultTag
argument, and an assert
block following the constructor.
The defaultTag
will allow us to set a global tag for each log. Some logs don't have an origin that can be appropriately tagged, or sometimes I get a little lazy and just use the global tag.
The assert
block is there to make sure the app does NOT use the ConsoleLogger
in production. The kDebugMode
variable comes from the flutter/foundation.dart
package, and denotes when the app is a release/debug build. Its good to have checks like this to make sure the app does not build with faulty or debugging code.
...
final class ConsoleLogger implements Logger {
...
void _log({
required String level,
required String message,
String? tag,
Exception? error,
StackTrace? stacktrace
}) {
final time = DateTime.now();
final buffer = StringBuffer('[$level]')
..write(' ')
..write('${time.hour}:')
..write('${time.minute}:')
..write('${time.second}:')
..write('${time.millisecond}')
..write(' - ')
..write(message);
log(
buffer.toString(),
time: time,
name: tag ?? defaultTag,
error: error,
stackTrace: stacktrace,
);
}
}
The _log
function is the core function to the ConsoleLogger
class. All of the interfacing functions make a call to this function to send a message to the console. The function will timestamp when the message was received, and format the rest of the message. We have a StringBuffer
to help write and format the message, then we use the log
function from the dart:developer
package.
Console Output
Lets see how the logs look in ConsoleLogger
!
void main() {
const logger = ConsoleLogger(defaultTag: 'Application');
runApp(const MyWidget(log: logger));
}
class MyWidget extends StatelessWidget {
const MyWidget({super.key, required this.log});
static const _tag = 'MyWidget';
final Logger log;
@override
Widget build(BuildContext context) {
log.i('Building my widget...', tag: _tag);
try {
throw Exception('MyWidget is throwing an Exception!');
} on Exception catch (exception, stacktrace) {
log.e('Error occurred', tag: _tag, error: exception, stacktrace: stacktrace);
}
return const MaterialApp(
home: Text('Hello, World!'),
);
}
}
Using the snippet above, lets see what the console looks like!
Performing hot reload...
Syncing files to device sdk gphone64 x86 64...
Reloaded 1 of 704 libraries in 246ms (compile: 20 ms, reload: 151 ms, reassemble: 49 ms).
[MyWidget] [Info] 20:56:24:575 - Building my widget...
[MyWidget] [Error] 20:56:24:575 - Error occurred
D/EGL_emulation(20678): app_time_stats: avg=4093.77ms min=11.69ms max=44815.84ms count=11
Exception: MyWidget is throwing an Exception!
#0 MyWidget.build (package:etm/main.dart:20:7)
#1 StatelessElement.build (package:flutter/src/widgets/framework.dart:5550:49)
#2 ComponentElement.performRebuild (package:flutter/src/widgets/framework.dart:5480:15)
#3 Element.rebuild (package:flutter/src/widgets/framework.dart:5196:7)
#4 BuildOwner.buildScope (package:flutter/src/widgets/framework.dart:2904:19)
#5 WidgetsBinding.drawFrame (package:flutter/src/widgets/binding.dart:989:21)
#6 RendererBinding._handlePersistentFrameCallback (package:flutter/src/rendering/binding.dart:448:5)
#7 SchedulerBinding._invokeFrameCallback (package:flutter/src/scheduler/binding.dart:1386:15)
#8 SchedulerBinding.handleDrawFrame (package:flutter/src/scheduler/binding.dart:1311:9)
#9 SchedulerBinding.scheduleWarmUpFrame.<anonymous closure> (package:flutter/src/scheduler/binding.dart:1034:7)
#10 Timer._createTimer.<anonymous closure> (dart:async-patch/timer_patch.dart:18:15)
#11 _Timer._runTimers (dart:isolate-patch/timer_impl.dart:398:19)
#12 _Timer._handleMessage (dart:isolate-patch/timer_impl.dart:429:5)
#13 _RawReceivePort._handleMessage (dart:isolate-patch/isolate_patch.dart:184:12)
Now, it looks like the app experienced a crash, but it didn't! The ConsoleLogger
logged the Exception
that was deliberately thrown from within the MyWidget
class. As you can see, the format for this ConsoleLogger
is something of the following:
[{TAG}] [{LEVEL}] {TIMESTAMP} - {MESSAGE}
Exceptions and Stacktraces will follow after the formatted message is printed in the console.
Conclusion
Now, in this project there is an interface Logger
that can be provided anywhere in the code and controlled from the start of the main
function! For example, we can check the kDebugMode
variable and build a different Logger
for release and debug builds.
void main() {
Logger logger;
if (kDebugMode) {
logger = const ConsoleLogger(defaultTag: 'Application');
} else {
logger = ...
}
runApp(MyWidget(log: logger));
}
Code written through out this article can be found here.
Thank you for reading!