Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Advanced File Output #65

Merged
merged 16 commits into from
Mar 23, 2024
2 changes: 2 additions & 0 deletions lib/logger.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,6 @@ library logger;

export 'src/outputs/file_output_stub.dart'
if (dart.library.io) 'src/outputs/file_output.dart';
export 'src/outputs/advanced_file_output_stub.dart'
if (dart.library.io) 'src/outputs/advanced_file_output.dart';
export 'web.dart';
196 changes: 196 additions & 0 deletions lib/src/outputs/advanced_file_output.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';

import '../log_level.dart';
import '../log_output.dart';
import '../output_event.dart';

extension _NumExt on num {
String toDigits(int digits) => toString().padLeft(digits, '0');
}

/// Accumulates logs in a buffer to reduce frequent disk, writes while optionally
/// switching to a new log file if it reaches a certain size.
///
/// [AdvancedFileOutput] offer various improvements over the original
/// [FileOutput]:
/// * Managing an internal buffer which collects the logs and only writes
/// them after a certain period of time to the disk.
/// * Dynamically switching log files instead of using a single one specified
/// by the user, when the current file reaches a specified size limit (optionally).
///
/// The buffered output can significantly reduce the
/// frequency of file writes, which can be beneficial for (micro-)SD storage
/// and other types of low-cost storage (e.g. on IoT devices). Specific log
/// levels can trigger an immediate flush, without waiting for the next timer
/// tick.
///
/// New log files are created when the current file reaches the specified size
/// limit. This is useful for writing "archives" of telemetry data and logs
/// while keeping them structured.
class AdvancedFileOutput extends LogOutput {
/// Creates a buffered file output.
///
/// By default, the log is buffered until either the [maxBufferSize] has been
/// reached, the timer controlled by [maxDelay] has been triggered or an
/// [OutputEvent] contains a [writeImmediately] log level.
///
/// [maxFileSizeKB] controls the log file rotation. The output automatically
/// switches to a new log file as soon as the current file exceeds it.
/// Use -1 to disable log rotation.
///
/// [maxDelay] describes the maximum amount of time before the buffer has to be
/// written to the file.
///
/// Any log levels that are specified in [writeImmediately] trigger an immediate
/// flush to the disk ([Level.warning], [Level.error] and [Level.fatal] by default).
///
/// [path] is either treated as directory for rotating or as target file name,
/// depending on [maxFileSizeKB].
AdvancedFileOutput({
required String path,
bool overrideExisting = false,
Encoding encoding = utf8,
List<Level>? writeImmediately,
Duration maxDelay = const Duration(seconds: 2),
int maxBufferSize = 2000,
int maxFileSizeKB = 1024,
String latestFileName = 'latest.log',
String Function(DateTime timestamp)? fileNameFormatter,
}) : _path = path,
_overrideExisting = overrideExisting,
_encoding = encoding,
_maxDelay = maxDelay,
_maxFileSizeKB = maxFileSizeKB,
_maxBufferSize = maxBufferSize,
_fileNameFormatter = fileNameFormatter ?? _defaultFileNameFormat,
_writeImmediately = writeImmediately ??
[
Level.error,
Level.fatal,
Level.warning,
// ignore: deprecated_member_use_from_same_package
Level.wtf,
],
_file = maxFileSizeKB > 0 ? File('$path/$latestFileName') : File(path);

/// Logs directory path by default, particular log file path if [_maxFileSizeKB] is 0.
final String _path;

final bool _overrideExisting;
final Encoding _encoding;

final List<Level> _writeImmediately;
final Duration _maxDelay;
final int _maxFileSizeKB;
final int _maxBufferSize;
final String Function(DateTime timestamp) _fileNameFormatter;

final File _file;
IOSink? _sink;
Timer? _bufferFlushTimer;
Timer? _targetFileUpdater;

final List<OutputEvent> _buffer = [];

bool get _rotatingFilesMode => _maxFileSizeKB > 0;

/// Formats the file with a full date string.
///
/// Example:
/// * `2024-01-01-10-05-02-123.log`
static String _defaultFileNameFormat(DateTime t) {
return '${t.year}-${t.month.toDigits(2)}-${t.day.toDigits(2)}'
'-${t.hour.toDigits(2)}-${t.minute.toDigits(2)}-${t.second.toDigits(2)}'
'-${t.millisecond.toDigits(3)}.log';
}

@override
Future<void> init() async {
if (_rotatingFilesMode) {
final dir = Directory(_path);
// We use sync directory check to avoid losing potential initial boot logs
// in early crash scenarios.
if (!dir.existsSync()) {
dir.createSync(recursive: true);
}

_targetFileUpdater = Timer.periodic(
const Duration(minutes: 1),
(_) => _updateTargetFile(),
);
}

_bufferFlushTimer = Timer.periodic(_maxDelay, (_) => _flushBuffer());
await _openSink();
if (_rotatingFilesMode) {
await _updateTargetFile(); // Run first check without waiting for timer tick
}
}

@override
void output(OutputEvent event) {
_buffer.add(event);
// If event level is present in writeImmediately, flush the complete buffer
// along with any other possible elements that accumulated since
// the last timer tick. Additionally, if the buffer is full.
if (_buffer.length > _maxBufferSize ||
_writeImmediately.contains(event.level)) {
_flushBuffer();
}
}

void _flushBuffer() {
if (_sink == null) return; // Wait until _sink becomes available
for (final event in _buffer) {
_sink?.writeAll(event.lines, Platform.isWindows ? '\r\n' : '\n');
_sink?.writeln();
}
_buffer.clear();
}
Bungeefan marked this conversation as resolved.
Show resolved Hide resolved

Future<void> _updateTargetFile() async {
try {
if (await _file.exists() &&
await _file.length() > _maxFileSizeKB * 1024) {
// Rotate the log file
await _closeSink();
await _file.rename('$_path/${_fileNameFormatter(DateTime.now())}');
await _openSink();
}
pyciko marked this conversation as resolved.
Show resolved Hide resolved
} catch (e, s) {
print(e);
print(s);
// Try creating another file and working with it
await _closeSink();
await _openSink();
}
}

Future<void> _openSink() async {
_sink = _file.openWrite(
mode: _overrideExisting ? FileMode.writeOnly : FileMode.writeOnlyAppend,
encoding: _encoding,
);
}

Future<void> _closeSink() async {
await _sink?.flush();
await _sink?.close();
_sink = null; // Explicitly set null until assigned again
}

@override
Future<void> destroy() async {
_bufferFlushTimer?.cancel();
_targetFileUpdater?.cancel();
try {
_flushBuffer();
} catch (e, s) {
print('Failed to flush buffer before closing the logger: $e');
print(s);
}
await _closeSink();
}
}
63 changes: 63 additions & 0 deletions lib/src/outputs/advanced_file_output_stub.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import 'dart:convert';

import '../log_level.dart';
import '../log_output.dart';
import '../output_event.dart';

/// Accumulates logs in a buffer to reduce frequent disk, writes while optionally
/// switching to a new log file if it reaches a certain size.
///
/// [AdvancedFileOutput] offer various improvements over the original
/// [FileOutput]:
/// * Managing an internal buffer which collects the logs and only writes
/// them after a certain period of time to the disk.
/// * Dynamically switching log files instead of using a single one specified
/// by the user, when the current file reaches a specified size limit (optionally).
///
/// The buffered output can significantly reduce the
/// frequency of file writes, which can be beneficial for (micro-)SD storage
/// and other types of low-cost storage (e.g. on IoT devices). Specific log
/// levels can trigger an immediate flush, without waiting for the next timer
/// tick.
///
/// New log files are created when the current file reaches the specified size
/// limit. This is useful for writing "archives" of telemetry data and logs
/// while keeping them structured.
class AdvancedFileOutput extends LogOutput {
/// Creates a buffered file output.
///
/// By default, the log is buffered until either the [maxBufferSize] has been
/// reached, the timer controlled by [maxDelay] has been triggered or an
/// [OutputEvent] contains a [writeImmediately] log level.
///
/// [maxFileSizeKB] controls the log file rotation. The output automatically
/// switches to a new log file as soon as the current file exceeds it.
/// Use -1 to disable log rotation.
///
/// [maxDelay] describes the maximum amount of time before the buffer has to be
/// written to the file.
///
/// Any log levels that are specified in [writeImmediately] trigger an immediate
/// flush to the disk ([Level.warning], [Level.error] and [Level.fatal] by default).
///
/// [path] is either treated as directory for rotating or as target file name,
/// depending on [maxFileSizeKB].
AdvancedFileOutput({
required String path,
bool overrideExisting = false,
Encoding encoding = utf8,
List<Level>? writeImmediately,
Duration maxDelay = const Duration(seconds: 2),
int maxBufferSize = 2000,
int maxFileSizeKB = 1024,
String latestFileName = 'latest.log',
String Function(DateTime timestamp)? fileNameFormatter,
}) {
throw UnsupportedError("Not supported on this platform.");
}

@override
void output(OutputEvent event) {
throw UnsupportedError("Not supported on this platform.");
}
}
Loading
Loading