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; } } }