feat(#97): mirror app/BLE logs to rotating files on disk (cross-platform)
Always-on file logging so users/support can retrieve a log without enabling anything in the UI. No-op on web (no filesystem). - FileLogWriter: rotating file writer (size cap + max files), single + batch writes, unit-tested against a real temp dir. - FileLogService: singleton initialised in main(); resolves getApplicationSupportDirectory()/logs, queues lines, periodic 2s batched flush, bounded queue, never crashes the app on an IO error. - AppDebugLogService.log + BleDebugLogService.logFrame write every entry to the file (app logs always, independent of the in-app viewer's enable gate). Location per the #97 decision: standard support dir on all platforms (Windows %APPDATA%\app.offband.meshcore\logs). Still TODO before recompile: access UI ("Open logs folder" on desktop / "Share logs" on mobile). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>feat/64-observer-config
parent
60273fb5da
commit
a50133f76d
@ -0,0 +1,83 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
|
import '../utils/file_log_writer.dart';
|
||||||
|
|
||||||
|
/// Always-on, app-wide file logging (#97). Mirrors the in-memory app + BLE logs
|
||||||
|
/// to a rotating file under `getApplicationSupportDirectory()/logs` so users
|
||||||
|
/// (and support) can retrieve a log without enabling anything. No-op on web
|
||||||
|
/// (no filesystem). A singleton initialised in `main()`; the two log services
|
||||||
|
/// call [write] on every entry, and a periodic flush batches them to disk so
|
||||||
|
/// the hot logging path never touches the disk directly.
|
||||||
|
class FileLogService {
|
||||||
|
FileLogService._();
|
||||||
|
static final FileLogService instance = FileLogService._();
|
||||||
|
|
||||||
|
FileLogWriter? _writer;
|
||||||
|
Directory? _dir;
|
||||||
|
final List<String> _queue = [];
|
||||||
|
Timer? _flushTimer;
|
||||||
|
bool _initStarted = false;
|
||||||
|
|
||||||
|
/// The logs directory once resolved (null on web / before init / on failure).
|
||||||
|
Directory? get logDir => _dir;
|
||||||
|
|
||||||
|
/// Resolve the platform logs dir and start the periodic flush. Safe to call
|
||||||
|
/// once; a no-op on web. Lines written before this completes are queued.
|
||||||
|
Future<void> init() async {
|
||||||
|
if (_initStarted || kIsWeb) return;
|
||||||
|
_initStarted = true;
|
||||||
|
try {
|
||||||
|
final support = await getApplicationSupportDirectory();
|
||||||
|
final dir = Directory('${support.path}${Platform.pathSeparator}logs');
|
||||||
|
await dir.create(recursive: true);
|
||||||
|
_dir = dir;
|
||||||
|
_writer = FileLogWriter(dir: dir);
|
||||||
|
_flushTimer = Timer.periodic(
|
||||||
|
const Duration(seconds: 2),
|
||||||
|
(_) => unawaited(_flush()),
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
// Logging must never crash the app. If the dir can't be resolved we stay
|
||||||
|
// a no-op; the in-memory logs still work.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Queue one already-formatted line for the next flush. No-op on web. Bounded
|
||||||
|
/// so a stalled flush can't grow memory without limit.
|
||||||
|
void write(String line) {
|
||||||
|
if (kIsWeb) return;
|
||||||
|
_queue.add(line);
|
||||||
|
if (_queue.length > 5000) _queue.removeRange(0, _queue.length - 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _flush() async {
|
||||||
|
final w = _writer;
|
||||||
|
if (w == null || _queue.isEmpty) return;
|
||||||
|
final batch = List<String>.of(_queue);
|
||||||
|
_queue.clear();
|
||||||
|
try {
|
||||||
|
await w.writeLines(batch);
|
||||||
|
} catch (_) {
|
||||||
|
// Drop on IO error; never crash the app over a log write.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flush pending lines and return the active log file (for share/export).
|
||||||
|
/// Null when file logging is unavailable (web / not initialised).
|
||||||
|
Future<File?> flushAndGetActiveFile() async {
|
||||||
|
await _flush();
|
||||||
|
return _writer?.activeFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancel the periodic flush and flush once more. The singleton normally lives
|
||||||
|
/// for the app's lifetime; this is for a clean shutdown / tests.
|
||||||
|
Future<void> dispose() async {
|
||||||
|
_flushTimer?.cancel();
|
||||||
|
_flushTimer = null;
|
||||||
|
await _flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,76 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
/// Appends log lines to a rotating file under [dir] (#97).
|
||||||
|
///
|
||||||
|
/// When writing a line would push the active file (`<baseName>.log`) past
|
||||||
|
/// [maxBytes], it rotates: `<baseName>.log` -> `.1.log` -> `.2.log` …, keeping
|
||||||
|
/// at most [maxFiles] files total (oldest dropped). Platform-agnostic: the
|
||||||
|
/// caller resolves [dir] (e.g. `getApplicationSupportDirectory()`) and only
|
||||||
|
/// constructs this where a filesystem exists — never on web. Writes are appended
|
||||||
|
/// and flushed so logs survive a crash.
|
||||||
|
class FileLogWriter {
|
||||||
|
FileLogWriter({
|
||||||
|
required this.dir,
|
||||||
|
this.baseName = 'offband',
|
||||||
|
this.maxBytes = 2 * 1024 * 1024,
|
||||||
|
this.maxFiles = 3,
|
||||||
|
}) : assert(maxFiles >= 1);
|
||||||
|
|
||||||
|
final Directory dir;
|
||||||
|
final String baseName;
|
||||||
|
final int maxBytes;
|
||||||
|
final int maxFiles;
|
||||||
|
|
||||||
|
File get activeFile => File('${dir.path}/$baseName.log');
|
||||||
|
File _rotated(int n) => File('${dir.path}/$baseName.$n.log');
|
||||||
|
|
||||||
|
/// Append [line] (a trailing newline is added), rotating first if it would
|
||||||
|
/// push the active file past [maxBytes].
|
||||||
|
Future<void> writeLine(String line) async {
|
||||||
|
if (!await dir.exists()) await dir.create(recursive: true);
|
||||||
|
final active = activeFile;
|
||||||
|
final size = await active.exists() ? await active.length() : 0;
|
||||||
|
if (size > 0 && size + line.length + 1 > maxBytes) {
|
||||||
|
await _rotate();
|
||||||
|
}
|
||||||
|
await activeFile.writeAsString(
|
||||||
|
'$line\n',
|
||||||
|
mode: FileMode.append,
|
||||||
|
flush: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append a batch of [lines] in one write (single open + flush), rotating
|
||||||
|
/// once first if the batch would push the active file past [maxBytes]. Used by
|
||||||
|
/// the service's periodic flush so frequent log calls don't each hit the disk.
|
||||||
|
Future<void> writeLines(List<String> lines) async {
|
||||||
|
if (lines.isEmpty) return;
|
||||||
|
if (!await dir.exists()) await dir.create(recursive: true);
|
||||||
|
final data = '${lines.join('\n')}\n';
|
||||||
|
final active = activeFile;
|
||||||
|
final size = await active.exists() ? await active.length() : 0;
|
||||||
|
if (size > 0 && size + data.length > maxBytes) {
|
||||||
|
await _rotate();
|
||||||
|
}
|
||||||
|
await activeFile.writeAsString(data, mode: FileMode.append, flush: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `<base>.(N-1).log` -> `<base>.N.log` … and active -> `.1.log`; anything
|
||||||
|
/// beyond [maxFiles] total is dropped. With [maxFiles] == 1 the active file is
|
||||||
|
/// simply restarted (no rotated copies).
|
||||||
|
Future<void> _rotate() async {
|
||||||
|
if (maxFiles <= 1) {
|
||||||
|
if (await activeFile.exists()) await activeFile.delete();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final oldest = _rotated(maxFiles - 1);
|
||||||
|
if (await oldest.exists()) await oldest.delete();
|
||||||
|
for (var n = maxFiles - 2; n >= 1; n--) {
|
||||||
|
final f = _rotated(n);
|
||||||
|
if (await f.exists()) await f.rename(_rotated(n + 1).path);
|
||||||
|
}
|
||||||
|
if (await activeFile.exists()) {
|
||||||
|
await activeFile.rename(_rotated(1).path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,79 @@
|
|||||||
|
// Rotating file-log writer (#97). Exercised against a real temp directory —
|
||||||
|
// dart:io File works in flutter_test on desktop, so the rotation behaviour is
|
||||||
|
// integration-tested, not mocked.
|
||||||
|
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:meshcore_open/utils/file_log_writer.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('FileLogWriter', () {
|
||||||
|
late Directory dir;
|
||||||
|
setUp(() => dir = Directory.systemTemp.createTempSync('flog'));
|
||||||
|
tearDown(() {
|
||||||
|
if (dir.existsSync()) dir.deleteSync(recursive: true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('appends lines to the active file', () async {
|
||||||
|
final w = FileLogWriter(dir: dir, baseName: 't', maxBytes: 1 << 20);
|
||||||
|
await w.writeLine('hello');
|
||||||
|
await w.writeLine('world');
|
||||||
|
expect(await w.activeFile.readAsString(), 'hello\nworld\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('creates the directory if missing', () async {
|
||||||
|
final sub = Directory('${dir.path}/nested/logs');
|
||||||
|
final w = FileLogWriter(dir: sub, baseName: 't', maxBytes: 1 << 20);
|
||||||
|
await w.writeLine('x');
|
||||||
|
expect(sub.existsSync(), isTrue);
|
||||||
|
expect(w.activeFile.existsSync(), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rotates past maxBytes and caps at maxFiles total', () async {
|
||||||
|
final w = FileLogWriter(
|
||||||
|
dir: dir,
|
||||||
|
baseName: 't',
|
||||||
|
maxBytes: 100,
|
||||||
|
maxFiles: 3,
|
||||||
|
);
|
||||||
|
for (var i = 0; i < 60; i++) {
|
||||||
|
await w.writeLine('line $i ${'x' * 20}');
|
||||||
|
}
|
||||||
|
expect(File('${dir.path}/t.log').existsSync(), isTrue);
|
||||||
|
expect(File('${dir.path}/t.1.log').existsSync(), isTrue);
|
||||||
|
expect(File('${dir.path}/t.2.log').existsSync(), isTrue);
|
||||||
|
// capped: active + .1 + .2 = 3 files, no .3
|
||||||
|
expect(File('${dir.path}/t.3.log').existsSync(), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('maxFiles=1 keeps only the active file (restart on rotate)', () async {
|
||||||
|
final w = FileLogWriter(
|
||||||
|
dir: dir,
|
||||||
|
baseName: 't',
|
||||||
|
maxBytes: 50,
|
||||||
|
maxFiles: 1,
|
||||||
|
);
|
||||||
|
for (var i = 0; i < 20; i++) {
|
||||||
|
await w.writeLine('line $i padding padding');
|
||||||
|
}
|
||||||
|
expect(File('${dir.path}/t.log').existsSync(), isTrue);
|
||||||
|
expect(File('${dir.path}/t.1.log').existsSync(), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('writeLines batches and still rotates', () async {
|
||||||
|
final w = FileLogWriter(
|
||||||
|
dir: dir,
|
||||||
|
baseName: 't',
|
||||||
|
maxBytes: 200,
|
||||||
|
maxFiles: 3,
|
||||||
|
);
|
||||||
|
for (var b = 0; b < 10; b++) {
|
||||||
|
await w.writeLines(
|
||||||
|
List.generate(5, (i) => 'batch $b line $i ${'y' * 10}'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
expect(File('${dir.path}/t.log').existsSync(), isTrue);
|
||||||
|
expect(File('${dir.path}/t.1.log').existsSync(), isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in new issue