Files
MoShiro/backend/fleet/src/Fleet.Hosting/BufferedNodeLog.cs
T
justme fb3a9c260f fleet: send log counters in heartbeats and pull the text on demand
Heartbeats carry per-level counts, the most frequent log types (a stable hash of the message) and the newest id, no text. The node keeps its last 2048 lines; moshiro.logs.fetch lets the backend pull them. The log page pulls when it is open, and the backend pulls warnings and errors once a minute after a node reports an error.
2026-10-22 05:28:29 +08:00

148 lines
5.1 KiB
C#

using System.Globalization;
using Fleet.Core;
namespace Fleet.Hosting;
/// <summary>
/// Writes logs locally and keeps a bounded ring of recent lines. Heartbeats carry only counters (see <see cref="TakeSummary"/>);
/// the text leaves the node when the control plane asks for it (see <see cref="Read"/>).
/// </summary>
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<NodeLogEntry> _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<string, string>(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<NodeLogEntry>(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);
}
}
/// <summary>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.</summary>
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];
}