Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions lib/core/util/log_ignored.dart
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
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
/// (`'LocalExtensionInstaller.backup'`), so a failure that matters later has a
/// 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');
}
150 changes: 150 additions & 0 deletions lib/core/util/querya_log.dart
Original file line number Diff line number Diff line change
@@ -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<void> _queue = Future.value();
final _pending = <String>[];

/// 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<void> 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<String>.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<void> flush() => _queue;

/// The last [count] lines of the current and the older files, oldest first;
/// empty when there is no log.
Future<List<String>> tail([int count = 200]) async {
final dir = _directory;
if (dir == null) return const [];
await flush();
final lines = <String>[];
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<void> _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<void> _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;
}
}
20 changes: 20 additions & 0 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -29,9 +31,19 @@ void main([List<String> 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();
Expand All @@ -52,6 +64,12 @@ void main([List<String> 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,
Expand All @@ -70,5 +88,7 @@ void main([List<String> args = const []]) async {
});
}, (error, stack) {
debugPrint('Unhandled async error: $error\n$stack');
QueryaLog.instance.error('zone', 'unhandled async error',
error: error, stack: stack);
}));
}
120 changes: 120 additions & 0 deletions test/core/util/querya_log_test.dart
Original file line number Diff line number Diff line change
@@ -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<String> 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<File>().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'));
});
}
Loading