Mindestanforderungen an einen selbstgeschriebenen Logger und eine Checkliste für Integrationstests
· Go Komura · Windows-Entwicklung, Protokollierung, Integrationstests, Testdesign, Zuverlässigkeit
Wenn sich ein fertiges Logging-Framework verwenden lässt, ist das die sicherere Wahl. Trotzdem gibt es Situationen, in denen Einschränkungen der Anwendung oder betriebliche Umstände einen selbstgeschriebenen Logger unumgänglich machen. Die erste Frage, die sich dabei stellt, ist, wie viel implementiert werden muss, damit das Design „weder zu grob noch zu schwer“ ausfällt.
In diesem Artikel beschränken wir uns auf Anwendungsprotokolle zur Störungsanalyse. Statt Audit-Trails, verteiltes Tracing, eine Metrik-Plattform und Cloud-Aggregation auf einmal zu schultern, definieren wir zunächst eine minimale Konfiguration, die in der Praxis nützlich ist, und ordnen anschließend die Integrationstestaspekte, mit denen sich diese Konfiguration wirklich als vertrauenswürdig erweist.
Zielgruppe und Voraussetzungen dieses Artikels
| Punkt | Inhalt |
|---|---|
| Zielgruppe | Entwickler, die eigene Diagnoseprotokolle in Fachanwendungen oder Tools einbauen. Vorausgesetzt wird ein kleines Team ohne eigene Zuständigkeit für die Protokollinfrastruktur |
| Geltungsbereich des Designs | Ist sprachunabhängig. In jeder Umgebung, in der an eine Datei angehängt werden kann, gelten dieselben Entscheidungen – ob C# oder C++ |
| Codebeispiele | Gezeigt in C# 12 / .NET 8 und PowerShell 7. In anderen Sprachen lassen sie sich mit denselben Entscheidungen in derselben Reihenfolge übertragen |
| Betrachtete Protokolle | Diagnoseprotokolle zur Eingrenzung von Anwendungsstörungen |
| Nicht betrachtet | Audit-Trails, verteiltes Tracing, Metrik-Infrastruktur, Cloud-Aggregation |
Begriffe, die in diesem Artikel verwendet werden
Bevor es an das Design geht, fassen wir Begriffe zusammen, die in späteren Abschnitten ohne weitere Erklärung vorkommen.
| Begriff | Bedeutung |
|---|---|
JSON Lines (.jsonl) |
Textformat, bei dem pro Zeile genau ein JSON-Wert steht, getrennt durch Zeilenumbrüche (\n). Die Zeichenkodierung ist UTF-8, und ein BOM darf laut Spezifikation nicht gesetzt werden1 |
strukturiertes fields |
Ein Behälter, der unabhängig vom Fließtext in message durchsuchbare Werte als Schlüssel-Wert-Paare aufnimmt, etwa in der Form {"file":"orders.csv","row":128} |
single writer |
Ein Design, bei dem tatsächlich nur eine einzige Stelle (ein Thread) in die Datei schreibt. Unabhängig davon, wie viele Threads aufrufen, gibt es nur einen einzigen Schreibpfad |
bounded queue |
Eine Warteschlange mit Obergrenze. Der Aufrufer legt nur einen Eintrag hinein und kehrt zurück; das eigentliche Schreiben übernimmt der single writer. Da es eine Obergrenze gibt, muss die Richtlinie für den Überlauffall festgelegt werden |
drain (Drain) |
Beim Beenden alle noch in der Warteschlange verbliebenen Protokolleinträge vollständig herausschreiben. Verhindert, dass eingereihte Einträge verworfen werden und verloren gehen |
flush (Flush) |
Den Puffer im Arbeitsspeicher tatsächlich in die Datei schreiben. Protokolleinträge, die diesen Schritt nicht durchlaufen haben, gehen bei abnormalem Beenden verloren |
| Rotation | Der Wechsel zu einer neuen Datei, wenn die aktuelle zu groß wird oder sich das Datum ändert |
| Aufbewahrung (Retention) | Die Obergrenze, wie viele oder wie alte Protokolldateien aufbewahrt werden |
Zuerst prüfen: die Option, gar nicht selbst zu bauen
Wie eingangs erwähnt, ist es sicherer, ein fertiges Logging-Framework zu verwenden, wenn das möglich ist. Damit sich das beurteilen lässt, nennen wir hier konkrete Namen. Reicht das, ist der Rest dieses Artikels nicht nötig.
| Umgebung | Option | Was von Haus aus mitgeliefert wird |
|---|---|---|
| .NET | Microsoft.Extensions.Logging |
Die ILogger-API des .NET-Standards. Protokollebenen (Trace bis Critical), Kategorien und ein Provider-Mechanismus zum Austauschen des Ausgabeziels. In vielen .NET-SDKs als impliziter Verweis enthalten2 |
| .NET | Serilog | Diagnoseprotokollierung, die von strukturierten Ereignissen ausgeht. Benennt die Parameter der Nachrichtenvorlage und hält deren Werte als Eigenschaften des Ereignisses fest3 |
| .NET | NLog | Unterstützt sowohl strukturierte als auch klassische Protokollierung. Bietet ein JSON-Layout als Ausgabeformat sowie automatische Benennung und Archivierung bei Dateiausgabe4 |
| C++ | spdlog | Eine ab C++11 einsetzbare Protokollierungsbibliothek. Bietet Dateiausgabe mit größenbasierter Rotation (rotating) und tagesbasiertem Wechsel (daily)5 |
Erst wenn diese Optionen nicht genutzt werden können – wachsende Abhängigkeiten sind nicht möglich, die Laufzeitumgebung ist eingeschränkt, oder bestehender Code setzt Grenzen –, greifen die folgenden Mindestanforderungen.
Das Ergebnis vorab
Die Punkte, die in der ersten Version sitzen sollten, sind die folgenden.
- Das Format ist
UTF-8JSON Lines - Ein Datensatz pro Zeile wird nie verletzt
- Pflichtfelder sind
timestamp,level,category,message, strukturiertesfields,sessionIdundprocessId - Grundsatz ist
ein Prozess, eine Datei - Bei geringer Last synchrones Schreiben, bei höherer Last
single writer + bounded queue Error/Criticalsowie Sitzungsbeginn und -ende werden synchron geflusht- Rotation und Aufbewahrung werden bereits in v1 eingebaut
- Ist der Zielort nicht nutzbar, wird nicht stillschweigend auf einen anderen Ort ausgewichen
Wird so weit eingegrenzt, brechen Implementierung und Betrieb beide seltener zusammen.
Zunächst den Anwendungsbereich eng fassen
Ein selbstgeschriebener Logger wird tendenziell schwierig, weil von Anfang an versucht wird, alles abzudecken. Sollen Diagnoseprotokolle, Audit-Protokolle, Performance-Messung, verteiltes Tracing und Nutzerverhaltensanalyse in einem einzigen Mechanismus zusammengeführt werden, wachsen die Anforderungen auf einen Schlag.
Der hier betrachtete Bereich sind Diagnoseprotokolle zur Eingrenzung von Anwendungsstörungen. Das heißt, Priorität hat, im Nachhinein nachvollziehen zu können, „wann“, „bei welcher Verarbeitung“, „was passiert ist“ und „welcher Kontext zu diesem Zeitpunkt bestand“. Allein diese Eingrenzung erleichtert die ersten Designentscheidungen erheblich.
Die Mindestanforderungen
1. Das Format ist UTF-8 JSON Lines
Auch aneinandergereihter Klartext kann Protokolle festhalten, wird später aber schwer maschinell verarbeitbar. Umgekehrt verschlechtert ein von Anfang an schweres, proprietäres Binärformat die Beobachtbarkeit im Betrieb.
Der handhabbare Mittelweg dazwischen ist UTF-8 JSON Lines. Ist eine Zeile ein Datensatz, bleibt die Datei als Text lesbar und lässt sich später leicht mit Skripten und Werkzeugen auswerten. Selbst wenn ein Schreibvorgang mittendrin abbricht, lässt sich leicht eingrenzen, welche Zeile beschädigt ist – ein praxistauglicher Vorteil.
Von der Spezifikation vorgegeben sind nur die folgenden drei Punkte.1
- Eine Zeile ist genau ein gültiger JSON-Wert (keine Leerzeilen)
- Zeilentrenner ist
\n - Die Zeichenkodierung ist
UTF-8. EinBOM(U+FEFF) darf nicht gesetzt werden
Als Dateiendung ist .jsonl üblich. Das Verbot des BOM wird leicht übersehen, wirkt sich aber deutlich aus. Wird mit BOM geschrieben, scheitert nur die erste Zeile beim Parsen in anderen Werkzeugen, was sich auf eine schwer zu durchschauende Weise als „nur die erste Zeile ist kaputt“ zeigt.
2. Pflichtfelder von Anfang an festlegen
Die mindestens vorzuhaltenden Felder sind die folgenden sieben.
timestamplevelcategorymessagefieldssessionIdprocessId
Ein tatsächlicher Datensatz sieht zum Beispiel so aus (aus Platzgründen hier umgebrochen dargestellt, aber in der eigentlichen Datei steht er ohne Zeilenumbruch in einer Zeile).
{"ts":"2026-04-02T01:15:03.4821567Z","level":"Error","category":"import","message":"Import fehlgeschlagen","fields":{"file":"orders.csv","row":128,"reason":"date parse failed"},"sessionId":"20260402-101500-8412-9f3c1d2a5b7e4f689a0c3d5e7f1b2c4d","pid":8412}
Stellt man Vorher/Nachher derselben Sitzung nebeneinander, sieht das so aus.
{"ts":"2026-04-02T01:15:00.1002233Z","level":"Info","category":"startup","message":"Anwendung gestartet","fields":{"version":"1.4.2"},"sessionId":"20260402-101500-8412-9f3c1d2a5b7e4f689a0c3d5e7f1b2c4d","pid":8412}
{"ts":"2026-04-02T01:15:03.4821567Z","level":"Error","category":"import","message":"Import fehlgeschlagen","fields":{"file":"orders.csv","row":128,"reason":"date parse failed"},"sessionId":"20260402-101500-8412-9f3c1d2a5b7e4f689a0c3d5e7f1b2c4d","pid":8412}
{"ts":"2026-04-02T01:15:03.9900011Z","level":"Info","category":"shutdown","message":"Anwendung wird beendet","fields":{"exitCode":1},"sessionId":"20260402-101500-8412-9f3c1d2a5b7e4f689a0c3d5e7f1b2c4d","pid":8412}
Die Schlüsselnamen dürfen kurz sein, aber wichtiger ist, sie einmal festgelegt nicht mehr zu ändern. Mischen sich später ts und timestamp, wird das später zu schreibende Analyseskript auf einen Schlag umständlich.
Ein reiner String-Log mit nur message wird zum Problem, sobald später mehr Suchkriterien gebraucht werden. Umgekehrt steigt bei zu vielen Feldern die Last auf Aufruferseite sprunghaft. Am sichersten ist es, die Menge zunächst auf dieser Größe festzulegen und Erweiterungen erst zu erwägen, wenn sie wirklich nötig werden.
Was in sessionId gehört
Da sessionId als Pflichtfeld genannt wird, muss festgelegt werden, welche Einheit es abbildet. In diesem Artikel gilt: ein Prozessstart ist eine Sitzung. Weder die Anmeldesitzung des Benutzers noch eine geschäftliche „Transaktion“ ist gemeint.
Mit dieser Definition wird Folgendes möglich.
- Alle Protokolleinträge eines einzelnen Starts lassen sich gebündelt herausziehen
- Auch wenn die Rotation die Datei aufteilt, lassen sich die Protokolle desselben Starts später wieder zusammenführen
- Zwei auf demselben Rechner morgens und abends aufgetretene Störungen lassen sich getrennt eingrenzen, ohne dass sie sich vermischen
Die Vergabe erfolgt einmalig beim Prozessstart und wird bis zum Ende des Prozesses weiterverwendet. Eine der beiden folgenden Methoden reicht aus.
| Methode | Beispiel | Geeignet für |
|---|---|---|
Startzeit + Prozess-ID + GUID |
20260402-101500-8412-9f3c1d2a5b7e4f689a0c3d5e7f1b2c4d |
Das ist die Vorgabe. Der Anfang ist für Menschen lesbar, der hintere Teil kollisionsfrei |
Nur UUID (GUID) |
9f0a1c72-3b58-4f2a-9a2e-6e7c1f0d55b1 |
Protokolle mehrerer Rechner werden später an einem Ort zusammengeführt. Lesbarkeit ist nicht nötig |
Verwenden Sie nicht nur Startzeit + Prozess-ID. Das Betriebssystem verwendet processId-Werte wieder. Startet der Prozess in einer Absturzschleife innerhalb derselben Sekunde neu, oder springt die lokale Uhrzeit zurück, kann dieselbe sessionId wie beim vorigen Mal entstehen – kombiniert mit einer von Windows wiederverwendeten PID. Wird derselbe Wert auch im Dateinamen verwendet, vermischen sich die Datensätze verschiedener Starts in dem Moment, in dem im Anhängemodus geöffnet wird, in einer einzigen Datei, und die Annahme „ein Start = eine Sitzung“ bricht stillschweigend zusammen. Und da dieser Fehler leise auftritt, fällt er später nicht auf.
processId wird zusätzlich als eigenes Feld geführt, damit sessionId „welcher Start“ und processId „welche konkrete Instanz im Betriebssystem zu diesem Zeitpunkt“ jeweils getrennt ausdrücken können.
3. Ein Prozess, eine Datei als Grundsatz
Ein Design, bei dem mehrere Prozesse an dieselbe Datei anhängen, birgt mehr Fehlerquellen, als es auf den ersten Blick scheint. Gegenseitiger Ausschluss, Teilschreibvorgänge, Rotationszeitpunkte und der Umgang mit abnormalem Beenden werden auf einen Schlag schwierig.
Machen Sie zunächst ein Prozess, eine Datei zum Grundsatz. Sollen mehrere Prozesse zusammengeführt werden, ist es sicherer, dies nachgelagert zu bündeln oder dafür ausdrücklich einen eigenen Sammelprozess aufzusetzen.
4. Die Schreibstrategie nach Last aufteilen
Solange das Protokollaufkommen gering ist, ist synchrones Schreiben verständlicher und erleichtert die Fehlersuche. Wird es ohne Not asynchron gemacht, gehen Protokolleinträge kurz vor dem Beenden verloren, oder die Flush-Bedingung im Ausnahmefall wird unklar.
Wird das Aufkommen dagegen groß und synchrones I/O zum limitierenden Faktor, kommt single writer + bounded queue zum Einsatz. Das heißt: der Aufrufer legt lediglich einen Eintrag in eine begrenzte Warteschlange und kehrt zurück, geschrieben wird nur an einer einzigen Stelle. Dieser Gedanke ist nichts Ungewöhnliches – auch die Protokollierungsrichtlinien von .NET empfehlen, nicht direkt in ein langsames Ziel zu schreiben, sondern synchron in eine Warteschlange im Arbeitsspeicher einzureihen und im Hintergrund abzuarbeiten.2
Wichtig dabei ist, die Richtlinie für den Fall eines vollen Puffers vorab festzulegen. Lassen Sie nicht offen, ob ältere Einträge verworfen, neuere Einträge verworfen oder eine Warnung ausgegeben wird.
5. Die Flush-Bedingungen festlegen
Error und Critical sowie die Protokolle zu Sitzungsbeginn und -ende synchron zu flushen, zahlt sich bei der Fehlersuche aus. Wird alles bis hin zum gewöhnlichen Info geflusht, wird es langsam – daher ist es realistisch, nicht alles gleich zu behandeln.
6. Rotation und Aufbewahrung schon in v1 einbauen
Rotation wird oft als etwas angesehen, das „später ergänzt werden kann“, ist aber eine Funktion, deren Fehlen im Betrieb plötzlich schmerzt. Das Verfahren kann beliebig sein – größenbasiert, täglich, pro Start –, aber mindestens sollte feststehen, dass die Datei „nicht unbegrenzt wächst“ und „wie viele Dateien aufbewahrt werden“.
7. Bei Speicherfehlern nicht eigenmächtig ausweichen
Ein Design, das bei einem nicht nutzbaren Zielort stillschweigend an einen anderen Ort schreibt, erschwert die spätere Untersuchung. Fehlen die Protokolle einfach an dem Ort, „an dem sie sein sollten“, verzögert sich für den Betrieb der erste Reaktionsschritt bei einer Störung.
Kann nicht gespeichert werden, machen Sie den Fehler über einen ausdrücklich sichtbaren Weg bemerkbar – eine Benachrichtigung in der Anwendung, das Ereignisprotokoll, die Standardfehlerausgabe. Vermeiden Sie zumindest den Zustand, „niemand weiß, wohin es gegangen ist“.
Eine minimale v1-Konfiguration
Für die erste Version genügt oft etwa Folgendes.
UTF-8 JSON Lines- Ein Prozess, eine Datei
- Dateinamen pro Sitzung
- Größenbasierte oder startbezogene Rotation
- Eine Obergrenze für die Anzahl aufbewahrter Dateien
- Synchroner Flush von
Error/Critical - Eine API, die strukturiertes
fieldsentgegennimmt
Alles darüber hinaus lässt sich besser warten, wenn es erst ergänzt wird, sobald der reale Betrieb zeigt, was tatsächlich fehlt.
Wie sieht der Schreibteil von v1 in C# aus?
Implementiert man von den obigen Anforderungen nur Format, Pflichtfelder, single writer und die Flush-Bedingung, ergibt sich etwa dieser Umfang (C# 12 / .NET 8). Rotation und Aufbewahrung sind bewusst nicht enthalten – sie werden in der nächsten Ausbaustufe ergänzt.
using System.Text;
using System.Text.Json;
public sealed class JsonLinesLogger : IDisposable
{
// Gibt nicht-ASCII-Zeichen unverändert aus. Der Standard-Encoder wandelt sonst alle nicht-ASCII-Zeichen in \uXXXX um
private static readonly JsonSerializerOptions JsonOptions = new()
{
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
};
private static readonly IReadOnlyDictionary<string, object?> NoFields =
new Dictionary<string, object?>();
private readonly object _gate = new(); // Schützt den single writer mit dieser Sperre
private readonly StreamWriter _writer;
private readonly string _sessionId;
private readonly int _processId = Environment.ProcessId;
public JsonLinesLogger(string path, string sessionId)
{
_sessionId = sessionId;
// JSON Lines verbietet ein BOM, daher wird UTF-8 ohne BOM ausdrücklich angegeben
_writer = new StreamWriter(
new FileStream(path, FileMode.Append, FileAccess.Write, FileShare.Read),
new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
}
public void Write(
string level,
string category,
string message,
IReadOnlyDictionary<string, object?>? fields = null)
{
var record = new Dictionary<string, object?>
{
["ts"] = DateTimeOffset.UtcNow.ToString("o"),
["level"] = level,
["category"] = category,
["message"] = message,
["fields"] = fields ?? NoFields, // Nicht null lassen, sondern immer alle 7 Felder liefern
["sessionId"] = _sessionId,
["pid"] = _processId,
};
// Erst JSON-serialisieren und dann schreiben, damit eine Zeile auch bei message mit Zeilenumbruch nicht zerbricht
var line = JsonSerializer.Serialize(record, JsonOptions);
lock (_gate)
{
_writer.WriteLine(line);
if (level is "Error" or "Critical")
{
_writer.Flush(); // Nur schwerwiegende Einträge synchron flushen
}
}
}
public void Dispose()
{
lock (_gate)
{
_writer.Flush(); // Beim normalen Beenden immer vollständig herausschreiben
_writer.Dispose();
}
}
}
Der Aufruf sieht dann so aus. sessionId wird beim Start einmalig festgelegt und derselbe Wert auch im Dateinamen verwendet.
// Verwendet den oben gezeigten JsonLinesLogger
var startedAt = DateTimeOffset.Now;
// Zeit und PID allein reichen nicht aus. Startet der Prozess in einer Absturzschleife
// innerhalb derselben Sekunde neu, oder springt die lokale Uhrzeit zurück, kann in
// Kombination mit einer von Windows wiederverwendeten PID dieselbe ID wie beim
// vorigen Mal entstehen. Da JsonLinesLogger im Modus FileMode.Append öffnet,
// vermischen sich dann die Datensätze verschiedener Starts in einer Datei,
// und die Annahme "ein Start = eine Sitzung" bricht stillschweigend zusammen
var sessionId = $"{startedAt:yyyyMMdd-HHmmss}-{Environment.ProcessId}-{Guid.NewGuid():N}";
var logDir = @"C:\ProgramData\MyApp\logs";
Directory.CreateDirectory(logDir);
using var logger = new JsonLinesLogger(Path.Combine(logDir, $"app-{sessionId}.jsonl"), sessionId);
logger.Write("Info", "startup", "Anwendung gestartet",
new Dictionary<string, object?> { ["version"] = "1.4.2" });
logger.Write("Error", "import", "Import fehlgeschlagen",
new Dictionary<string, object?> { ["file"] = "orders.csv", ["row"] = 128 });
Kurzer Code, aber alle getroffenen Entscheidungen stecken darin.
| Entscheidung | Wo sie sich niederschlägt |
|---|---|
UTF-8 ohne BOM |
UTF8Encoding(encoderShouldEmitUTF8Identifier: false) |
Nicht-ASCII-Zeichen nicht in \uXXXX umwandeln |
JavaScriptEncoder.UnsafeRelaxedJsonEscaping6 |
| Ein Datensatz pro Zeile | message wird nicht direkt aneinandergehängt; stattdessen wird das Ergebnis von JsonSerializer.Serialize per WriteLine geschrieben |
| Die 7 Pflichtfelder werden jedes Mal gesetzt | Ist fields nicht angegeben, wird NoFields eingesetzt, damit nichts fehlt |
single writer |
_writer wird nur innerhalb von lock (_gate) angefasst |
flush-Bedingung |
Nur bei Error / Critical sofortiges Flush(), beim Beenden immer Flush() über Dispose() |
UnsafeRelaxedJsonEscaping escaped <, >, & und ' nicht, daher darf diese Ausgabe nicht direkt in eine HTML-Seite oder ein script-Element eingebettet werden.6 Verwenden Sie es nur für den Zweck, als Protokolldatei gelesen zu werden.
Häufige Fehler
Hier noch die typischen Fälle, die vermieden werden sollten.
- Alles in den
message-String stopfen - Dieselbe Datei zwischen mehreren Prozessen teilen
- Vollständig asynchron werden, ohne die Flush-Bedingungen festzulegen
- Rotation und Aufbewahrung aufschieben
- Bei Speicherfehlern stillschweigend in einen anderen Ordner ausweichen
- Netzwerkübertragung oder lokale Datenbankspeicherung bereits in v1 aufnehmen
Jeder Punkt wirkt auf den ersten Blick praktisch, macht aber Eingrenzung und Betrieb tendenziell schwerer.
Integrationstests mit echten Dateien, echten Threads und echten Prozessen denken
Ein Logger ist eine Komponente, bei der Unit-Tests allein kein Vertrauen schaffen. Werden nur String-Formatierung und JSON-Serialisierung geprüft, bleibt genau das unentdeckt, was im echten Betrieb zum Problem wird: I/O, Nebenläufigkeit, Rotation, Flush beim Beenden und Berechtigungsfehler.
Deshalb müssen Integrationstests mit echten Dateien, echten Threads und, wo nötig, echten Prozessen prüfen. Mindestens sollte der Zustand vermieden werden, „läuft im Alltag durch, ist aber im Störungsfall nicht vertrauenswürdig“.
Integrationstestpunkte, die bestehen sollten
Integrität eines einzelnen Schreibvorgangs
- Ist jede Zeile genau ein JSON-Datensatz?
- Lässt sie sich als
UTF-8zurücklesen? - Sind die Pflichtfelder jedes Mal vorhanden?
- Ist ein eingebetteter Zeilenumbruch nicht in mehrere Zeilen zerbrochen?
Nebenläufigkeit innerhalb desselben Prozesses
- Bleiben Datensätze intakt, wenn mehrere Threads gleichzeitig schreiben?
- Stimmt die Anzahl der Datensätze, weder zu wenig noch zu viel?
- Verhalten sich Reihenfolge und Verlustfall bei Verwendung einer Warteschlange spezifikationsgemäß?
Flush- und Beendigungsverhalten
- Werden
Error/Criticalsofort wirksam? - Ist die Warteschlange beim normalen Beenden leer?
- Bleiben die nötigen Abschlussprotokolle auch auf Pfaden nahe einem Ausnahmeende erhalten?
Rotation und Aufbewahrung
- Wird bei Erfüllung der Rotationsbedingung zu einer neuen Datei gewechselt?
- Werden alte Dateien jenseits der Aufbewahrungsgrenze spezifikationsgemäß gelöscht?
- Bleiben JSON-Zeilen unmittelbar vor und nach der Rotation intakt?
Fehlerfälle
- Verhalten, wenn das Zielverzeichnis nicht existiert
- Verhalten bei fehlender Schreibberechtigung
- Benachrichtigung oder Rückgabewert bei einem Fehler wie vollem Datenträger
- Verhalten bei Überlauf der Warteschlange
Umgang mit mehreren Prozessen
Lautet die Spezifikation ein Prozess, eine Datei, kann bereits die Tatsache, dass kein anderer Prozess versucht, in dieselbe Datei einzutreten, selbst ein Prüfziel sein. Beim Modell mit Sammelprozess muss die Prüfung dagegen auch Übergabefehler an diesen Prozess einschließen.
Wie „Beschädigung“ erkannt wird
Nur Prüfpunkte aufzulisten reicht nicht, um Tests zu schreiben. Von den obigen Punkten ist es am schwierigsten zu entscheiden, wie beurteilt wird, ob „ein Datensatz nicht beschädigt ist“. Visuelle Prüfung reicht dafür keinesfalls aus, daher werden die folgenden drei Punkte maschinell geprüft.
| Was geprüft wird | Prüfmethode | Damit erkennbare Beschädigung |
|---|---|---|
| Zeilenanzahl | Stimmt die Anzahl der geschriebenen Datensätze mit der Zeilenanzahl der Datei überein? | Verlust, doppeltes Schreiben, verpasster drain |
| Jede Zeile | Lässt sich jede Zeile für sich als JSON parsen? | Eingebetteter Zeilenumbruch, unterbrochener Schreibvorgang, eingeschleustes BOM |
| Jeder Datensatz | Sind die 7 Pflichtfelder jedes Mal vollständig? | Vergessene Felder, eingeschleustes null |
„Die ersten 10 Zeilen sehen gut aus“ hilft bei Tests für paralleles Schreiben so gut wie nie weiter. Beschädigt ist fast immer genau eine Zeile irgendwo in der Mitte. Nur wenn alle Zeilen durchlaufen werden, ergibt die Prüfung Sinn.
In PowerShell 7 lassen sich diese drei Punkte gemeinsam wie folgt prüfen. Gedacht zum Aufruf aus der Nachbereitung eines Tests.
# Prüft alle Zeilen des ausgegebenen Protokolls. Ist auch nur eine Zeile beschädigt, wird geworfen und abgebrochen
$path = 'C:\ProgramData\MyApp\logs\app-20260402-101500-8412-9f3c1d2a5b7e4f689a0c3d5e7f1b2c4d.jsonl'
$expected = 10000 # Anzahl der Write-Aufrufe auf Testseite
# "Der Name ist vorhanden" allein reicht nicht aus. Auch bei einem Datensatz wie
# `"level": null` existiert der Eigenschaftsname, daher würde eine reine Prüfung
# auf das Vorhandensein des Namens durchgehen. Um eingeschleustes null zu erkennen,
# müssen Wert und Typ geprüft werden
$required = [ordered]@{
'ts' = { param($v) $v -is [string] -and $v -ne '' }
'level' = { param($v) $v -is [string] -and $v -ne '' }
'category' = { param($v) $v -is [string] -and $v -ne '' }
'message' = { param($v) $v -is [string] } # Leerstring ist erlaubt, null nicht
'fields' = { param($v) $v -is [pscustomobject] }
'sessionId' = { param($v) $v -is [string] -and $v -ne '' }
'pid' = { param($v) ($v -is [int] -or $v -is [long]) -and $v -gt 0 }
}
# Die BOM-Prüfung erfolgt vor dem Dekodieren anhand der Rohbytes.
# Get-Content -Encoding utf8 überspringt ein führendes BOM und liefert erst
# danach die Zeilen zurück, daher lässt sich ein eingeschleustes BOM in der
# dekodierten Zeichenkette nicht mehr erkennen, egal wie genau man hinsieht
# (unter 5.1 anstelle von -AsByteStream: -Encoding Byte)
$head = @(Get-Content -LiteralPath $path -AsByteStream -TotalCount 3)
if ($head.Count -ge 3 -and $head[0] -eq 0xEF -and $head[1] -eq 0xBB -and $head[2] -eq 0xBF) {
throw 'Die Datei beginnt mit einem UTF-8-BOM. Das ist als JSON Lines ungültig'
}
$lines = @(Get-Content -LiteralPath $path -Encoding utf8)
if ($lines.Count -ne $expected) {
throw "Die Zeilenanzahl stimmt nicht überein. Erwartet: $expected Zeilen / tatsächlich: $($lines.Count) Zeilen"
}
$lineNo = 0
foreach ($line in $lines) {
$lineNo++
try {
$record = $line | ConvertFrom-Json
}
catch {
throw "Zeile $lineNo lässt sich nicht als JSON lesen: $line"
}
$names = $record.PSObject.Properties.Name
foreach ($name in $required.Keys) {
if ($names -notcontains $name) {
throw "Zeile $lineNo hat kein Pflichtfeld: $name"
}
if (-not (& $required[$name] $record.$name)) {
$shown = if ($null -eq $record.$name) { '(null)' } else { "'$($record.$name)'" }
throw "Der Wert von $name in Zeile $lineNo ist ungültig: $shown"
}
}
}
"OK: Alle $($lines.Count) Zeilen ließen sich als je ein Datensatz pro Zeile lesen"
In dieser Form lässt sich das Skript direkt für weitere Tests wiederverwenden.
- Paralleles Schreiben: Aus mehreren Threads
n-mal schreiben und$expectedaufnsetzen - Rotation und Aufbewahrung: Nach der Rotation dieselbe Prüfung auf alle verbliebenen Dateien anwenden und die Zeilenanzahl in Summe abgleichen
drainbeim Beenden: Nach dem Beendigungsvorgang prüfen und kontrollieren, ob die Anzahl mit den eingereihten Einträgen übereinstimmt
Fehlerfälle lassen sich auf diese Weise nicht messen, daher werden sie gesondert betrachtet. Legen Sie zunächst Bedingungen wie ein fehlendes Zielverzeichnis oder eine fehlende Schreibberechtigung an, bevor Sie den Logger initialisieren, und prüfen Sie, dass der Fehler über eine Ausnahme, einen Rückgabewert oder eine Benachrichtigung sichtbar wird. Eine Implementierung, bei der hier „nichts passiert und der Fehler stillschweigend verschluckt wird“, ist im Betrieb die problematischste Art des Versagens.
Die Mindestanzahl an Tests für v1
Versucht man von Anfang an alles abzudecken, werden die Tests zu schwer. In v1 sollten mindestens die folgenden sechs bestanden werden.
- Normales Schreiben aus einem einzelnen Thread
- Gleichzeitiges Schreiben aus mehreren Threads
- Flush bei
Error/Critical - Rotation und Aufbewahrung
- Fehlerbenachrichtigung bei einem gestörten Zielort
drainund abschließender Flush beim normalen Beenden
Sind allein diese sechs Tests bestanden, ist man von einem Logger, der zwar Zeichenketten ausgibt, dem im Betrieb aber nicht zu trauen ist, schon weit entfernt.
Zusammenfassung
Das erste Ziel eines selbstgeschriebenen Loggers ist nicht Funktionsreichtum, sondern „im Störungsfall glaubwürdig zu sein“. Dazu ist es wirksam, das Format auf UTF-8 JSON Lines festzulegen, die Pflichtfelder eng zu halten, ein Prozess, eine Datei zum Grundsatz zu machen und Flush, Rotation, Aufbewahrung und das Verhalten im Fehlerfall früh festzulegen.
Und ob dieses Design wirklich funktioniert, muss mit Integrationstests geprüft werden, die echte Dateien, echte Threads und echte Prozesse verwenden. Bevor die Implementierung wächst, zahlt es sich aus, zunächst die minimale Konfiguration und den minimalen Testsatz zu fixieren – so lässt sich der Logger später ohne Zwang weiter ausbauen.
Quellen
-
JSON Lines, JSON Lines ↩ ↩2
-
Microsoft Learn, Logging in C# - .NET ↩ ↩2
-
gabime, spdlog - Fast C++ logging library ↩
-
Microsoft Learn, How to customize character encoding with System.Text.Json ↩ ↩2
Verwandte Artikel
Aktuelle Artikel mit denselben Schlagwörtern führen zu verwandten Themen weiter.
Wo man die Grenze zwischen Unit-Tests und Integrationstests zieht
Wir ordnen die Grenze zwischen Unit-Tests und Integrationstests anhand der Achsen reine Logik, Format, Verdrahtung, Umgebungsunterschiede...
Design für Windows-Apps, das bei einem Absturz Protokolle und Dumps hinterlässt
Wie sich normale Protokolle, ein letzter Absturzmarker, WER LocalDumps und ein Überwachungsprozess kombinieren lassen, damit auch bei ein...
Entscheidungstabelle: Nach einer unerwarteten Ausnahme beenden oder fortsetzen?
Wenn eine unerwartete Ausnahme auftritt, ordnet dieser Artikel ein, ob die Anwendung beendet oder fortgesetzt werden sollte – aus der Per...
Was bedeutet Windows' „Speicherauslastung“ eigentlich? — Working Set, Private Bytes, Commit und die Auslagerungsdatei richtig lesen
Der Speicherwert im Task-Manager, Working Set, Private Bytes und Commit sind nicht dasselbe. Dieser Artikel erklärt das Verhältnis von vi...
WMI/CIM aus C# und PowerShell verwenden — Praxisleitfaden für Hardwareinformationen, Prozessüberwachung und Remoteabfragen
WMI/CIM ist die Standardlösung, um die Seriennummer eines PCs auszulesen, freien Festplattenspeicher zu überwachen und den Start von Proz...
Verwandte Themen
Diese Seiten ordnen den Artikel in einen größeren Leistungs- und Entscheidungskontext ein.
Technische Windows-Themen
Portal zu Windows-Entwicklung, Fehleranalyse und der Nutzung bestehender Assets.
Leistungen zu diesem Thema
Dieser Artikel ist direkt mit den folgenden Leistungen verbunden.
Windows-App-Entwicklung
Das Thema passt gut zu der Aufgabe, Protokolldesign, -implementierung und -betrieb für Windows-Tools und Fachanwendungen an den tatsächlichen Anforderungen im Betrieb auszurichten.
Technische Beratung und Design-Review
Protokollformat, Rotation, Verhalten im Fehlerfall und Umfang der Integrationstests schon vor der Implementierung zu klären, ist selbst ein naheliegendes Thema für eine technische Beratung.
Häufige Fragen
Fragen, die in Beratungen zu diesem Artikelthema häufig gestellt werden.
- Welches Format sollte ein selbstgeschriebener Logger verwenden?
- Empfehlenswert ist UTF-8 JSON Lines, bei dem ein Datensatz nicht über mehrere Zeilen zerfällt. Aneinandergereihter Klartext lässt sich später schwer maschinell verarbeiten, und ein eigenes Binärformat verschlechtert die Beobachtbarkeit im Betrieb. JSON Lines bleibt dagegen als Text lesbar, lässt sich leicht mit Skripten und Werkzeugen auswerten, und selbst wenn ein Schreibvorgang mittendrin abbricht, lässt sich die beschädigte Zeile leicht eingrenzen – praxistauglich. Die Pflichtfelder werden auf sieben festgelegt: timestamp, level, category, message, das strukturierte fields, sessionId und processId.
- Sollte das Schreiben von Protokollen synchron oder asynchron erfolgen?
- Das hängt von der Last ab. Solange das Protokollaufkommen gering ist, ist synchrones Schreiben verständlicher und erleichtert die Fehlersuche. Wird es ohne Not asynchron gemacht, gehen Protokolleinträge kurz vor dem Beenden verloren, oder die Flush-Bedingungen im Ausnahmefall werden unklar. Wird das Aufkommen groß und synchrones I/O zum limitierenden Faktor, kommt single writer + bounded queue zum Einsatz; dabei sollte die Richtlinie für den Fall eines vollen Puffers vorab festgelegt werden – ob ältere oder neuere Protokolleinträge verworfen werden. Error/Critical sowie die Protokolle zu Sitzungsbeginn und -ende sollten synchron geflusht werden, das hilft bei der Fehlersuche.
- Darf von mehreren Prozessen aus in dieselbe Protokolldatei geschrieben werden?
- Das sollte vermieden werden. Ein Design, bei dem mehrere Prozesse an dieselbe Datei anhängen, macht gegenseitigen Ausschluss, Teilschreibvorgänge, Rotationszeitpunkte und den Umgang mit abnormalem Beenden auf einen Schlag schwieriger, mit mehr Fehlerquellen, als es auf den ersten Blick scheint. Im Grundsatz gilt ein Prozess, eine Datei; sollen mehrere Prozesse zusammengeführt werden, ist es sicherer, dies nachgelagert zu bündeln oder dafür einen eigenen, ausdrücklich dafür vorgesehenen Sammelprozess aufzusetzen.
- Was sollte bei Integrationstests für einen selbstgeschriebenen Logger geprüft werden?
- Geprüft werden sollte mit echten Dateien, echten Threads und echten Prozessen. Unit-Tests für String-Formatierung und JSON-Serialisierung allein erfassen nicht das, was im echten Betrieb zum Problem wird: I/O, Nebenläufigkeit, Rotation, Flush beim Beenden und Berechtigungsfehler. Für v1 sollten mindestens sechs Tests bestehen: normales Schreiben aus einem einzelnen Thread, gleichzeitiges Schreiben aus mehreren Threads, Flush bei Error/Critical, Rotation und Aufbewahrung, Fehlerbenachrichtigung bei einem gestörten Zielort sowie Drain und abschließender Flush beim normalen Beenden. Sind diese sechs Tests bestanden, ist man von einem Logger, dem im Betrieb nicht zu trauen ist, schon ein gutes Stück entfernt.
Autorenprofil
Profilseite des Artikelautors.
Go Komura
Geschäftsführer von KomuraSoft LLC
Spezialisiert auf Windows-Softwareentwicklung, technische Beratung und Fehleranalyse, insbesondere bei bestehenden Systemen und schwer reproduzierbaren Störungen.