159 lines
6.4 KiB
C#
159 lines
6.4 KiB
C#
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);
|