diff --git a/lib/core/util/log_ignored.dart b/lib/core/util/log_ignored.dart index b7bd8de7..1b252d96 100644 --- a/lib/core/util/log_ignored.dart +++ b/lib/core/util/log_ignored.dart @@ -1,4 +1,5 @@ import 'package:flutter/foundation.dart'; +import 'package:querya_desktop/core/util/querya_log.dart'; /// Records an error that the caller deliberately goes on from: a best-effort /// close or delete, a cache that can be rebuilt. [where] names the place @@ -6,5 +7,6 @@ import 'package:flutter/foundation.dart'; /// trace in the debug log instead of none. void logIgnored(String where, Object error, [StackTrace? stack]) { debugPrint('[$where] ignored: $error'); + QueryaLog.instance.warn(where, 'ignored', error: error); if (stack != null && kDebugMode) debugPrint('$stack'); } diff --git a/lib/core/util/querya_log.dart b/lib/core/util/querya_log.dart new file mode 100644 index 00000000..acb6102e --- /dev/null +++ b/lib/core/util/querya_log.dart @@ -0,0 +1,150 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:path/path.dart' as p; +import 'package:querya_desktop/core/mcp/mcp_redaction.dart'; + +/// Severity of a [QueryaLog] line. +enum QueryaLogLevel { info, warn, error } + +/// The app's persistent log: timestamped lines in a file under the app data +/// root, rotated by size. +/// +/// Logging never throws and never blocks the caller: lines are queued and +/// written in order, and a file that cannot be written is only reported on +/// the console. Text is passed through [McpRedaction] (URL user info, +/// `password=...`, private keys) before it is written. Callers must still not +/// pass SQL text or whole connection strings. +class QueryaLog { + QueryaLog({this.maxBytes = 2 * 1024 * 1024, this.maxFiles = 5}) + : assert(maxFiles >= 1); + + static final QueryaLog instance = QueryaLog(); + + static const fileName = 'querya.log'; + + /// A file is rotated before a line would take it past this size. + final int maxBytes; + + /// The current file plus the older ones kept (`querya.1.log`, ...). + final int maxFiles; + + /// Lines logged before [init] (start-up) wait here, up to this many. + static const _maxPending = 500; + + Directory? _directory; + int _size = 0; + Future _queue = Future.value(); + final _pending = []; + + /// Directory of the log files, once [init] has succeeded. + Directory? get directory => _directory; + + /// Starts writing into [directory] and flushes what was logged before. + /// A directory that cannot be used leaves the log console-only. + Future init(Directory directory) async { + try { + await directory.create(recursive: true); + final current = File(p.join(directory.path, fileName)); + _size = await current.exists() ? await current.length() : 0; + _directory = directory; + } catch (e) { + debugPrint('QueryaLog.init: $e'); + _pending.clear(); + return; + } + final waiting = List.of(_pending); + _pending.clear(); + waiting.forEach(_enqueue); + } + + void info(String area, String message) => + write(QueryaLogLevel.info, area, message); + + void warn(String area, String message, {Object? error, StackTrace? stack}) => + write(QueryaLogLevel.warn, area, message, error: error, stack: stack); + + void error(String area, String message, {Object? error, StackTrace? stack}) => + write(QueryaLogLevel.error, area, message, error: error, stack: stack); + + /// Queues one line. Never throws. + void write( + QueryaLogLevel level, + String area, + String message, { + Object? error, + StackTrace? stack, + }) { + try { + final text = StringBuffer(message); + if (error != null) text.write(': $error'); + if (stack != null) text.write('\n$stack'); + final body = McpRedaction.redact(text.toString()) + .trimRight() + .replaceAll('\n', '\n '); + final tag = level.name.toUpperCase().padRight(5); + _enqueue( + '${DateTime.now().toUtc().toIso8601String()} $tag [$area] $body\n', + ); + } catch (e) { + debugPrint('QueryaLog.write: $e'); + } + } + + /// Completes when every line queued so far has been written. + Future flush() => _queue; + + /// The last [count] lines of the current and the older files, oldest first; + /// empty when there is no log. + Future> tail([int count = 200]) async { + final dir = _directory; + if (dir == null) return const []; + await flush(); + final lines = []; + try { + for (var i = 0; i < maxFiles && lines.length < count; i++) { + final file = _file(dir, i); + if (!await file.exists()) continue; + final fileLines = const LineSplitter().convert(await file.readAsString()); + lines.insertAll(0, fileLines); + } + } catch (e) { + debugPrint('QueryaLog.tail: $e'); + } + return lines.length > count ? lines.sublist(lines.length - count) : lines; + } + + File _file(Directory dir, int index) => File( + p.join(dir.path, index == 0 ? fileName : 'querya.$index.log'), + ); + + void _enqueue(String line) { + if (_directory == null) { + if (_pending.length < _maxPending) _pending.add(line); + return; + } + _queue = _queue.then((_) => _append(line)).catchError((Object e) { + debugPrint('QueryaLog: could not write: $e'); + }); + } + + Future _append(String line) async { + final dir = _directory!; + final bytes = utf8.encode(line); + if (_size > 0 && _size + bytes.length > maxBytes) await _rotate(dir); + await _file(dir, 0).writeAsBytes(bytes, mode: FileMode.append); + _size += bytes.length; + } + + Future _rotate(Directory dir) async { + final oldest = _file(dir, maxFiles - 1); + if (await oldest.exists()) await oldest.delete(); + for (var i = maxFiles - 2; i >= 0; i--) { + final file = _file(dir, i); + if (await file.exists()) await file.rename(_file(dir, i + 1).path); + } + _size = 0; + } +} diff --git a/lib/main.dart b/lib/main.dart index 3268a7d7..78785fc1 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -17,7 +17,9 @@ import 'core/motion/querya_motion_controller.dart'; import 'core/platform/file_launch_service.dart'; import 'core/platform/macos_file_open_channel.dart'; import 'core/security/ssl_certificate_support.dart'; +import 'core/extensions/sandbox/sandbox_log_paths.dart'; import 'core/storage/local_db.dart'; +import 'core/util/querya_log.dart'; import 'core/theme/theme_controller.dart'; import 'features/recovery/querya_startup_recovery_app.dart'; import 'features/updater/update_controller.dart'; @@ -29,9 +31,19 @@ void main([List args = const []]) async { FileLaunchService.instance.processLaunchArguments(args); await MacOsFileOpenChannel.install(); + unawaited(SandboxLogPaths.logsDirectory() + .then(QueryaLog.instance.init) + .catchError((Object e) => debugPrint('QueryaLog: no log dir: $e'))); + QueryaLog.instance.info('app', 'start'); + FlutterError.onError = (details) { FlutterError.presentError(details); debugPrint('FlutterError: ${details.exceptionAsString()}'); + QueryaLog.instance.error( + 'flutter', + details.exceptionAsString(), + stack: details.stack, + ); }; DisplayRefreshService.initialize(); @@ -52,6 +64,12 @@ void main([List args = const []]) async { runApp(const QueryaApp()); } catch (error, stack) { debugPrint('Startup database initialization failed: $error\n$stack'); + QueryaLog.instance.error( + 'startup', + 'database initialization failed', + error: error, + stack: stack, + ); runApp( QueryaStartupRecoveryApp( error: error, @@ -70,5 +88,7 @@ void main([List args = const []]) async { }); }, (error, stack) { debugPrint('Unhandled async error: $error\n$stack'); + QueryaLog.instance.error('zone', 'unhandled async error', + error: error, stack: stack); })); } diff --git a/test/core/util/querya_log_test.dart b/test/core/util/querya_log_test.dart new file mode 100644 index 00000000..92ce2433 --- /dev/null +++ b/test/core/util/querya_log_test.dart @@ -0,0 +1,120 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:querya_desktop/core/util/log_ignored.dart'; +import 'package:querya_desktop/core/util/querya_log.dart'; + +void main() { + late Directory root; + + setUp(() async { + root = await Directory.systemTemp.createTemp('querya_log_test_'); + }); + tearDown(() => root.delete(recursive: true)); + + Future read(String name) => + File(p.join(root.path, name)).readAsString(); + + test('writes timestamped lines with level and area', () async { + final log = QueryaLog(); + await log.init(root); + log.warn('storage', 'could not save', error: StateError('disk full')); + await log.flush(); + + final text = await read(QueryaLog.fileName); + expect(text, matches(RegExp(r'^\d{4}-\d\d-\d\dT[\d:.]+Z WARN \[storage\] could not save'))); + expect(text, contains('disk full')); + }); + + test('lines logged before init are kept and written on init', () async { + final log = QueryaLog(); + log.info('app', 'start'); + await log.init(root); + await log.flush(); + + expect(await read(QueryaLog.fileName), contains('[app] start')); + }); + + test('rotates by size and keeps at most maxFiles', () async { + final log = QueryaLog(maxBytes: 200, maxFiles: 3); + await log.init(root); + for (var i = 0; i < 40; i++) { + log.info('t', 'line number $i ${'x' * 20}'); + } + await log.flush(); + + final files = root.listSync().whereType().map((f) => p.basename(f.path)).toSet(); + expect(files, {'querya.log', 'querya.1.log', 'querya.2.log'}); + expect(await read(QueryaLog.fileName), contains('line number 39')); + expect(await read('querya.1.log'), isNot(contains('line number 0 '))); + for (final name in files) { + expect(File(p.join(root.path, name)).lengthSync(), lessThanOrEqualTo(200)); + } + final tail = await log.tail(3); + expect(tail.last, contains('line number 39')); + }); + + test('passwords and URL user info are redacted', () async { + final log = QueryaLog(); + await log.init(root); + log.error( + 'conn', + 'connect failed for postgresql://admin:hunter22@db/shop password=hunter22', + error: 'mysql://root:s3cret@h/x', + ); + await log.flush(); + + final text = await read(QueryaLog.fileName); + expect(text, isNot(contains('hunter22'))); + expect(text, isNot(contains('s3cret'))); + expect(text, contains('[conn]')); + }); + + test('a multi-line stack stays inside one entry', () async { + final log = QueryaLog(); + await log.init(root); + log.error('x', 'boom', stack: StackTrace.fromString('#0 a\n#1 b')); + await log.flush(); + + final lines = (await read(QueryaLog.fileName)).trimRight().split('\n'); + expect(lines.first, contains('boom')); + expect(lines.skip(1), everyElement(startsWith(' '))); + }); + + test('a directory that cannot be used never makes logging throw', () async { + final blocker = File(p.join(root.path, 'a_file'))..writeAsStringSync('x'); + final log = QueryaLog(); + + await log.init(Directory(p.join(blocker.path, 'logs'))); + log.error('x', 'still fine'); + await log.flush(); + + expect(log.directory, isNull); + expect(await log.tail(), isEmpty); + }); + + test('a file that fails to write is skipped, later lines still go out', () async { + final log = QueryaLog(); + await log.init(root); + // A directory where the log file belongs makes the append fail. + await Directory(p.join(root.path, QueryaLog.fileName)).create(); + log.info('x', 'lost'); + await log.flush(); + await Directory(p.join(root.path, QueryaLog.fileName)).delete(); + log.info('x', 'kept'); + await log.flush(); + + expect(await read(QueryaLog.fileName), contains('kept')); + }); + + test('logIgnored writes to the shared log', () async { + await QueryaLog.instance.init(root); + logIgnored('Somewhere.close', StateError('already closed')); + await QueryaLog.instance.flush(); + + final text = await read(QueryaLog.fileName); + expect(text, contains('[Somewhere.close] ignored')); + expect(text, contains('already closed')); + }); +}