Dart CI Pub Publisher Pub Version GitHub License

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 CustomLogger with 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 Lazy and LazyString to avoid expensive operations (like string interpolations or JSON encoding) when a logging level is disabled.
  • Async & Buffered Publishers: Base classes like AsyncPublisherBase for printing logs asynchronously or buffering them before sending (e.g., to an analytics service).
  • Transformers: A LogTransformer on 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?

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 at Levels.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.off for the app and Levels.all for authLog: one noisy subsystem, without recompiling and without drowning in everything else.
  • Levels. print has 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 logger
  • LevelLogger - type of level logger
  • LogFn - type of log function
  • Log - 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 the Levels class as values. In it there are and those that use developer.log and the logging package: finest, finer, fine, config, info, warning, severe, shout. But there are also additional trace, 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 the CustomLog structure, this value is stored with the name levelName.

  • shortName - short name of the log level. This is an optional parameter. If it is not specified, the first character of name will be used as shortName. In the CustomLog structure, this value is stored with the name shortLevelName. You can use this value as you wish.

  • noLog 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 the LogFn. 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 the Log event. 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:

  1. Create a Log.
  2. Publish the Log via publishLog — the protected method that applies CustomLogger.transformer and then hands the log to the publisher. Calling publisher.publish directly skips the transformer.
  3. Return true (if you decided to follow the advice and use bool as 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 in runZonedGuarded;
  • sync — whether the internal StreamController delivers 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 to onDropped and never enters the queue, so everything already accepted is still delivered and flush() and close() 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. null gives 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 its maxRetries budget and entries handed back to retryBuffer after close() 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 by close(). There is no timer behind that, on purpose, and the consequence is worth knowing: a burst that ends without a later loss and without a close() is announced by that first line and never counted. It says it with print, 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 a print of your own redirects it: print goes through the current zone. The unbuffered four hand you one log at a time (with its param, 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 through retryBuffer. The default Duration.zero still goes through the event loop, so a dead sink cannot starve timers or your own close(), 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 through retryBuffer is 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 — a toString that throws, a value that will not serialise — and never drops it either, so it pins a core, drowns onError, 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:

  • snapshotAsyncPublisher, AsyncFormatter and their WithParam variants 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.

Reading state the constructor body has not set yet

final class MyLogger extends CustomLogger<MyLogger, LevelLogger, LogFn, Log> {
  MyLogger(this.tag) {
    _sink = Sink(tag); // the body runs last
  }

  final String tag;
  late final Sink _sink;

  @override
  void registerLevels() {
    _sink.warmUp(); // BAD: throws LateError, nothing has assigned it yet
    registerLevel(_info);
  }
}

The package calls into your subclass before that subclass's constructor body has run. registerLevels always does — both constructors call it from the base class. processLog does it too whenever a sublogger inherits a level its parent already had enabled: CustomLogger.sub registers the levels and then takes the parent's level, so the level switches on while the body is still pending.

Field initializers and the initializer list have run by then, which is why levels kept in final fields work. Anything the body assigns has not, and a late field read there throws. Keep both hooks off anything the body touches, or move that work into a field initializer.

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

Async publishers

Libraries

logger_builder
A toolkit for creating your own customizable and hierarchical loggers in Dart with good performance when disabled.