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