namespace Aeshnidae.AdminAudit;
///
/// The system of record: one JSON object per line, one file per UTC day.
///
/// Append-only and never rewritten, so a record that has been flushed cannot be
/// disturbed by anything this mod does later. Writes happen on a background task
/// because is called from the world and network threads
/// and must never wait on a disk.
///
/// The flush interval is the honest limit on durability: a hard crash loses whatever
/// is still queued. It defaults to one second rather than the chat relay's two,
/// because losing an audit record matters more than losing a line of Trade chat.
///
internal sealed class AuditLog : IDisposable
{
private readonly string _directory;
private readonly LogSettings _settings;
private readonly ConcurrentQueue _queue = new();
private readonly CancellationTokenSource _cts = new();
private readonly Task _pump;
private long _written, _dropped;
private volatile bool _stopping;
private volatile string _lastError = "";
private string _currentFile = "";
public long Written => Interlocked.Read(ref _written);
public long Dropped => Interlocked.Read(ref _dropped);
public string LastError => _lastError;
public string Directory => _directory;
public string CurrentFile => _currentFile;
public int Pending => _queue.Count;
///
/// UTF-8 with no byte-order mark. Encoding.UTF8 emits a BOM when it creates
/// the file, which lands in front of the first record - System.Text.Json tolerates
/// that, but strict parsers (Python's json, jq on some builds) reject the line. A
/// JSONL trail whose whole point is being readable by ordinary tools cannot start
/// with three bytes that break them.
///
private static readonly Encoding Utf8NoBom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false);
private static readonly JsonSerializerOptions LineOptions = new()
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
};
public AuditLog(string directory, LogSettings settings)
{
_directory = directory;
_settings = settings;
System.IO.Directory.CreateDirectory(_directory);
Prune();
_pump = Task.Run(PumpAsync);
}
public void Write(AuditEvent record)
{
if (_stopping)
{
Interlocked.Increment(ref _dropped);
return;
}
_queue.Enqueue(record);
}
public string FileFor(DateTime utcDay) =>
Path.Combine(_directory, $"audit-{utcDay:yyyyMMdd}.jsonl");
private async Task PumpAsync()
{
var interval = TimeSpan.FromSeconds(Math.Clamp(_settings.FlushSeconds, 0.25, 30));
var lastPruneDay = DateTime.UtcNow.Date;
while (!_stopping)
{
try
{
await Task.Delay(interval, _cts.Token);
}
catch (OperationCanceledException)
{
break;
}
Flush();
if (DateTime.UtcNow.Date != lastPruneDay)
{
lastPruneDay = DateTime.UtcNow.Date;
Prune();
}
}
Flush();
}
///
/// Drains the queue into today's file in one open/append/close. Grouped by day so a
/// batch that straddles midnight lands in the right files rather than all in one.
///
private void Flush()
{
if (_queue.IsEmpty)
return;
var batch = new List();
while (_queue.TryDequeue(out var record))
batch.Add(record);
foreach (var day in batch.GroupBy(r => DayOf(r.Timestamp)))
{
var path = FileFor(day.Key);
try
{
var text = new StringBuilder();
foreach (var record in day)
text.AppendLine(JsonSerializer.Serialize(record, LineOptions));
File.AppendAllText(path, text.ToString(), Utf8NoBom);
_currentFile = path;
Interlocked.Add(ref _written, day.Count());
}
catch (Exception ex)
{
Interlocked.Add(ref _dropped, day.Count());
_lastError = $"{ex.GetType().Name}: {ex.Message}";
// Logged, not thrown: an audit sink that takes the server down when the
// disk fills is a worse outcome than a gap in the trail. The gap is
// counted and visible in /adminaudit.
ModManager.Log($"[{Mod.Name}] could not append to {path}: {ex.Message}", ModManager.LogLevel.Error);
}
}
}
private static DateTime DayOf(string timestamp) =>
DateTime.TryParse(timestamp, null, DateTimeStyles.RoundtripKind, out var parsed)
? parsed.ToUniversalTime().Date
: DateTime.UtcNow.Date;
///
/// Deletes audit files past the retention window. Retention defaults to 0 - keep
/// everything - because for an audit trail, silently discarding history is the
/// surprising behaviour, not the safe one.
///
private void Prune()
{
if (_settings.RetentionDays <= 0)
return;
try
{
var cutoff = DateTime.UtcNow.Date.AddDays(-_settings.RetentionDays);
foreach (var path in System.IO.Directory.GetFiles(_directory, "audit-*.jsonl"))
{
var stamp = Path.GetFileNameWithoutExtension(path).Replace("audit-", "");
if (DateTime.TryParseExact(stamp, "yyyyMMdd", null, DateTimeStyles.None, out var day) && day < cutoff)
{
File.Delete(path);
ModManager.Log($"[{Mod.Name}] pruned audit file past retention: {Path.GetFileName(path)}");
}
}
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] prune failed: {ex.Message}", ModManager.LogLevel.Warn);
}
}
/// Most recent lines from the audit files, newest last. Used by /adminaudit tail and search.
public List Tail(int count, string? filter = null)
{
var lines = new List();
try
{
var files = System.IO.Directory.GetFiles(_directory, "audit-*.jsonl")
.OrderByDescending(f => f)
.Take(7); // a week of files is plenty to satisfy any reasonable tail
foreach (var file in files)
{
// Read a copy - the pump may be appending to this very file.
using var stream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
string? line;
var fromFile = new List();
while ((line = reader.ReadLine()) is not null)
{
if (filter is null || line.Contains(filter, StringComparison.OrdinalIgnoreCase))
fromFile.Add(line);
}
lines.InsertRange(0, fromFile);
if (lines.Count >= count)
break;
}
}
catch (Exception ex)
{
_lastError = $"{ex.GetType().Name}: {ex.Message}";
}
return lines.Count <= count ? lines : lines.GetRange(lines.Count - count, count);
}
public void Dispose()
{
_stopping = true;
try
{
_cts.Cancel();
_pump.Wait(TimeSpan.FromSeconds(10));
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] audit log did not stop cleanly: {ex.Message}", ModManager.LogLevel.Warn);
}
finally
{
Flush(); // belt and braces: anything queued between Cancel and here
_cts.Dispose();
}
}
}