A toolkit for creating your own customizable and hierarchical loggers in Dart with good performance when disabled.
dart pub add logger_builder
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, publishers) 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 Publishers: Base classes like
AsyncPublisherBasefor printing logs asynchronously or buffering them before sending (e.g., to an analytics service). - Transformers: A
LogTransformeron the logger or on a single destination masks secrets and PII, or drops forbidden logs entirely, before they reach any output. - Flexible Formatting & Output: Loggers decouple the format step (which formats the entry into a string or other object) and the output step (which decides what to do with the formatted object, like printing to the console).
Table of contents
- What can this toolkit do?
- Performance
- Why not just
if (logging)? - How to make your own logger?
- Lazy Evaluation
- Custom Publishers
- Async Publishers
- Several Publishers
- Hierarchical Loggers
- Transformers
- Common Scenarios
- Common Mistakes
- Using logger_builder in your own package
- Examples
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.
Note
The snippets below show the output you get once logging is switched on. Two things are needed for that, and a fresh logger has neither:
..level = Levels.all(or any other threshold) — a freshly built logger starts atLevels.off. That default is deliberate, see Using logger_builder in your own package;..publisher = ...— every level starts on a no-op publisher that discards what it gets.
Get one of them wrong and you see silence with no hint which: an
unconfigured level still reports isEnabled == true, because it is
enabled — it just publishes into nothing.
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. Nested loggers do not need to be disposed of: the parent holds them through weak references, so once you stop referencing a sublogger it is collected and dropped from the parent on the next traversal. Therefore, you can safely create them in each function or unit where needed. See Hierarchical Loggers for the inheritance rules.
final log = Logger('app');
final authLog = log.child('auth');
final loginLog = authLog.child('login');
final logoutLog = authLog.child('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
child is not a package method — the package gives you the protected
CustomLogger.sub constructor and you expose it under whatever name reads
best. It is defined on the Logger built in
Hierarchical Loggers.
The app | auth | login shape in the comments is not automatic either.
Nothing in the package knows a logger's name: it comes from the fuller
logger in the example below, whose Log carries a path and joins it with
|. The Logger in Hierarchical Loggers joins
with a dot and does not print the path at all.
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 = JsonReporter()
..level = Levels.all
..publisher = const DefaultJsonPublisher();
log.i('info-event', data: {'id': 2, 'data': 'Info data'});
// {"level":"info","timestamp":1786903488805201,"event":"info-event",
// "data":{"id":2,"data":"Info data"}}
See example: json_reporter.dart.
Customizable formatting
final log = Logger();
log.publisher = CustomLogPublisher(
(log) {
print('[${log.shortLevelName}] ${log.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('package');
// Your logs:
log.i('feature', 'Info message');
// [i] package | feature | Info message
// User logs after configuration as an example:
log.publisher = ...;
// 2026-02-23 19:19:09.123 [INFO] package/feature/ Info message
Customizable output
Formatting and output can be separated so that different formatting can be configured for different logging levels, but with a single output:
import 'package:ansi_escape_codes/style.dart';
String format(Log log) => '[${log.shortLevelName}] ${log.message}';
final log = Logger()
..publisher = CustomLogFormatter(
format: format,
output: print,
)
..[Levels.error].publisher = CustomLogFormatter(
format: (log) => Styles.red(format(log)),
output: print,
);
Or, conversely, a single formatting, but different outputs for different levels.
import 'package:ansi_escape_codes/style.dart';
String format(Log log) => '[${log.shortLevelName}] ${log.message}';
final log = Logger()
..publisher = CustomLogFormatter(
format: format,
output: print,
)
..[Levels.error].publisher = CustomLogFormatter(
format: format,
output: (str) => print(Styles.red(str)),
);
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.
Depth is free. A sublogger costs the same as the root, and that is the point of holding the inherited settings as copied-down values rather than resolving them per call. Measured at depths 0, 1, 5 and 20, AOT: 83.2, 83.2, 83.6, 83.5 ns with the level enabled, and 1.66, 1.78, 1.72, 1.77 ns with it disabled. Flat, within the sd of each figure.
The exception is anything your own publisher resolves per log. The
Logger in the example carries a lazily built path, and a publisher that
reads it pays for the walk: the same four depths become 101.7, 103.5, 112.6
and 152.0 ns — roughly 2.5 ns per level. That cost is yours, not the
package's, and it only arrives if a publisher asks for the path.
Benchmarks can be seen here: benchmarks.dart.
Why not just if (logging)?
If your needs are covered by
const logging = bool.fromEnvironment('logging');
if (logging) print('User $id logged in');
then keep doing that. It costs nothing, and this package does not ask you
to give it up — as shown above, logging && log.i(...) is the same trick
and compiles away just as completely.
The difference is when the switch is thrown.
if (logging) is a compile-time switch. One constant turns the whole
program's logging on or off, and the destination — print — is written
into every call site. That is fine while the only reader is you, at your
own terminal, right now.
A logger is a runtime switch, and that buys four things a bare print
cannot:
- Granularity.
Levels.offfor the app andLevels.allforauthLog: one noisy subsystem, without recompiling and without drowning in everything else. - Levels.
printhas exactly one severity. Someone chasing a bug wants debug output; the same person in production wants errors only. - A destination you can change later. The call site says what happened; the publisher decides where that goes. Console today, a file or an analytics service tomorrow, both at once when you need it — and not one call site changes.
- Something to hand your users. A package built on
if (logging) print(...)offers its users nothing: they cannot turn its logs on, cannot format them, cannot fold them into their own log stream. See Using logger_builder in your own package.
And none of it is paid for while logging is off: a disabled level is one
call to an empty function, and under assert or a constant the call
disappears entirely.
So it is not one or the other. Use assert(log.d(...)) for what should be
gone from release builds, and levels and publishers for what should stay
switchable at runtime.
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 LogFn =
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 is 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 Log extends CustomLog {
final LazyString _lazyMessage;
Log(
super.levelLogger, {
required Object? message,
super.error,
super.stackTrace,
super.zone,
}) : _lazyMessage = LazyString(message);
/// A copy is not a new log event: `CustomLog.copy` keeps the level and the
/// zone, and your copy constructor carries over your own fields — as the
/// two lines below do. Transformers need this.
Log.copy(Log original, {Object? message})
: _lazyMessage =
message != null ? LazyString(message) : original._lazyMessage,
super.copy(
original,
error: original.error,
stackTrace: original.stackTrace,
);
String get message => _lazyMessage.value;
}
This is a structure that will store all information about a specific log, which
will be obtained from the LogFn 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, shortLevelName. The
reference itself is not saved.
Also, the base class CustomLog 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;
CustomLog 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, LogFn, Log> {
LevelLogger({
required super.level,
required super.name,
super.shortName,
}) : super(
noLog: (_, {error, stackTrace}) => true,
);
@override
LogFn get processLog => (message, {error, stackTrace}) {
publishLog(
Log(
this,
message: message,
error: error,
stackTrace: stackTrace,
),
);
return true;
};
}
When extending the CustomLevelLogger class, you need to pass several types to
it:
Logger- type of main loggerLevelLogger- type of level loggerLogFn- type of log functionLog- type of log event
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.all) and less than 2000 (Levels.off). 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 theCustomLogstructure, 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 theCustomLogstructure, this value is stored with the nameshortLevelName. You can use this value as you wish. -
noLogis 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 theLogFn. 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.
-
publisher- a publisher that will be called by default to publish theLogevent. Typically, the publisher handles formatting and outputting the results. However, it may also forward the log to other publishers (MultiPublisher) or place the event processing in an asynchronous queue (AsyncPublisher). By default,CustomLogPublisher.noOp()is used, which does nothing.final log = Logger() ..level = Levels.all ..publisher = CustomLogPublisher( (log) => print(log.message), );or:
final class DefaultLogPublisher implements CustomLogPublisher<Log> { const DefaultLogPublisher(); @override void publish(Log log) { print('[${log.shortLevelName}] ${log.message}'); } } // ... final log = Logger() ..level = Levels.all ..publisher = const DefaultLogPublisher();
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 LogFn, which accepts either a function or a closure of
the corresponding type. Implement processLog as you see fit.
For example, using a closure:
@override
LogFn get processLog => (message, {error, stackTrace}) {
publishLog(
Log(
this,
message: message,
error: error,
stackTrace: stackTrace,
),
);
return true;
};
Or using a method:
@override
LogFn get processLog => _processLog;
bool _processLog(Object? message, {Object? error, StackTrace? stackTrace}) {
final log = Log(this, message: message, error: error, stackTrace: stackTrace);
publishLog(log);
return true;
}
The two are equivalent in practice, and the usual argument for the method —
that it avoids allocating a closure on every call — describes something that
never happens. processLog is read once per level toggle: switching a level
on assigns its result to the field that log dispatches through, so the
closure is created when the level is enabled, not when a log is written.
Measured over 1M calls per form
(benchmarks.dart),
the two land within about 1 ns of each other. AOT: 10.25 ns for the closure
against 11.12 ns for the method, sd 0.05 and 0.07 — a real gap, and a
negligible one. The JIT: 11.75 against 11.99, sd 0.25 and 0.28 — no gap at
all. Use whichever reads better.
Inside processLog, you need to do three things:
- Create a
Log. - Publish the
LogviapublishLog— the protected method that appliesCustomLogger.transformerand then hands the log to the publisher. Callingpublisher.publishdirectly skips the transformer. - 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, LogFn, Log> {
Logger();
@override
void registerLevels() {
registerLevel(_debug);
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');
LogFn get d => _debug.log;
LogFn get i => _info.log;
LogFn get e => _error.log;
}
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
..publisher = const DefaultLogPublisher();
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++. The asymmetry is what makes it pay: with the level
enabled the three forms are indistinguishable — 128.9, 126.8 and 128.4 ns
with sd up to 2 — while with the level disabled the closure turns 36 ns into
3.6 ns. Nothing measurable when you lose, an order of magnitude when you
win.
A tear-off of an existing function is cheaper still: log.d(buildMessage)
allocates nothing per call and comes to 1.89 ns on a disabled level, against
3.57 ns for log.d(() => ...), which allocates one closure on every call
whether the level is on or off. Both are far below the 36 ns of building the
string eagerly. (Median of ten runs of 1M calls, AOT, on one machine; the
JIT numbers differ in scale, not in shape. The benchmark prints min, max,
mean and sd next to every result — do not trust a gap smaller than the sd
beside it, including the ones quoted here.)
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
Lazy returns Object?. For a typed value, extend the TypedLazy class:
final class LazyString extends TypedLazy<String> {
final String fallbackValue;
LazyString(super.unresolved, [this.fallbackValue = 'null']);
@override
String convert(Object? resolved) => resolved?.toString() ?? fallbackValue;
}
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-to-use LazyString or LazyStringOrNull classes.
If the value type does not match String, the toString() method will be
called.
Custom Publishers
At runtime, you can swap out publishers for the whole logger, or just a specific level:
import 'package:ansi_escape_codes/style.dart';
//...
final class DefaultLogPublisher implements CustomLogPublisher<Log> {
const DefaultLogPublisher();
static String format(Log log) =>
'[${log.shortLevelName}] ${DateTime.now()} | ${log.message}';
static void output(String out) => print(out);
@override
void publish(Log log) {
output(format(log));
}
}
//...
final log = Logger()
..level = Levels.all
// Change the publisher globally
..publisher = const DefaultLogPublisher()
// Change the publisher for errors only (e.g. print in red using ansi codes)
..[Levels.error].publisher = CustomLogFormatter(
format: DefaultLogPublisher.format,
output: (str) => print(Styles.red(str)),
);
CustomLogFormatter above is the second, shorter route: it is a
CustomLogPublisher that splits the job in two — format turns a Log into
an Out object, output decides what to do with it. Writing a class like
DefaultLogPublisher gives you a name and a place for state; reaching for
CustomLogFormatter saves you the class when all you want is to reuse one
format with a different output, which is exactly what the error level does
here.
Async Publishers
CustomLogger does not natively support asynchronous log processing. You can,
of course, specify an asynchronous function as a publisher:
log.publisher = CustomLogPublisher((log) async {
await ...
});
But the logs will be output in parallel without waiting for each other. In some cases, this might be exactly what you need. But if the order of log processing is important to you (for example, when writing to a file), then this is not the right option for you.
Therefore, for asynchronous processing of logs, log.publisher should act
internally to register and coordinate events sequentially.
logger_builder already has a set of ready-made asynchronous publishers.
AsyncPublisher
AsyncPublisher is a simple version of an asynchronous handler.
Future<void> main() async {
final asyncPublisher = AsyncPublisher<Log>((log) async {
await ...;
});
final log = Logger()
..level = Levels.all
..publisher = asyncPublisher;
log.d('Debug message');
log.i('Info message');
log.e('Error message');
await asyncPublisher.flush();
}
See an example: async_publisher.dart.
AsyncPublisherBase
AsyncPublisherBase allows you to create your own handler class.
final class MyAsyncPublisher extends AsyncPublisherBase<Log> {
@override
FutureOr<void> handle(Log log) async {
await ...;
}
}
Future<void> main() async {
final asyncPublisher = MyAsyncPublisher();
final log = Logger()
..level = Levels.all
..publisher = asyncPublisher;
log.d('Debug message');
log.i('Info message');
log.e('Error message');
await asyncPublisher.flush();
}
See also an example: async_publisher.dart.
Note
All versions of handlers have a Base version.
AsyncPublisherWithParam
AsyncPublisherWithParam allows you to add an additional parameter to the
handler.
Future<void> main() async {
final asyncPublisher =
AsyncPublisherWithParam<bool, Log>((sendToAnalytics, log) async {
print(log.message);
if (sendToAnalytics) {
await ...;
}
});
final log = Logger()
..level = Levels.all
..publisher = asyncPublisher.withParam(false)
..[Levels.error].publisher = asyncPublisher.withParam(true);
log.d('Debug message');
log.i('Info message');
log.e('Error message');
}
See also an example: async_publisher_with_param.dart.
AsyncPublisherWithBuffer
Future<void> main() async {
final asyncPublisher = AsyncPublisherWithBuffer<Log>((logs, retryBuffer) async {
try {
await ...;
} catch (e) {
retryBuffer.addAll(logs); // Failed logs handled automatically
}
});
final log = Logger()
..level = Levels.all
..publisher = asyncPublisher;
log.d('1 Debug message');
log.i('1 Info message');
log.e('1 Error message');
await null; // 3 messages handled
log.d('2 Debug message');
log.i('2 Info message');
log.e('2 Error message');
log.d('3 Debug message');
log.i('3 Info message');
log.e('3 Error message');
await null; // 6 messages handled
await asyncPublisher.flush();
}
See also an example: async_publisher_with_buffer.dart.
The full set
Two independent axes — does the handler need an extra parameter, and does it work on batches — give four base classes, and each comes in a "do it yourself" and a "format + output" flavour:
| one log at a time | batches | |
|---|---|---|
| no parameter | AsyncPublisher / AsyncFormatter |
AsyncPublisherWithBuffer / AsyncFormatterWithBuffer |
| with a parameter | AsyncPublisherWithParam / AsyncFormatterWithParam |
AsyncPublisherWithBufferAndParam / AsyncFormatterWithBufferAndParam |
The Async*Publisher* half takes one handle/handler callback and you do
everything in it. The AsyncFormatter* half splits that in two — format
turns the log (or the batch) into an Out object, output sends that
object somewhere — which is what you want when the same payload goes to
several destinations, or when formatting is the expensive part:
final asyncFormatter = AsyncFormatter<Log, Map<String, Object?>>(
format: (log) async => {'level': log.levelName, 'message': log.message},
output: (out) async => apiClient.post('/logs', data: out),
);
All eight take the same four optional arguments:
onError— called when the handler throws. Without it the error goes to the current zone, and in a plain Dart program without an error zone an uncaught asynchronous error terminates the isolate, after which nothing keeps processing your logs. Set it, or wrap the app inrunZonedGuarded;sync— whether the internalStreamControllerdelivers synchronously. Leave it alone unless you know you need it;maxQueueSize— the most entries the queue accepts before it starts refusing them, counting what has been accepted and not yet handled: the entries waiting plus the one (or the batch) being handled right now. Default 100 000 — about 20 MB at two hundred bytes a log. At the limit it is the incoming log that is refused — it goes toonDroppedand never enters the queue, so everything already accepted is still delivered andflush()andclose()mean exactly what they meant before. The queue drains only when the event loop turns, so a tight loop that publishes more than the bound without awaiting anything loses the rest however healthy the sink is; that loop, not a stalled sink, is what the default is sized against — this package's own benchmark publishes 20 000 logs in exactly such a burst, and the bound carries all of it.nullgives the bound up on purpose: the queue then grows until the process runs out of memory, which is the right trade only when the input is bounded elsewhere and losing a log is worse than dying;onDropped— called with what was dropped. In all eight that means a log the full queue refused; in the buffered four it also means a batch that spent itsmaxRetriesbudget and entries handed back toretryBufferafterclose()was called, which can never be processed. Leaving it unset does not hide the loss: the publisher says so itself, printing the first one at once and counting the rest into a summary — printed by the next loss to arrive more than five seconds later, widening to a minute while they keep coming, or byclose(). There is no timer behind that, on purpose, and the consequence is worth knowing: a burst that ends without a later loss and without aclose()is announced by that first line and never counted. It says it withprint, so it lands in the application's stdout — worth a thought if your stdout carries a protocol rather than a console.onDropped: (_) {}silences all of it, and aprintof your own redirects it:printgoes through the current zone. The unbuffered four hand you one log at a time (with itsparam, where there is one), the buffered four a list.
The four buffered ones take two more:
retryDelay— how long to wait before retrying a batch that was handed back throughretryBuffer. The defaultDuration.zerostill goes through the event loop, so a dead sink cannot starve timers or your ownclose(), but it retries as fast as the loop allows. Set a real delay when the destination can be down for a while; it doubles with each attempt, capped at 32 times the base.close()does not wait any of it out — it cancels the pending timer and makes one prompt final attempt instead;maxRetries— how many times a batch handed back throughretryBufferis retried before it is dropped. Default 100. It counts a run of failures, so a batch that gets through pays the whole budget back and a sink that recovers gets the full allowance again. Zero drops a handed-back batch at once, and there is deliberately no unbounded setting: retrying for ever never delivers a batch that fails deterministically — atoStringthat throws, a value that will not serialise — and never drops it either, so it pins a core, drownsonError, and keeps a pending timer alive, which on its own is enough to stop a worker from ever exiting.
The Base classes (AsyncPublisherBase and friends) are for when you want a
named class with its own state instead of a callback; isClosed tells you
whether close() has been called.
flush() completes when everything accepted so far has been processed, and
close() drains before it finishes. Calling flush() while a close() is
still draining hands you that close rather than an already-completed future
— the same answer from all eight and from both wrappers, so a shutdown that
awaits a flush cannot be told the queue is empty while it is not.
Important
All of these queues are bounded: maxQueueSize defaults to 100 000
entries accepted and not yet handled. Past that the incoming log is
refused and handed to onDropped — set it to keep those logs, or the
publisher will at least tell you how many it lost. Nothing already
accepted is lost, so flush() and close() keep their meaning.
maxQueueSize: null gives the bound up and lets pending logs accumulate
until the process runs out of memory; that is the right trade only when
you bound the input yourself.
In the buffered variants retryBuffer is also the only thing that keeps a
log across a failure: a throwing handler drops everything it did not hand
back.
Several Publishers
The MultiPublisher helper class allows you to send a log to multiple
publishers.
// The `<Log>` is required on all three. A publisher stored in a local
// variable has no context type to infer from, so `Log` would become
// `CustomLog` and the assignment to `log.publisher` would not compile.
final consolePrinter = CustomLogPublisher<Log>((log) => print('Console: $log'));
final filePrinter = AsyncPublisher<Log>((log) async {/* write to file */});
final multiPublisher = MultiPublisher<Log>([
consolePrinter,
filePrinter,
]);
log.publisher = multiPublisher;
// ...
await multiPublisher.flush();
An exception thrown by one publisher does not interrupt publishing: the
remaining publishers still receive the log. Pass onError to handle such
errors yourself — it receives the failing publisher along with the error:
final multiPublisher = MultiPublisher<Log>(
[consolePrinter, filePrinter],
onError: (publisher, error, stackTrace) =>
print('$publisher failed: $error'),
);
Without onError, the error is reported to the current zone as an uncaught
asynchronous error (in Flutter it ends up in PlatformDispatcher.onError,
inside runZonedGuarded — in its handler). Note that a plain Dart program
without an error zone terminates the isolate on such errors by default.
flush() and close() cascade only to the publishers that can be flushed
and closed — the ones implementing Flushable and Closable. All the
asynchronous publishers do, including the adapter returned by withParam();
a plain CustomLogPublisher has nothing to drain and is skipped.
See also an example: multi_publisher.dart.
Hierarchical Loggers
A sublogger is created through the protected CustomLogger.sub constructor.
Since it is protected, you expose it the way that suits your logger — usually
a named constructor plus a method that reads well at the call site:
final class Logger extends CustomLogger<Logger, LevelLogger, LogFn, Log> {
final String name;
Logger(this.name);
Logger._sub(Logger parent, this.name) : super.sub(parent);
Logger child(String name) => Logger._sub(this, '${this.name}.$name');
// registerLevels(), getters, ... as before
}
final root = Logger('app')
..level = Levels.info
..publisher = const DefaultLogPublisher();
final db = root.child('db'); // inherits level and publisher
final http = root.child('http');
What is inherited. Three things by copy — the level, the per-level
publishers and the transformer — and onError by lookup. Each of the
three has its own link, and the publishers carry one
knob more — a pin per level, on top of the logger's link. A change on the
parent reaches every sublogger whose corresponding link is still up, and
there every level that holds no pin of its own:
root.level = Levels.debug; // db and http switch to debug too
onError is the odd one out. It is resolved through the parent chain at the
moment it is needed rather than copied down, so a sublogger with no handler
of its own uses its parent's. There is no link flag for it, relink() does
not affect it, and assigning null restores the inherited handler instead
of detaching — see
Errors on the publish path.
How a sublogger detaches. Assigning the level, the common publisher
or the transformer directly on the sublogger drops that link — from then
on the sublogger keeps its own value and ignores the parent:
http.level = Levels.all; // http is now independent, db still follows root
root.level = Levels.error; // db → error, http stays at all
A publisher assigned to a single level is the exception: it does not drop
the link, it pins that level. The other levels of that sublogger keep
following the parent, publisherLinked stays true, and the pin is
lifted per level — see How to re-attach below.
Assigning the same value is the idiom for unlinking without changing
anything: child.level = child.level and child.transformer = child.transformer. For publishers that idiom is per level too
(child[Levels.info].publisher = child[Levels.info].publisher pins
without changing anything); there is no idiom for detaching every
publisher at once without changing values, so assign a common publisher,
or loop over levels.
How to re-attach. relink() re-inherits all three from the parent and
turns propagation back on. It drops every per-level pin along the way, so
the logger follows the parent whole again. It returns false only for a
root logger:
http.relink(); // follows root again, pins included
CustomLevelLogger.relink() is the narrower one: called on a level, it
lifts that level's pin alone and leaves the rest of the logger as it is.
It returns nothing, unlike the logger's relink() — a level always has
something above it, at worst its own logger:
http[Levels.info].relink(); // only this level returns to the chain
When there is nothing above to take — a logger configured only per level,
with no common publisher anywhere up the chain — the level goes back to the
no-op publisher and hasPublisher becomes false, rather than keeping what
it happened to hold. CustomLogger.relink() applies the same rule to every
level whose pin it drops.
A parent keeps its subloggers through weak references, so subloggers never need disposing — an abandoned branch is collected whole. A sublogger, on the other hand, holds its parent strongly, so a logger you keep never loses the chain it inherits from.
A sublogger is not required to register the same levels as its parent. A
per-level publisher for a level the sublogger does not have is skipped
silently; reaching for an unregistered level through operator [] throws a
StateError.
See also an example: hierarchical_logger.dart.
Transformers
A transformer runs on every log right before it is handed to the publisher.
Returning a log publishes it instead of the original; returning null drops
the log entirely. It exists mainly for security — masking secrets and PII
before they reach any output:
final log = Logger('app')
..level = Levels.all
..publisher = const DefaultLogPublisher()
..transformer = (log) {
final message = log.message;
return message.contains('token=')
? Log.copy(
log,
message: message.replaceAll(RegExp(r'token=\S+'), 'token=***'),
)
: log;
};
log.i('GET /api?token=abcdef'); // [i] GET /api?token=***
Transformers are inherited by subloggers and follow the same link rules as the level and the publishers (see above).
Fail-closed. If the transformer throws, the log is not published — the untransformed log never leaks — and the error goes to the current zone.
Per destination: TransformPublisher. CustomLogger.transformer applies
to everything the logger publishes. To mask for one destination only, wrap
that destination:
log.publisher = MultiPublisher([
consolePrinter, // verbatim
TransformPublisher(fileStorage, transformer: redact), // masked
]);
TransformPublisher takes its own onError, and it covers both halves of
the job: a throwing transformer and a throwing wrapped publisher. Without
a handler the two part ways — a transformer error goes to the current zone,
while the wrapped publisher's error keeps travelling to the logging call
site, exactly as it would without the wrapper. flush and close are
delegated to the wrapped publisher when it supports them; close is
terminal either way.
Warning
A transformer must not log through its own logger, and neither must a
publisher: the nested call comes straight back and would recurse until the
stack is exhausted. Both cycles are detected — the nested log is dropped
and a StateError is reported. Treat that as a guard against runaway
recursion, not as a way to log from a transformer.
The guard is synchronous, and it is really two guards with different reaches. The transformer half is per logger: it catches any cycle coming back while that logger's transformer is running, across several levels or several loggers. The publish half is per level logger, so a publisher that logs at a different level of the same logger is allowed — a cycle still trips the moment it returns to a level whose publisher is running.
Neither half catches a cycle through a sublogger which inherited the
same transformer (a sublogger is a separate logger with its own guard),
and neither survives an asynchronous hop — a transformer that defers
the nested log with scheduleMicrotask or a Future is outside the guard
entirely, and an unconditional one will loop forever. The same goes for an
asynchronous publisher, whose handle runs long after publish
returned.
Errors on the publish path
CustomLogger.onError is the single place every error the logger catches on
the way to a publisher ends up: a throwing transformer, a throwing
publisher, and a reentrancy guard violation.
final log = Logger('app')
..level = Levels.all
..onError = ((error, stackTrace) => report(error, stackTrace))
..publisher = const DefaultLogPublisher();
Wrap the arrow function in parentheses inside a cascade, as with
transformer: without them the .. of the next line is parsed as part of
the arrow's body.
Without a handler each case keeps its historical behaviour — the transformer error and the guard violation go to the current zone, a publisher error propagates out of the logging call. Note what the zone route means in a plain Dart program without an error zone: an uncaught asynchronous error terminates the isolate, so a bug in a masking transformer takes the process down. Setting this callback is how logging stops being able to break the application that logs.
It is resolved through the parent chain rather than copied down, so a sublogger with no handler of its own uses its parent's.
Warning
The handler must not log through the logger it belongs to. Both guards above are still latched while it runs, so a handler that logs comes straight back into the guard that just fired, which would report through the handler again. That is detected — the second error goes to the current zone instead of back into the handler — but the nested log is dropped, not published. Report into a different logger if you want the failure logged; that is untouched.
Common Scenarios
The snippets below use the logger built in
simple_logger.dart:
a Log carrying a message, and a Logger with d, i and e.
How to log to stdout and stderr
On native targets print always writes to stdout, so error output ends up
mixed into the program's normal output. Give the error level its own
publisher:
import 'dart:io';
String format(Log log) => '[${log.shortLevelName}] ${log.message}';
final log = Logger()
..level = Levels.all
..publisher = CustomLogFormatter(format: format, output: stdout.writeln)
..[Levels.error].publisher =
CustomLogFormatter(format: format, output: stderr.writeln);
Note
Native only. The web has no stdout: print goes to dartPrint if the
embedder defines one and to console.log otherwise — the same code in
dart compile js and in dart compile wasm. dart:io is worse than
unavailable there: the import compiles, and stdout/stderr throw
UnsupportedError at runtime. Under Node console.log does land on
stdout, in a browser it lands in the devtools console, and Dart never
calls console.error — so on the web the two streams cannot be split
this way at all.
How to log to a file
Writing to a file is asynchronous, and the writes must not interleave, so use a buffered async publisher: it gathers logs into batches and processes one batch at a time.
import 'dart:io';
final file = File('app.log');
final filePublisher = AsyncPublisherWithBuffer<Log>((logs, retryBuffer) async {
final batch =
logs.map((log) => '[${log.shortLevelName}] ${log.message}\n').join();
try {
await file.writeAsString(batch, mode: FileMode.append);
} on Object catch (error) {
// Keep the batch for the next attempt instead of losing it.
retryBuffer.addAll(logs);
stderr.writeln('cannot write to ${file.path}: $error');
}
});
final log = Logger()
..level = Levels.all
..publisher = filePublisher;
The queue in front of the file holds 100 000 entries by default: if the disk
stalls for longer than that, the newest logs are refused rather than kept,
and onDropped is where you see them. Pass maxQueueSize: null to let the
queue grow instead until the process dies — see
the full set for that, retryDelay and the rest.
Drain the queue before the program exits, or the last batch never reaches the disk:
await filePublisher.close();
close() is terminal, and "refuses" is stronger than it sounds: it
processes everything accepted so far, and any later log.i(...) throws a
StateError at the call site. That is deliberate — closing a publisher and
then logging is a shutdown-ordering bug, and finding out immediately beats
feeding logs into a dead buffer — but it does mean a stray log line in a
finally can bring the program down. Close last, or use flush() when you
only want to wait for the queue to empty and keep logging afterwards.
Note that flush() means two different things depending on which publisher
you picked, and both are useful:
- snapshot —
AsyncPublisher,AsyncFormatterand theirWithParamvariants complete when everything queued at the moment of the call has been processed. Logs published after it land in the next round; - drain — the buffered variants complete when the buffer is empty, including logs published after the call. Under a steady stream of logging a drain-flush finishes later than a snapshot-flush, and on a busy logger it may not finish promptly at all.
A MultiPublisher holding one of each mixes the two guarantees, so reach for
close() when you need a hard "everything is out" point.
How to add a timestamp
Record the time in the log, not in the formatter:
final class Log extends CustomLog {
final DateTime time;
final LazyString _lazyMessage;
Log(
super.levelLogger, {
required Object? message,
super.error,
super.stackTrace,
}) : time = DateTime.now(),
_lazyMessage = LazyString(message);
String get message => _lazyMessage.value;
}
String format(Log log) =>
'${log.time} [${log.shortLevelName}] ${log.message}';
DateTime.now() inside the formatter only tells the truth for synchronous
publishers. As soon as the output is asynchronous or buffered, formatting
happens when the batch is processed rather than when the event occurred —
and every log in a batch ends up with nearly the same, wrong, timestamp.
How to colour the logs
Colour is part of formatting, so it belongs in the publisher:
import 'package:ansi_escape_codes/style.dart';
String format(Log log) => '[${log.shortLevelName}] ${log.message}';
final log = Logger()
..level = Levels.all
..publisher = CustomLogFormatter(format: format, output: print)
..[Levels.error].publisher = CustomLogFormatter(
format: format,
output: (str) => print(Styles.red(str)),
);
Escape codes are for terminals, not for files: colouring the shared
formatter would put \x1b[31m into your log file too. When a log goes to
both, give each destination its own publisher:
final log = Logger()
..level = Levels.all
..publisher = MultiPublisher<Log>([
// Terminal: coloured.
CustomLogFormatter(format: format, output: (str) => print(Styles.red(str))),
// File: plain text.
filePublisher,
]);
Common Mistakes
Building the message eagerly
log.d('Cache state: ${jsonEncode(cache)}'); // BAD
The interpolation runs before log.d is even called, so jsonEncode runs
whether or not the debug level is enabled — the exact cost this package
exists to avoid. Pass a closure and it is evaluated only if the level is
on:
log.d(() => 'Cache state: ${jsonEncode(cache)}'); // GOOD
See Lazy Evaluation.
Deferring what is never deferred
The mirror image of the same mistake. A closure pays off only for a level that can actually be off — on a level you keep enabled at all times it is evaluated on every call anyway, and all it adds is an allocation:
log.i(() => 'User $id logged in'); // pointless if `i` is always on
log.i('User $id logged in'); // just pass the value
Exposing processLog instead of log
LogFn get d => _d.processLog; // BAD: always logs
LogFn get d => _d.log; // GOOD: switches with the level
log is the field the package swaps between processLog and the no-op
function. Handing out processLog directly gives you a level that can
never be turned off — and none of the performance the switch exists for.
Publishing with publisher.publish instead of publishLog
@override
LogFn get processLog => (message, {error, stackTrace}) {
publisher.publish(Log(this, message: message)); // BAD
publishLog(Log(this, message: message)); // GOOD
return true;
};
publishLog is what applies CustomLogger.transformer before handing the
log on. Going straight to the publisher silently skips it, so masking and
filtering never run.
Timestamping in the formatter
DateTime.now() in a formatter is the time the log was printed, which
stops matching the time it happened the moment a buffered or async
publisher is involved. See
How to add a timestamp.
Exiting without draining an async publisher
log.i('done');
exit(0); // BAD: the queued logs are still in memory
Async and buffered publishers process logs after the call returns. Await
flush() or close() before the program ends.
Logging from inside a transformer
log.transformer = (entry) {
log.d('masking $entry'); // BAD: re-enters the transformer
return mask(entry);
};
The nested call runs the transformer again, and again. Such a call is
detected and dropped with a StateError, but the log you meant to write
is lost — collect what you need into a plain list instead, or use
a logger that this transformer never reaches.
Using logger_builder in your own package
A package that logs through print gives its users nothing to work with:
they cannot turn the output on, cannot change its shape, cannot route it
anywhere. Exposing a logger instead costs you one public field and gives
them all three.
Expose the logger, leave it off
// lib/src/log.dart
final packageLog = Logger('my_package');
A freshly built logger starts at Levels.off, so a user who never touches
it never sees your output — which is what a well-behaved dependency does.
Log freely inside your package; nothing is published until someone asks
for it.
Let the user decide everything about the output
// The user's app:
import 'package:my_package/my_package.dart';
void main() {
packageLog
..level = Levels.info
..publisher = myAppPublisher; // their format, their destination
}
Do not install a publisher yourself, do not wrap anything in
runZonedGuarded on the user's behalf, and do not decide that errors
belong on stderr. Those are application decisions, and taking them makes
your logs a foreign body in someone else's log stream instead of a part
of it.
Give the hierarchy to the user, too
Subloggers inherit level and publisher from their parent, so one assignment configures your whole package — while a user who wants only your HTTP layer can still say so:
final httpLog = packageLog.child('http');
final cacheLog = packageLog.child('cache');
// In the app: everything at warning level, the HTTP layer in full.
packageLog.level = Levels.warning;
httpLog.level = Levels.all;
Remember that your Log type is public API
Users write formatters against it, so its fields are part of your package's contract: adding one is safe, renaming or removing one is a breaking change. Keep the type exported and documented.
Examples
The example/logger_builder_examples directory contains more elaborate examples, demonstrating:
Loggers
- Simple logger (logger, usage).
- Multi-parameter log methods (logger, usage).
- Complex hierarchy loggers (logger, usage).
- Custom formatters converting log directly to JSON dictionaries (logger, usage).
Async publishers
Libraries
- logger_builder
- A toolkit for creating your own customizable and hierarchical loggers in Dart with good performance when disabled.