feat: complete TASK-WP0-08 logging
This commit is contained in:
@@ -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);
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user