using System.Collections.Concurrent; using System.Text.RegularExpressions; namespace ClawdDotNet.App.Services; /// Eine gelesene Logzeile. /// Der Dateiname ohne Endung — jedes Modul schreibt in seine eigene Datei. /// INF, WRN, ERR … oder leer, wenn die Zeile kein bekanntes Format hat. public readonly record struct LogLine(string Module, string Level, string Text); /// Ab welcher Stufe angezeigt wird. public enum LogLevelFilter { All, Info, Warn, Error } /// /// Liest neu hinzugekommene Zeilen aus den Logdateien. /// /// Die vorige Fassung (LiveLogViewerService) schrieb unmittelbar in eine /// RichTextBox und taktete über einen System.Windows.Forms.Timer — Lesen /// und Darstellen waren dasselbe Ding und ohne Fenster nicht zu haben. Hier bleibt nur /// das Lesen; was damit geschieht, entscheidet der Aufrufer. /// /// Merkt sich je Datei die Leseposition, gibt also bei jedem Aufruf nur das /// Neue zurück. Wird eine Datei kürzer, gilt sie als rotiert und wird von vorn gelesen. /// public sealed partial class LogTail(string logDirectory) { private readonly ConcurrentDictionary _positions = new(); /// /// Der zuletzt gesehene Zeitstempel je Datei. Trägt Fortsetzungszeilen über /// Poll-Grenzen hinweg — siehe . /// private readonly ConcurrentDictionary _lastTimestamps = new(); [GeneratedRegex(@"\[(TRC|DBG|INF|WRN|ERR|FTL)\]", RegexOptions.IgnoreCase)] private static partial Regex LevelPattern(); [GeneratedRegex(@"^\[(\d{2}:\d{2}:\d{2}\.\d{3})\]")] private static partial Regex TimestampPattern(); /// Die Module, für die heute Dateien vorliegen — füllt das Auswahlfeld. public IReadOnlyList AvailableModules() { var directory = TodayDirectory(); if (directory is null) return []; return Directory.GetFiles(directory, "*.log") .Select(Path.GetFileNameWithoutExtension) .Where(name => !string.IsNullOrEmpty(name)) .Select(name => name!) .OrderBy(name => name, StringComparer.OrdinalIgnoreCase) .ToList(); } /// /// Alles, was seit dem letzten Aufruf dazugekommen ist — in der Reihenfolge, in /// der es entstanden ist. /// /// leer heißt: alle Module. /// public IReadOnlyList ReadNew(string? module = null, LogLevelFilter level = LogLevelFilter.All) { var directory = TodayDirectory(); if (directory is null) return []; var collected = new List<(string Timestamp, LogLine Line)>(); // Nach Modulnamen geordnet einsammeln. Directory.GetFiles liefert unter Linux // die Verzeichnisreihenfolge, nicht die alphabetische — was bei gleichem // Zeitstempel die Reihenfolge der Ansicht von Lauf zu Lauf ändern könnte. Die // stabile Sortierung weiter unten braucht einen festen Ausgangspunkt, sonst ist // sie nur die halbe Zusage. foreach (var path in Directory.GetFiles(directory, "*.log") .OrderBy(p => p, StringComparer.Ordinal)) { var moduleName = Path.GetFileNameWithoutExtension(path); if (!string.IsNullOrEmpty(module) && !string.Equals(moduleName, module, StringComparison.OrdinalIgnoreCase)) { // Position trotzdem nachziehen: Sonst käme beim Wechsel des Filters // die gesamte bisherige Datei auf einmal herein. TrackWithoutReading(path); continue; } ReadFile(path, moduleName, level, collected); } // Jedes Modul schreibt in seine eigene Datei, eingesammelt wird Datei für // Datei — also alphabetisch nach Modulname. Ohne diesen Schritt stehen die // Zeilen in Blöcken je Modul statt in der Reihenfolge, in der sie entstanden // sind. Das ist kein Schönheitsfehler: Wer einen Fehler sucht, liest die // Reihenfolge der Ereignisse, und eine Ursache unter ihrer Wirkung schickt ihn // an die falsche Stelle. // // OrderBy sortiert stabil — bei gleichem Zeitstempel bleibt die bisherige // Ordnung, zusammengehörende Zeilen eines Moduls bleiben also beieinander. // Verglichen wird die Zeichenkette [HH:mm:ss.fff]: Innerhalb eines // Tagesverzeichnisses ist sie in derselben Ordnung sortierbar wie die Zeit, // und ein Umweg über DateTime.Parse spart nichts. return collected .OrderBy(entry => entry.Timestamp, StringComparer.Ordinal) .Select(entry => entry.Line) .ToList(); } private void ReadFile(string path, string module, LogLevelFilter level, List<(string Timestamp, LogLine Line)> into) { var lastPosition = _positions.GetOrAdd(path, 0L); try { // FileShare.ReadWrite | Delete: Der Schreiber muss weiterarbeiten und die // Datei auch ersetzen können, während wir lesen. using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); if (stream.Length < lastPosition) lastPosition = 0; // rotiert oder gekürzt if (stream.Length == lastPosition) return; stream.Seek(lastPosition, SeekOrigin.Begin); using var reader = new StreamReader(stream); while (reader.ReadLine() is { } line) { if (string.IsNullOrWhiteSpace(line)) continue; var lineLevel = ExtractLevel(line); var timestamp = ExtractTimestamp(line); if (timestamp.Length == 0) { // Fortsetzungszeile: Eine Ausnahme hängt ihren Stacktrace ohne // eigenen Zeitstempel unter die Meldung. Sie erbt den Zeitstempel // der Zeile davor, damit der Stacktrace bei seiner Meldung bleibt — // auch dann, wenn die Meldung schon im vorigen Takt gelesen wurde // und nur der Rest jetzt nachkommt. timestamp = _lastTimestamps.GetValueOrDefault(path, ""); } else { _lastTimestamps[path] = timestamp; } // Erst danach filtern: Eine weggefilterte Zeile darf den Zeitstempel // trotzdem weiterreichen. if (!Passes(lineLevel, level)) continue; into.Add((timestamp, new LogLine(module, lineLevel, line))); } _positions[path] = stream.Position; } catch (IOException) { // Wird gerade geschrieben — beim nächsten Takt erneut versuchen. } } private void TrackWithoutReading(string path) { try { _positions[path] = new FileInfo(path).Length; } catch (IOException) { } } private static string ExtractLevel(string line) { var match = LevelPattern().Match(line); return match.Success ? match.Groups[1].Value.ToUpperInvariant() : ""; } /// /// Der Zeitstempel am Zeilenanfang, oder leer bei einer Fortsetzungszeile. /// Nur am Anfang, nicht irgendwo: Eine Meldung darf eine Uhrzeit enthalten, ohne /// dass sie zum Sortierschlüssel wird. /// private static string ExtractTimestamp(string line) { var match = TimestampPattern().Match(line); return match.Success ? match.Groups[1].Value : ""; } private static bool Passes(string lineLevel, LogLevelFilter filter) => filter switch { LogLevelFilter.All => true, // Unbekanntes Format durchlassen: Lieber eine Zeile zu viel als eine // Fehlermeldung, die der Filter verschluckt. _ when lineLevel.Length == 0 => true, LogLevelFilter.Info => lineLevel is "INF" or "WRN" or "ERR" or "FTL", LogLevelFilter.Warn => lineLevel is "WRN" or "ERR" or "FTL", LogLevelFilter.Error => lineLevel is "ERR" or "FTL", _ => true }; /// /// Das Verzeichnis des heutigen Tages, oder null. /// /// DateTime.Now und nicht UTC: Die Schreibseite legt die Verzeichnisse nach /// Ortszeit an, also muss hier dieselbe Rechnung gelten. Auf einem Server mit /// TZ=UTC wechselt der Ordner damit um Mitternacht UTC — richtig, aber /// erwähnenswert, wenn jemand die Umstellung um 02:00 Ortszeit sucht. /// private string? TodayDirectory() { if (!Directory.Exists(logDirectory)) return null; var directory = Path.Combine(logDirectory, DateTime.Now.ToString("yyyy-MM-dd")); return Directory.Exists(directory) ? directory : null; } }