using Microsoft.Extensions.Logging; namespace ClawdDotNet.Core.Deploymentcenter; /// Meldet Laufzeitfehler an das Deploymentcenter. public interface IErrorReporter { /// /// Meldet eine Ausnahme. heißt: Der Prozess endet daran. /// Gibt zurück, ob die Meldung angekommen ist — der Aufrufer muss das nicht prüfen. /// Task ReportAsync(Exception exception, bool fatal = false, IReadOnlyDictionary? context = null, CancellationToken ct = default); } /// Tut nichts. Für abgeschaltete Meldung und für Tests. public sealed class NullErrorReporter : IErrorReporter { public static readonly NullErrorReporter Instance = new(); public Task ReportAsync(Exception exception, bool fatal = false, IReadOnlyDictionary? context = null, CancellationToken ct = default) => Task.FromResult(false); } /// /// Anbindung an POST /api/errors/v1/report — den Eingang für den globalen /// Ausnahmebehandler. /// /// Gespeichert wird serverseitig in derselben Tabelle wie der Bugtracker. Die /// Trennung von Rauschen und Signal leisten dort die Ignore-Regeln: Ein bekannter, /// harmloser Fehler wird weiterhin gezählt, bleibt aber aus der Übersicht — und schlägt /// Alarm, wenn er plötzlich hundertmal so oft auftritt. /// /// Eigener Schutz gegen Fehlerschleifen. Der Server drosselt auf 300 /// Meldungen pro Minute und IP. Diese Klasse drosselt schon vorher: Derselbe Fehler /// (gleicher Typ, gleiche Stelle) geht höchstens einmal je Zeitfenster raus. Ohne das /// erzeugt eine Schleife in einem Timer tausende identische Anfragen, die der Server /// dann verwerfen muss — und der einzige, der davon etwas hat, ist die Leitung. /// public sealed class ErrorReporter : IErrorReporter, IDisposable { /// Wie lange derselbe Fehler nach einer Meldung stumm bleibt. private static readonly TimeSpan RepeatWindow = TimeSpan.FromMinutes(5); /// Obergrenze für den Stacktrace — der Server schneidet sonst mitten im Wort ab. private const int MaxStackTraceLength = 8000; private readonly DeploymentcenterApi _api; private readonly bool _ownsApi; private readonly string _projectSlug; private readonly string _environment; private readonly string _build; private readonly ILogger _logger; private readonly Func _now; private readonly Dictionary _lastSent = []; private readonly Lock _gate = new(); public ErrorReporter( DeploymentcenterApi api, string projectSlug, string environment, string build, ILogger logger, bool ownsApi = false, Func? now = null) { _api = api; _projectSlug = projectSlug; _environment = environment; _build = build; _logger = logger; _ownsApi = ownsApi; _now = now ?? (() => DateTimeOffset.UtcNow); } public async Task ReportAsync(Exception exception, bool fatal = false, IReadOnlyDictionary? context = null, CancellationToken ct = default) { if (!ShouldSend(exception)) return false; var payload = new Dictionary { ["project_slug"] = _projectSlug, ["exception"] = exception.GetType().FullName, ["message"] = exception.Message, ["stack_trace"] = Truncate(exception.ToString(), MaxStackTraceLength), ["level"] = fatal ? "fatal" : "error", ["build"] = _build, ["environment"] = _environment, // Idempotenz: Kommt derselbe Fehler nach einem Neustart erneut, erhöht der // Server den Zähler, statt einen zweiten Eintrag anzulegen. ["client_ref"] = Fingerprint(exception) }; if (context is { Count: > 0 }) payload["context"] = context; if (exception.TargetSite?.DeclaringType?.FullName is { } declaringType) payload["file"] = declaringType; try { await _api.PostAsync("/api/errors/v1/report", payload, ct).ConfigureAwait(false); return true; } catch (Exception ex) { // Ein Meldeweg, der selbst wirft, wäre die schlechteste aller Welten: // Der ursprüngliche Fehler ginge dabei verloren. _logger.LogDebug(ex, "Fehlermeldung an das Deploymentcenter fehlgeschlagen (ignoriert)."); return false; } } /// Drosselung je Fehlerart, damit eine Schleife nicht die Leitung flutet. private bool ShouldSend(Exception exception) { var key = Fingerprint(exception); var now = _now(); lock (_gate) { if (_lastSent.TryGetValue(key, out var last) && now - last < RepeatWindow) return false; // Alte Einträge räumen, damit das Wörterbuch bei wechselnden Fehlern nicht wächst. if (_lastSent.Count > 200) { foreach (var stale in _lastSent .Where(e => now - e.Value > RepeatWindow) .Select(e => e.Key) .ToList()) { _lastSent.Remove(stale); } } _lastSent[key] = now; return true; } } /// /// Kennzeichen eines Fehlers: Typ plus oberste Stelle im Stacktrace. Die Meldung /// bleibt bewusst außen vor — sie enthält oft wechselnde Werte (IDs, Pfade), und /// dann wäre jeder Aufruf ein neuer Fehler. /// private static string Fingerprint(Exception exception) { var frame = exception.StackTrace? .Split('\n', StringSplitOptions.RemoveEmptyEntries) .FirstOrDefault()? .Trim() ?? ""; return $"{exception.GetType().FullName}|{frame}"; } private static string Truncate(string value, int max) => value.Length <= max ? value : value[..max] + "\n… (gekürzt)"; public void Dispose() { if (_ownsApi) _api.Dispose(); } }