using System.Globalization; using Fleet.Core; namespace Fleet.Hosting; /// /// Writes logs locally and keeps a bounded ring of recent lines. Heartbeats carry only counters (see ); /// the text leaves the node when the control plane asks for it (see ). /// public sealed class BufferedNodeLog : INodeLog, INodeLogSource { private const int Capacity = 2048; private const int MaxFields = 20; private const int MaxMessageLength = 512; private const int MaxFieldValueLength = 256; private const int MaxPageBytes = 96 << 10; private const int MaxSummaryTypes = 8; private readonly object _gate = new(); private readonly LinkedList _ring = []; private readonly Dictionary<(uint Type, string Level), long> _counts = []; private readonly INodeLog _local; private readonly TimeProvider _time; private long _lastId; private long _info; private long _warn; private long _error; public BufferedNodeLog(INodeLog local, TimeProvider time) { _local = local; _time = time; } public void Write(NodeLogLevel level, string message, params (string Key, object? Value)[] fields) { _local.Write(level, message, fields); var safeFields = new Dictionary(Math.Min(fields.Length, MaxFields), StringComparer.Ordinal); foreach ((string key, object? value) in fields.Take(MaxFields)) { string safeValue = IsSensitiveKey(key) ? "[redacted]" : Convert.ToString(value, CultureInfo.InvariantCulture) ?? ""; safeFields[key.Length <= 64 ? key : key[..64]] = Truncate(safeValue, MaxFieldValueLength); } string levelName = level.ToString().ToLowerInvariant(); var record = new NodeLogRecord(_time.GetUtcNow().ToUnixTimeMilliseconds(), levelName, Truncate(message, MaxMessageLength), safeFields); uint type = TypeOf(record.Message); lock (_gate) { _ring.AddLast(new NodeLogEntry(++_lastId, type, record)); if (_ring.Count > Capacity) { _ring.RemoveFirst(); } _counts[(type, levelName)] = _counts.GetValueOrDefault((type, levelName)) + 1; switch (level) { case NodeLogLevel.Error: _error++; break; case NodeLogLevel.Warn: _warn++; break; default: _info++; break; } } } public NodeLogSummary TakeSummary() { lock (_gate) { var summary = new NodeLogSummary( _lastId, _info, _warn, _error, [.. _counts.OrderByDescending(pair => pair.Value).Take(MaxSummaryTypes).Select(pair => new NodeLogTypeCount(pair.Key.Type, pair.Key.Level, pair.Value))]); _counts.Clear(); _info = _warn = _error = 0; return summary; } } public NodeLogPage Read(long afterId, int limit, string? minLevel) { int rank = LevelRank(minLevel); lock (_gate) { var items = new List(Math.Min(Math.Max(limit, 0), 200)); int bytes = 0; foreach (NodeLogEntry entry in _ring) { if (entry.Id <= afterId || LevelRank(entry.Record.Level) < rank) { continue; } int entryBytes = 64 + entry.Record.Message.Length + entry.Record.Fields.Sum(pair => pair.Key.Length + pair.Value.Length + 8); if (items.Count >= Math.Clamp(limit, 0, 200) || (items.Count > 0 && bytes + entryBytes > MaxPageBytes)) { break; } items.Add(entry); bytes += entryBytes; } return new NodeLogPage(items, _lastId); } } /// The line's type: a stable 32-bit FNV-1a hash of its message text, so the same template always maps to the same number. public static uint TypeOf(string message) { uint hash = 2166136261; foreach (char c in message) { hash = (hash ^ c) * 16777619; } return hash; } private static int LevelRank(string? level) => level switch { "error" => 2, "warn" => 1, _ => 0, }; private static bool IsSensitiveKey(string key) { string lower = key.ToLowerInvariant(); return lower.Contains("token", StringComparison.Ordinal) || lower.Contains("secret", StringComparison.Ordinal) || lower.Contains("password", StringComparison.Ordinal) || lower.Contains("private", StringComparison.Ordinal) || lower.Contains("signature", StringComparison.Ordinal) || lower.Contains("authorization", StringComparison.Ordinal) || lower.Contains("cookie", StringComparison.Ordinal) || lower == "mac"; } private static string Truncate(string value, int maxLength) => value.Length <= maxLength ? value : value[..maxLength]; }