Files
PolyTraderSharp/src/PolyTrader.Core/Services/TerminalLogger.cs
T
RichardandClaude Opus 5 9b2294cb4c
CI / Build & Tests (Linux) (push) Canceled after 0s
CI / Plattformneutralität & Hygiene (push) Canceled after 0s
Testanleitung: eigenes X-Display je Projekt fuer Parallelbetrieb
Damit mehrere Agenten gleichzeitig Oberflaechen testen koennen, bekommt jedes
Projekt eine feste Displaynummer (Vorgabe Richard):

  :99 PolyTrader, :98 Predictalytics, :97 ClawdDotNet, :96 IBKRTrader,
  :95 und absteigend fuer kuenftige Projekte.

Zwei Prozesse auf demselben Display sehen die Fenster des jeweils anderen -
Klicks landen im falschen Programm, Aufnahmen zeigen fremde Anwendungen.

Beim Durcharbeiten fielen zwei weitere Kollisionspunkte auf, die zum
Parallelbetrieb dazugehoeren und mit aufgenommen wurden:

- Screenshot- und Logdateien in /tmp brauchen ein Projektpraefix. /tmp/v_1.png
  ueberschreiben sich parallel laufende Projekte gegenseitig.
- "xvfb-run -a" darf NICHT mehr verwendet werden: Das -a sucht sich selbst ein
  freies Display und beginnt dabei bei :99 - ausgerechnet PolyTraders Nummer.
  Stattdessen "xvfb-run -n <nummer>".

Die Warnung ist nicht theoretisch: Auf der VM lag ein verwaister X98-Socket,
entstanden durch genau so einen "xvfb-run -a"-Aufruf beim smoke-ui-Test, als
:99 belegt war. Er blockierte Predictalytics' reservierte Nummer, ohne dass ein
Prozess lief. Entfernt, und die Anleitung erklaert jetzt, wie man verwaiste
Sockets erkennt (ls /tmp/.X11-unix/ gegen pgrep -a Xvfb lesen) und aufraeumt.

Weiter ergaenzt:
- pkill nie ohne Displaynummer - "pkill Xvfb" erschlaegt die Sitzungen aller
  anderen Projekte.
- DISPLAY in jedem Skript setzen, sonst landet man auf :0, dem echten Desktop.
- Die Skriptvorlage hat jetzt einen Kopf mit PROJEKT/DISP/FENSTER; das ist
  alles, was sich je Projekt unterscheidet. Praktisch gegengetestet.
- Aufraeumabschnitt und Belegungsliste um Displays und Verzeichnisse erweitert.

Nebenbei aufgefallen: Das PolyTrader-Publish lag unter ~/pt-test statt
~/polytrader-test und verletzte damit die eigene Verzeichniskonvention.
Umbenannt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 20:49:28 +02:00

191 lines
8.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; }
/// <summary>
/// Zeitpunkt in der Anzeige-Zeitzone der Anwendung, mit passendem Offset.
///
/// <para>Bis zum 27.08.2026 stand hier <c>DateTime.Now</c> – die Zeit des <i>Systems</i>.
/// Auf einem UTC-Server (dem Normalfall) stempelten die Logs damit UTC, während die
/// Oberfläche Ortszeit anzeigte: Wer einen Vorfall von „22:30" suchte, fand ihn unter
/// 20:30 – zwischen Mitternacht und 2 Uhr sogar in der Logdatei des Vortags
/// (§8 des Befundberichts vom 26.08.2026).</para>
/// </summary>
public DateTimeOffset 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 = AppTimeZone.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
{
// ISO-Datum wie bei der JSONL-Datei. Bis zum 26.08.2026 stand hier
// "dd-MM-yyyy" - im selben Logs-Ordner lagen damit zwei Namenskonventionen
// nebeneinander: eine Logrotate-Regel brauchte zwei Muster, und im
// Dateimanager sortierten die .log-Dateien nach Tag statt nach Datum
// (Befund B-2 des Linux-Laufs).
string dateStr = e.Timestamp.ToString("yyyy-MM-dd");
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 = DateTimeOffset.UtcNow - maxAge;
return _history.Where(x => x.Timestamp >= cutoff).ToList();
}
}
}
}