Dart CI

Принципиально минималистичный логер для библиотек.

По сути, я просто оформил в виде отдельного пакета код, который кочует из одной моей библиотеки в другую. Возможно, большинство библиотек не нуждаются в логерах. Но в некоторых случаях, когда библиотека выполняет какие-то сложные асинхронные операции, где важна точная работа со стримами, асинхронными генераторами, фьючами, где легко пропустить асинхронные ошибки, очень сложно без логера отлаживать код. Но и затаскивать в библиотеку один из существующих мощных логеров для приложений я не хотел. Для отладки пакета они обычно слишком многословны и перегружены функциями, которые библиотеке не нужны.

Why?

Итак, суть логера в том, что бы легко включить его для отладки и тестирования библиотеки, но при этом так же легко выключить для предоставления пользователям.

И если логер выключен, то вызов любой функции логирования не должен ничего делать. Не должен даже вычислять параметры, которые передаются в функцию. Для логера недопустимо, что бы вызов:

log.i('main', 'info');

раскрывался под капотом во что-то подобное:

void i(String package, String message) {
  if (level <= Level.info) {
    print(...);
  }
}

Да, при отключенном логере в консоль ничего не будет выведено, но функция всё равно выполнится и её параметры вычислятся. А это всё потеря производительности.

В идеале при выключении логера код логирования вообще должен исчезнуть, что бы он не влиял никак на производительность приложения. Но такое возможно только при использовании assert'ов:

assert(() {
  log.i('main', 'info');
  return true;
}());

или констант:

const logIsEnabled = bool.fromEnvironment('logging');
if (logIsEnabled) {
  log.i('main', 'info');
}

В обоих случаях при отключенном логере код будет проигнорирован компилятором. Оба способа можно и нужно применять в рабочем коде, хотя они сильно загромождают код.

pkglog предоставляет небольшое удобство для использования этих вариантов, немного уменьшая бойлерплейт. Все функции логирования всегда возвращают true, что позволяет использовать их в assert и && цепочках.

final log = Logger('pkglog', level: LogLevel.all);

// ...

assert(log.i('main', 'info'));

и:

const logIsEnabled = bool.fromEnvironment('logging');

// ...

logIsEnabled && log.d('main', 'debug') && log.i('main', 'info');

Но, конечно, нам хотелось бы иметь возможность просто написать где-то:

log.isEnabled = false;

И дальше использовать логер без бойлерплейта. Чтобы всё делалось само под капотом:

log.i('main', 'info');

При таком использовании код не может быть удалён при выключении логера, но pkglog подставляет вместо реальной функции логирования пустую функцию-заглушку. Это не так эффективно, как удаление кода, но всё же лучше, чем ничего.

Но вызова функции-заглушки недостаточно для оптимизации. Поэтому что параметры, которые мы будем передавать в функцию, если это не просто константные строки, всё равно будут вычислены:

log.i('$MyClass#${shortHash(this)}', 'Request: $request');

Избежать вычисления параметров можно, передав в функцию не готовые строки, а функции, которые возвращают строки:

log.i(() => '$MyClass#${shortHash(this)}', () => 'Request: $request');

В этом случае, если логирование отключено, функции не будут вызваны и ненужные операции не будут выполнены.

Можно использовать такой вариант не только для отложенной передачи параметров, но и как скоуп, который будет полностью отключен при выключении данного уровня логирования:

log.d('main', () {
  final response = jsonDecode(json) as Map<String, Object?>;
  final good = analyze(response);
  return 'Response: ${good ? 'good response' : 'bad response'}';
});

Usage

Basic Usage

Create a Logger instance for your package. It is recommended to keep a static or final instance to reuse it throughout your package.

import 'package:pkglog/pkglog.dart';

// Create a logger for your package
final log = Logger('my_package', level: LogLevel.critical);

void main() {
  log.v('Verbose message');
  log.d('Debug message');
  log.i('Info message');
  log.w('Warning message');
  log.e('Error message');
  log.critical('Critical message');
}

Please note that by default, only critical errors are logged in the logger. This is the correct setting when developing a package. In your case, it may even be necessary to disable the logger completely: LogLevel.off. Enable other logging levels only in your tests and examples.

log.level = LogLevel.all;

Performance

Если вашему пакету важна производительность, оберните вызовы функций логирования в assert'ы:

assert(log.v('main', 'Verbose message'));
assert(log.d('main', 'Debug message'));
assert(log.i('main', 'Info message'));
assert(log.w('main', 'Warning message'));
assert(log.e('main', 'Error message'));
assert(log.critical('main', 'Critical message'));

или используйте константу:

const logIsEnabled = bool.fromEnvironment('logging');
logIsEnabled && log.d('main', 'Debug message') && log.i('main', 'Info message');

Это позволит компилятору удалить все вызовы логирования в релизной сборке.

Достигается это тем, что все функции логирования всегда возвращают true, что позволяет использовать их в assert и && цепочках.

Log levels

Если вы используете pkglog только для собственной отладки своего пакета, можете использовать функции v, d, i, w, e, s так, как вам и только вам удобно.

Но если ваше логирование может быть полезным для разработчика, использующего ваш пакет (дальше будем называть его пользователем), то рассмотрите следуюшие рекомендации:

v - ваша отладка. То, что точно не нужно видеть пользователю пакета. Этот уровень обычно характеризуется большим кол-вом сложной, очень специфичной информации, которая может быть понятна только в контексте вашей разработки. Точно не стоит загружать ею пользователя.

d - отладочная информация для пользователя, которая может пригодиться ему, если что-то идёт не так в его коде при использовании вашего пакета. Как и предыдущий, это тоже уровень отладки, но на языке, понятном пользователю. Вы сами решаете, что, когда и в каком виде может быть полезно пользователю. Главное здесь понять, что этот уровень логирования пользователь включит только, когда что-то пойдёт не так. Но если он включил, значит он готов разбираться с деталями.

i - важная информация для пользователя: основные события нормальных и ожидаемых им процессов: в его коде при использовании вашего пакета. Этот уровень логирования рассчитан на то, что работа вашего пакета действительно важна пользователю, что по вашим логам он будет сверять ход выполнения и собственных процессов. Если такой информации ваш пакет не даёт, не используйте этот уровень. А если предоставляете, то помните, что это не отладочная информация, т.е. пользователь не готов к глубокому её анализу. Соответственно, она должна быть максимально простой, понятной и полезной для пользователя.

w - предупреждения: неожиданные события, связанные с кодом разработчика: в его коде при использовании вашего пакета! Используйте этот уровень, когда пользователь, на ваш взгляд, делает что-то не так, но в целом ваш пакет может продолжить нормально функционировать. Например, логика подразумевала предоставление данных от пользователя, а он их не предоставил, и вы переключились на fallback значение.

e - ошибки: нештатные события, связанные с кодом разработчика: в его коде при использовании вашего пакета! Используйте там, где произошла ошибка, но не критический сбой. Возможно, вы перейдёте на fallback решение, или откатитесь назад, или сообщите пользователю об ошибке и будете ждать его дальнейших действий. Важно, что жизнь на этом не останавливается и у пользователь есть выход. Например, отсутствие сети или сбой авторизации.

s - критические ошибки, связанные с вашим кодом. Подразумевается, что дальнейшая работа невозможна. И если даже пакет продолжает функционировать, то вы уже не гарантируете корректной работы. Для пакета это, скорее всего, уровень появления последующего issue. Если у вас есть такой уровень логирования, то, на мой взгляд, при отладке и тестировании он никогда не должен отключаться. Но такое логирование мало полезно в релизной сборке, и поэтому лучше рассмотрите использование исключений или FlutterError.reportError, или совместное их использование.

Custom building and printing

По умолчанию pkglog использует функцию print для вывода логов и строит сообщения в следующем виде:

[l(evel)] package | source | message: error
stacktrace

Скорее всего, для разработки пакета этого будет достаточно. Но если разработчик хочет скорректировать лог, добавить данные или предоставить возможность пользователю пакета изменить форматирование или способ вывода, то это можно сделать, установив собственные методы builder и printer.

log.builder = (msg) {
  return
      '${DateTime.now()} [${msg.level.shortName}] ${msg.package} |'
      '${msg.source == null ? '' : ' ${msg.source} |'}'
      ' ${msg.message}'
      '${msg.error == null ? '' : ': ${msg.error}'}';
};

log.print = stderr.writeln;

Параметры source и message придут в функцию уже преобразованными в строку. Если source был null, то он останется null. Но если message был null, то он превратится в пустую строку. По этой причине я не рекомендую передавать в функции логирования в чистом виде объекты, которые могут быть null. Это сделано сознательно, чтобы вы написали сообщение, а не просто отправили данные без пояснений:

log.i('main', () => 'Data received: $data');

Функцию печати сообщения можно разместить и внутри builder, а printer отключить:

log.builder = (msg) {
  final out = msg.level.isError ? stderr : stdout;
  out.writeln(msg.toString());
  return '';
};
log.print = null;

С помощью Logger.builder и Logger.printer настраивается общий вывод для всех уровней логирования. Но можно настроить каждый уровень логирования отдельно:

log.printer = stdout.writeln;
log[Level.error].printer = stderr.writeln;
log[Level.critical].printer = stderr.writeln;

Important

Разумеется, можно использовать любой другой способ вывода. Например, отправлять логи в файл, в сеть и т.д. Но pkglog не разруливает асинхронный код! Если вам нужна такая возможность, то реализуйте асинхронность самостоятельно. Не забудьте, что обычного добавления async вашей функции printer не будет достаточно. pkglog не ждёт завершения асинхронных вызовов, и события будут приходить параллельно. Сила pkglog в максимальной простоте и эффективности, а не в широкой функциональности. pkglog сознательно не работает с асинхронным кодом.

Sub-loggers

Все функции логирования в pkglog принимают помимо самого сообщения параметр source, чтобы указать источник лога. Например, имя класса, имя функции, имя метода и т.д. Соответственно, передавать это значение необходимо в каждую функцию логирования:

log.d('$MyClass', 'debug');
log.i('$MyClass', 'info');
log.w('$MyClass', 'warning');

В какой-то момент это может надоесть и появится разумное желание создать отдельную функцию, в которой source будет подставляться автоматически:

void _d(Object? message) => log.d('source', message);
void _i(Object? message) => log.i('source', message);
void _w(Object? message) => log.w('source', message);

_d('debug');
_i('info');
_w('warning');

Но помимо того, что это лишний код, здесь ещё и лишний вызов функции: логирование может быть отключено, но _d при запуске всегда вызовет log.d. Для тех, кто думает о производительности, это важный момент.

В pkglog есть возможность создать sub-logger, в котором нет этой проблемы:

final _log = log.withSource(MyClass);

_log.d('debug');
_log.i('info');
_log.w('warning');

// [d] pkglog | MyClass | debug
// [i] pkglog | MyClass | info
// [w] pkglog | MyClass | warning

Когда логирование отключено, _log.d превратится в пустую функцию-заглушку. Если же включено, то под капотом будет вызвана нужная функция логирования с подставленным значением source.

Таким же образом можно сделать дополнительное форматирование сообщения:

final sw = Stopwatch()..start();

//...

final _log = log.withSource(
    'event processing',
    format: (message) => '${sw.elapsed} | $message',
);

_log.i('info');

// [i] pkglog | event processing | 0:00:01.234567 | info

или форматирование с передаваемым пользователем типизированным параметром:

final _log = log.withContext<String>(
    MyClass,
    (method, message) => '$method | $message',
);

_log.i('dispose', 'info');

// [i] pkglog | MyClass | dispose | info

Package user interaction

Настройка логирования пользователем

Предоставьте пользователю самому решать, какие уровни логирования ему нужны, а какие нет. Но и не усложняйте ему жизнь, предоставляя прямой доступ к логгеру. Не экспортируйте pkglog из своего пакета и не вынуждайте разработчика добавлять ещё одну зависимость в своё приложение. Экспортирование pkglog усложнит жизнь не только пользователю, но и вам. Проксируйте любые изменения. Например, так:

abstract final class MyPackageConfig {
  static logEnabled get => _logEnabled;
  static bool _logEnabled = false;
  static set logEnabled(bool enabled) {
    _logEnabled = enabled;
    log.level = enabled ? Level.debug : Level.error;
  }
}

Если вам нужно предоставить пользователю несколько уровней логирования, то создайте собственные уровни:

enum MyPackageLevel {
  debug,
  info,
  error,
  off;
}

// ...

abstract final class MyPackageConfig {
  static MyPackageLevelLevel get level => _level;
  static MyPackageLevel _level = MyPackageLevel.off;
  static set level(MyPackageLevel level) {
    _level = level;
    log.level = switch (level) {
      MyPackageLevel.debug => Level.debug,
      MyPackageLevel.info => Level.info,
      MyPackageLevel.error => Level.error,
      MyPackageLevel.off => Level.off,
    };
  }
}

// ...

final log = Logger('my_package', level: Level.off);

То же самое сделайте с возможностью настроить пользователем свой format и свой print, если в этом есть необходимость.

Tip

С одной стороны, обычно пакеты не заморачиваются с настройкой логирования. И вы тоже можете смело избежать этого. С другой стороны, некоторые пакеты сильно засоряют консоль своими логами, не давая возможности их отключить. И за это их ненавидишь.

Возможно ваш случай из тех, где логирование является важной частью функционала вашего пакета. И пользователь, используя его, захочет встроить ваши логи в свой собственный поток логирования, чтобы и внешне он не выбивался из общего стиля.

Обратите внимание, как в pkglog_example реализована идея цветного оформления логов с помощью пакета ansi_escape_codes:

import 'package:ansi_escape_codes/ansi_escape_codes.dart' as ansi;
import 'package:pkglog/pkglog.dart';

for (final level in Level.values) {
  final printer = ansi.AnsiPrinter(
    ansiCodesEnabled: !Platform.isIOS,
    defaultState: ansi.SgrPlainState(
      foreground: switch (level) {
        Level.verbose => const ansi.Color256(ansi.Colors.gray8),
        Level.debug => const ansi.Color256(ansi.Colors.gray12),
        Level.info => const ansi.Color256(ansi.Colors.rgb345),
        Level.warning => const ansi.Color256(ansi.Colors.rgb440),
        Level.error => const ansi.Color256(ansi.Colors.rgb400),
        Level.critical => const ansi.Color256(ansi.Colors.rgb550),
      },
      background: switch (level) {
        Level.critical => const ansi.Color256(ansi.Colors.rgb300),
        _ => null,
      },
    ),
  );

  log[level].print = printer.print;
}

Возможно, что-то подобное захочет сделать и пользователь вашего пакета. Вам самому делать такое в своём пакете вряд ли может понадобиться (хотя и не исключено), но дать возможность пользователю сделать это самостоятельно, если ваши логи для него важны - это хорошая идея.

Экспорт логера

Для удобства использования пусть ссылка на ваш логер хранится в глобальной переменной. Но не включайте её в экспорт. А если она находится в общем файле, обязательно скройте её при экспорте:

// my_package.dart:
export 'src/my_package.dart' hide log;

Libraries

pkglog
A fundamentally minimalistic package providing logging functions to libraries.