chain-studio/lib/data/chain_log.dart
flemming-it bb606a8b23
Some checks failed
Security / Security check (push) Failing after 2s
fix(approvals,l10n,theme): usertest findings — localized prompts, no-data confirm, .chain log path, honest wording, AA contrast
- approvals: empty/legacy hub prompts render the localized fallback;
  approving without show: data asks for conscious confirmation first
- chain_log: write to ~/.chain/logs/studio-errors.log (was .fai),
  one-time best-effort migration of the legacy file + rotation sibling
- l10n: 'manipulationssicher' -> 'manipulationserkennend', neutral
  WORM-1 blurb, doctor pill 'Integritätskette v1', federation hint
  says the CA authenticates the first connect (DE+EN)
- theme: muted text token now >=4.5:1 on canvas, cards and elevated
  dark surfaces (was 3.7:1 on cards)

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-07-11 02:38:24 +02:00

152 lines
5.3 KiB
Dart

// Central error log for Studio. Every error surfaced to the
// operator (SnackBar, error sheet, recoverable dialog) is also
// appended here so `chain doctor` and "Open log" can show what
// went wrong without the operator having to reproduce the
// failure.
//
// Path: `~/.chain/logs/studio-errors.log` (cross-platform via
// HOME / USERPROFILE). One line per event, JSON-shaped so
// `chain doctor` can parse it; humans can still read it because
// the JSON is single-line and short.
//
// Best-effort by design: a failed write to the log file must
// never break the UI layer that asked us to log. We swallow
// IO errors silently — the worst case is the operator loses
// the trail of one failure, not the failure itself.
//
// Rotation: when the file passes 256 KiB we move it to
// `studio-errors.log.1` and start fresh. One rotation level is
// enough — anything older is noise, and a Studio session
// rarely produces more than a few KiB of error text.
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:meta/meta.dart';
import 'package:path/path.dart' as p;
class ChainLog {
ChainLog._();
static final ChainLog instance = ChainLog._();
// Serialise concurrent writes — Future-based mutex so a burst
// of errors (rare but happens during failed install retries)
// doesn't interleave half-lines on disk.
Future<void> _tail = Future.value();
static const int _rotateBytes = 256 * 1024;
/// Test seam. Set this in `setUp` to redirect the singleton at
/// a per-test temp file; reset to `null` in `tearDown`. Production
/// code never touches it — the default getter computes from HOME.
@visibleForTesting
static String? testPathOverride;
String get _path {
final override = testPathOverride;
if (override != null) return override;
final home =
Platform.environment['HOME'] ??
Platform.environment['USERPROFILE'] ??
'.';
final path = p.join(home, '.chain', 'logs', 'studio-errors.log');
_migrateLegacyLog(home, path);
return path;
}
// Pre-rename installs wrote to `~/.fai/logs/`. Move that file (and
// its rotation sibling) over once so the error trail survives the
// rename; never overwrite an existing new-path file. Best-effort
// and cheap enough to run per access (two stat calls after the
// first migration).
static void _migrateLegacyLog(String home, String newPath) {
try {
for (final suffix in const ['', '.1']) {
final legacy = File(
p.join(home, '.fai', 'logs', 'studio-errors.log$suffix'),
);
final target = File('$newPath$suffix');
if (legacy.existsSync() && !target.existsSync()) {
target.parent.createSync(recursive: true);
legacy.renameSync(target.path);
}
}
} catch (_) {
// Best-effort: a failed migration must not break logging.
}
}
/// Absolute path of the studio-errors log. Public so the
/// Doctor page and the in-app log viewer can read it without
/// hard-coding the layout twice.
String get path => _path;
/// Append a single error event. The [source] is a short tag
/// (`flows.run`, `daemon.logs`, …) that lets a reader group
/// related failures. [error] can be any thrown object — gRPC
/// codes, plain strings, exceptions; we serialise it via
/// [error.toString()] so the on-disk record matches what the
/// operator saw on screen.
Future<void> error(String source, Object error, {Object? context}) {
final entry = <String, Object?>{
'ts': DateTime.now().toUtc().toIso8601String(),
'level': 'error',
'source': source,
'error': error.toString(),
if (context != null) 'context': context.toString(),
};
return _append(entry);
}
/// Read the last [maxLines] lines back. Used by `chain doctor`
/// + Settings → "Recent errors" view. Returns oldest first so
/// the caller can render in chronological order.
Future<List<String>> tail({int maxLines = 50}) async {
try {
final f = File(_path);
if (!await f.exists()) return const <String>[];
final raw = await f.readAsString();
final lines = raw.split('\n').where((l) => l.isNotEmpty).toList();
if (lines.length <= maxLines) return lines;
return lines.sublist(lines.length - maxLines);
} catch (_) {
return const <String>[];
}
}
Future<void> _append(Map<String, Object?> entry) {
final next = _tail.then((_) async {
try {
final f = File(_path);
final dir = Directory(p.dirname(_path));
if (!await dir.exists()) {
await dir.create(recursive: true);
}
// Rotate before write so a hot-loop never balloons past
// the cap. Single-level rotation: keep the previous file
// as `.1`, anything older drops on the floor.
if (await f.exists()) {
final size = await f.length();
if (size >= _rotateBytes) {
final rotated = File('$_path.1');
if (await rotated.exists()) {
await rotated.delete();
}
await f.rename(rotated.path);
}
}
await f.writeAsString(
'${jsonEncode(entry)}\n',
mode: FileMode.append,
flush: false,
);
} catch (_) {
// Best-effort. A failed log write must not crash the UI.
}
});
_tail = next.catchError((_) {});
return next;
}
}