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 StatusCategories = ["starting", "ready", "completed", "failed", "unavailable", "degraded", "blocked", "stopping", "stopped"]; private static readonly HashSet 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 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? 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 { ["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);