Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions docs/logging-backend.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Lokale Protokollierung – Backend (#78)

## Zuständigkeit und Stand

Tuni implementiert Core-Vertrag und Infrastructure-Dienste. Manuel übernimmt App-Komposition, ViewModels und Anzeige. Ezra betreut die zukünftigen KI-Tools. Dieses Issue ändert keine Dateien unter `Sortify.App` und ergänzt keine UI.

Das Backend ist vorbereitet, aber ohne die nachfolgende Anbindung noch nicht im laufenden Programm aktiv. Kein automatischer Export, kein Netzwerk und keine zusätzliche Bibliothek.

## Vertrag

`IActivityLog.TryWrite(level, eventName, message)` persistiert einen Eintrag und meldet Fehler mit `false`. Ungültige oder überlange Einträge werden abgelehnt (Ereigniskennung maximal 100, Nachricht maximal 2000 Zeichen). `Information`, `Warning` und `Error` werden unterstützt. `LogEntry` enthält `TimestampUtc`, `Level`, `EventName` und `Message`.

`ReadRecent(limit)` liefert 1–1000 Einträge, standardmäßig 100, neuester zuerst. Die Reihenfolge entspricht der Schreibreihenfolge, auch bei korrigierter Systemzeit. Fehlende Dateien ergeben eine leere erfolgreiche Liste, ohne Verzeichnisse anzulegen. `ErrorMessage` meldet Lesefehler; beschädigte Zeilen werden übersprungen und in `SkippedEntries` gezählt. Die UI soll bei solchen Ergebnissen einen Hinweis anzeigen. Keine Exceptions oder Dateiinhalte an die UI weiterreichen.

## Speicherung und Grenzen

`JsonActivityLog.GetDefaultDirectory()` liefert den Ordner `logs` neben der bestehenden `settings.json`, auf Basis von `LocalApplicationData` des Betriebssystems. Unter Linux ist dies üblicherweise `~/.local/share/Sortify/logs`. Keine hartcodierten Benutzerpfade.

JSONL mit UTF-8 und einem JSON-Objekt je Zeile: `activity.jsonl`, danach `activity.1.jsonl` (neuestes Archiv) bis `activity.3.jsonl`. Standard: maximal 1 MiB pro Datei und drei Archive, also höchstens ungefähr 4 MiB. Rotation vor dem Schreiben; älteste Archive werden ersetzt. Es gibt keine zeitbasierte Aufbewahrungsfrist. Größe und Archivanzahl sind Konstruktorparameter (4 KiB–16 MiB, 1–10 Archive); für denselben Ordner einheitlich konfigurieren.

Schreiben und Lesen sind innerhalb eines Prozesses auch über mehrere Logger-Instanzen synchronisiert. Mehrere unabhängige Prozesse dürfen nicht denselben Log-Ordner verwenden; Sortify verwendet bereits eine Einzelinstanz-Sitzung. Symlink-Aliase eines Ordners sind keine zusätzlich unterstützte Synchronisationsgrenze.

Die API verwendet kurze synchrone Dateioperationen, keine Warteschlange und keinen Shutdown-Flush. Bei einer späteren UI-Anbindung `ReadRecent` außerhalb des UI-Threads aufrufen. Sehr langsame Datenträger können die aufrufende Backend-Operation verzögern. Ein Prozessabsturz kann eine unvollständige letzte Zeile hinterlassen; diese wird beim Lesen übersprungen. Extern vergrößerte Dateien oberhalb des Limits werden beim Lesen mit Fehler abgelehnt.

## Anbindung für Manuel

Eine gemeinsame Instanz bei der App-Zusammenstellung erstellen und weitergeben:

```csharp
IActivityLog log = new JsonActivityLog(JsonActivityLog.GetDefaultDirectory());
ISettingsStore store = new LoggedSettingsStore(
new JsonSettingsStore(settingsPath, settingsValidator), log);
IFileMoveService moves = new LoggedFileMoveService(new FileMoveService(), log);
IRuleEngine engine = new LoggedRuleEngine(
new RuleEngine(ruleValidator, ruleTargetValidator), log);
var monitor = new FolderMonitor(log);
```

Namespaces: `Sortify.Core.Services`, `Sortify.Infrastructure.Logging` sowie die bestehenden Namespaces der umschlossenen Dienste. `settingsPath` und Validatoren entsprechen der vorhandenen App-Komposition. Die dekorierten Dienste müssen an sämtliche Verbraucher übergeben werden; vorhandene direkte `new FileMoveService()`-Aufrufe im App-Projekt werden durch diesen Backend-Branch nicht umgestellt. Keine neuen Dienste parallel zum vorhandenen Monitor starten.

Für die Anzeige die gleiche `IActivityLog`-Instanz bereitstellen. Eine spätere Aktualisierung erfolgt durch erneutes `ReadRecent`, kein UI-Event und keine Dateisystemabhängigkeit im ViewModel. Kein Ereignis pro Polling-Schritt der Dateibereitschaft; nur das Endergebnis wird protokolliert.

## Ereignisse

| Kennung | Bedeutung |
| --- | --- |
| `FileMove.<Status>` | Ergebnis einschließlich Erfolg, fehlender Bestätigung, geänderter Quelle oder existierendem Ziel |
| `FileMove.Cancelled` / `FileMove.Exception` | Abbruch beziehungsweise unerwartete Exception |
| `FileSource.Ready` / `FileSource.Unavailable` / `FileSource.Exception` | Ergebnis der Bereitschaftsprüfung |
| `Settings.Load.<Status>` / `Settings.Save.Succeeded` / `Settings.Save.Failed` | Laden/Speichern; unerwartete Exceptions mit `.Exception` |
| `Rules.<Status>` / `Rules.Exception` | Ergebnis der Regelauswertung; mehrdeutig = Warnung, fehlgeschlagen = Fehler |
| `Monitor.Started` / `Monitor.Stopped` / `Monitor.StartFailed` / `Monitor.Interrupted` | Überwachungszustand |
| `Monitor.FileDetected` | Erkennung einer relevanten neuen Datei |

Automatische Nachrichten enthalten keine vollständigen Pfade, Dateinamen, Dateiinhalte, Regeltexte, Einstellungswerte oder Exception-Details. Lokale Logs liefern damit technische Ablaufdiagnose, keine vollständige Historie einzelner Dateipfade. Erweiterungen mit sensiblen Informationen müssen bewusst vereinbart werden. Benutzereingaben mit Zeilenumbrüchen werden JSON-escaped; sie erzeugen keine zusätzlichen Log-Zeilen.

Die Dekoratoren geben Originalergebnisse unverändert zurück und reichen ursprüngliche Exceptions/Abbrüche weiter. Logger-Fehler werden isoliert, auch wenn ein anderer `IActivityLog` entgegen dem Vertrag wirft. Es gibt keine rekursive Protokollierung von Log-Fehlern. Die aufrufende Anwendung kann direkte `TryWrite`-Fehler oder `ReadRecent.ErrorMessage` bei Bedarf sichtbar machen.

## Verifikation

Gezielte Tests für paralleles Schreiben, erneutes Lesen, Rotation, beschädigte Zeilen, Fehler/Rückkehr des Speichers, Logger-Ausfälle bei echten Dateioperationen, fehlende Bestätigung, Abbruch, Einstellungen, Überwachung und Regelauswertung. Bestehende Tests sichern die ursprüngliche Dateisicherheit weiterhin ab.

Geprüfter Stand: `dotnet build Sortify.sln --configuration Release --no-restore` ohne Warnungen/Fehler; `dotnet test Sortify.sln --configuration Release --no-build --no-restore`: 411 erfolgreich (237 Core, 107 Infrastructure, 67 App). Neun gezielte Backend-Tests wurden ergänzt. `git diff --check` ist sauber. Die Live-Anbindung und visuelle Abnahme gehören zum Frontend-Folgeauftrag.
3 changes: 3 additions & 0 deletions src/Sortify.Core/Models/LogEntry.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
namespace Sortify.Core.Models;

public sealed record LogEntry(DateTimeOffset TimestampUtc, LogLevel Level, string EventName, string Message);
3 changes: 3 additions & 0 deletions src/Sortify.Core/Models/LogLevel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
namespace Sortify.Core.Models;

public enum LogLevel { Information, Warning, Error }
6 changes: 6 additions & 0 deletions src/Sortify.Core/Models/LogReadResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace Sortify.Core.Models;

public sealed record LogReadResult(IReadOnlyList<LogEntry> Entries, string? ErrorMessage, int SkippedEntries = 0)
{
public bool IsSuccess => ErrorMessage is null;
}
12 changes: 12 additions & 0 deletions src/Sortify.Core/Services/IActivityLog.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using Sortify.Core.Models;

namespace Sortify.Core.Services;

public interface IActivityLog
{
// Returns false when persistence fails. Does not throw for unavailable storage.
bool TryWrite(LogLevel level, string eventName, string message);

// Newest entries first. Reading never creates a directory or file.
LogReadResult ReadRecent(int limit = 100);
}
14 changes: 14 additions & 0 deletions src/Sortify.Infrastructure/Logging/ActivityLogExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using Sortify.Core.Models;
using Sortify.Core.Services;

namespace Sortify.Infrastructure.Logging;

internal static class ActivityLogExtensions
{
// Even a replacement logger must never change an application's operation result.
internal static void Record(this IActivityLog? log, LogLevel level, string name, string message)
{
try { log?.TryWrite(level, name, message); }
catch (Exception) { /* Logging is best effort; never recursively log its own failure. */ }
}
}
121 changes: 121 additions & 0 deletions src/Sortify.Infrastructure/Logging/JsonActivityLog.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
using System.Collections.Concurrent;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Sortify.Core.Models;
using Sortify.Core.Services;
using Sortify.Infrastructure.Persistence;

namespace Sortify.Infrastructure.Logging;

public sealed class JsonActivityLog : IActivityLog
{
private static readonly ConcurrentDictionary<string, object> Gates = new(
OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal);
private readonly string directory;
private readonly long maxFileBytes;
private readonly int archiveCount;
private readonly TimeProvider clock;
private readonly object gate;
private static readonly JsonSerializerOptions JsonOptions = new()
{
RespectRequiredConstructorParameters = true,
Converters = { new JsonStringEnumConverter<LogLevel>() }
};
private static readonly Encoding Utf8 = new UTF8Encoding(false);

public JsonActivityLog(string directory, long maxFileBytes = 1_048_576, int archiveCount = 3,
TimeProvider? clock = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(directory);
if (!Path.IsPathFullyQualified(directory)) throw new ArgumentException("An absolute log directory is required.", nameof(directory));
if (maxFileBytes < 4096 || maxFileBytes > 16_777_216) throw new ArgumentOutOfRangeException(nameof(maxFileBytes));
if (archiveCount < 1 || archiveCount > 10) throw new ArgumentOutOfRangeException(nameof(archiveCount));
this.directory = Path.TrimEndingDirectorySeparator(Path.GetFullPath(directory));
this.maxFileBytes = maxFileBytes;
this.archiveCount = archiveCount;
this.clock = clock ?? TimeProvider.System;
gate = Gates.GetOrAdd(this.directory, _ => new object());
}

public static string GetDefaultDirectory() => Path.Combine(
Path.GetDirectoryName(SettingsStoragePath.GetDefaultFilePath())!, "logs");

private string FilePath(int index) => Path.Combine(directory,
index == 0 ? "activity.jsonl" : $"activity.{index}.jsonl");

public bool TryWrite(LogLevel level, string eventName, string message)
{
if (!Enum.IsDefined(level) || string.IsNullOrWhiteSpace(eventName) || eventName.Length > 100 ||
string.IsNullOrWhiteSpace(message) || message.Length > 2000) return false;
try
{
var entry = new LogEntry(clock.GetUtcNow(), level, eventName, message);
var bytes = Utf8.GetBytes(JsonSerializer.Serialize(entry, JsonOptions) + "\n");
if (bytes.Length > maxFileBytes) return false;
lock (gate)
{
Directory.CreateDirectory(directory);
var current = FilePath(0);
if (File.Exists(current) && new FileInfo(current).Length + bytes.Length > maxFileBytes)
{
for (var index = archiveCount; index >= 1; index--)
{
var previous = FilePath(index - 1);
if (File.Exists(previous)) File.Move(previous, FilePath(index), overwrite: true);
}
}
using var stream = new FileStream(current, FileMode.Append, FileAccess.Write, FileShare.Read);
stream.Write(bytes);
stream.Flush();
}
return true;
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or NotSupportedException)
{
return false;
}
}

public LogReadResult ReadRecent(int limit = 100)
{
if (limit is < 1 or > 1000) throw new ArgumentOutOfRangeException(nameof(limit));
var entries = new Queue<LogEntry>();
var skipped = 0;
try
{
lock (gate)
{
if (File.Exists(directory)) return new([], "Der Protokollordner ist nicht verfügbar.");
for (var index = archiveCount; index >= 0; index--)
{
var path = FilePath(index);
if (!File.Exists(path)) continue;
// Refuse externally replaced oversized files rather than reading unbounded data.
if (new FileInfo(path).Length > maxFileBytes)
return new([], "Eine Log-Datei überschreitet die zulässige Größe.", skipped);
foreach (var line in File.ReadLines(path, Utf8))
{
if (string.IsNullOrWhiteSpace(line)) continue;
try
{
var entry = JsonSerializer.Deserialize<LogEntry>(line, JsonOptions);
if (entry is null || entry.TimestampUtc == default || !Enum.IsDefined(entry.Level) ||
string.IsNullOrWhiteSpace(entry.EventName) || string.IsNullOrWhiteSpace(entry.Message))
{ skipped++; continue; }
entries.Enqueue(entry);
if (entries.Count > limit) entries.Dequeue();
}
catch (JsonException) { skipped++; }
}
}
}
// Storage order breaks timestamp ties and tolerates system-clock corrections.
return new(entries.Reverse().ToArray(), null, skipped);
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or NotSupportedException)
{
return new([], "Die Protokolldateien konnten nicht gelesen werden.", skipped);
}
}
}
46 changes: 46 additions & 0 deletions src/Sortify.Infrastructure/Logging/LoggedFileMoveService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
using Sortify.Core.Models;
using Sortify.Core.Services;

namespace Sortify.Infrastructure.Logging;

public sealed class LoggedFileMoveService(IFileMoveService inner, IActivityLog log) : IFileMoveService
{
public async Task<FileMoveResult> MoveAsync(FileMoveRequest request, CancellationToken cancellationToken = default)
{
try
{
var result = await inner.MoveAsync(request, cancellationToken);
log.Record(result.Status == FileMoveStatus.Succeeded ? LogLevel.Information : LogLevel.Warning,
"FileMove." + result.Status, "Dateivorgang abgeschlossen: " + result.Status);
return result;
}
catch (OperationCanceledException)
{
log.Record(LogLevel.Information, "FileMove.Cancelled", "Dateivorgang abgebrochen.");
throw;
}
catch (Exception)
{
log.Record(LogLevel.Error, "FileMove.Exception", "Unerwarteter Fehler beim Dateivorgang.");
throw;
}
}

public async Task<FileSourceSnapshotResult> InspectSourceAsync(string sourcePath, CancellationToken cancellationToken = default)
{
try
{
var result = await inner.InspectSourceAsync(sourcePath, cancellationToken);
log.Record(result.Snapshot is null ? LogLevel.Warning : LogLevel.Information,
result.Snapshot is null ? "FileSource.Unavailable" : "FileSource.Ready",
result.Snapshot is null ? "Datei noch nicht bereit zum Einsortieren." : "Datei bereit zum Einsortieren.");
return result;
}
catch (OperationCanceledException) { throw; }
catch (Exception)
{
log.Record(LogLevel.Error, "FileSource.Exception", "Unerwarteter Fehler bei der Dateiprüfung.");
throw;
}
}
}
29 changes: 29 additions & 0 deletions src/Sortify.Infrastructure/Logging/LoggedRuleEngine.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using Sortify.Core.Models;
using Sortify.Core.Services;

namespace Sortify.Infrastructure.Logging;

public sealed class LoggedRuleEngine(IRuleEngine inner, IActivityLog log) : IRuleEngine
{
public RuleEvaluationResult Evaluate(FileDescriptor file, IReadOnlyCollection<SortRule> rules,
IReadOnlyCollection<TargetFolder> targets)
{
try
{
var result = inner.Evaluate(file, rules, targets);
var level = result.Status switch
{
RuleEvaluationStatus.Failed => LogLevel.Error,
RuleEvaluationStatus.Ambiguous => LogLevel.Warning,
_ => LogLevel.Information
};
log.Record(level, "Rules." + result.Status, "Regelauswertung abgeschlossen: " + result.Status);
return result;
}
catch (Exception)
{
log.Record(LogLevel.Error, "Rules.Exception", "Unerwarteter Fehler bei der Regelauswertung.");
throw;
}
}
}
42 changes: 42 additions & 0 deletions src/Sortify.Infrastructure/Logging/LoggedSettingsStore.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
using Sortify.Core.Models;
using Sortify.Core.Services;

namespace Sortify.Infrastructure.Logging;

public sealed class LoggedSettingsStore(ISettingsStore inner, IActivityLog log) : ISettingsStore
{
public async Task<SettingsLoadResult> LoadAsync(CancellationToken cancellationToken = default)
{
try
{
var result = await inner.LoadAsync(cancellationToken);
log.Record(result.Status == SettingsLoadStatus.Failed ? LogLevel.Error : LogLevel.Information,
"Settings.Load." + result.Status, "Einstellungen geladen: " + result.Status);
return result;
}
catch (OperationCanceledException) { throw; }
catch (Exception)
{
log.Record(LogLevel.Error, "Settings.Load.Exception", "Unerwarteter Fehler beim Laden der Einstellungen.");
throw;
}
}

public async Task<SettingsSaveResult> SaveAsync(AppSettings settings, CancellationToken cancellationToken = default)
{
try
{
var result = await inner.SaveAsync(settings, cancellationToken);
log.Record(result.IsSuccess ? LogLevel.Information : LogLevel.Error,
result.IsSuccess ? "Settings.Save.Succeeded" : "Settings.Save.Failed",
result.IsSuccess ? "Einstellungen gespeichert." : "Einstellungen konnten nicht gespeichert werden.");
return result;
}
catch (OperationCanceledException) { throw; }
catch (Exception)
{
log.Record(LogLevel.Error, "Settings.Save.Exception", "Unerwarteter Fehler beim Speichern der Einstellungen.");
throw;
}
}
}
Loading
Loading