feat(log): crash-survivable FileLogSink + dev/prod verbosity toggle (T-432)

First increment of the observability epic (T-425), the productive pivot after
the ConPTY freeze refused to reproduce on CI: if we can't reproduce it, make
the next occurrence leave evidence.

- FileLogSink (lib/kernel/src/file_log_sink.dart): synchronous, crash-survivable
  LogSink. Appends each record as one JSON line to a size-rotated file; fsyncs
  warn/error + risky-source (pty/ffi/conpty/watchdog) records immediately so the
  last breadcrumb is on disk before a hard death, batches the rest on a timer.
  Never throws. Flutter-free → unit-tested under dart test against a temp dir.
- logDirectory() (paths.dart): persistent per-platform log dir (LOCALAPPDATA /
  ~/Library/Logs / $XDG_STATE_HOME) — durable across reboot, unlike the
  ephemeral socketDirectory.
- resolveLogLevel() (log.dart): the requested dev/prod toggle. CLIDE_LOG
  dart-define → CLIDE_LOG env → app.log.level setting → warn(release)/info(debug).
  Lenient parse; an invalid source falls through.
- Boot wiring (facade.boot + main.dart): FileLogSink leads the sink chain (so a
  crash records before the volatile stderr/ring sinks) and the resolved level
  sets Logger.minLevel.

Tests: FileLogSink (JSON shape, error/stack, rotation cap, append-across-restart,
timer-cancel), resolveLogLevel precedence + fall-through, logDirectory per-OS.
Coverage gate 95.11%.

Follow-ups under T-425: live toggle CLI/command/chip (T-433), FFI breadcrumbs
(T-434), watchdog isolate (T-435), CI artifact wiring (T-436).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-15 09:29:41 +02:00
co-authored by Claude Opus 4.8
parent b3acb8a34c
commit 85cc34e09c
12 changed files with 494 additions and 2 deletions
+5 -1
View File
@@ -137,9 +137,13 @@ class KernelServices {
Future<void> Function(String path)? onProjectOpen,
Future<String?> Function(String path)? onValidateProject,
DaemonBus? sharedBus,
List<LogSink> additionalSinks = const [],
LogLevel? minLogLevel,
}) async {
final logRing = LogRing();
final log = Logger(sinks: [stderrSink, logRing.add]);
// additionalSinks lead the chain so a crash-survivable sink (FileLogSink,
// T-425) records the tail before the volatile stderr/ring sinks run.
final log = Logger(minLevel: minLogLevel ?? LogLevel.info, sinks: [...additionalSinks, stderrSink, logRing.add]);
final events = sharedBus ?? DaemonBus();
final messages = MessageBus();
final filterStates = FilterStateCache(messages: messages);
+165
View File
@@ -0,0 +1,165 @@
/// Crash-survivable [LogSink] (T-425).
///
/// Every other sink in clide is volatile: [stderrSink] dies with the console,
/// the [LogRing] dies with the process. The Windows freeze that motivated this
/// (a hard power-cycle, no dumps, no logs) left no evidence for exactly that
/// reason. [FileLogSink] is the durable tail: it appends each [LogRecord] as
/// one JSON line to a size-rotated file under a persistent per-platform log
/// dir, and — crucially — fsyncs the records most likely to immediately
/// precede a crash, so the last breadcrumb is on disk before the box dies.
///
/// Design choices that matter for a CRASH logger:
/// - Synchronous I/O only. No async buffering / no IOSink — a hard death
/// between an `await` and its flush would lose the tail, which is the one
/// thing this sink exists to keep.
/// - Tiered flush. `warn`/`error` and records from inherently-risky sources
/// (pty/ffi/conpty/watchdog) `flushSync` immediately. High-volume
/// `info`/`debug` write through to the OS (surviving a process crash) and
/// are fsynced on a low-frequency timer — enough to bound power-loss to a
/// couple of seconds without an fsync per line.
/// - Never throws. A disk-full / permission error must not take logging — or
/// the app — down; every operation swallows its own failure.
///
/// Flutter-free (only `dart:io`/`dart:async`/`dart:convert` + [LogRecord]) so
/// it unit-tests under `dart test` against a temp dir, and so the PTY/FFI
/// layer (also Flutter-free) can route breadcrumbs through it.
library;
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'log.dart';
class FileLogSink {
FileLogSink({
required Directory dir,
String baseName = 'clide',
int maxBytes = 5 * 1024 * 1024,
int maxFiles = 5,
Set<String> eagerSources = const {'pty', 'ffi', 'conpty', 'watchdog'},
LogLevel eagerLevel = LogLevel.warn,
Duration flushInterval = const Duration(seconds: 2),
bool startFlushTimer = true,
}) : _dir = dir,
_baseName = baseName,
_maxBytes = maxBytes,
_maxFiles = maxFiles < 1 ? 1 : maxFiles,
_eagerSources = eagerSources,
_eagerLevel = eagerLevel {
_open();
if (startFlushTimer && flushInterval > Duration.zero) {
_timer = Timer.periodic(flushInterval, (_) => _flush());
}
}
final Directory _dir;
final String _baseName;
final int _maxBytes;
final int _maxFiles;
final Set<String> _eagerSources;
final LogLevel _eagerLevel;
RandomAccessFile? _raf;
int _size = 0;
bool _dirty = false;
Timer? _timer;
String get _sep => Platform.pathSeparator;
File get _active => File('${_dir.path}$_sep$_baseName.log');
File _archive(int i) => File('${_dir.path}$_sep$_baseName.$i.log');
/// The active log file's path — handy for the caller to surface (e.g. an
/// "open log folder" affordance) or to add to a CI artifact upload.
String get activePath => _active.path;
void _open() {
try {
_dir.createSync(recursive: true);
final f = _active;
_size = f.existsSync() ? f.lengthSync() : 0;
_raf = f.openSync(mode: FileMode.append);
} catch (_) {
_raf = null; // a disk problem must never kill logging
}
}
/// The [LogSink] entry point: `logger.addSink(fileSink.call)`.
void call(LogRecord r) {
if (_raf == null) return;
try {
final bytes = utf8.encode('${jsonEncode(_encode(r))}\n');
// Rotate BEFORE writing when this line would push the file past the cap,
// so the newest entries always live in the active file (and a single
// oversized line still lands rather than spinning rotations on an empty
// file).
if (_size > 0 && _size + bytes.length > _maxBytes) _rotate();
final raf = _raf;
if (raf == null) return;
raf.writeFromSync(bytes);
_size += bytes.length;
_dirty = true;
if (r.level.index >= _eagerLevel.index || _eagerSources.contains(r.source)) {
raf.flushSync();
_dirty = false;
}
} catch (_) {
// swallow — a logging failure must never propagate to the app
}
}
Map<String, Object?> _encode(LogRecord r) => {
'ts': r.timestamp.toIso8601String(),
'lvl': r.level.name,
'src': r.source,
'msg': r.message,
if (r.error != null) 'err': r.error.toString(),
if (r.stackTrace != null) 'stack': r.stackTrace.toString(),
};
void _flush() {
if (!_dirty) return;
try {
_raf?.flushSync();
_dirty = false;
} catch (_) {}
}
/// Roll `<base>.log` → `<base>.1.log`, shifting older archives up and
/// dropping the oldest past [maxFiles]. With `maxFiles == 1` the active file
/// is simply truncated (no archives kept).
void _rotate() {
try {
_raf?.flushSync();
_raf?.closeSync();
} catch (_) {}
_raf = null;
try {
if (_maxFiles <= 1) {
if (_active.existsSync()) _active.deleteSync();
} else {
final oldest = _archive(_maxFiles - 1);
if (oldest.existsSync()) oldest.deleteSync();
for (var i = _maxFiles - 2; i >= 1; i--) {
final src = _archive(i);
if (src.existsSync()) src.renameSync(_archive(i + 1).path);
}
if (_active.existsSync()) _active.renameSync(_archive(1).path);
}
} catch (_) {}
_size = 0;
_open();
}
/// Flush + close. Call on orderly shutdown; a crash is covered by the eager
/// fsync above, not by this.
Future<void> close() async {
_timer?.cancel();
_timer = null;
try {
_raf?.flushSync();
_raf?.closeSync();
} catch (_) {}
_raf = null;
}
}
+29
View File
@@ -62,3 +62,32 @@ void stderrSink(LogRecord r) {
stderr.writeln(r);
if (r.stackTrace != null) stderr.writeln(r.stackTrace);
}
/// Parse a level name (case-insensitive, trimmed) to a [LogLevel], or null if
/// it is absent/blank/unknown — so an invalid source falls through to the next
/// one in [resolveLogLevel] rather than crashing the boot.
LogLevel? parseLogLevel(String? name) {
if (name == null) return null;
final n = name.trim().toLowerCase();
if (n.isEmpty) return null;
for (final l in LogLevel.values) {
if (l.name == n) return l;
}
return null;
}
/// Resolve the effective [Logger.minLevel] at boot — the dev/prod verbosity
/// toggle (T-425). Highest precedence first:
///
/// 1. `--dart-define=CLIDE_LOG=<level>` (baked into the build)
/// 2. the `CLIDE_LOG` environment variable
/// 3. the `app.log.level` setting
/// 4. a build-mode default: `warn` in release (a shipped app stays quiet),
/// `info` in debug.
///
/// Each named source is parsed leniently; an unknown name is skipped, not
/// fatal. The build-mode flag is passed in (rather than read here) to keep
/// this Flutter-free — `main.dart` supplies `kReleaseMode`.
LogLevel resolveLogLevel({required bool isRelease, String? dartDefine, String? envVar, String? settingValue}) {
return parseLogLevel(dartDefine) ?? parseLogLevel(envVar) ?? parseLogLevel(settingValue) ?? (isRelease ? LogLevel.warn : LogLevel.info);
}