Files
PolyTraderSharp/src/PolyTrader.Core/Services/TerminalLogger.cs
T
RichardandClaude Opus 4.8 ca00977ea3 Supervisor S-0c: Log Viewer im Terminal (JSONL menschenlesbar, CID-Forensik)
Terminal-Fenster jetzt mit TabControl (via Designer): Tab 'Live' = bisherige Anzeige,
Tab 'Log Viewer' = JSONL-Tagesdateien menschenlesbar aufbereitet:
- Filter: Datum (DateTimePicker), Level, CorrelationId (SignalId), Suchtext.
- Doppelklick auf eine CID laedt die komplette Signal-Kette ('zeig mir alles zu diesem Signal').
- LogJson.ParseLine (pur, Gegenstueck zu Format): tolerant gegen kaputte Zeilen (null statt Crash);
  Viewer ueberspringt sie. UI-Schutz bei sehr grossen Tagen (20k-Limit).

Tests: +5 (Parse-Roundtrip, kaputte Zeilen). Build 0 Fehler, 341 Tests gruen, --smoke-ui komplett gruen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 18:52:15 +02:00

176 lines
7.0 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.IO;
using System.Threading.Tasks;
using System.Threading.Channels;
namespace PolyTraderSharp.Services
{
public enum LogLevel { Debug, Info, Warning, Error, Trade, TradeReasoning }
public class LogMessageEventArgs : EventArgs
{
public string Message { get; }
public LogLevel Level { get; }
public DateTime Timestamp { get; }
/// <summary>Korrelations-ID (z. B. SignalId) für die Log-Forensik; leer wenn ohne Kontext.</summary>
public string CorrelationId { get; }
public LogMessageEventArgs(string message, LogLevel level, string correlationId = "")
{
Message = message;
Level = level;
Timestamp = DateTime.Now;
CorrelationId = correlationId ?? "";
}
}
/// <summary>
/// Reiner JSONL-Formatter für Log-Events (S-0, Supervisor-Konzept): eine JSON-Zeile je Event
/// append-fähig, streambar, maschinen-/KI-lesbar. Statisch und seiteneffektfrei → unit-getestet.
/// </summary>
public static class LogJson
{
public static string Format(LogMessageEventArgs e)
{
var obj = new
{
ts = e.Timestamp.ToString("yyyy-MM-ddTHH:mm:ss.fffzzz"),
level = e.Level.ToString(),
cid = string.IsNullOrEmpty(e.CorrelationId) ? null : e.CorrelationId,
msg = e.Message
};
return System.Text.Json.JsonSerializer.Serialize(obj,
new System.Text.Json.JsonSerializerOptions
{
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
});
}
/// <summary>Geparste JSONL-Zeile für den Log Viewer.</summary>
public sealed record ParsedLogLine(string Time, string Level, string Cid, string Message);
/// <summary>
/// Parst eine JSONL-Zeile (Gegenstück zu <see cref="Format"/>). Liefert null bei leeren/
/// beschädigten Zeilen (der Viewer überspringt sie, statt zu brechen).
/// </summary>
public static ParsedLogLine? ParseLine(string line)
{
if (string.IsNullOrWhiteSpace(line)) return null;
try
{
using var doc = System.Text.Json.JsonDocument.Parse(line);
var root = doc.RootElement;
string ts = root.TryGetProperty("ts", out var t) ? t.GetString() ?? "" : "";
string level = root.TryGetProperty("level", out var l) ? l.GetString() ?? "" : "";
string cid = root.TryGetProperty("cid", out var c) ? c.GetString() ?? "" : "";
string msg = root.TryGetProperty("msg", out var m) ? m.GetString() ?? "" : "";
return new ParsedLogLine(ts, level, cid, msg);
}
catch (System.Text.Json.JsonException)
{
return null;
}
}
}
public class TerminalLogger
{
public event EventHandler<LogMessageEventArgs>? OnLogMessage;
private readonly List<LogMessageEventArgs> _history = new();
private readonly object _lock = new();
private readonly string _logsDirectory;
private readonly Channel<LogMessageEventArgs> _logChannel;
public TerminalLogger()
{
_logsDirectory = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Logs");
if (!Directory.Exists(_logsDirectory))
{
Directory.CreateDirectory(_logsDirectory);
}
_logChannel = Channel.CreateUnbounded<LogMessageEventArgs>(new UnboundedChannelOptions
{
SingleReader = true
});
Task.Run(ProcessLogQueueAsync);
}
private async Task ProcessLogQueueAsync()
{
await foreach (var e in _logChannel.Reader.ReadAllAsync())
{
try
{
string dateStr = e.Timestamp.ToString("dd-MM-yyyy");
string fileName = $"{dateStr}-{e.Level}.log";
string fullPath = Path.Combine(_logsDirectory, fileName);
// Remove Emojis (Surrogate pairs and common symbols)
string safeMsg = System.Text.RegularExpressions.Regex.Replace(e.Message, @"\p{Cs}|[✅❌🌐📈🔴🧪ℹ️🚨🏆💰⬇️⬆️🔹🔸✨🔥📊📝🔄⏸️]", "");
safeMsg = safeMsg.Replace("\r\n", " | ").Replace("\n", " | ").Replace(" ", " ").Trim();
string logLine = $"[{e.Timestamp:HH:mm:ss}] {safeMsg}{Environment.NewLine}";
await File.AppendAllTextAsync(fullPath, logLine);
// S-0: zusätzlich JSONL (eine Datei je Tag, alle Level) maschinen-/KI-lesbar,
// Grundlage für den Log Viewer. Dual-Sink; Text-Sink später abschaltbar.
string jsonlPath = Path.Combine(_logsDirectory, $"{e.Timestamp:yyyy-MM-dd}.jsonl");
await File.AppendAllTextAsync(jsonlPath, LogJson.Format(e) + Environment.NewLine);
}
catch
{
// Ignored to prevent cascading lockups
}
}
}
public void Log(string message, LogLevel level = LogLevel.Info, string correlationId = "")
{
var e = new LogMessageEventArgs(message, level, correlationId);
lock (_lock)
{
_history.Add(e);
// Optimize list pruning to avoid heavy O(N) operations per log
if (_history.Count > 10500)
{
// Remove older items efficiently in a batch
int itemsToRemove = _history.Count - 9000;
_history.RemoveRange(0, itemsToRemove);
}
}
try
{
OnLogMessage?.Invoke(this, e);
}
catch { }
// Standard Console output as fallback/debug
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [{level}] {message}");
_logChannel.Writer.TryWrite(e);
}
public void Info(string message) => Log(message, LogLevel.Info);
public void Debug(string message) => Log(message, LogLevel.Debug);
public void Warning(string message) => Log(message, LogLevel.Warning);
public void Error(string message) => Log(message, LogLevel.Error);
public void Trade(string message) => Log(message, LogLevel.Trade);
public void TradeReasoning(string message) => Log(message, LogLevel.TradeReasoning);
public List<LogMessageEventArgs> GetHistory(TimeSpan maxAge)
{
lock (_lock)
{
var cutoff = DateTime.Now - maxAge;
return _history.Where(x => x.Timestamp >= cutoff).ToList();
}
}
}
}