using System.Collections.Concurrent;
using System.Text.Json;
using System.Text.Json.Serialization;
using StackExchange.Redis;
namespace DorkNet.Server.Services;
///
/// Rolling per-player request log used by the admin UI's "Player logs"
/// tab. The HTTP trace middleware in Program.cs appends one entry
/// per authenticated request; the admin endpoint reads the most-recent
/// N entries back for a single player so we can spot what API call a
/// player was making when they hit a bug.
///
/// Storage:
/// - When Redis is configured, entries live in a Redis LIST keyed by
/// playerlog:{id}. LPUSH + LTRIM keeps it at the
/// most recent records, so memory
/// per player is bounded regardless of traffic.
/// - Without Redis, falls back to an in-process per-player ring buffer
/// capped to the same size; lost on restart and only visible on the
/// replica that handled the request, but fine for single-instance
/// dev workflows.
///
/// Entries are intentionally ephemeral — there's no Postgres mirror,
/// no long retention, no cross-replica fan-out beyond Redis. Treat
/// this as a "what just happened" diagnostic, not an audit trail.
///
public class PlayerLogService
{
/// Cap per player. Redis list takes ~250 bytes per entry
/// with truncated bodies, so ~125 KB per player at 500 entries.
/// At 5-second heartbeat cadence that's ~40 minutes of recent
/// activity, plenty for "what was the player doing when X
/// happened" diagnostics.
public const int MaxEntriesPerPlayer = 500;
private readonly IConnectionMultiplexer? _redis;
private readonly ILogger? _log;
// Process-local fallback when Redis isn't configured. Each player
// gets a bounded queue we trim on append. ConcurrentQueue itself
// has no built-in cap so we trim explicitly under a per-player lock.
private readonly ConcurrentDictionary> _local = new();
private static readonly JsonSerializerOptions JsonOpts = new()
{
PropertyNamingPolicy = null,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
public PlayerLogService(
IConnectionMultiplexer? redis = null,
ILogger? log = null)
{
_redis = redis;
_log = log;
}
/// Append one entry for a player. Fire-and-forget — never
/// throws so a Redis blip can't 500 a request that just succeeded.
public void Record(PlayerLogEntry entry)
{
if (entry.PlayerId <= 0) return;
if (_redis is { } mux)
{
try
{
var json = JsonSerializer.Serialize(entry, JsonOpts);
var db = mux.GetDatabase();
var key = Key(entry.PlayerId);
// LPUSH + LTRIM 0..(N-1) keeps the list at most N long
// with the newest entry at index 0. The two ops are
// batched so a concurrent reader never sees an
// intermediate over-long list.
var batch = db.CreateBatch();
_ = batch.ListLeftPushAsync(key, json);
_ = batch.ListTrimAsync(key, 0, MaxEntriesPerPlayer - 1);
// 7-day TTL so abandoned-account log keys age out.
_ = batch.KeyExpireAsync(key, TimeSpan.FromDays(7));
batch.Execute();
}
catch (Exception ex)
{
_log?.LogWarning(ex, "[playerlog] redis append failed for player={PlayerId}", entry.PlayerId);
}
return;
}
// Process-local fallback: append + trim under per-player lock.
var queue = _local.GetOrAdd(entry.PlayerId, _ => new ConcurrentQueue());
queue.Enqueue(entry);
while (queue.Count > MaxEntriesPerPlayer && queue.TryDequeue(out _)) { }
}
/// Most recent entries for a player,
/// newest-first. Returns an empty list when the player has no
/// recorded activity (or when Redis is down on the read path —
/// admin UI shows "no entries" instead of erroring out).
public IReadOnlyList GetRecent(long playerId, int take)
{
take = Math.Clamp(take, 1, MaxEntriesPerPlayer);
if (_redis is { } mux)
{
try
{
var raw = mux.GetDatabase().ListRange(Key(playerId), 0, take - 1);
var result = new List(raw.Length);
foreach (var v in raw)
{
var e = JsonSerializer.Deserialize(v.ToString(), JsonOpts);
if (e is not null) result.Add(e);
}
return result;
}
catch (Exception ex)
{
_log?.LogWarning(ex, "[playerlog] redis read failed for player={PlayerId}", playerId);
return Array.Empty();
}
}
if (!_local.TryGetValue(playerId, out var queue)) return Array.Empty();
// ConcurrentQueue enumerates oldest-first; reverse + take to
// match Redis newest-first ordering.
return queue.ToArray().Reverse().Take(take).ToList();
}
private static string Key(long playerId) => $"playerlog:{playerId}";
}
/// One captured request. Bodies are pre-truncated by the
/// caller so the JSON we serialise into Redis stays bounded.
public sealed class PlayerLogEntry
{
[JsonPropertyName("ts")] public DateTime Timestamp { get; set; }
[JsonPropertyName("playerId")] public long PlayerId { get; set; }
[JsonPropertyName("method")] public string Method { get; set; } = string.Empty;
[JsonPropertyName("host")] public string Host { get; set; } = string.Empty;
[JsonPropertyName("path")] public string Path { get; set; } = string.Empty;
[JsonPropertyName("query")] public string Query { get; set; } = string.Empty;
[JsonPropertyName("status")] public int Status { get; set; }
[JsonPropertyName("elapsedMs")] public long ElapsedMs { get; set; }
[JsonPropertyName("reqBody")] public string? ReqBody { get; set; }
[JsonPropertyName("respBody")] public string? RespBody { get; set; }
}