feat: complete TASK-WP0-08 logging

This commit is contained in:
suyx
2026-07-28 00:11:02 +08:00
parent 096a1f1279
commit 361bda2f22
21 changed files with 1050 additions and 22 deletions
@@ -32,6 +32,7 @@ internal static class Program
{
var security = await TestCredentialBoundaryAsync();
var supervisor = await TestSupervisorLifecycleAsync();
TestStructuredLogging();
WriteEvidence(Environment.GetEnvironmentVariable("DADA_EVIDENCE_DIR_SEC"), security);
WriteEvidence(Environment.GetEnvironmentVariable("DADA_EVIDENCE_DIR_SUP"), supervisor);
Console.WriteLine(JsonSerializer.Serialize(new { security = "passed", supervisor = "passed" }, JsonOptions));
@@ -44,6 +45,35 @@ internal static class Program
}
}
private static void TestStructuredLogging()
{
Equal(10 * 1024 * 1024L, StructuredJsonlLogger.SizeLimitBytes, "Supervisor log byte limit");
Equal(10, StructuredJsonlLogger.FileLimit, "Supervisor log file limit");
Equal(30, StructuredJsonlLogger.RetentionDays, "Supervisor log retention");
var directory = Path.Combine(Path.GetTempPath(), $"dada-supervisor-log-{Guid.NewGuid():N}");
try
{
var logger = new StructuredJsonlLogger(directory, "supervisor", sizeLimitBytes: 512, fileLimit: 10);
for (var index = 0; index < 40; index++)
{
logger.Write(new StructuredLogEvent("completed", $"corr_{index}", $"obj_{index}", index, "none"));
}
var files = Directory.GetFiles(directory, "*.jsonl");
True(files.Length <= StructuredJsonlLogger.FileLimit, "Supervisor log file cap");
True(files.All(path => new FileInfo(path).Length <= 512), "Supervisor log rotation byte boundary");
foreach (var line in files.SelectMany(File.ReadLines))
{
using var document = JsonDocument.Parse(line);
var keys = document.RootElement.EnumerateObject().Select(property => property.Name).ToHashSet(StringComparer.Ordinal);
True(keys.IsSubsetOf(["schema_version", "timestamp", "component", "status_category", "correlation_id", "object_id", "duration_ms", "error_category"]), "Supervisor log field allowlist");
}
}
finally
{
if (Directory.Exists(directory)) Directory.Delete(directory, recursive: true);
}
}
private static async Task<object> TestCredentialBoundaryAsync()
{
var store = new TestCredentialStore();
@@ -184,6 +214,11 @@ internal static class Program
{
EqualSequence(new[] { "打开 Dada", "运行状态", "打开诊断", "重新启动服务", "退出 Dada" }, menuForm.TrayMenuLabels, "tray menu order");
}
using (var diagnostics = new DiagnosticsForm(SupervisorState.StorageUnavailable))
{
True(diagnostics.CopyPayload.Contains("log_write_failed", StringComparison.Ordinal), "diagnostic log status");
False(diagnostics.CopyPayload.Contains("CredentialBlob", StringComparison.Ordinal), "diagnostic credential redaction");
}
var screenshotPath = Path.Combine(Environment.GetEnvironmentVariable("DADA_EVIDENCE_DIR_SUP") ?? Path.GetTempPath(), "screenshots", "system-ui.png");
Directory.CreateDirectory(Path.GetDirectoryName(screenshotPath)!);
+6 -3
View File
@@ -9,9 +9,12 @@ internal enum DiagnosticResult
internal sealed record DiagnosticCheck(string CheckCode, DiagnosticResult Result, string MessageKey, DateTimeOffset CheckedAt)
{
internal static DiagnosticCheck Pass(string checkCode, string messageKey) => new(checkCode, DiagnosticResult.Pass, messageKey, DateTimeOffset.UtcNow);
internal static DiagnosticCheck Warning(string checkCode, string messageKey) => new(checkCode, DiagnosticResult.Warning, messageKey, DateTimeOffset.UtcNow);
internal static DiagnosticCheck Fail(string checkCode, string messageKey) => new(checkCode, DiagnosticResult.Fail, messageKey, DateTimeOffset.UtcNow);
internal static DiagnosticCheck Pass(string checkCode, string messageKey) => Create(checkCode, DiagnosticResult.Pass, messageKey);
internal static DiagnosticCheck Warning(string checkCode, string messageKey) => Create(checkCode, DiagnosticResult.Warning, messageKey);
internal static DiagnosticCheck Fail(string checkCode, string messageKey) => Create(checkCode, DiagnosticResult.Fail, messageKey);
private static DiagnosticCheck Create(string checkCode, DiagnosticResult result, string messageKey) =>
new(RedactionPolicy.Code(checkCode, "invalid_check"), result, RedactionPolicy.Code(messageKey, "diagnostic_redacted"), DateTimeOffset.UtcNow);
}
internal sealed record DiagnosticStorageSummary(long UsedBytes, long CapacityBytes, long ReclaimableBytes);
@@ -8,18 +8,32 @@ internal sealed class ManagedChildProcess : IAsyncDisposable
{
private readonly NamedPipeServerStream controlPipe;
private readonly StreamWriter controlWriter;
private readonly StreamReader controlReader;
private Action<string>? statusReceived;
private bool stopping;
private ManagedChildProcess(Process process, NamedPipeServerStream controlPipe, StreamWriter controlWriter)
private ManagedChildProcess(Process process, NamedPipeServerStream controlPipe, StreamReader controlReader, StreamWriter controlWriter)
{
Process = process;
this.controlPipe = controlPipe;
this.controlReader = controlReader;
this.controlWriter = controlWriter;
_ = ListenForStatusAsync();
}
internal Process Process { get; }
internal bool IsStopping => stopping;
internal static TimeSpan ShutdownDeadline { get; } = TimeSpan.FromSeconds(15);
internal string? LastStatus { get; private set; }
internal event Action<string> StatusReceived
{
add
{
statusReceived += value;
if (LastStatus is not null) value(LastStatus);
}
remove => statusReceived -= value;
}
internal static async Task<ManagedChildProcess> StartAsync(
ProcessStartInfo startInfo,
@@ -48,7 +62,7 @@ internal sealed class ManagedChildProcess : IAsyncDisposable
var ready = await reader.ReadLineAsync(timeout.Token);
if (!string.Equals(ready, "ready", StringComparison.Ordinal)) throw new InvalidOperationException("Managed child did not report ready.");
var writer = new StreamWriter(pipe, new UTF8Encoding(false), leaveOpen: true) { AutoFlush = true };
return new ManagedChildProcess(process, pipe, writer);
return new ManagedChildProcess(process, pipe, reader, writer);
}
catch
{
@@ -59,6 +73,23 @@ internal sealed class ManagedChildProcess : IAsyncDisposable
}
}
private async Task ListenForStatusAsync()
{
try
{
while (controlPipe.IsConnected && !Process.HasExited)
{
var status = await controlReader.ReadLineAsync();
if (status is null) return;
LastStatus = status;
statusReceived?.Invoke(status);
}
}
catch (IOException) when (stopping || Process.HasExited)
{
}
}
internal async Task StopAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default)
{
if (stopping || Process.HasExited) return;
@@ -77,6 +108,7 @@ internal sealed class ManagedChildProcess : IAsyncDisposable
{
if (!Process.HasExited) await StopAsync();
controlWriter.Dispose();
controlReader.Dispose();
controlPipe.Dispose();
Process.Dispose();
}
@@ -0,0 +1,158 @@
using System.Text.Json;
namespace Dada.Supervisor;
internal sealed record StructuredLogEvent(
string StatusCategory,
string? CorrelationId = null,
string? ObjectId = null,
long? DurationMs = null,
string? ErrorCategory = null);
internal static class RedactionPolicy
{
private static readonly HashSet<string> StatusCategories =
["starting", "ready", "completed", "failed", "unavailable", "degraded", "blocked", "stopping", "stopped"];
private static readonly HashSet<string> ErrorCategories =
["none", "invalid_input", "storage_unavailable", "log_write_failed", "service_unavailable", "timeout", "internal_error"];
internal static string StatusCategory(string value) => StatusCategories.Contains(value) ? value : "failed";
internal static string ErrorCategory(string value) => ErrorCategories.Contains(value) ? value : "internal_error";
internal static string Code(string value, string fallback) =>
value.Length is > 0 and <= 64 && value[0] is >= 'a' and <= 'z' && value.All(character => character is >= 'a' and <= 'z' or >= '0' and <= '9' or '_')
? value
: fallback;
internal static string? Identifier(string? value) =>
value is { Length: > 0 and <= 64 } && value.All(character => char.IsAsciiLetterOrDigit(character) || character is '_' or '-')
? value
: null;
}
internal sealed class StructuredJsonlLogger
{
internal const long SizeLimitBytes = 10 * 1024 * 1024;
internal const int FileLimit = 10;
internal const int RetentionDays = 30;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
};
private readonly string directory;
private readonly string component;
private readonly long sizeLimitBytes;
private readonly int fileLimit;
private readonly Func<DateTimeOffset> now;
private readonly Action onWriteFailure;
private static readonly System.Text.Encoding Utf8WithoutBom = new System.Text.UTF8Encoding(false);
private bool initialized;
private long currentSize;
internal StructuredJsonlLogger(
string directory,
string component,
Action? onWriteFailure = null,
Func<DateTimeOffset>? now = null,
long sizeLimitBytes = SizeLimitBytes,
int fileLimit = FileLimit)
{
this.directory = directory;
this.component = component is "api" or "worker" or "supervisor" ? component : "supervisor";
this.onWriteFailure = onWriteFailure ?? (() => { });
this.now = now ?? (() => DateTimeOffset.UtcNow);
this.sizeLimitBytes = sizeLimitBytes;
this.fileLimit = fileLimit;
}
internal void Write(StructuredLogEvent input)
{
try
{
Initialize();
var entry = new Dictionary<string, object?>
{
["schema_version"] = "1.0",
["timestamp"] = now().ToString("O"),
["component"] = component,
["status_category"] = RedactionPolicy.StatusCategory(input.StatusCategory),
};
var correlationId = RedactionPolicy.Identifier(input.CorrelationId);
var objectId = RedactionPolicy.Identifier(input.ObjectId);
if (correlationId is not null) entry["correlation_id"] = correlationId;
if (objectId is not null) entry["object_id"] = objectId;
if (input.DurationMs is >= 0) entry["duration_ms"] = input.DurationMs;
if (input.ErrorCategory is not null) entry["error_category"] = RedactionPolicy.ErrorCategory(input.ErrorCategory);
var line = JsonSerializer.Serialize(entry, JsonOptions) + Environment.NewLine;
var bytes = System.Text.Encoding.UTF8.GetByteCount(line);
if (bytes > sizeLimitBytes) throw new InvalidOperationException("log_entry_too_large");
if (currentSize > 0 && currentSize + bytes > sizeLimitBytes) Rotate();
File.AppendAllText(ActivePath(), line, Utf8WithoutBom);
currentSize += bytes;
}
catch (Exception exception) when (exception is not LogWriteException)
{
try { onWriteFailure(); } catch { }
throw new LogWriteException(exception);
}
}
internal void Maintain()
{
try
{
Directory.CreateDirectory(directory);
var cutoff = now().AddDays(-RetentionDays);
foreach (var file in Files())
{
if (file.Index > fileLimit - 1 || file.Info.LastWriteTimeUtc < cutoff.UtcDateTime) file.Info.Delete();
}
foreach (var file in Files().Skip(fileLimit)) file.Info.Delete();
currentSize = File.Exists(ActivePath()) ? new FileInfo(ActivePath()).Length : 0;
initialized = true;
}
catch (Exception exception) when (exception is not LogWriteException)
{
try { onWriteFailure(); } catch { }
throw new LogWriteException(exception);
}
}
private void Initialize()
{
if (!initialized) Maintain();
}
private void Rotate()
{
File.Delete(Path.Combine(directory, $"{component}.{fileLimit - 1}.jsonl"));
for (var index = fileLimit - 2; index >= 1; index--)
{
var source = Path.Combine(directory, $"{component}.{index}.jsonl");
if (File.Exists(source)) File.Move(source, Path.Combine(directory, $"{component}.{index + 1}.jsonl"));
}
if (File.Exists(ActivePath())) File.Move(ActivePath(), Path.Combine(directory, $"{component}.1.jsonl"));
currentSize = 0;
}
private IReadOnlyList<(int Index, FileInfo Info)> Files()
{
if (!Directory.Exists(directory)) return [];
var prefix = component + ".";
return Directory.EnumerateFiles(directory, $"{component}*.jsonl")
.Select(path =>
{
var name = Path.GetFileName(path);
var index = name == $"{component}.jsonl"
? 0
: int.TryParse(name[prefix.Length..^".jsonl".Length], out var parsed) ? parsed : int.MaxValue;
return (Index: index, Info: new FileInfo(path));
})
.OrderBy(file => file.Index)
.ToArray();
}
private string ActivePath() => Path.Combine(directory, $"{component}.jsonl");
}
internal sealed class LogWriteException(Exception innerException) : IOException("log_write_failed", innerException);
+14 -1
View File
@@ -268,6 +268,8 @@ internal sealed class SupervisorForm : Form
internal sealed class DiagnosticsForm : Form
{
internal string CopyPayload { get; }
internal DiagnosticsForm(SupervisorState state)
{
BackColor = Color.White;
@@ -281,11 +283,22 @@ internal sealed class DiagnosticsForm : Form
checks.Columns.Add("组件", 170);
checks.Columns.Add("状态", 110);
checks.Columns.Add("检查结果", 390);
var report = DiagnosticReport.Create(
state,
[
state == SupervisorState.StorageUnavailable
? DiagnosticCheck.Fail("log_writable", "log_write_failed")
: DiagnosticCheck.Pass("log_writable", "log_ready"),
DiagnosticCheck.Pass("fixed_port", "loopback_only"),
],
new DiagnosticStorageSummary(0, 0, 0),
[]);
CopyPayload = System.Text.Json.JsonSerializer.Serialize(report, new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
checks.Items.Add(new ListViewItem(["Supervisor", "正常", StateLabel(state)]));
checks.Items.Add(new ListViewItem(["固定端口", "43121", "仅绑定 127.0.0.1"]));
checks.Items.Add(new ListViewItem(["凭据", "已脱敏", "仅显示是否已配置"]));
var copy = new Button { Location = new Point(592, 382), Size = new Size(138, 36), Text = "复制脱敏结果" };
copy.Click += (_, _) => Clipboard.SetText("Dada diagnostics: redacted");
copy.Click += (_, _) => Clipboard.SetText(CopyPayload);
Controls.AddRange([title, detail, checks, copy]);
}
@@ -7,6 +7,7 @@ internal sealed class SupervisorRuntime : IAsyncDisposable
private readonly ICredentialStore credentials;
private ManagedComponentSupervisor? api;
private ManagedComponentSupervisor? worker;
private StructuredJsonlLogger? logger;
internal SupervisorRuntime(ICredentialStore credentials)
{
@@ -24,6 +25,15 @@ internal sealed class SupervisorRuntime : IAsyncDisposable
{
return SupervisorState.StartupFailed;
}
try
{
logger = new StructuredJsonlLogger(Path.Combine(configuration.LocalDataRoot, "logs", "supervisor"), "supervisor");
logger.Write(new StructuredLogEvent("starting", ErrorCategory: "none"));
}
catch (LogWriteException)
{
return SupervisorState.StorageUnavailable;
}
if (CredentialCatalog.RequiredFor(ChildRole.Api).Concat(CredentialCatalog.RequiredFor(ChildRole.Worker)).Any(target => !credentials.IsConfigured(target)))
{
return SupervisorState.StartupFailed;
@@ -35,22 +45,50 @@ internal sealed class SupervisorRuntime : IAsyncDisposable
if (!File.Exists(node) || !File.Exists(apiEntry) || !File.Exists(workerEntry)) return SupervisorState.StartupFailed;
api = CreateComponent(node, apiEntry, ChildRole.Api, SupervisorState.ApiDegraded);
api.StateChanged += state => StateChanged?.Invoke(state);
await api.StartAsync(cancellationToken);
worker = CreateComponent(node, workerEntry, ChildRole.Worker, SupervisorState.WorkerDegraded);
worker.StateChanged += state => StateChanged?.Invoke(state);
await worker.StartAsync(cancellationToken);
return SupervisorState.Ready;
return TryLog(new StructuredLogEvent("ready", ErrorCategory: "none")) ? SupervisorState.Ready : SupervisorState.StorageUnavailable;
}
private ManagedComponentSupervisor CreateComponent(string node, string entry, ChildRole role, SupervisorState degradedState) =>
new(async cancellationToken =>
private ManagedComponentSupervisor CreateComponent(string node, string entry, ChildRole role, SupervisorState degradedState)
{
var component = new ManagedComponentSupervisor(async cancellationToken =>
{
var startInfo = new ProcessStartInfo(node);
startInfo.ArgumentList.Add(entry);
return await ManagedChildProcess.StartAsync(startInfo, role, credentials, cancellationToken);
var child = await ManagedChildProcess.StartAsync(startInfo, role, credentials, cancellationToken);
child.StatusReceived += status =>
{
if (status == "storage_unavailable")
{
TryLog(new StructuredLogEvent("unavailable", ErrorCategory: "log_write_failed"));
StateChanged?.Invoke(SupervisorState.StorageUnavailable);
}
};
return child;
}, degradedState);
component.StateChanged += state =>
{
if (TryLog(new StructuredLogEvent("degraded", ErrorCategory: "service_unavailable"))) StateChanged?.Invoke(state);
};
return component;
}
private bool TryLog(StructuredLogEvent entry)
{
try
{
logger?.Write(entry);
return true;
}
catch (LogWriteException)
{
StateChanged?.Invoke(SupervisorState.StorageUnavailable);
return false;
}
}
public async ValueTask DisposeAsync()
{