using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using PolyTrader.Core;
using PolyTrader.Core.Configuration;
using PolyTrader.Core.Security;
namespace PolyTraderSharp.Services
{
///
/// Meldet ungefangene Fehler an den Fehler-Stream des Deploymentcenters (D-3,
/// POST /api/errors/v1/report). Fängt drei Quellen ab:
/// (der Prozess stirbt gerade – level: "fatal"),
/// (ein Hintergrund-Task ist fehlgeschlagen, ohne dass jemand hinsah – "warning") und
/// bei ("error").
///
/// **Grundregel wie beim Watchdog:** Ein nicht erreichbares Deploymentcenter darf PolyTrader nie
/// beeinträchtigen. Zusätzlich hier besonders wichtig: **keine Rückkopplung.** Der Reporter selbst
/// darf einen Sendefehler NIEMALS über melden – das würde exakt
/// den Log-Kanal auslösen, den er selbst abhört, und sich damit endlos selbst melden. Eigene
/// Fehlermeldungen laufen deshalb ausschließlich über .
///
/// Fehlermeldungen enthalten möglicherweise Wallet-Adressen, Marktkennungen oder Beträge; der
/// Deploymentcenter-Server liegt beim Hoster. Nachricht und Stacktrace laufen deshalb vor dem
/// Versand durch .
///
public sealed class DeploymentcenterErrorReporter : BackgroundService
{
///
/// Eigene, deutlich strengere Grenze als das serverseitige Limit (60/Minute/IP) – ein
/// durchdrehender Fehlerpfad soll lokal gedrosselt werden, bevor er den Server überhaupt
/// erreicht oder mit anderen Diensten auf derselben IP kollidiert.
///
private const int MaxReportsPerMinute = 20;
private const int MaxStackTraceLength = 4000;
private readonly TerminalLogger _logger;
private readonly DeploymentcenterOptions _options;
private readonly HttpClient _http;
private readonly object _rateLock = new();
private readonly Queue _recentSendsUtc = new();
private bool _throttleWarningLogged;
public DeploymentcenterErrorReporter(
TerminalLogger logger,
DeploymentcenterOptions options,
HttpClient? httpClient = null)
{
_logger = logger;
_options = options;
_http = httpClient ?? new HttpClient(new SocketsHttpHandler
{
PooledConnectionLifetime = TimeSpan.FromMinutes(2)
})
{
Timeout = TimeSpan.FromSeconds(10)
};
_logger.OnLogMessage += OnAppLog;
AppDomain.CurrentDomain.UnhandledException += OnUnhandledException;
TaskScheduler.UnobservedTaskException += OnUnobservedTaskException;
}
/// Rein ereignisgesteuert – kein eigener Takt nötig, nur Auf- und Abbau der Abonnements.
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
try
{
await Task.Delay(Timeout.Infinite, stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// Normales Herunterfahren.
}
}
public override void Dispose()
{
_logger.OnLogMessage -= OnAppLog;
AppDomain.CurrentDomain.UnhandledException -= OnUnhandledException;
TaskScheduler.UnobservedTaskException -= OnUnobservedTaskException;
base.Dispose();
}
internal void OnAppLog(object? sender, LogMessageEventArgs e)
{
if (e.Level != LogLevel.Error) return;
// Fire-and-forget: OnLogMessage ist synchron, darf den loggenden Thread nicht blockieren.
// ReportAsync fängt jede eigene Ausnahme selbst ab (No-Throw-Vertrag) – hier entsteht also
// keine unbeobachtete Task-Exception, die wiederum den UnobservedTaskException-Zweig
// triggern und sich selbst melden würde.
_ = ReportAsync("ApplicationError", e.Message, stackTrace: null, level: "error");
}
internal void OnUnhandledException(object sender, UnhandledExceptionEventArgs e)
{
string message = (e.ExceptionObject as Exception)?.Message ?? e.ExceptionObject?.ToString() ?? "Unbekannter Fehler";
string? stack = (e.ExceptionObject as Exception)?.StackTrace;
string type = (e.ExceptionObject as Exception)?.GetType().FullName ?? "UnknownException";
// Der Prozess beendet sich gleich (IsTerminating in aller Regel true) - synchron und mit
// kurzer Frist senden, statt auf einen Fire-and-forget-Task zu setzen, der nie mehr laufen
// könnte.
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
ReportAsync(type, message, stack, "fatal", cts.Token).GetAwaiter().GetResult();
}
catch { /* Der Prozess stirbt ohnehin - hier ist nichts mehr zu retten. */ }
}
internal void OnUnobservedTaskException(object? sender, UnobservedTaskExceptionEventArgs e)
{
var ex = e.Exception?.Flatten().InnerException ?? (Exception?)e.Exception;
_ = ReportAsync(ex?.GetType().FullName ?? "UnobservedTaskException",
ex?.Message ?? "Unbeobachtete Task-Exception", ex?.StackTrace, "warning");
// Verhindert, dass .NET den Prozess wegen der unbeobachteten Exception beendet - wir haben
// sie ja gerade beobachtet und gemeldet.
e.SetObserved();
}
///
/// Meldet einen Fehler. Wirft nie, meldet eigene Fehlschläge nur über
/// (siehe Klassendokumentation zur Rückkopplung).
///
public async Task ReportAsync(
string exceptionType, string message, string? stackTrace, string level, CancellationToken ct = default)
{
try
{
if (!_options.ErrorReportingEnabled) return false;
if (string.IsNullOrWhiteSpace(_options.BaseUrl) || string.IsNullOrWhiteSpace(_options.Token))
return false;
if (!TryBuildEndpoint(_options.BaseUrl, out Uri? uri, out string urlProblem))
{
WarnOnce($"⚠️ Fehler-Reporting nicht sendebereit: {urlProblem}");
return false;
}
if (!TryReserveRateSlot())
{
if (!_throttleWarningLogged)
{
_logger.Warning("⚠️ Fehler-Reporting lokal gedrosselt (mehr als " +
$"{MaxReportsPerMinute} Meldungen/Minute) – weitere Meldungen werden " +
"verworfen, bis sich die Rate beruhigt. Details bleiben im Terminal-Log.");
_throttleWarningLogged = true;
}
return false;
}
string token;
try { token = SecretProtection.Unprotect(_options.Token); }
catch (Exception ex) { WarnOnce($"⚠️ Fehler-Reporting-Token nicht lesbar: {ex.Message}"); return false; }
var payload = new
{
project_slug = _options.ProjectSlug,
exception = exceptionType,
message = WatchdogHeartbeatService.Sanitize(message),
stack_trace = SanitizeStackTrace(stackTrace),
level,
build = BuildInfo.Version,
environment = Environment.GetEnvironmentVariable("POLYTRADER_ENVIRONMENT") ?? "production"
};
using var request = new HttpRequestMessage(HttpMethod.Post, uri) { Content = JsonContent.Create(payload) };
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
using var response = await _http.SendAsync(request, ct).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
WarnOnce($"⚠️ Fehler-Reporting fehlgeschlagen: HTTP {(int)response.StatusCode} {response.ReasonPhrase}.");
return false;
}
return true;
}
catch (OperationCanceledException)
{
return false;
}
catch (Exception ex)
{
// Letztes Sicherheitsnetz - siehe Klassendokumentation: NIE über .Error loggen.
WarnOnce($"⚠️ Fehler-Reporting unerwartet fehlgeschlagen: {ex.Message}");
return false;
}
}
/// Entprellt Warnungen wie der Watchdog: der erste Fehlschlag wird gemeldet, nicht jeder einzelne.
private bool _lastSendFailed;
private void WarnOnce(string message)
{
if (_lastSendFailed) return;
_logger.Warning(message);
_lastSendFailed = true;
}
/// Gleitendes Ein-Minuten-Fenster, ohne Hintergrund-Timer.
private bool TryReserveRateSlot()
{
lock (_rateLock)
{
DateTime now = DateTime.UtcNow;
while (_recentSendsUtc.Count > 0 && (now - _recentSendsUtc.Peek()) > TimeSpan.FromMinutes(1))
_recentSendsUtc.Dequeue();
if (_recentSendsUtc.Count >= MaxReportsPerMinute) return false;
_recentSendsUtc.Enqueue(now);
_throttleWarningLogged = false; // erneutes Überschreiten wieder genau einmal melden
return true;
}
}
///
/// Stacktraces dürfen (anders als die kurze Statuszeile im Heartbeat) mehrzeilig bleiben –
/// sie sind sonst wertlos. Nur die bekannten Secret-Muster werden entfernt, dazu eine
/// großzügige Längenbegrenzung gegen ausufernde Payloads.
///
private static string? SanitizeStackTrace(string? stackTrace)
{
if (string.IsNullOrEmpty(stackTrace)) return null;
string s = SecretRedactor.RemoveSecrets(stackTrace);
return s.Length > MaxStackTraceLength ? s.Substring(0, MaxStackTraceLength - 1) + "…" : s;
}
private static bool TryBuildEndpoint(string baseUrl, out Uri? uri, out string problem)
{
uri = null;
problem = string.Empty;
if (!Uri.TryCreate(baseUrl?.Trim().TrimEnd('/'), UriKind.Absolute, out Uri? parsed))
{
problem = $"Deploymentcenter-URL ist keine gültige absolute URL: '{baseUrl}'.";
return false;
}
bool isHttps = parsed.Scheme == Uri.UriSchemeHttps;
bool isLocalHttp = parsed.Scheme == Uri.UriSchemeHttp && parsed.IsLoopback;
if (!isHttps && !isLocalHttp)
{
problem = $"Deploymentcenter-URL muss HTTPS verwenden (http:// nur für localhost): '{baseUrl}'.";
return false;
}
uri = new Uri(parsed, $"{parsed.AbsolutePath.TrimEnd('/')}/api/errors/v1/report");
return true;
}
}
}