Files
ClawdDotNet/src/ClawdDotNet.Core/Deploymentcenter/ErrorReporter.cs
T

171 lines
6.3 KiB
C#

using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Core.Deploymentcenter;
/// <summary>Meldet Laufzeitfehler an das Deploymentcenter.</summary>
public interface IErrorReporter
{
/// <summary>
/// Meldet eine Ausnahme. <paramref name="fatal"/> heißt: Der Prozess endet daran.
/// Gibt zurück, ob die Meldung angekommen ist — der Aufrufer muss das nicht prüfen.
/// </summary>
Task<bool> ReportAsync(Exception exception, bool fatal = false,
IReadOnlyDictionary<string, object?>? context = null, CancellationToken ct = default);
}
/// <summary>Tut nichts. Für abgeschaltete Meldung und für Tests.</summary>
public sealed class NullErrorReporter : IErrorReporter
{
public static readonly NullErrorReporter Instance = new();
public Task<bool> ReportAsync(Exception exception, bool fatal = false,
IReadOnlyDictionary<string, object?>? context = null, CancellationToken ct = default)
=> Task.FromResult(false);
}
/// <summary>
/// Anbindung an <c>POST /api/errors/v1/report</c> — den Eingang für den globalen
/// Ausnahmebehandler.
///
/// <para>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.</para>
///
/// <para><b>Eigener Schutz gegen Fehlerschleifen.</b> 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.</para>
/// </summary>
public sealed class ErrorReporter : IErrorReporter, IDisposable
{
/// <summary>Wie lange derselbe Fehler nach einer Meldung stumm bleibt.</summary>
private static readonly TimeSpan RepeatWindow = TimeSpan.FromMinutes(5);
/// <summary>Obergrenze für den Stacktrace — der Server schneidet sonst mitten im Wort ab.</summary>
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<DateTimeOffset> _now;
private readonly Dictionary<string, DateTimeOffset> _lastSent = [];
private readonly Lock _gate = new();
public ErrorReporter(
DeploymentcenterApi api,
string projectSlug,
string environment,
string build,
ILogger logger,
bool ownsApi = false,
Func<DateTimeOffset>? now = null)
{
_api = api;
_projectSlug = projectSlug;
_environment = environment;
_build = build;
_logger = logger;
_ownsApi = ownsApi;
_now = now ?? (() => DateTimeOffset.UtcNow);
}
public async Task<bool> ReportAsync(Exception exception, bool fatal = false,
IReadOnlyDictionary<string, object?>? context = null, CancellationToken ct = default)
{
if (!ShouldSend(exception))
return false;
var payload = new Dictionary<string, object?>
{
["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;
}
}
/// <summary>Drosselung je Fehlerart, damit eine Schleife nicht die Leitung flutet.</summary>
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;
}
}
/// <summary>
/// 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.
/// </summary>
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();
}
}