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';
147 changes: 147 additions & 0 deletions lib/src/outputs/advanced_file_output.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
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 get twoDigits => toString().padLeft(2, '0');
pyciko marked this conversation as resolved.
Show resolved Hide resolved
String get threeDigits => toString().padLeft(3, '0');
}

/// Writes the log output to a file.
pyciko marked this conversation as resolved.
Show resolved Hide resolved
class AdvancedFileOutput extends LogOutput {
AdvancedFileOutput({
this.directory,
this.file,
pyciko marked this conversation as resolved.
Show resolved Hide resolved
this.overrideExisting = false,
this.encoding = utf8,
List<Level>? writeImmediately,
this.maxDelay = const Duration(seconds: 2),
this.maxBufferSize = 2000,
this.maxLogFileSizeMB = 1,
}) : writeImmediately = writeImmediately ??
[
Level.error,
Level.fatal,
Level.warning,
// ignore: deprecated_member_use_from_same_package
Level.wtf,
],
assert(
(file != null ? 1 : 0) + (directory != null ? 1 : 0) == 1,
'Either file or directory must be set',
);

final File? file;
final Directory? directory;

final bool overrideExisting;
final Encoding encoding;

final List<Level> writeImmediately;
final Duration maxDelay;
final int maxLogFileSizeMB;
final int maxBufferSize;

IOSink? _sink;
File? _targetFile;
Timer? _bufferWriteTimer;
Timer? _targetFileUpdater;

final List<OutputEvent> _buffer = [];

bool get dynamicFilesMode => directory != null;
File? get targetFile => _targetFile;

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

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

_bufferWriteTimer = Timer.periodic(maxDelay, (_) => _writeOutBuffer());
await _updateTargetFile(); //run first setup without waiting for timer tick
}

@override
void output(OutputEvent event) {
_buffer.add(event);
// If event level is present in writeImmediately, write out the buffer
// along with any other possible elements that accumulated in it since
// the last timer tick
// Also write out if buffer is overfilled
if (_buffer.length > maxBufferSize ||
writeImmediately.contains(event.level)) {
_writeOutBuffer();
}
}

void _writeOutBuffer() {
if (_sink == null) return; //wait until _sink becomes available
for (final event in _buffer) {
_sink?.writeAll(event.lines, Platform.lineTerminator);
_sink?.writeln();
}
_buffer.clear();
}
Bungeefan marked this conversation as resolved.
Show resolved Hide resolved

Future<void> _updateTargetFile() async {
if (!dynamicFilesMode) {
await _openFile(file!);
return;
}

final t = DateTime.now();
final newName =
'${t.year}-${t.month.twoDigits}-${t.day.twoDigits}_${t.hour.twoDigits}-${t.minute.twoDigits}-${t.second.twoDigits}-${t.millisecond.threeDigits}';
pyciko marked this conversation as resolved.
Show resolved Hide resolved
if (_targetFile == null) {
// just create a new file on first boot
await _openFile(File('${directory!.path}/${newName}_init.txt'));
} else {
final proposed = File('${directory!.path}/${newName}_next.txt');
pyciko marked this conversation as resolved.
Show resolved Hide resolved
try {
if (await _targetFile!.length() > maxLogFileSizeMB * 1000000) {
await _closeCurrentFile();
await _openFile(proposed);
}
} catch (e) {
// try creating another file and working with it
await _closeCurrentFile();
await _openFile(proposed);
}
pyciko marked this conversation as resolved.
Show resolved Hide resolved
}
}

Future<void> _openFile(File proposed) async {
_targetFile = proposed;
_sink = _targetFile?.openWrite(
pyciko marked this conversation as resolved.
Show resolved Hide resolved
mode: overrideExisting ? FileMode.writeOnly : FileMode.writeOnlyAppend,
encoding: encoding,
);
}

Future<void> _closeCurrentFile() async {
await _sink?.flush();
await _sink?.close();
_sink = null; //explicitly make null until assigned again
}

@override
Future<void> destroy() async {
_bufferWriteTimer?.cancel();
_targetFileUpdater?.cancel();
await _closeCurrentFile();
pyciko marked this conversation as resolved.
Show resolved Hide resolved
}
}
29 changes: 29 additions & 0 deletions lib/src/outputs/advanced_file_output_stub.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import 'dart:convert';
import 'dart:io';

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

/// Writes the log output to a file.
class AdvancedFileOutput extends LogOutput {
AdvancedFileOutput({
Directory? directory,
File? file,
bool overrideExisting = false,
Encoding encoding = utf8,
List<Level>? writeImmediately,
Duration maxDelay = const Duration(seconds: 2),
int maxBufferSize = 2000,
int maxLogFileSizeMB = 1,
}) {
throw UnsupportedError("Not supported on this platform.");
}

File? get targetFile => null;
pyciko marked this conversation as resolved.
Show resolved Hide resolved

@override
void output(OutputEvent event) {
throw UnsupportedError("Not supported on this platform.");
}
}
80 changes: 80 additions & 0 deletions test/outputs/advanced_file_output_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import 'dart:io';

import 'package:logger/logger.dart';
import 'package:test/test.dart';

void main() {
var file = File("${Directory.systemTemp.path}/dart_advanced_logger_test.log");
var dir = Directory("${Directory.systemTemp.path}/dart_advanced_logger_dir");
setUp(() async {
await file.create(recursive: true);
await dir.create(recursive: true);
});

tearDown(() async {
await file.delete();
await dir.delete(recursive: true);
});

test('Real file read and write with buffer accumulation', () async {
var output = AdvancedFileOutput(
file: file,
maxDelay: const Duration(milliseconds: 500),
);
await output.init();

final event0 = OutputEvent(LogEvent(Level.info, ""), ["First event"]);
final event1 = OutputEvent(LogEvent(Level.info, ""), ["Second event"]);
final event2 = OutputEvent(LogEvent(Level.info, ""), ["Third event"]);

output.output(event0);
output.output(event1);
output.output(event2);

//wait until buffer writes out to file
await Future.delayed(const Duration(seconds: 1));

await output.destroy();

var content = await file.readAsString();
expect(
content,
allOf(
contains("First event"),
contains("Second event"),
contains("Third event"),
),
);
});

test('Real file read and write with dynamic file names and immediate output',
() async {
var output = AdvancedFileOutput(
directory: dir,
writeImmediately: [Level.info],
);
await output.init();

final event0 = OutputEvent(LogEvent(Level.info, ""), ["First event"]);
final event1 = OutputEvent(LogEvent(Level.info, ""), ["Second event"]);
final event2 = OutputEvent(LogEvent(Level.info, ""), ["Third event"]);

output.output(event0);
output.output(event1);
output.output(event2);

final targetFile = output.targetFile;

await output.destroy();

var content = await targetFile?.readAsString();
expect(
content,
allOf(
contains("First event"),
contains("Second event"),
contains("Third event"),
),
);
});
}
Loading