171 lines
6.3 KiB
C#
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();
|
|
}
|
|
}
|