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
@@ -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);