feat(ops): upload node logs and task traffic [CI]
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/verify Pipeline was successful
ci/woodpecker/push/dev-image Pipeline was successful

This commit is contained in:
justme committed 2026-10-11 23:57:13 +08:00
1 parent e82b165eb0
commit 6cf6b23585
25 files changed
+714 -13

No files matched your search

@@ -72,9 +72,11 @@ public sealed class MoShiroNodeEventSink : INodeEventSink
private readonly NodeOwnerService _owners;
private readonly ProbeQuicRuntime _quicRuntime;
private readonly ProbeRuntime _probeRuntime;
private readonly NodeRuntimeLogService _nodeLogs;
public MoShiroNodeEventSink(IAgentStore agents, AgentRuntime runtime, IEventStore events, LiveHub live, AgentService service, NodeInfoService nodes, ReleaseService releases, ScheduleService schedule, IProbeStore probes, NodeOwnerService owners, ProbeQuicRuntime quicRuntime, ProbeRuntime probeRuntime)
public MoShiroNodeEventSink(IAgentStore agents, AgentRuntime runtime, IEventStore events, LiveHub live, AgentService service, NodeInfoService nodes, ReleaseService releases, ScheduleService schedule, IProbeStore probes, NodeOwnerService owners, ProbeQuicRuntime quicRuntime, ProbeRuntime probeRuntime, NodeRuntimeLogService nodeLogs)
{
_nodeLogs = nodeLogs;
_owners = owners;
_quicRuntime = quicRuntime;
_probeRuntime = probeRuntime;
@@ -125,6 +127,7 @@ public sealed class MoShiroNodeEventSink : INodeEventSink
_releases.RecordStatus(id, beat?.Update);
if (beat is not null)
{
_nodeLogs.Append(id, beat.RuntimeLogs, beat.DroppedRuntimeLogs ?? 0);
state.ReplaceProbes(beat.Probes.Select(p => new ConnectedProbeInfo(p.Id.Value, p.Version, p.Remote, p.SinceUtcMs, p.IpVersion)));
foreach (ConnectedProbe p in beat.Probes)
{
@@ -185,6 +188,7 @@ public sealed class MoShiroNodeEventSink : INodeEventSink
_releases.RecordStatus(id, heartbeat?.Update);
if (heartbeat is not null)
{
_nodeLogs.Append(id, heartbeat.RuntimeLogs, heartbeat.DroppedRuntimeLogs ?? 0);
_probeRuntime.RecordHeartbeat(id.Value, nodeEvent.AtMs, heartbeat);
}
if (heartbeat?.QuicLinks is { } quicLinks)
@@ -0,0 +1,107 @@
using System.Collections.Concurrent;
using Fleet.Core;
using MoShiro.Protocol;
namespace MoShiro.Backend.Application;
public sealed record NodeRuntimeLog(long Id, long AtUtcMs, string Level, string Message, IReadOnlyDictionary<string, string> Fields);
public sealed record NodeRuntimeLogSnapshot(IReadOnlyList<NodeRuntimeLog> Items, long Dropped);
/// <summary>Bounded in-memory recent logs uploaded by authenticated Agent and Probe heartbeats.</summary>
public sealed class NodeRuntimeLogService(TimeProvider time)
{
private const int CapacityPerNode = 256;
private const int MaxBatch = 32;
private readonly ConcurrentDictionary<NodeId, LogBuffer> _nodes = new();
public void Append(NodeId node, IReadOnlyList<NodeLogRecord>? records, long droppedByNode)
{
if ((records is null || records.Count == 0) && droppedByNode <= 0)
{
return;
}
LogBuffer buffer = _nodes.GetOrAdd(node, static _ => new LogBuffer());
lock (buffer.Gate)
{
buffer.DroppedByNode += Math.Max(0, droppedByNode);
if (records is null)
{
return;
}
foreach (NodeLogRecord record in records.Take(MaxBatch))
{
long now = time.GetUtcNow().ToUnixTimeMilliseconds();
long at = record.AtUtcMs < now - 86_400_000 || record.AtUtcMs > now + 86_400_000 ? now : record.AtUtcMs;
var entry = new NodeRuntimeLog(
++buffer.LastId,
at,
record.Level is "info" or "warn" or "error" ? record.Level : "info",
Truncate(record.Message ?? "", 384),
SafeFields(record.Fields));
if (buffer.Items.Count == CapacityPerNode)
{
buffer.Items.Dequeue();
buffer.DroppedByBackend++;
}
buffer.Items.Enqueue(entry);
}
}
}
public NodeRuntimeLogSnapshot List(NodeId node, int limit)
{
if (!_nodes.TryGetValue(node, out LogBuffer? buffer))
{
return new NodeRuntimeLogSnapshot([], 0);
}
lock (buffer.Gate)
{
return new NodeRuntimeLogSnapshot(
[.. buffer.Items.Reverse().Take(Math.Clamp(limit, 1, 200))],
buffer.DroppedByNode + buffer.DroppedByBackend);
}
}
private static string Truncate(string value, int maxLength) => value.Length <= maxLength ? value : value[..maxLength];
private static Dictionary<string, string> SafeFields(IReadOnlyDictionary<string, string>? fields)
{
var safe = new Dictionary<string, string>(StringComparer.Ordinal);
if (fields is null)
{
return safe;
}
foreach ((string key, string value) in fields.Take(8))
{
string safeKey = Truncate(key, 48);
string lower = key.ToLowerInvariant();
bool sensitive = 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";
safe[safeKey] = sensitive ? "[redacted]" : Truncate(value ?? "", 128);
}
return safe;
}
private sealed class LogBuffer
{
public object Gate { get; } = new();
public Queue<NodeRuntimeLog> Items { get; } = [];
public long LastId;
public long DroppedByNode;
public long DroppedByBackend;
}
}
@@ -17,6 +17,7 @@ public static class ApplicationServiceCollectionExtensions
services.AddSingleton<AgentRuntime>();
services.AddSingleton<ProbeRuntime>();
services.AddSingleton<NodeRuntimeLogService>();
services.AddSingleton<ProbeQuicRuntime>();
services.AddSingleton<LiveHub>();
services.AddSingleton<HotLayer>();