A toolkit for creating your own customizable and hierarchical loggers in Dart with good performance when disabled.
Table of contents
- Features
- What can this toolkit do?
- Performance
- How to make your own logger?
- Lazy Evaluation
- Custom Builders and Printers
- Examples
Features
- Custom Loggers: Build your own logger classes extending
CustomLoggerwith tailored log methods, entries, and customizable properties. - Hierarchical Loggers: Inbuilt support for hierarchical structures where subloggers inherit capabilities (levels, builders, printers) from parents, with the flexibility to override them.
- Lazy Evaluation: Includes utilities like
LazyandLazyStringto avoid expensive operations (like string interpolations or JSON encoding) when a logging level is disabled. - Async & Buffered Printers: Base classes like
AsyncLogPrinterfor printing logs asynchronously or buffering them before sending (e.g., to an analytics service). - Flexible Formatting & Output: Loggers decouple the builder (which formats the entry into a string or other object) and the printer (which decides what to do with the formatted object, like printing to the console).
What can this toolkit do?
Next, there will be examples of logger implementations: what can be done. How to do them will be explained below.
Custom logging methods
You yourself determine which logging methods your logger will have.
For example, like this:
final log = Logger();
log.info('Hello');
log.error('Error', error: Exception('Test'));
Or like this:
final log = Logger();
log.v('Verbose info');
log.d('Debug info');
log.w('Warning info');
log.e('Error', error: Exception('Test'));
log.f('Fatal error', error: Exception('Test'));
Any parameters you need
For example, like this:
final log = Logger();
log.i('MyClass', 'Info message');
Or like this:
final log = Logger();
log.i(
'Info message',
unit: 'MyClass',
method: 'myMethod',
tags: ['tag1', 'tag2'],
time: DateTime.now(),
zone: Zone.root,
);
Hierarchical loggers
You can create nested loggers associated with the main one. In this case, nested loggers do not need to be disposed of: when they leave the scope, they are automatically removed from the main logger. Therefore, you can safely create them in each function or unit where needed.
final log = Logger('app');
final authLog = log.withAddedName('auth');
final loginLog = authLog.withAddedName('login');
final logoutLog = authLog.withAddedName('logout');
log.i('App started'); // app | App started
authLog.i('Check user authentification'); // app | auth | Check user authentification
loginLog.i('User login'); // app | auth | login | User login
logoutLog.i('User logout'); // app | auth | logout | User logout
See example: hierarchical_logger.dart.
Any output type
The output type does not necessarily have to be a String. It can be, for
example, ready-made json or a type prepared for conversion to json:
final log = Logger();
log.i('event', 'Hello', data: {'a': 1});
// {"level":"info","name":"event","message":"Hello","data":{"a":1}}
See example: json_reporter.dart.
Customizable formatting
final log = Logger();
log.builder = (entry) => '[${entry.shortLevelName}] ${entry.message}';
Formatting is available not only at the stage of creating a logger class, but also later, in real time. This allows you to create loggers for packages: you create a package and logging in it, which will be useful not only to you as the package developer, but also to its users. And you give the user not only access to the logs, but also the ability to configure the output format so that YOUR logs become an integral part of the USER's logs in the form in which they want to see them.
final log = Logger('your_package');
// Your logs:
log.i('your_feature', 'Info message');
// [i] your_package | your_feature | Info message
// User logs after configuration:
log.builder = ...;
// 2026-02-23 19:19:09.123 [INFO] your_package/your_feature/ Info message
Customizable output
Printing and building are deliberately separated so that different formatting can be configured for different logging levels, but with a single output:
final log = Logger();
log[Levels.debug].builder = ...;
log[Levels.info].builder = ...;
log[Levels.error].builder = ...;
log.printer = print;
Or, conversely, a single formatting, but different outputs for different levels.
final log = Logger();
log.builder = ...;
log[Levels.debug].printer = ...;
log[Levels.info].printer = ...;
log[Levels.error].printer = ...;
Performance
When logging, it is often not so important how much time is spent on logging, but it is very important how much time logging takes when it is disabled.
Basic level of logger disabling
final log = Logger()..level = Levels.off;
log.d('This will not be logged');
If logging is disabled, a no-op function is called under the hood. No calculations, no checks. Just one call to an empty no-op function, which, as a rule, is well optimized by the compiler.
Lazy evaluation of parameters
However, in normal use, the function parameters will still be evaluated. Therefore, the package provides utilities for deferred evaluation:
final log = Logger();
log.d(() => expensiveCalculation());
// or:
log.d(expensiveCalculation);
Complete removal of logging code
For more demanding cases where maximum performance is required, the logger is designed for convenient use with asserts and constants.
Using asserts is a common life hack for cutting out not only unnecessary checks from the code, but also logging functions. Usually it looks like this:
assert(() {
log.d('Debug info');
return true;
}());
It's quite cumbersome! The package provides the ability to do this:
assert(log.d('Debug info'));
And instead of:
const logging = bool.fromEnvironment('logging');
if (logging) log.d('Debug info');
you can do this:
logging && log.d('Debug info 1');
The result will be the same in both cases. It's just sugar.
The only important thing here is that when you disable assertions or your
constant, the logging code will be removed by the compiler. This is not just
if (false). This is a full reset.
Benchmarks can be seen here: benchmarks.dart.
How to make your own logger?
Building a basic custom logger involves defining your log function signature, the log entry payload, the level logger configuration, and the main logger manager.
Here is a simplified example of how you can build a logger tailored strictly to your application's needs:
1. Define the log function signature
import 'package:logger_builder/logger_builder.dart';
typedef LogFunction =
bool Function(Object? message, {Object? error, StackTrace? stackTrace});
You can choose void as the return value, but if you want to use the logger
together with assert, it is better to choose bool. If you haven't decided
yet, definitely choose bool.
Pay attention to the type of message - Object?. First, this is done so that
any object can be passed to the logger. The logger will then convert it to a
string itself. Secondly, this makes it possible to use deferred calculations by
passing a function to the logger that will be called only if the log is output.
2. Define the Entry Payload
final class LogEntry extends CustomLogEntry {
final LazyString _lazyMessage;
LogEntry(
super.levelLogger, {
super.error,
super.stackTrace,
required Object? message,
}) : _lazyMessage = LazyString(message);
String? get message => _lazyMessage.value;
}
This is a structure that will store all information about a specific log, which
will be obtained from the LogFunction function or calculated independently.
The constructor always requires a reference to levelLogger (more on that
below). But in fact, the reference to levelLogger is only needed to extract
the data about the level from it: level, levelName, levelShortName. The
reference itself is not saved.
Also, the base class CustomLogEntry already has ready-made fields error and
stackTrace. They are not required to be filled in, but you can use them
if your logging system requires it. stackTrace can be used independently of
error. But if you do not pass stackTrace, and pass Error instead of
Exception as error, then stackTrace will be taken automatically from
error, if it is there:
stackTrace ??= error is Error ? error.stackTrace : null;
CustomLogEntry also has a ready-made zone field. By default, it is equal to
Zone.current, i.e., the zone in which the logger was called.
3. Define the Level Logger (handles logic for a specific log level)
final class LevelLogger
extends
CustomLevelLogger<Logger, LevelLogger, LogFunction, LogEntry, String> {
LevelLogger({
required super.level,
required super.name,
}) : super(
noLog: (_, {error, stackTrace}) => true,
builder: Logger.defaultBuilder,
printer: print,
);
@override
LogFunction get processLog => (message, {error, stackTrace}) {
final entry = LogEntry(this, error: error, stackTrace: stackTrace, message: message);
printer(builder(entry));
return true;
};
}
When extending the CustomLevelLogger class, you need to pass several types to
it:
Logger- type of main loggerLevelLogger- type of level loggerLogFunction- type of log functionLogEntry- type of log entryString- type of final result, which will be printed
The constructor of the CustomLevelLogger class accepts several parameters:
-
level- level of log. It is an integer. The higher the number, the higher the level of the log. You can use ready constants from theLevelsclass as values. In it there are and those that usedeveloper.logand theloggingpackage:finest,finer,fine,config,info,warning,severe,shout. But there are also additionaltrace,verbose,debug,error,critical. In any case, it is just numbers: greater than 0 (Levels.off) and less than 2000 (Levels.all). You can use your own values. -
name- the name of the log level. This is a string value that you can use to output the log. The parameter is mandatory, although it is not necessary to use it. In theCustomLogEntrystructure, this value is stored with the namelevelName. -
shortName- short name of the log level. This is an optional parameter. If it is not specified, the first character ofnamewill be used asshortName. In theCustomLogEntrystructure, this value is stored with the namelevelShortName. You can use this value as you wish. -
noLogis the very important parameter. It is a no-op function that will be called when this log level is not enabled. Since you yourself define the signature of the log function, you will have to define this no-op function yourself. That is, its type must match exactly the type of theLogFunction. Pass a global function or static method here:noLog: _noLog, ... static bool _noLog( Object? message, { Object? error, StackTrace? stackTrace, }) => true;Or an empty closure:
noLog: (_, {error, stackTrace}) => true,Performance will be the same in both cases.
-
builder- a function that will be called by default to build the log result, whichLogEntryconverts toString, or to the type you specified as the last type when extending theCustomLevelLoggerclass. The function must be passed through the constructor, so you cannot pass the logger instance method here. Use a global function or static method. Do not use a private method or closure here so that developers who change the value ofbuildercan then restore it back:log.builder = Logger.defaultBuilder;Or can use it within their implementation of builder:
log.builder = (e) => '${DateTime.now()}: ${Logger.defaultBuilder(e)}'; -
printer- a function that will be called to print the log string. It will take the result ofbuilder, i.e.String, and print it. (Or send it to a file, console, server, etc.) In our case, we use the usualprintfunction.And of course, you can use only the
builderfunction for both building and printing the log, and not use theprinterfunction, passing a no-op function to it.
Finally, you need to create the main function processLog, which will be
called under the hood instead of log.info, log.error, etc.
Due to technical features, processLog cannot be just a function. It is a
getter of type LogFunction, which accepts either a function or a closure
of the corresponding type. Implement processLog as you see fit.
For example, using a closure:
@override
LogFunction get processLog => (message, {error, stackTrace}) {
final entry = LogEntry(this, error: error, stackTrace: stackTrace, message: message);
printer(builder(entry));
return true;
};
Or using a method:
@override
LogFunction get processLog => _processLog;
bool _processLog(Object? message, {Object? error, StackTrace? stackTrace}) {
final entry = LogEntry(this, error: error, stackTrace: stackTrace, message: message);
printer(builder(entry));
return true;
}
Theoretically, the second option should be more performant, as it does not
create a closure on each call. But in practice, the compiler makes the
difference minimal.
Inside processLog, you need to do four things:
- Create a
LogEntry. - Build a log string using
builder. - Output the log string using
printer. - Return
true(if you decided to follow the advice and useboolas the return value).
You will have to do all this yourself. Yes, creating a logger requires writing a large amount of code. But this is only done once, and it will be YOUR own unique logger.
4. Define the Main Logger (manages the different level loggers)
final class Logger
extends
CustomLogger<Logger, LevelLogger, LogFunction, LogEntry, String> {
Logger();
@override
void registerLevels() {
registerLevel(_info);
registerLevel(_error);
}
final _debug = LevelLogger(level: Levels.debug, name: 'debug');
final _info = LevelLogger(level: Levels.info, name: 'info');
final _error = LevelLogger(level: Levels.error, name: 'error');
LogFunction get debug => _debug.log;
LogFunction get info => _info.log;
LogFunction get error => _error.log;
static String defaultBuilder(LogEntry entry) =>
'[${entry.shortLevelName}] ${entry.message}';
}
When extending the CustomLogger class, the same types are used as when
extending CustomLevelLogger.
Next, you need to decide which logging levels you need and create the
corresponding level loggers, then register them in the registerLevels method
using registerLevel method. Then, using the appropriate getters, pass
a reference to the log getter of the corresponding logger. Be careful not to
make a mistake here: do not accidentally pass a reference to processLog!
log will automatically change to noLog when logging at this level is
disabled, and to processLog when it is enabled!
5. Use the Logger
void main() {
final log = Logger()..level = Levels.all;
log.i('Hello, world!');
log.e('Something went wrong', error: Exception('Something went wrong'));
}
The entire example can be viewed here: simple_logger.dart.
Lazy Evaluation
When invoking log methods with potentially expensive payload evaluations, you
can use closures. The log entry will lazily convert closures using
Lazy and LazyString only when the specific level is enabled and printed.
// The closure will only execute if the 'info' level is currently enabled
log.info(() => jsonEncode(hugeObject));
I recommend using closures in all cases when you pass something other than
ready-made values, even if it's a simple string with minor interpolations or
something like i++. Better safe than sorry.
The main class for lazy computations is Lazy:
final lazy = Lazy(() => expensiveComputation());
print(lazy.resolved); // expensiveComputation() will be called only here
print(lazy.resolved); // expensiveComputation() will not be called again
print(lazy.unresolved); // access to the original "unresolved" value
Lazy returns Object?. For a typed value, extend the TypedLazy class:
class MyLazyString extends TypedLazy<String> {
MyLazyString(super.unresolved);
@override
String convert(Object? resolved) => resolved?.toString() ?? '(no data)';
}
The convert function will only be called for values whose type does not match
the specified one. Therefore, if you expect a specific type and conversion from
other types is impossible, throw an exception or return a fallback value.
For String? use the ready-made LazyString class. If the value type does
not match String, the toString() method will be called. The null value
is returned as is.
Custom Builders and Printers
At runtime, you can swap out builders and printers for the whole logger, or just a specific level:
final log = Logger()..level = Levels.all;
// Change the builder for the whole logger
log.builder = (entry) => '${DateTime.now()} | ${entry.message}';
// Change the printer for errors only (e.g. print in red using ansi codes)
log[Levels.error].printer = (text) => print('\x1B[31m$text\x1B[0m');
Examples
The example/logger_builder_examples directory contains more elaborate examples, demonstrating:
Libraries
- logger_builder
- A toolkit for creating your own customizable and hierarchical loggers in Dart with good performance when disabled.