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.
148 lines
5.1 KiB
C#
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];
|
|
}
|