Beyond print(): Levelling Up Your Flutter Logging
Your guide to diagnosing issues and understanding your app
Logging is a crucial aspect of software development, and every framework has its own way of handling it.
Flutter comes with different logging options, and I think there is a missing extensive guide on how to use them properly.
The article will cover the basics of logging and show you some useful tips and tricks to make your logging more effective.
Printing in Flutter
print()
The simplest way to log in Flutter is using the print()
function.
print()
is coming from Dart, and it is a simple way to output text to the console. It can be useful for quick debugging, but it has some limitations.
The output is printed to the console, but it can be truncated if the message is too long.
Moreover, it does not provide any way to filter or categorize the logs, which can make it difficult to find information in a large output.
debugPrint()
A better alternative to print()
is the debugPrint()
function.
The debugPrint()
function is coming from Flutter foundation, and it is similar to print()
, but it has one advantage.
The log messages are not truncated, so you can see the full message in the console.
The most common misconception is that
debugPrint()
will not print in release mode. This is not true, asdebugPrint()
will print in all modes. You can accompany it withkDebugMode
in an if statement to conditionally print only in debug mode.
if (kDebugMode) {
debugPrint('This will only print in debug mode');
}
Downside to the print functions
Both print()
and debugPrint()
are useful for quick debugging, but they have some downsides:
- truncated output in
print()
- No log filtering or categorization
- No severity levels (info, warning, error)
- No file logging capability
- A single logger configuration only
- Synchronous logging which can block the UI thread
developer.log
In addition to print()
and debugPrint()
, Flutter provides a more extensive built-in logging mechanism that allows you to log messages - developer.log
.
The developer.log
provides more input options, such as:
message
: Information to store/log (required)time
: DateTime when it happened(optional)sequenceNumber
: A number that increases with each log, useful for ordering (optional)level
: Importance expressed as a number (0–2000, optional). Usually, the values are like 500 for debug, 800 for info, 1000 for warning, 2000 for error. These levels help you filter and prioritize logs based on their severity.name
: Name to localize the origin of the log, for example:InitializationBloc
(optional)zone
: The Dart zone where the log was made (optional)error
: Any error you want to attach to the log (optional)stackTrace
: A stack trace coming from the error catch or last stored stack trace (optional)
For example:
import 'dart:developer' as developer;
developer.log(
'Whatever you want to log',
time: DateTime.now(),
level: 1000,
name: 'my.app.logger',
error: Exception('An error occurred'),
stackTrace: StackTrace.current, // returns the current stack trace as from catch(error, stackTrace)
);
Afterwards, in devtools, you can filter the logs by name
or level
, which makes it easier to find relevant information.
For devtools in VSCode, just hit
F1
and start typing open devtools.
This already gives you a lot more flexibility than print()
or debugPrint()
, but still we miss the ability to log to a file or have multiple loggers with different configurations.
Using a Logging Package
This is not coincidental, but the Dart team provides us with this package, which has analogous functionality to developer.log
, but with more features and flexibility.
You can see even in the documentation that the developer.log
is designed to mimic the logging
package, so the migration is easy.
Go to the logging package pub.dev and feel free to add it to your yaml.
dependencies:
logging: ^1.3.0
Suddenly, you have a lot more options to log messages.
You can create a logger instance:
import 'package:logging/logging.dart';
final log = Logger('my.app.logger');
void main() {
log.info('Info');
log.warning('Warning');
log.severe('Error');
}
You can set up different log levels and also listen to the changes:
Logger.root.level = Level.WARNING; // Set the root logger level to WARNING
Logger.root.onLevelChanged.listen((level) {
// Handle level changes, e.g., update UI or save to file
});
But you need to add handlers to log messages to different outputs, such as console, files, or even remote servers:
void main() {
Logger.root.level = Level.ALL; // allow all log levels
Logger.root.onRecord.listen((record) {
// handle log records, e.g.
});
}
The package, however, does not provide any way to do anything with the logs, so you have to implement your own handlers.
You can use the prints, File, sending them to a server, native loggers, etc. Here, some people are stuck with print()
or debugPrint()
, but you can also use the developer.log
function to log messages.
But the implementation can be better.
In an advanced situation, you can send logs, for example, to a remote server or cache them to a file, but this is mostly done in advanced applications and enterprise applications.
For more basic usage, you probably want to implement Sentry or Firebase Crashlytics, which will handle the logs for you.
Logger Package
The logging
package is a great start, but what if we want even more features?
The logger
package is a popular choice which brings in more features, but it is not maintained by the Dart team.
You can find it on pub.dev.
dependencies:
logger: ^2.6.1
This package provides a lot of the same features, but it provides them out of the box, so you do not need to be bothered with the implementation:
- debugging and production filters
- formatting options
- file writing capabilities
- stubbing of the logger
- buffering of the logs
import 'package:logger/logger.dart';
final logger = Logger(
level: Level.debug, // Set the minimum log level
filter: ProductionFilter(), // Filter out debug logs in production
output: FileOutput(File('logs.txt')), // Print logs to a file or ConsoleOutput() is default
printer: PrettyPrinter(
stackTraceBeginIndex: 0,
methodCount: 2,
errorMethodCount: 8,
lineLength: 120,
colors: true,
printEmojis: true,
dateTimeFormat: DateTimeFormat.none,
excludeBox: const {},
noBoxingByDefault: false,
excludePaths: const [],
levelColors: null,
levelEmojis: null,
), // Format logs for readability
);
void main() {
logger.i('Info');
logger.w('Warning');
logger.e('Error');
}
If you do not want to be bothered by the customizations, you can use the default Logger()
constructor, which will use the default settings, and it works just fine for most cases:
// for debugging this is fine
final logger = Logger();
// for production, you want to log less
final logger = Logger(
level: Level.warning,
filter: ProductionFilter()
);
As you can see, the logger takes in a lot of formatters, outputs and filters. You can create your own or use the built-in ones.
Final Thoughts
Here are a couple of tips to make your logging more appropriate:
- never log sensitive information - at least not in production. If so, obscure it or remove it.
- include context in your log messages - add a request ID, or operation details to make debugging easier (no personal data)
- in high security applications, the logging is turned off in production and external telemetry is used instead - like Sentry or Firebase Crashlytics
- log only what is essential
- use the right log level - use
info
for informational messages,warning
for potential issues, anderror
for errors. - log in a structured way - use all the fields available in the packages like name, level, time, etc.
- find a convention that works for you and colleagues - where you can format the logs in a way that is easy to read and understand
- if you have an error, mention in the stack trace or error object where it happened, so you can easily find it in the logs.
- use consistent naming conventions for your loggers (e.g., package.class.method format)
- remove redundant logs, because you can run out of memory if you keep logging too much
- avoid logging in loops or frequently called methods to prevent log spam
- if you write into a file, use the buffering to prevent performance issues as IO operations can be slow
- if you are in a prototype, it is ok to use
print()
ordebugPrint()
, but as soon as you start to build a real application, switch to a logging package.
What I do personally, I always have only one logger instance in my application as a mixin:
import 'package:logger/logger.dart';
import 'package:get_it/get_it.dart';
/// somewhere in your dependency injection setup:
GetIt.I.registerSingleton<Logger>(Logger());
mixin class LoggerMixin {
@protected
Logger get l {
try {
return GetIt.I.get<Logger>();
} catch (e) {
return Logger();
}
}
}
The reason is simple: I want to have only one logger instance in my application,and I want to be able to use it everywhere.
If I change the logger implementation, all the code will use the same changes. No fuss with multiple loggers.
Then I can use it like this:
class MyClass with LoggerMixin {
void myMethod() {
l.i('Info log');
l.w('Warning log');
l.e('Error log');
}
}
Usage of a different logger is also possible, but in most cases, you will not need it. If so, just create another logger instance with a different name or level.
Socials
Thanks for reading this article!
For more content like this, follow me here or on X or LinkedIn.