From 60e34b29f66a344eb18e374dd405cefcd5301019 Mon Sep 17 00:00:00 2001 From: Deploymentcenter Bot Date: Fri, 7 Aug 2026 21:55:23 +0200 Subject: [PATCH] =?UTF-8?q?feat(errors,=20watchdog):=20Fehler-Stream=20mit?= =?UTF-8?q?=20Ignore-Regeln,=20Metrik-Verlauf,=20Abh=C3=A4ngigkeits-Alarme?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fehler-Schnittstelle - Neuer schlanker Eingang POST /api/errors/v1/report für den globalen Exception-Handler einer Anwendung. Titel und Dringlichkeit leitet der Server ab; gespeichert wird in derselben Tabelle wie der Bugtracker. Ein zweiter Speicher wäre nur ein zweiter Ort, an dem man suchen müsste. - error_level (fatal/error/warning) trennt die technische Art des Ereignisses von der geschäftlichen Dringlichkeit. Ein Duplicate-Entry ist technisch ein error, geschäftlich belanglos — beides zu vermischen war der Grund, warum solche Meldungen als Bug im Dashboard landeten. Ignore-Regeln gegen bekanntes Rauschen - bugtracker_ignore_rules mit contains/regex/exception_class, Pflichtfeld für die Begründung und optionaler Alarmschwelle. - Ein Treffer bedeutet nicht "wegwerfen": Der Fehler wird weiterhin erfasst und hochgezählt, bleibt aber aus der Übersicht heraus und löst keine Benachrichtigung aus. Der Zähler ist der eigentliche Zweck — dass ein bekannter Fehler auftritt, ist normal; dass er plötzlich hundertmal so oft auftritt, ist ein Signal. Dafür das rollende Stundenfenster und error.rate_exceeded. - Neue Regeln lassen sich rückwirkend auf bestehende Einträge anwenden. Gruppierung überarbeitet - Der Schlüssel nahm bisher 300 Zeichen Stacktrace auf. Derselbe Fehler zersplitterte dadurch, sobald ein Aufrufer den Stack einmal mitschickte und einmal nicht. Jetzt zählt der Ursprungsort: bevorzugt die Dateiangabe, sonst der erste Rahmen des Stacktrace. - Die Normalisierung ersetzte nur Zahlen ab vier Stellen, wodurch 'AA-1' und 'BB-2' getrennt blieben. Werte in Anführungszeichen, die Ziffern enthalten, gelten jetzt als veränderlich — der Schlüsselname bleibt erhalten, sodass verschiedene Unique-Keys unterscheidbar sind. Mit 9 Testfällen belegt. Metrik-Verlauf - watchdog_metrics speichert numerische Heartbeat-Werte mit Zeitstempel. Zuvor wurde metrics_json bei jedem Heartbeat überschrieben; damit ließ sich "die Platte läuft seit drei Tagen voll" nicht erkennen, nur "sie ist voll". - GET /api/watchdog/v1/metrics liefert den verdichteten Verlauf und die Abweichung vom eigenen Sieben-Tage-Durchschnitt. Dieser relative Ansatz braucht keine projektspezifischen Schwellwerte. - Aufbewahrung 14 Tage, Bereinigung stündlich durch den Evaluator. Health-Checks per Push statt Abruf - Der Heartbeat nimmt ein checks-Objekt entgegen, das die Anwendung selbst ermittelt. Das Deploymentcenter interpretiert die Namen nicht, es liest nur ok und message — was "gesund" bedeutet, entscheidet jede Anwendung selbst. Schlägt eine Prüfung fehl, wird ein als ok gemeldeter Heartbeat auf warning herabgestuft. - Bewusst ausgehend: auf den Zielmaschinen müssen keine Ports geöffnet werden. Abhängigkeitsbewusste Alarmierung - Fällt ein Monitor aus, dessen Parent selbst unten ist, wird der Alarm unterdrückt. Der Zustand bleibt sichtbar. Vorher erzeugte ein ausgefallener Hypervisor mit zwölf VMs dreizehn Meldungen für ein Problem. - Mehrere Ebenen und fehlerhafte Hierarchien (Zyklen, gelöschte Parents) sind abgesichert; mit 10 Testfällen belegt. WebUI - Neue Ansicht "Fehler-Stream" mit Filtern nach Projekt, Fehlerklasse, Umgebung, Zeitraum und Sichtbarkeit sowie Volltextsuche und Pagination. Stummgeschaltete Einträge sind standardmäßig ausgeblendet. - Verwaltung der Ignore-Regeln inklusive Trefferzähler. - Die Detailansicht zeigt Fehlerklasse, Stummschaltungsgrund und die Häufung im laufenden Stundenfenster. Co-Authored-By: Claude Opus 5 --- .htaccess | 1 + docs/UPGRADE.md | 75 ++- public/api/errors/v1/report.php | 162 +++++ public/api/openapi.php | 56 ++ public/api/watchdog/v1/index.php | 61 +- public/docs/bugtracker.md | 121 ++++ public/index.php | 578 ++++++++++++++++++ .../007_error_stream_and_metrics.sql | 101 +++ src/Modules/Bugtracker/BugRepo.php | 359 ++++++++++- src/Modules/Bugtracker/IgnoreRules.php | 278 +++++++++ src/Modules/Watchdog/Evaluator.php | 117 +++- src/Modules/Watchdog/MetricStore.php | 270 ++++++++ src/Modules/Watchdog/MonitorRepo.php | 94 ++- 13 files changed, 2228 insertions(+), 45 deletions(-) create mode 100644 public/api/errors/v1/report.php create mode 100644 sql/migrations/007_error_stream_and_metrics.sql create mode 100644 src/Modules/Bugtracker/IgnoreRules.php create mode 100644 src/Modules/Watchdog/MetricStore.php diff --git a/.htaccess b/.htaccess index 842777d..08f0e9a 100644 --- a/.htaccess +++ b/.htaccess @@ -24,6 +24,7 @@ Options -Indexes RewriteRule ^api/health/?$ public/api/health.php [L,QSA] RewriteRule ^api/openapi(?:\.json)?/?$ public/api/openapi.php [L,QSA] + RewriteRule ^api/errors/v1/report/?$ public/api/errors/v1/report.php [L,QSA] RewriteRule ^api/bugtracker/v1/report/?$ public/api/bugtracker/v1/report.php [L,QSA] RewriteRule ^api/bugtracker/v1/projects/?$ public/api/bugtracker/v1/projects.php [L,QSA] RewriteRule ^api/bugtracker/v1/manage(?:/(.*))?$ public/api/bugtracker/v1/manage/index.php [L,QSA] diff --git a/docs/UPGRADE.md b/docs/UPGRADE.md index 91c6f05..fa5d623 100644 --- a/docs/UPGRADE.md +++ b/docs/UPGRADE.md @@ -171,7 +171,80 @@ Zusätzlich stichprobenartig im WebUI prüfen: --- -## 10. Optional: Webhooks +## 10. Fehler-Stream (Migration 007) + +Neu ist eine eigene Schnittstelle für Laufzeitfehler, gedacht für den globalen +Exception-Handler einer Anwendung: + +``` +POST /api/errors/v1/report +``` + +Gespeichert wird in derselben Tabelle wie der Bugtracker — ein zweiter Speicher +wäre nur ein zweiter Ort, an dem man suchen müsste. Die Trennung von Rauschen +und Signal leisten stattdessen **Ignore-Regeln**. + +### Bekannte, harmlose Fehler stummschalten + +Im WebUI unter **Bugtracker → 🔇 Ignore-Regeln**. Ein Treffer bedeutet nicht +„wegwerfen": Der Fehler wird weiterhin erfasst und hochgezählt, bleibt aber aus +der Übersicht heraus und löst keine Benachrichtigung aus. + +Der Zähler ist dabei der eigentliche Zweck. Zu jeder Regel lässt sich eine +Alarmschwelle hinterlegen — dass ein bekannter Fehler auftritt, ist normal; +dass er plötzlich hundertmal so oft auftritt, bedeutet, dass sich etwas +geändert hat. + +Eine deaktivierte Vorlage für den Duplicate-Entry-Fall liegt bereits vor; sie +lässt sich im WebUI anpassen und einschalten. + +### Gruppierung + +Veränderliche Anteile werden beim Zusammenfassen ausgeblendet: Werte in +Anführungszeichen, die Ziffern enthalten, Speicheradressen, GUIDs, Zeitstempel +und Zeilennummern. `Duplicate entry 'MKT-88213'` und `Duplicate entry 'AA-1'` +landen damit in einer Gruppe — ein anderer Unique-Key dagegen nicht. + +> **Hinweis:** Migration 007 ändert die Berechnung des Gruppenschlüssels. +> Bereits erfasste Einträge behalten ihren alten Schlüssel; ein erneut +> auftretender Fehler legt daher einmalig eine neue Gruppe an. Danach ist der +> Zustand wieder konsistent. + +## 11. Metrik-Verlauf und Health-Checks + +Numerische Werte aus dem Heartbeat-Feld `metrics` werden jetzt mit Zeitstempel +abgelegt (Aufbewahrung 14 Tage) und lassen sich über +`GET /api/watchdog/v1/metrics` abfragen. Zuvor wurde `metrics_json` bei jedem +Heartbeat überschrieben — es gab immer nur den letzten Moment. + +Zusätzlich kann eine Anwendung ihren Gesundheitszustand selbst mitschicken: + +```json +{ "source": "polytrader-worker", "status": "ok", "interval": 60, + "checks": { + "db": { "ok": true }, + "market_feed": { "ok": false, "message": "Letzter Tick vor 14 min" } + } } +``` + +Das Deploymentcenter interpretiert die Namen nicht — es liest nur `ok` und +`message`. Schlägt eine Prüfung fehl, wird ein als `ok` gemeldeter Heartbeat auf +`warning` herabgestuft. **Es müssen keine Ports geöffnet werden**, der Weg ist +ausgehend. + +## 12. Abhängigkeitsbewusste Alarmierung + +Fällt ein Monitor aus, für den `parent_source` gesetzt ist, und ist der +übergeordnete Monitor selbst unten, wird der Alarm für das Kind unterdrückt. +Der Zustand bleibt im Dashboard sichtbar. + +Vorher erzeugte ein ausgefallener Hypervisor mit zwölf VMs dreizehn Meldungen +für ein Problem. + +Damit das greift, muss die Hierarchie gepflegt sein — im WebUI unter +**WatchDog → System-Hierarchie** über das Feld *Übergeordnete Entität*. + +## 13. Optional: Webhooks Ereignisgesteuerte Benachrichtigung statt Polling. Ziel direkt in der Datenbank eintragen: diff --git a/public/api/errors/v1/report.php b/public/api/errors/v1/report.php new file mode 100644 index 0000000..afdc2e1 --- /dev/null +++ b/public/api/errors/v1/report.php @@ -0,0 +1,162 @@ +check(Http::clientIp())) { + Http::fail(429, 'rate_limited', 'Zu viele Fehlermeldungen. Bitte Sendefrequenz reduzieren.'); +} + +$data = Http::body(); +if ($data === []) { + Http::fail(400, 'empty_body', 'Der Request-Body ist leer.'); +} + +$environment = is_string($data['environment'] ?? null) ? $data['environment'] : null; +$context = ApiAuth::requireScope($db, 'bugtracker:report', $environment, false); + +$projectSlug = is_string($data['project_slug'] ?? null) ? trim($data['project_slug']) : null; +ApiAuth::enforceProject($context, $projectSlug); + +$boundProject = ApiAuth::projectFilter($context); +if ($boundProject !== null) { + $projectSlug = $boundProject; +} + +// --- Eingaben normalisieren --- +$exception = trimOrNull($data['exception'] ?? $data['exception_class'] ?? $data['type'] ?? null); +$message = trimOrNull($data['message'] ?? $data['error_message'] ?? null); +$stack = trimOrNull($data['stack_trace'] ?? $data['stacktrace'] ?? $data['trace'] ?? null); + +if ($message === null && $exception === null) { + Http::fail(400, 'missing_error', 'Es wird mindestens "message" oder "exception" benoetigt.'); +} + +// fatal = Prozess beendet, error = Vorgang fehlgeschlagen, Programm laeuft weiter. +$level = is_string($data['level'] ?? $data['error_level'] ?? null) + ? strtolower(trim($data['level'] ?? $data['error_level'])) + : 'error'; +if (!in_array($level, BugRepo::ERROR_LEVELS, true)) { + $level = 'error'; +} + +$repo = new BugRepo($db); + +$result = $repo->reportItem([ + 'project_slug' => $projectSlug ?? 'default', + 'type' => 'bug', + 'title' => buildTitle($exception, $message), + 'error_message' => $message, + 'stack_trace' => $stack, + 'exception' => $exception, + 'error_level' => $level, + 'severity' => $level === 'fatal' ? 'high' : 'medium', + 'environment' => $environment ?? 'production', + 'build_version' => trimOrNull($data['build'] ?? $data['build_version'] ?? null) ?? 'unknown', + 'created_by' => $context['actor'], + 'client_ref' => trimOrNull($data['client_ref'] ?? null) ?? Http::header('idempotency-key'), + + // Optionaler Kontext - alles freiwillig + 'repo_url' => trimOrNull($data['repo_url'] ?? null), + 'git_branch' => trimOrNull($data['git_branch'] ?? null), + 'commit_sha' => trimOrNull($data['commit_sha'] ?? null), + 'file_path' => trimOrNull($data['file'] ?? $data['file_path'] ?? null), + 'line_no' => $data['line'] ?? $data['line_no'] ?? null, + 'tags' => trimOrNull($data['tags'] ?? null), + 'context' => isset($data['context']) && is_array($data['context']) ? $data['context'] : null, +]); + +$ignored = (bool)($result['ignored'] ?? false); + +Http::ok([ + 'item_id' => $result['id'], + 'is_new' => $result['is_new'], + 'ignored' => $ignored, + 'ignore_rule_id' => $result['ignore_rule_id'] ?? null, + 'occurrence_count' => $result['occurrence_count'], + 'error_level' => $level, + 'rate_alerted' => (bool)($result['rate_alerted'] ?? false), + 'message' => $ignored + ? 'Als bekannt eingestuft, gezaehlt, nicht gemeldet.' + : ($result['is_new'] ? 'Fehler erfasst.' : 'Wiederkehrender Fehler, Zaehler erhoeht.'), +], $result['is_new'] ? 201 : 200); + +// ====================================================================== + +/** @param mixed $value */ +function trimOrNull($value): ?string +{ + if ($value === null || is_array($value) || is_object($value)) { + return null; + } + $value = trim((string)$value); + return $value === '' ? null : $value; +} + +/** + * Baut einen lesbaren Titel aus Ausnahmeklasse und Meldung. + * + * Die Meldung wird gekuerzt und von veraenderlichen Bestandteilen befreit, + * damit der Titel bei wiederkehrenden Fehlern stabil bleibt. + */ +function buildTitle(?string $exception, ?string $message): string +{ + $summary = $message ?? ''; + + // Anfuehrungszeichen mit wechselndem Inhalt entfernen, z. B. konkrete + // Schluesselwerte in "Duplicate entry 'XY-123' for key ..." + $summary = preg_replace("/'[^']{0,80}'/", "'…'", $summary) ?? $summary; + $summary = trim(preg_replace('/\s+/', ' ', $summary) ?? $summary); + + if ($summary === '') { + return $exception ?? 'Unbehandelter Fehler'; + } + + if (mb_strlen($summary) > 180) { + $summary = mb_substr($summary, 0, 177) . '...'; + } + + return $exception !== null ? $exception . ': ' . $summary : $summary; +} diff --git a/public/api/openapi.php b/public/api/openapi.php index 9158764..524fd6c 100644 --- a/public/api/openapi.php +++ b/public/api/openapi.php @@ -147,6 +147,62 @@ $spec = [ ], ], + '/api/errors/v1/report' => [ + 'post' => [ + 'tags' => ['Fehler'], + 'summary' => 'Laufzeitfehler melden', + 'description' => + "Schlanker Eingang fuer den globalen Exception-Handler. Titel und Dringlichkeit " + . "leitet der Server ab.\n\n" + . "Gleiche Fehler werden zu einer Gruppe zusammengefasst; veraenderliche Anteile " + . "(Werte in Anfuehrungszeichen mit Ziffern, Adressen, GUIDs, Zeitstempel) werden " + . "dabei ausgeblendet.\n\n" + . "Greift eine Ignore-Regel, kommt `\"ignored\": true` zurueck: der Fehler wird " + . "gezaehlt, aber nicht gemeldet. Ueberschreitet er die hinterlegte Alarmschwelle, " + . "meldet die Antwort `\"rate_alerted\": true`.", + 'requestBody' => [ + 'required' => true, + 'content' => ['application/json' => ['schema' => [ + 'type' => 'object', + 'properties' => [ + 'project_slug' => ['type' => 'string'], + 'exception' => ['type' => 'string', 'example' => 'PDOException'], + 'message' => ['type' => 'string'], + 'stack_trace' => ['type' => 'string'], + 'level' => ['type' => 'string', 'enum' => BugRepo::ERROR_LEVELS, 'default' => 'error'], + 'build' => ['type' => 'string'], + 'environment' => ['type' => 'string', 'enum' => BugRepo::ENVIRONMENTS], + 'file' => ['type' => 'string'], + 'line' => ['type' => 'integer'], + 'client_ref' => ['type' => 'string'], + 'context' => ['type' => 'object'], + ], + ]]], + ], + 'responses' => [ + '201' => ['description' => 'Neue Fehlergruppe angelegt'], + '200' => ['description' => 'Bestehende Gruppe hochgezaehlt'], + '401' => $errorResponse, + '429' => $errorResponse, + ], + ], + ], + + '/api/watchdog/v1/metrics' => [ + 'get' => [ + 'tags' => ['Watchdog'], + 'summary' => 'Metrik-Verlauf abrufen', + 'description' => 'Ohne "metric" die verfuegbaren Namen, mit "metric" den verdichteten Verlauf samt Abweichung vom eigenen Durchschnitt.', + 'parameters' => [ + ['name' => 'source', 'in' => 'query', 'required' => true, 'schema' => ['type' => 'string']], + ['name' => 'metric', 'in' => 'query', 'schema' => ['type' => 'string']], + ['name' => 'hours', 'in' => 'query', 'schema' => ['type' => 'integer', 'default' => 24]], + ['name' => 'bucket', 'in' => 'query', 'schema' => ['type' => 'integer', 'default' => 15], 'description' => 'Fenstergroesse in Minuten'], + ], + 'responses' => ['200' => ['description' => 'Verlauf'], '401' => $errorResponse], + ], + ], + '/api/bugtracker/v1/projects' => [ 'get' => [ 'tags' => ['Bugtracker'], diff --git a/public/api/watchdog/v1/index.php b/public/api/watchdog/v1/index.php index a7eec9e..469e85c 100644 --- a/public/api/watchdog/v1/index.php +++ b/public/api/watchdog/v1/index.php @@ -32,6 +32,7 @@ use Deploymentcenter\Core\Db; use Deploymentcenter\Core\Http; use Deploymentcenter\Modules\Watchdog\Evaluator; use Deploymentcenter\Modules\Watchdog\EventLog; +use Deploymentcenter\Modules\Watchdog\MetricStore; use Deploymentcenter\Modules\Watchdog\MonitorRepo; use Deploymentcenter\Modules\Watchdog\TokenManager as LegacyTokenManager; @@ -56,18 +57,27 @@ switch ($action) { authorizeSource($db, $source); + $instance = Http::str('instance') ?? 'default'; + $metrics = Http::input('metrics'); + $monitor = $monitorRepo->upsertHeartbeat( $source, - Http::str('instance') ?? 'default', + $instance, Http::str('type') ?? 'heartbeat', Http::int('interval', 0) ?: Http::int('expected_interval_sec', 60), - Http::input('metrics'), + $metrics, strtolower(Http::str('status') ?? 'ok'), Http::str('message') ?? Http::str('reason'), Http::str('group') ?? Http::str('group_key'), - Http::str('os') + Http::str('os'), + // Gesundheitszustand, den die Anwendung selbst ermittelt hat. + Http::input('checks') ); + // Numerische Werte in den Verlauf uebernehmen, damit sich Trends + // erkennen lassen statt nur der letzte Moment. + $recordedMetrics = (new MetricStore($db))->record($source, $instance, $metrics); + // Zustandswechsel im Ereignisprotokoll festhalten. if (!empty($monitor['_state_changed'])) { $previous = (string)$monitor['_previous_state']; @@ -87,13 +97,15 @@ switch ($action) { Http::ok([ 'message' => 'Heartbeat empfangen.', 'monitor' => [ - 'source' => $monitor['source'], - 'instance' => $monitor['instance'], - 'state' => $monitor['state'], - 'last_status' => $monitor['last_status'], - 'last_seen_utc' => $monitor['last_seen_utc'], - 'state_changed' => (bool)($monitor['_state_changed'] ?? false), + 'source' => $monitor['source'], + 'instance' => $monitor['instance'], + 'state' => $monitor['state'], + 'last_status' => $monitor['last_status'], + 'last_seen_utc' => $monitor['last_seen_utc'], + 'state_changed' => (bool)($monitor['_state_changed'] ?? false), + 'failing_checks' => $monitor['_failing_checks'] ?? [], ], + 'metrics_recorded' => $recordedMetrics, ]); case 'event': @@ -135,6 +147,34 @@ switch ($action) { ); Http::ok(['count' => count($events), 'events' => $events]); + case 'metrics': + ApiAuth::requireScope($db, 'watchdog:read'); + + $source = Http::str('source'); + if ($source === null) { + Http::fail(400, 'missing_source', 'Der Parameter "source" wird benoetigt.'); + } + + $store = new MetricStore($db); + $instance = Http::str('instance') ?? 'default'; + $metricKey = Http::str('metric'); + + if ($metricKey === null) { + Http::ok([ + 'source' => $source, + 'metrics' => $store->keysFor($source, $instance), + 'hint' => 'Mit &metric= den Verlauf abrufen.', + ]); + } + + Http::ok([ + 'source' => $source, + 'metric' => $metricKey, + 'hours' => Http::int('hours', 24), + 'history' => $store->history($source, $metricKey, Http::int('hours', 24), $instance, Http::int('bucket', 15)), + 'deviation' => $store->deviation($source, $metricKey, $instance), + ]); + case 'evaluate': // Bewusst nur fuer Shared Key oder eine angemeldete Sitzung - // ein Agenten-Token soll den Zustand aller Monitore nicht umschreiben. @@ -149,7 +189,7 @@ switch ($action) { default: Http::fail(404, 'unknown_action', 'Endpunkt nicht gefunden.', null, [ - 'available' => ['ping', 'event', 'status', 'events', 'evaluate'], + 'available' => ['ping', 'event', 'status', 'events', 'metrics', 'evaluate'], ]); } @@ -213,6 +253,7 @@ function resolveWatchdogAction(): string 'events' => 'events', 'status' => 'status', 'evaluate' => 'evaluate', + 'metrics' => 'metrics', default => 'status', }; } diff --git a/public/docs/bugtracker.md b/public/docs/bugtracker.md index 435e32c..073a49d 100644 --- a/public/docs/bugtracker.md +++ b/public/docs/bugtracker.md @@ -377,6 +377,74 @@ else: --- +## 8a. Fehler melden (Laufzeitfehler) + +Für den globalen Exception-Handler einer Anwendung gibt es einen schlankeren +Eingang. Titel und Dringlichkeit leitet der Server ab: + +```bash +curl -X POST https://dc.mhdf.de/api/errors/v1/report \ + -H "Authorization: Bearer $DC_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "project_slug": "polytrader", + "exception": "PDOException", + "message": "SQLSTATE[23000]: Duplicate entry '\''MKT-88213'\'' for key '\''uq_market'\''", + "stack_trace": "at Importer.php:142", + "level": "error", + "build": "v2.0.1", + "environment": "production", + "file": "src/Market/Importer.php", + "line": 142 + }' +``` + +`level` unterscheidet die technische Art des Ereignisses — unabhängig von der +geschäftlichen Dringlichkeit: + +| Wert | Bedeutung | +|---|---| +| `fatal` | Der Prozess hat sich beendet | +| `error` | Ein Vorgang ist fehlgeschlagen, das Programm läuft weiter (Vorgabe) | +| `warning` | Auffälligkeit ohne Funktionsverlust | + +### Gruppierung + +Gleiche Fehler werden zu einer Gruppe zusammengefasst und hochgezählt. +Veränderliche Bestandteile werden dabei ausgeblendet — Werte in +Anführungszeichen, die Ziffern enthalten, Speicheradressen, GUIDs, Zeitstempel +und Zeilennummern. Diese drei Meldungen ergeben **eine** Gruppe: + +``` +Duplicate entry 'MKT-88213' for key 'uq_market' +Duplicate entry 'AA-1' for key 'uq_market' +Duplicate entry 'X-99471' for key 'uq_market' +``` + +Ein anderer Unique-Key (`uq_orders`) bleibt dagegen eine eigene Gruppe — der +Schlüsselname enthält keine Ziffern und zählt damit zur Identität des Fehlers. + +### Bekannte, harmlose Fehler + +Manche Fehler treten betriebsbedingt auf und sind belanglos. Dafür gibt es +Ignore-Regeln, die im WebUI unter **Bugtracker → Ignore-Regeln** gepflegt +werden. Greift eine Regel, wird der Fehler weiterhin erfasst und **hochgezählt**, +bleibt aber aus der Übersicht heraus und löst keine Benachrichtigung aus: + +```json +{ "status": "success", "item_id": 42, "ignored": true, + "ignore_rule_id": 3, "occurrence_count": 3841, "rate_alerted": false, + "message": "Als bekannt eingestuft, gezaehlt, nicht gemeldet." } +``` + +Der Zähler ist dabei der eigentliche Zweck: Zu jeder Regel lässt sich eine +Alarmschwelle hinterlegen. Dass ein bekannter Fehler auftritt, ist normal — +dass er plötzlich hundertmal so oft auftritt, ist ein Signal. Wird die Schwelle +überschritten, meldet die Antwort `"rate_alerted": true` und ein Webhook +`error.rate_exceeded` wird ausgelöst. + +--- + ## 9. Watchdog-Heartbeat Läuft dein Agent als Dienst, melde dich regelmäßig: @@ -393,6 +461,59 @@ curl -X POST https://dc.mhdf.de/api/watchdog/v1/ping \ stuft der Evaluator den Monitor nach dem Doppelten auf `warning` und nach dem Vierfachen auf `down`. +### Eigenen Gesundheitszustand mitsenden + +Ein Heartbeat beweist nur, dass ein Thread läuft — nicht, dass die Anwendung +ihre Arbeit tut. Deshalb kann sie ihren Zustand selbst mitschicken: + +```json +{ "source": "polytrader-worker", + "status": "warning", + "interval": 60, + "checks": { + "db": { "ok": true }, + "market_feed": { "ok": false, "message": "Letzter Tick vor 14 min" }, + "queue": { "ok": true, "value": 23 } + }, + "metrics": { "cpu": 18, "ram": 42, "queue_depth": 23 } } +``` + +Das Deploymentcenter interpretiert die Namen der Prüfungen **nicht** — es liest +nur `ok` und `message`. Was „gesund" bedeutet, entscheidet jede Anwendung +selbst. Schlägt eine Prüfung fehl, wird ein als `ok` gemeldeter Heartbeat auf +`warning` herabgestuft. + +Der Weg ist bewusst ausgehend: Es müssen keine Ports auf den Zielmaschinen +geöffnet werden. + +### Metriken + +Numerische Werte aus `metrics` landen im Verlauf und lassen sich abfragen: + +```bash +# Welche Metriken liefert dieser Monitor? +curl "https://dc.mhdf.de/api/watchdog/v1/metrics?source=polytrader-worker" \ + -H "Authorization: Bearer $DC_TOKEN" + +# Verlauf einer Metrik, auf 15-Minuten-Fenster verdichtet +curl "https://dc.mhdf.de/api/watchdog/v1/metrics?source=polytrader-worker&metric=queue_depth&hours=24" \ + -H "Authorization: Bearer $DC_TOKEN" +``` + +Die Antwort enthält zusätzlich `deviation` — den Vergleich des aktuellen Werts +mit dem Durchschnitt der letzten sieben Tage desselben Monitors. Damit lassen +sich Auffälligkeiten erkennen, ohne für jedes Projekt Schwellwerte zu pflegen. + +Verschachtelte Werte werden flach abgelegt: `{"cpu":{"load":1.2}}` wird zu +`cpu.load`. Rohwerte werden 14 Tage aufbewahrt. + +### Abhängigkeiten + +Ist bei einem Monitor `parent_source` gesetzt und fällt der übergeordnete +Monitor aus, werden Alarme für die Kinder unterdrückt. Ihr Zustand bleibt im +Dashboard sichtbar — es entsteht nur nicht für jede VM eines ausgefallenen +Hypervisors eine eigene Meldung. + --- ## 10. Verfügbarkeit prüfen diff --git a/public/index.php b/public/index.php index 3bac72e..d0f651c 100644 --- a/public/index.php +++ b/public/index.php @@ -29,6 +29,7 @@ use Deploymentcenter\Core\Http; use Deploymentcenter\Core\Logger; use Deploymentcenter\Core\TokenManager as CoreTokenManager; use Deploymentcenter\Modules\Bugtracker\BugRepo; +use Deploymentcenter\Modules\Bugtracker\IgnoreRules; use Deploymentcenter\Modules\License\Audit; use Deploymentcenter\Modules\License\KeyGen; use Deploymentcenter\Modules\UpdateService\UpdateManager; @@ -854,6 +855,94 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { dc_redirect('#tab-bugtracker'); } + // ------------------------------------------------ Ignore-Regeln + case 'ir_create': { + $rules = new IgnoreRules($pdo); + + try { + $ruleId = $rules->create( + trim((string)($_POST['project_slug'] ?? '')), + (string)($_POST['match_type'] ?? 'contains'), + (string)($_POST['pattern'] ?? ''), + (string)($_POST['reason'] ?? ''), + (int)($_POST['alert_on_rate'] ?? 0), + 'user:' . $actor + ); + } catch (InvalidArgumentException $e) { + dc_flash('Regel nicht angelegt: ' . e($e->getMessage()), 'danger'); + dc_redirect('#sub-bugtracker-ignore'); + } + + // Bereits erfasste, passende Fehler gleich mit stummschalten - + // sonst muesste man sie einzeln nachpflegen. + $affected = 0; + if (!empty($_POST['apply_retroactively'])) { + $affected = $rules->applyRetroactively($ruleId); + } + + dc_flash(sprintf( + 'Regel #%d angelegt.%s', + $ruleId, + $affected > 0 ? sprintf(' %d bestehende(s) Item(s) wurden stummgeschaltet.', $affected) : '' + )); + dc_redirect('#sub-bugtracker-ignore'); + } + + case 'ir_toggle': { + $ruleId = (int)($_POST['rule_id'] ?? 0); + $enable = (int)($_POST['enable'] ?? 0) === 1; + + if ($ruleId > 0 && (new IgnoreRules($pdo))->setEnabled($ruleId, $enable)) { + dc_flash(sprintf('Regel #%d %s.', $ruleId, $enable ? 'aktiviert' : 'deaktiviert')); + } else { + dc_flash('Regel nicht gefunden.', 'danger'); + } + dc_redirect('#sub-bugtracker-ignore'); + } + + case 'ir_delete': { + $ruleId = (int)($_POST['rule_id'] ?? 0); + + if ($ruleId > 0 && (new IgnoreRules($pdo))->delete($ruleId)) { + // Items behalten ihren Status, verlieren aber die Bindung. + $pdo->prepare('UPDATE bugtracker_items SET ignore_rule_id = NULL WHERE ignore_rule_id = :id') + ->execute([':id' => $ruleId]); + dc_flash(sprintf('Regel #%d geloescht.', $ruleId)); + } else { + dc_flash('Regel nicht gefunden.', 'danger'); + } + dc_redirect('#sub-bugtracker-ignore'); + } + + case 'ir_apply': { + $ruleId = (int)($_POST['rule_id'] ?? 0); + $affected = $ruleId > 0 ? (new IgnoreRules($pdo))->applyRetroactively($ruleId) : 0; + + dc_flash(sprintf('%d bestehende(s) Item(s) durch Regel #%d stummgeschaltet.', $affected, $ruleId)); + dc_redirect('#sub-bugtracker-ignore'); + } + + // Einzelnes Item stummschalten oder wieder aufnehmen + case 'bt_set_ignored': { + $itemId = (int)($_POST['item_id'] ?? 0); + $ignore = (int)($_POST['ignore'] ?? 1) === 1; + + if ($itemId > 0) { + $ok = (new BugRepo($pdo))->updateItemDetails( + $itemId, + ['status' => $ignore ? 'ignored' : 'open'], + 'user:' . $actor + ); + dc_flash( + $ok + ? sprintf('Item #%d %s.', $itemId, $ignore ? 'stummgeschaltet' : 'wieder aufgenommen') + : 'Item nicht gefunden.', + $ok ? 'success' : 'danger' + ); + } + dc_redirect('#sub-bugtracker-errors'); + } + case 'bt_release_claim': { $itemId = (int)($_POST['item_id'] ?? 0); if ($itemId > 0) { @@ -1165,6 +1254,58 @@ function btUrl(array $overrides = []): string return 'index.php' . ($params !== [] ? '?' . http_build_query($params) : '') . '#tab-bugtracker'; } +// ---------------------------------------------------------------------- +// Fehler-Stream +// ---------------------------------------------------------------------- +$errPerPage = 40; +$errPage = max(1, (int)($_GET['err_page'] ?? 1)); +$errShow = (string)($_GET['err_show'] ?? 'active'); // active | ignored | all + +$errFilters = [ + 'project_slug' => (string)($_GET['err_project'] ?? 'all'), + 'environment' => (string)($_GET['err_env'] ?? 'all'), + 'error_level' => (string)($_GET['err_level'] ?? 'all'), + 'search' => (string)($_GET['err_q'] ?? ''), + 'since_hours' => (int)($_GET['err_hours'] ?? 0), + 'errors_only' => true, + 'order' => 'newest', + 'limit' => $errPerPage, + 'offset' => ($errPage - 1) * $errPerPage, +]; + +if ($errShow === 'ignored') { + $errFilters['only_ignored'] = true; +} elseif ($errShow === 'all') { + $errFilters['include_ignored'] = true; +} + +$errorResult = $bugRepo->getItems($errFilters); +$errorItems = $errorResult['items']; +$errorTotal = $errorResult['total']; +$errorPages = max(1, (int)ceil($errorTotal / $errPerPage)); + +$ignoreRuleRepo = new IgnoreRules($pdo); +$ignoreRules = $ignoreRuleRepo->all(); + +/** Baut eine URL mit den aktuellen Fehler-Filtern. */ +function errUrl(array $overrides = []): string +{ + $params = [ + 'err_project' => $_GET['err_project'] ?? null, + 'err_env' => $_GET['err_env'] ?? null, + 'err_level' => $_GET['err_level'] ?? null, + 'err_show' => $_GET['err_show'] ?? null, + 'err_hours' => $_GET['err_hours'] ?? null, + 'err_q' => $_GET['err_q'] ?? null, + 'err_page' => $_GET['err_page'] ?? null, + ]; + + $params = array_merge($params, $overrides); + $params = array_filter($params, static fn($v): bool => $v !== null && $v !== '' && $v !== 'all' && $v !== '0'); + + return 'index.php' . ($params !== [] ? '?' . http_build_query($params) : '') . '#sub-bugtracker-errors'; +} + // Agenten-Uebersicht: wer bearbeitet gerade was $agentWorkload = $pdo->query(' SELECT claimed_by, @@ -2811,6 +2952,418 @@ $csrfField = Csrf::field(); + +
+
+
+
Fehler (24 h)
+
+
+
+
💀 Offene Abstürze
+
+
+
+
🔇 Stummgeschaltete Gruppen
+
+
+
+
davon Vorkommnisse
+
+ +
+
+
+ + 0): ?> +
+ Vorkommnisse + wurden als bekannt eingestuft und aus dieser Ansicht herausgehalten — ohne sie zu verwerfen. + Der Zähler bleibt erhalten, damit eine auffällige Häufung trotzdem auffällt. +
+ + +
+
+

+ ⚡ Fehler-Stream + Gruppen +

+ +
+ + + + + + + + + + + + + Zurücksetzen +
+
+ + + + + + + + + + + + + + + + + + + + + + '💀 FATAL', + 'warning' => 'ℹ️ WARN', + default => '⚠️ ERROR', + }; + + // Rate im laufenden Fenster – auffällige Häufung sichtbar machen + $windowCount = (int)($item['rate_window_count'] ?? 0); + $windowFresh = !empty($item['rate_window_start']) + && (time() - (int)strtotime((string)$item['rate_window_start'] . ' UTC')) < 3600; + ?> + + + + + + + + + + + +
KlasseFehlerProjekt / UmgebungBuildAnzahlZuletztAktionen
+ Keine Fehler für diese Filterkombination. + +
Stummgeschaltete Einträge sind ausgeblendet — über „Nur stummgeschaltete“ sichtbar. + +
+ +
+ + + 🔇 stummgeschaltet + + + 1): ?> + + 📈 × in dieser Stunde + + + + 📄 + +
+
+ +
+
+ + × + + +
+ + +
+ + + + + +
+
+
+ + 1): ?> +
+ + Seite von ( Gruppen) + +
+ 1): ?> + ← Zurück + + + Weiter → + +
+
+ +
+
+ + +
+
+

🔇 Bekannte, harmlose Fehler

+

+ Ein Treffer bedeutet nicht „wegwerfen“: Der Fehler wird weiterhin erfasst und + hochgezählt, bleibt aber aus der Übersicht heraus und löst keine Benachrichtigung + aus. Der Zähler ist dabei der eigentliche Gewinn — dass ein bekannter Fehler + auftritt, ist normal; dass er plötzlich hundertmal so oft auftritt, ist ein Signal. + Dafür ist die Alarmschwelle da. +

+ +
+ + + +
+
+ + +
+
+ + +
+
+ + + 0 oder leer = nie alarmieren +
+
+ +
+ + +
+ +
+ + + + Pflichtfeld: In sechs Monaten weiß sonst niemand mehr, warum hier weggeschaut wird. + +
+ + + + +
+
+ +
+

Bestehende Regeln

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#ProjektMusterBegründungAlarmschwelleTrefferStatusAktionen
+ Noch keine Regeln angelegt. +
+ + ALLE + + + + + +
+
+ + + + /h + + + + + + + +
+ Item(s) + + · + +
+
+ + + + +
+
+ + + + + +
+ +
+ + + + +
+ +
+ + + + +
+
+
+
+ +
+

💻 Anbindung

+

+ Ein Aufruf, den man in den globalen Exception-Handler hängt. Titel und + Dringlichkeit leitet der Server ab, die Regeln oben greifen automatisch. +

+
POST /api/errors/v1/report +Authorization: Bearer <DC_TOKEN> +Content-Type: application/json + +{ + "project_slug": "polytrader", + "exception": "PDOException", + "message": "SQLSTATE[23000]: Duplicate entry '...' for key 'uq_market'", + "stack_trace": "...", + "level": "error", // fatal = Prozess beendet + "build": "v2.0.1", + "environment": "production", + "file": "src/Market/Importer.php", + "line": 142 +} + +Antwort: +{ "status": "success", "item_id": 42, "ignored": true, + "occurrence_count": 3841, "rate_alerted": false, + "message": "Als bekannt eingestuft, gezaehlt, nicht gemeldet." }
+
+
+ @@ -3226,6 +3779,8 @@ SYSTEM ], 'bugtracker': [ { id: 'sub-bugtracker-items', label: '🐛 Bugs & Features', active: true }, + { id: 'sub-bugtracker-errors', label: '⚡ Fehler-Stream' }, + { id: 'sub-bugtracker-ignore', label: '🔇 Ignore-Regeln' }, { id: 'sub-bugtracker-new', label: '➕ Item Anlegen' } ], 'tokens': [ @@ -3399,6 +3954,26 @@ SYSTEM }); } + const levelBadge = item.error_level + ? `${esc(item.error_level).toUpperCase()}` + : ''; + + // Stummgeschaltet: Grund der Regel sichtbar machen, damit + // nicht geraten werden muss, warum hier weggeschaut wird. + const ignoreBanner = (item.status === 'ignored') + ? `
+ 🔇 Als bekannt eingestuft${item.ignore_rule_id ? ` durch Regel #${esc(item.ignore_rule_id)}` : ''}. + Wird weiter gezählt (${esc(item.occurrence_count)}×), löst aber keine Benachrichtigung aus. +
` + : ''; + + const rateInfo = (item.rate_window_count && Number(item.rate_window_count) > 1) + ? `
+ 📈 ${esc(item.rate_window_count)} Vorkommnisse im laufenden Stundenfenster + ${item.rate_alerted_at ? `· zuletzt alarmiert ${esc(item.rate_alerted_at)} UTC` : ''} +
` + : ''; + const claimBanner = (item.claimed_by && item.lease_until) ? `
🤖 In Bearbeitung durch ${esc(item.claimed_by)} bis ${esc(item.lease_until)} UTC @@ -3426,6 +4001,7 @@ SYSTEM ${esc(item.type).toUpperCase()} ${esc(item.environment).toUpperCase()} ${esc(item.status).toUpperCase()} + ${levelBadge}

#${esc(item.id)}: ${esc(item.title)}

Projekt: ${esc(item.project_slug)} @@ -3437,7 +4013,9 @@ SYSTEM
+ ${ignoreBanner} ${claimBanner} + ${rateInfo} ${item.description ? `
diff --git a/sql/migrations/007_error_stream_and_metrics.sql b/sql/migrations/007_error_stream_and_metrics.sql new file mode 100644 index 0000000..496f4ab --- /dev/null +++ b/sql/migrations/007_error_stream_and_metrics.sql @@ -0,0 +1,101 @@ +-- Migration 007: Fehler-Stream, Ignore-Regeln, Metrik-Verlauf, Health-Checks +-- +-- Additive Migration. Der Migrator toleriert 1050/1060/1061/1062. + +-- --------------------------------------------------------------------------- +-- 1. Fehlerklasse getrennt vom Schweregrad +-- --------------------------------------------------------------------------- +-- severity beschreibt die geschaeftliche Dringlichkeit (low ... critical), +-- error_level die technische Art des Ereignisses. Ein "Duplicate entry" ist +-- technisch ein error, geschaeftlich aber belanglos - beides zu vermischen +-- war der Grund, warum solche Meldungen bisher als Bug im Dashboard landeten. +ALTER TABLE bugtracker_items + ADD COLUMN error_level ENUM('fatal','error','warning') NULL AFTER severity; + +-- Status "ignored": bekannt, harmlos, wird weiter gezaehlt, aber nicht gemeldet. +ALTER TABLE bugtracker_items + MODIFY COLUMN status ENUM('open','planned','in_progress','resolved','closed','rejected','ignored') + NOT NULL DEFAULT 'open'; + +-- Rollendes Stundenfenster fuer die Ratenerkennung. Interessant ist nicht, +-- DASS ein bekannter Fehler auftritt, sondern wenn er ploetzlich viel +-- haeufiger auftritt. +ALTER TABLE bugtracker_items ADD COLUMN rate_window_start DATETIME NULL AFTER occurrence_count; +ALTER TABLE bugtracker_items ADD COLUMN rate_window_count INT NOT NULL DEFAULT 0 AFTER rate_window_start; +ALTER TABLE bugtracker_items ADD COLUMN rate_alerted_at DATETIME NULL AFTER rate_window_count; + +-- Welche Regel hat dieses Item stummgeschaltet? +ALTER TABLE bugtracker_items ADD COLUMN ignore_rule_id INT NULL AFTER error_level; + +ALTER TABLE bugtracker_items ADD KEY ix_bt_error_level (error_level, last_seen_at); + +-- --------------------------------------------------------------------------- +-- 2. Ignore-Regeln +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS bugtracker_ignore_rules ( + id INT AUTO_INCREMENT PRIMARY KEY, + project_slug VARCHAR(64) NULL, -- NULL = gilt fuer alle Projekte + match_type ENUM('contains','regex','exception_class') NOT NULL DEFAULT 'contains', + pattern VARCHAR(255) NOT NULL, + -- Warum ist das harmlos? Pflichtfeld, damit in sechs Monaten noch + -- nachvollziehbar ist, weshalb hier weggeschaut wird. + reason TEXT NOT NULL, + -- Ab wie vielen Vorkommnissen pro Stunde soll trotzdem alarmiert werden? + -- NULL = nie alarmieren. + alert_on_rate INT NULL, + enabled TINYINT(1) NOT NULL DEFAULT 1, + match_count BIGINT NOT NULL DEFAULT 0, + last_match_at DATETIME NULL, + created_by VARCHAR(100) NOT NULL DEFAULT 'admin', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY ix_ignore_project (project_slug, enabled) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- --------------------------------------------------------------------------- +-- 3. Metrik-Verlauf +-- --------------------------------------------------------------------------- +-- Bisher wurde metrics_json bei jedem Heartbeat ueberschrieben - es gab immer +-- nur den letzten Moment. Damit laesst sich "Platte laeuft seit drei Tagen +-- voll" nicht erkennen, sondern nur "Platte ist voll". +CREATE TABLE IF NOT EXISTS watchdog_metrics ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + source VARCHAR(100) NOT NULL, + instance VARCHAR(100) NOT NULL DEFAULT 'default', + metric_key VARCHAR(64) NOT NULL, + metric_value DOUBLE NOT NULL, + recorded_utc DATETIME NOT NULL, + KEY ix_metric_lookup (source, instance, metric_key, recorded_utc), + KEY ix_metric_time (recorded_utc) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- --------------------------------------------------------------------------- +-- 4. Health-Checks per Push +-- --------------------------------------------------------------------------- +-- Die Anwendung sendet ihren Gesundheitszustand im Heartbeat mit. Das +-- Deploymentcenter interpretiert die Namen der Pruefungen nicht - es liest +-- nur ok und message. Damit muessen auf den Zielmaschinen keine Ports +-- geoeffnet werden. +ALTER TABLE watchdog_monitors ADD COLUMN health_json JSON NULL AFTER metrics_json; +ALTER TABLE watchdog_monitors ADD COLUMN failing_checks VARCHAR(255) NULL AFTER health_json; + +-- Alarme fuer Kinder unterdruecken, solange der Parent unten ist. +ALTER TABLE watchdog_monitors ADD COLUMN alert_suppressed_by VARCHAR(100) NULL AFTER failing_checks; + +-- Aufraeumauftrag fuer den Metrik-Verlauf +INSERT INTO watchdog_cron_jobs (name, interval_sec, enabled) VALUES +('metrics_cleanup', 86400, 1) +ON DUPLICATE KEY UPDATE interval_sec = VALUES(interval_sec); + +-- --------------------------------------------------------------------------- +-- 5. Beispielregel, damit der Aufbau sofort erkennbar ist +-- --------------------------------------------------------------------------- +-- Bewusst deaktiviert (enabled = 0): sie dient als Vorlage und greift erst, +-- wenn sie im WebUI bewusst eingeschaltet wird. +INSERT INTO bugtracker_ignore_rules + (project_slug, match_type, pattern, reason, alert_on_rate, enabled, created_by) +SELECT 'polytrader', 'contains', 'Duplicate entry', + 'Marktdaten kommen doppelt aus dem Feed. Der Eintrag wird ohnehin nur einmal benoetigt, die Verarbeitung laeuft normal weiter. Alarm erst bei auffaelliger Haeufung.', + 500, 0, 'system:vorlage' +WHERE NOT EXISTS ( + SELECT 1 FROM bugtracker_ignore_rules WHERE project_slug = 'polytrader' AND pattern = 'Duplicate entry' +); diff --git a/src/Modules/Bugtracker/BugRepo.php b/src/Modules/Bugtracker/BugRepo.php index c63e7bb..729c983 100644 --- a/src/Modules/Bugtracker/BugRepo.php +++ b/src/Modules/Bugtracker/BugRepo.php @@ -30,11 +30,20 @@ final class BugRepo public const TYPES = ['bug', 'feature_request']; public const ENVIRONMENTS = ['production', 'development', 'staging', 'testing']; public const SEVERITIES = ['idea', 'wishlist', 'low', 'medium', 'high', 'critical']; - public const STATUSES = ['open', 'planned', 'in_progress', 'resolved', 'closed', 'rejected']; + public const STATUSES = ['open', 'planned', 'in_progress', 'resolved', 'closed', 'rejected', 'ignored']; + + /** + * Technische Art des Ereignisses, unabhaengig von der Dringlichkeit. + * Ein "Duplicate entry" ist technisch ein error, geschaeftlich belanglos. + */ + public const ERROR_LEVELS = ['fatal', 'error', 'warning']; /** Status, in denen ein Item als offen/aktiv gilt. */ public const ACTIVE_STATUSES = ['open', 'planned', 'in_progress']; + /** Laenge des rollenden Fensters fuer die Ratenerkennung, in Minuten. */ + private const RATE_WINDOW_MINUTES = 60; + private const DEFAULT_LIMIT = 100; private const MAX_LIMIT = 500; @@ -77,6 +86,17 @@ final class BugRepo $createdBy = self::text($data['created_by'] ?? null) ?? 'agent'; $clientRef = self::text($data['client_ref'] ?? null); + $exceptionClass = self::text($data['exception'] ?? $data['exception_class'] ?? null); + $errorLevel = self::oneOfOrNull($data['error_level'] ?? null, self::ERROR_LEVELS); + + // Bekannte, harmlose Fehler werden erfasst und gezaehlt, aber nicht + // gemeldet. Der Zaehler bleibt erhalten, damit eine auffaellige + // Haeufung trotzdem auffaellt. + $ignoreRules = new IgnoreRules($this->db); + $ignoreRule = $type === 'bug' + ? $ignoreRules->match($projectSlug, $exceptionClass, $errorMessage, $title) + : null; + // Strukturierter Code-Kontext $repoUrl = self::text($data['repo_url'] ?? null); $gitBranch = self::text($data['git_branch'] ?? null); @@ -107,11 +127,26 @@ final class BugRepo } // --- 2. Deduplizierung ueber einen stabilen Schluessel --- - $dedupKey = self::buildDedupKey($type, $projectSlug, $environment, $title, $errorMessage, $stackTrace); + $dedupKey = self::buildDedupKey( + $type, + $projectSlug, + $environment, + $title, + $errorMessage, + $stackTrace, + $exceptionClass, + $filePath + ); $duplicate = $this->findByDedupKey($dedupKey); - if ($duplicate !== null && in_array($duplicate['status'], self::ACTIVE_STATUSES, true)) { - return $this->registerRecurrence($duplicate, $pushId, $buildVersion, $severity); + // Auch stummgeschaltete Items werden weiter hochgezaehlt. + $countable = array_merge(self::ACTIVE_STATUSES, ['ignored']); + + if ($duplicate !== null && in_array($duplicate['status'], $countable, true)) { + if ($ignoreRule !== null) { + $ignoreRules->recordMatch((int)$ignoreRule['id']); + } + return $this->registerRecurrence($duplicate, $pushId, $buildVersion, $severity, $ignoreRule); } // Bereits geloest und tritt erneut auf: neues Item mit Verweis auf das alte. @@ -148,19 +183,23 @@ final class BugRepo // --- 3. Neues Item anlegen --- $projectId = $this->projectIdForSlug($projectSlug); + $initialStatus = $ignoreRule !== null ? 'ignored' : 'open'; + $stmt = $this->db->prepare(' INSERT INTO bugtracker_items ( project_id, project_slug, type, title, description, error_message, stack_trace, error_hash, dedup_key, build_version, repo_url, git_branch, commit_sha, file_path, line_no, context_json, - environment, severity, status, occurrence_count, + environment, severity, error_level, ignore_rule_id, status, occurrence_count, + rate_window_start, rate_window_count, push_id, target_agent, tags, client_ref, regression_of, first_seen_at, last_seen_at, created_by, created_at ) VALUES ( :project_id, :project_slug, :type, :title, :description, :error_message, :stack_trace, :error_hash, :dedup_key, :build_version, :repo_url, :git_branch, :commit_sha, :file_path, :line_no, :context_json, - :environment, :severity, "open", 1, + :environment, :severity, :error_level, :ignore_rule_id, :status, 1, + UTC_TIMESTAMP(), 1, :push_id, :target_agent, :tags, :client_ref, :regression_of, UTC_TIMESTAMP(), UTC_TIMESTAMP(), :created_by, UTC_TIMESTAMP() ) @@ -186,6 +225,9 @@ final class BugRepo ':context_json' => $contextJson, ':environment' => $environment, ':severity' => $severity, + ':error_level' => $errorLevel, + ':ignore_rule_id' => $ignoreRule !== null ? (int)$ignoreRule['id'] : null, + ':status' => $initialStatus, ':push_id' => $pushId, ':target_agent' => $targetAgent, ':tags' => $tags, @@ -222,6 +264,40 @@ final class BugRepo $newId = (int)$this->db->lastInsertId(); + if ($ignoreRule !== null) { + $ignoreRules->recordMatch((int)$ignoreRule['id']); + + $this->addComment( + $newId, + 'system', + sprintf( + "Automatisch stummgeschaltet durch Regel #%d (%s: \"%s\").\n\nBegruendung: %s", + $ignoreRule['id'], + $ignoreRule['match_type'], + $ignoreRule['pattern'], + $ignoreRule['reason'] + ), + 'auto_ignored' + ); + + // Bewusst keine Benachrichtigung: genau dafuer ist die Regel da. + return [ + 'id' => $newId, + 'is_new' => true, + 'idempotent_hit' => false, + 'occurrence_count' => 1, + 'dedup_key' => $dedupKey, + 'error_hash' => $dedupKey, + 'type' => $type, + 'status' => 'ignored', + 'ignored' => true, + 'ignore_rule_id' => (int)$ignoreRule['id'], + 'environment' => $environment, + 'push_id' => $pushId, + 'regression_of' => $regressionOf, + ]; + } + $note = $type === 'bug' ? 'Bug im System erfasst.' : ($severity === 'idea' ? 'Neue Idee hinterlegt.' : 'Feature-Request eingereicht.'); @@ -232,13 +308,15 @@ final class BugRepo 'environment' => $environment, 'project_slug' => $projectSlug, 'title' => $title, + 'error_level' => $errorLevel, ]); - if ($severity === 'critical') { + if ($severity === 'critical' || $errorLevel === 'fatal') { $this->notify('bug.critical', $newId, [ 'project_slug' => $projectSlug, 'title' => $title, 'environment' => $environment, + 'error_level' => $errorLevel, ]); } @@ -251,26 +329,54 @@ final class BugRepo 'error_hash' => $type === 'bug' ? $dedupKey : null, 'type' => $type, 'status' => 'open', + 'ignored' => false, 'environment' => $environment, 'push_id' => $pushId, 'regression_of' => $regressionOf, ]; } - /** Zaehlt ein wiederkehrendes Vorkommnis hoch. */ - private function registerRecurrence(array $existing, ?string $pushId, string $build, string $severity): array - { + /** + * Zaehlt ein wiederkehrendes Vorkommnis hoch und pflegt das Ratenfenster. + * + * @param array|null $ignoreRule + */ + private function registerRecurrence( + array $existing, + ?string $pushId, + string $build, + string $severity, + ?array $ignoreRule = null + ): array { + $itemId = (int)$existing['id']; + $isIgnored = (string)$existing['status'] === 'ignored'; $newCount = (int)$existing['occurrence_count'] + 1; // Eskalation: ein bereits offener Bug, der erneut mit hoeherem // Schweregrad gemeldet wird, wird hochgestuft - nie herabgestuft. + // Fuer stummgeschaltete Items entfaellt das; dort entscheidet die Rate. $currentRank = array_search((string)$existing['severity'], self::SEVERITIES, true); $incomingRank = array_search($severity, self::SEVERITIES, true); - $escalate = is_int($currentRank) && is_int($incomingRank) && $incomingRank > $currentRank; + $escalate = !$isIgnored + && is_int($currentRank) + && is_int($incomingRank) + && $incomingRank > $currentRank; + // Reihenfolge beachten: rate_window_count muss den alten Wert von + // rate_window_start sehen und steht deshalb davor. $stmt = $this->db->prepare(' UPDATE bugtracker_items - SET occurrence_count = :count, + SET rate_window_count = IF( + rate_window_start IS NULL + OR rate_window_start < UTC_TIMESTAMP() - INTERVAL ' . self::RATE_WINDOW_MINUTES . ' MINUTE, + 1, rate_window_count + 1 + ), + rate_window_start = IF( + rate_window_start IS NULL + OR rate_window_start < UTC_TIMESTAMP() - INTERVAL ' . self::RATE_WINDOW_MINUTES . ' MINUTE, + UTC_TIMESTAMP(), rate_window_start + ), + occurrence_count = :count, last_seen_at = UTC_TIMESTAMP(), push_id = COALESCE(:push_id, push_id), build_version = COALESCE(:build, build_version), @@ -283,20 +389,22 @@ final class BugRepo ':build' => $build, ':escalate' => $escalate ? 1 : 0, ':severity' => $severity, - ':id' => (int)$existing['id'], + ':id' => $itemId, ]); if ($escalate) { $this->addComment( - (int)$existing['id'], + $itemId, 'system', sprintf('Schweregrad automatisch hochgestuft: %s -> %s (erneutes Auftreten).', $existing['severity'], $severity), 'severity_escalated' ); } + $rateAlerted = $this->checkRateThreshold($itemId, $ignoreRule, $existing); + return [ - 'id' => (int)$existing['id'], + 'id' => $itemId, 'is_new' => false, 'idempotent_hit' => false, 'occurrence_count' => $newCount, @@ -304,11 +412,95 @@ final class BugRepo 'error_hash' => $existing['error_hash'], 'type' => $existing['type'], 'status' => $existing['status'], + 'ignored' => $isIgnored, + 'rate_alerted' => $rateAlerted, 'environment' => $existing['environment'], 'push_id' => $pushId ?? $existing['push_id'], ]; } + /** + * Prueft, ob ein stummgeschalteter Fehler auffaellig haeufig auftritt. + * + * Das ist der eigentliche Grund, warum bekannte Fehler weiter gezaehlt und + * nicht verworfen werden: Dass ein Duplicate-Entry auftritt, ist normal. + * Dass er ploetzlich hundertmal so oft auftritt, bedeutet, dass sich am + * Datenfeed etwas geaendert hat. + * + * @param array|null $ignoreRule + * @param array $existing + */ + private function checkRateThreshold(int $itemId, ?array $ignoreRule, array $existing): bool + { + $threshold = null; + + if ($ignoreRule !== null && $ignoreRule['alert_on_rate'] !== null) { + $threshold = (int)$ignoreRule['alert_on_rate']; + } elseif (!empty($existing['ignore_rule_id'])) { + // Regel ueber das Item nachschlagen, wenn sie nicht mitgeliefert wurde. + $lookup = $this->db->prepare('SELECT alert_on_rate, pattern, reason FROM bugtracker_ignore_rules WHERE id = :id'); + $lookup->execute([':id' => (int)$existing['ignore_rule_id']]); + $row = $lookup->fetch(); + + if (is_array($row) && $row['alert_on_rate'] !== null) { + $threshold = (int)$row['alert_on_rate']; + $ignoreRule = $row; + } + } + + if ($threshold === null || $threshold <= 0) { + return false; + } + + $stmt = $this->db->prepare(' + SELECT rate_window_count, rate_alerted_at, rate_window_start, project_slug, title + FROM bugtracker_items WHERE id = :id + '); + $stmt->execute([':id' => $itemId]); + $current = $stmt->fetch(); + + if (!is_array($current) || (int)$current['rate_window_count'] < $threshold) { + return false; + } + + // Nur einmal je Fenster alarmieren, sonst entsteht genau der + // Meldungssturm, den die Regel verhindern soll. + if ($current['rate_alerted_at'] !== null + && $current['rate_window_start'] !== null + && $current['rate_alerted_at'] >= $current['rate_window_start']) { + return false; + } + + $this->db->prepare('UPDATE bugtracker_items SET rate_alerted_at = UTC_TIMESTAMP() WHERE id = :id') + ->execute([':id' => $itemId]); + + $message = sprintf( + 'Auffaellige Haeufung: %d Vorkommnisse in %d Minuten (Schwelle %d). ' + . 'Der Fehler gilt als bekannt und harmlos, tritt aber deutlich haeufiger auf als erwartet.', + (int)$current['rate_window_count'], + self::RATE_WINDOW_MINUTES, + $threshold + ); + + $this->addComment($itemId, 'system', $message, 'rate_exceeded'); + + $this->notify('error.rate_exceeded', $itemId, [ + 'project_slug' => $current['project_slug'], + 'title' => $current['title'], + 'count' => (int)$current['rate_window_count'], + 'threshold' => $threshold, + 'window_min' => self::RATE_WINDOW_MINUTES, + ]); + + Logger::warning('Ratenschwelle eines stummgeschalteten Fehlers ueberschritten', [ + 'item_id' => $itemId, + 'count' => (int)$current['rate_window_count'], + 'threshold' => $threshold, + ]); + + return true; + } + // ================================================================== // Abfragen // ================================================================== @@ -399,8 +591,15 @@ final class BugRepo } } - // status und severity duerfen als Liste kommen: status=open,in_progress - foreach (['status' => self::STATUSES, 'severity' => self::SEVERITIES] as $key => $allowed) { + // status, severity und error_level duerfen als Liste kommen, + // z. B. status=open,in_progress + $listFilters = [ + 'status' => self::STATUSES, + 'severity' => self::SEVERITIES, + 'error_level' => self::ERROR_LEVELS, + ]; + + foreach ($listFilters as $key => $allowed) { $value = $filters[$key] ?? null; if ($value === null || $value === '' || $value === 'all') { continue; @@ -438,6 +637,33 @@ final class BugRepo $where[] = '(lease_until IS NULL OR lease_until < UTC_TIMESTAMP())'; } + // Stummgeschaltete Fehler bleiben standardmaessig aussen vor. Sie sind + // bekannt und harmlos - genau deshalb sollen sie die Uebersicht nicht + // fuellen. Wer sie sehen will, fragt sie ausdruecklich an. + $includeIgnored = isset($filters['include_ignored']) + && filter_var($filters['include_ignored'], FILTER_VALIDATE_BOOLEAN); + $onlyIgnored = isset($filters['only_ignored']) + && filter_var($filters['only_ignored'], FILTER_VALIDATE_BOOLEAN); + + $statusRequested = ($filters['status'] ?? 'all') !== 'all' && ($filters['status'] ?? '') !== ''; + + if ($onlyIgnored) { + $where[] = 'status = "ignored"'; + } elseif (!$includeIgnored && !$statusRequested) { + $where[] = 'status <> "ignored"'; + } + + // Nur Eintraege, die ueber die Fehler-Schnittstelle kamen. + if (!empty($filters['errors_only'])) { + $where[] = 'error_level IS NOT NULL'; + } + + // Zeitraum, z. B. "letzte 24 Stunden" + $sinceHours = isset($filters['since_hours']) ? (int)$filters['since_hours'] : 0; + if ($sinceHours > 0) { + $where[] = 'last_seen_at > (UTC_TIMESTAMP() - INTERVAL ' . min($sinceHours, 8760) . ' HOUR)'; + } + // Delta-Abfrage fuer Polling. $since = $filters['updated_since'] ?? null; if (is_string($since) && trim($since) !== '') { @@ -759,6 +985,19 @@ final class BugRepo $params[':line_no'] = is_numeric($updates['line_no']) ? (int)$updates['line_no'] : null; } + if (array_key_exists('error_level', $updates)) { + $fields[] = 'error_level = :error_level'; + $params[':error_level'] = self::oneOfOrNull($updates['error_level'], self::ERROR_LEVELS); + } + + // Wird ein stummgeschaltetes Item wieder geoeffnet, verliert es die + // Regelbindung - sonst wuerde das naechste Vorkommnis es sofort + // wieder stummschalten. + if (isset($updates['status']) && $updates['status'] !== 'ignored' + && (string)($existing['status'] ?? '') === 'ignored') { + $fields[] = 'ignore_rule_id = NULL'; + } + if ($fields === []) { return true; } @@ -902,7 +1141,17 @@ final class BugRepo SUM(status = "resolved") AS resolved_total, SUM(type = "bug" AND severity = "critical" AND status IN ("open","in_progress")) AS critical_bugs, SUM(claimed_by IS NOT NULL AND lease_until > UTC_TIMESTAMP()) AS in_progress_by_agents, - SUM(status IN ("open","planned","in_progress")) AS open_total + SUM(status IN ("open","planned","in_progress")) AS open_total, + + -- Fehler-Stream + SUM(error_level = "fatal" AND status IN ("open","in_progress")) AS fatal_open, + SUM(error_level IS NOT NULL AND status = "ignored") AS ignored_groups, + -- Nur was noch offen ist: bereits abgehakte oder stummgeschaltete + -- Gruppen sind keine aktuellen Fehler mehr. + SUM(error_level IS NOT NULL + AND status IN ("open","planned","in_progress") + AND last_seen_at > UTC_TIMESTAMP() - INTERVAL 24 HOUR) AS errors_24h, + COALESCE(SUM(CASE WHEN status = "ignored" THEN occurrence_count ELSE 0 END), 0) AS ignored_occurrences FROM bugtracker_items' . $where; $stmt = $this->db->prepare($sql); @@ -912,6 +1161,7 @@ final class BugRepo $keys = [ 'open_bugs_prod', 'open_bugs_dev', 'open_features', 'ideas_count', 'resolved_total', 'critical_bugs', 'in_progress_by_agents', 'open_total', + 'fatal_open', 'ignored_groups', 'errors_24h', 'ignored_occurrences', ]; $stats = []; @@ -995,14 +1245,17 @@ final class BugRepo string $environment, string $title, ?string $errorMessage, - ?string $stackTrace + ?string $stackTrace, + ?string $exceptionClass = null, + ?string $filePath = null ): string { if ($type === 'bug') { $signature = implode('|', [ $projectSlug, $environment, + $exceptionClass !== null ? mb_strtolower(trim($exceptionClass)) : '', self::normalizeForHash($errorMessage ?? $title), - self::normalizeForHash(mb_substr($stackTrace ?? '', 0, 300)), + self::originOf($stackTrace, $filePath), ]); } else { $signature = implode('|', [ @@ -1015,6 +1268,39 @@ final class BugRepo return substr(hash('sha256', $signature), 0, 40); } + /** + * Stabile Herkunftsangabe fuer den Deduplizierungsschluessel. + * + * Zuvor flossen 300 Zeichen Stacktrace ein. Damit zersplitterte derselbe + * Fehler in mehrere Gruppen, sobald ein Aufrufer den Stack einmal mitschickte + * und einmal nicht - oder wenn er aus unterschiedlicher Aufruftiefe kam. + * Jetzt zaehlt nur der Ursprungsort: bevorzugt die ausdrueckliche + * Dateiangabe, sonst der erste verwertbare Rahmen des Stacktrace. + */ + private static function originOf(?string $stackTrace, ?string $filePath): string + { + if ($filePath !== null && trim($filePath) !== '') { + // Zeilennummern verschieben sich bei jeder Aenderung an der Datei; + // die Datei selbst bleibt dieselbe Fehlerquelle. + return mb_strtolower(trim($filePath)); + } + + if ($stackTrace === null || trim($stackTrace) === '') { + return ''; + } + + foreach (preg_split('/\r?\n/', trim($stackTrace)) ?: [] as $line) { + $line = trim($line); + if ($line === '') { + continue; + } + // Erster nicht leerer Rahmen, von veraenderlichen Anteilen befreit. + return self::normalizeForHash(mb_substr($line, 0, 200)); + } + + return ''; + } + /** * Entfernt Rauschen, das denselben Fehler sonst als neu erscheinen liesse: * Zeilennummern, Speicheradressen, Zeitstempel, GUIDs, Mehrfach-Leerzeichen. @@ -1022,13 +1308,25 @@ final class BugRepo private static function normalizeForHash(string $value): string { $value = mb_strtolower(trim($value)); + $patterns = [ - '/0x[0-9a-f]+/' => '0xADDR', + '/0x[0-9a-f]+/' => '0xADDR', '/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/' => 'GUID', - '/\d{4}-\d{2}-\d{2}[t ]\d{2}:\d{2}:\d{2}/' => 'TIMESTAMP', - '/:line \d+/' => ':line N', - '/\b\d{4,}\b/' => 'N', - '/\s+/' => ' ', + '/\d{4}-\d{2}-\d{2}[t ]\d{2}:\d{2}:\d{2}/' => 'TIMESTAMP', + + // Werte in Anfuehrungszeichen, die mindestens eine Ziffer + // enthalten, sind praktisch immer konkrete Datensatzwerte und + // gehoeren nicht zur Identitaet des Fehlers: + // Duplicate entry 'MKT-88213' for key 'uq_market' + // Der Schluesselname bleibt erhalten, weil er ohne Ziffern + // auskommt - unterschiedliche Unique-Keys bleiben damit + // unterscheidbar. + "/'[^']*\d[^']*'/" => "'VALUE'", + '/"[^"]*\d[^"]*"/' => '"VALUE"', + + '/:line \d+/' => ':line N', + '/\b\d{3,}\b/' => 'N', + '/\s+/' => ' ', ]; foreach ($patterns as $pattern => $replacement) { @@ -1072,4 +1370,15 @@ final class BugRepo } return $default; } + + /** + * Wie oneOf(), liefert aber null statt eines Standardwerts. + * + * @param mixed $value + * @param list $allowed + */ + private static function oneOfOrNull($value, array $allowed): ?string + { + return is_string($value) && in_array($value, $allowed, true) ? $value : null; + } } diff --git a/src/Modules/Bugtracker/IgnoreRules.php b/src/Modules/Bugtracker/IgnoreRules.php new file mode 100644 index 0000000..c25af0e --- /dev/null +++ b/src/Modules/Bugtracker/IgnoreRules.php @@ -0,0 +1,278 @@ +>>|null Regeln je Projekt, pro Request zwischengespeichert */ + private static ?array $cache = null; + + private PDO $db; + + public function __construct(PDO $db) + { + $this->db = $db; + } + + /** + * Sucht die erste zutreffende Regel. + * + * @return array|null + */ + public function match(string $projectSlug, ?string $exceptionClass, ?string $message, ?string $title = null): ?array + { + $haystack = trim(implode("\n", array_filter([$exceptionClass, $message, $title]))); + if ($haystack === '') { + return null; + } + + foreach ($this->rulesFor($projectSlug) as $rule) { + if (self::ruleMatches($rule, $exceptionClass, $haystack)) { + return $rule; + } + } + + return null; + } + + /** @param array $rule */ + private static function ruleMatches(array $rule, ?string $exceptionClass, string $haystack): bool + { + $pattern = (string)$rule['pattern']; + if ($pattern === '') { + return false; + } + + switch ((string)$rule['match_type']) { + case 'exception_class': + return $exceptionClass !== null + && strcasecmp(trim($exceptionClass), $pattern) === 0; + + case 'regex': + // Das Muster stammt aus der Verwaltungsoberflaeche und wird als + // reiner Ausdruck ohne Begrenzer gespeichert. Der Begrenzer wird + // hier gesetzt, damit kein eigener Modifikator untergeschoben + // werden kann. + $delimited = '/' . str_replace('/', '\/', $pattern) . '/i'; + $result = @preg_match($delimited, $haystack); + + if ($result === false) { + Logger::warning('Ignore-Regel enthaelt einen ungueltigen regulaeren Ausdruck', [ + 'rule_id' => $rule['id'] ?? null, + 'pattern' => $pattern, + ]); + return false; + } + + return $result === 1; + + case 'contains': + default: + return stripos($haystack, $pattern) !== false; + } + } + + /** + * Regeln des Projekts plus die projektuebergreifenden. + * + * @return list> + */ + private function rulesFor(string $projectSlug): array + { + if (self::$cache === null) { + self::$cache = []; + + try { + $rows = $this->db->query(' + SELECT * FROM bugtracker_ignore_rules + WHERE enabled = 1 + ORDER BY project_slug IS NULL ASC, id ASC + ')->fetchAll() ?: []; + + foreach ($rows as $row) { + $key = $row['project_slug'] !== null && $row['project_slug'] !== '' + ? (string)$row['project_slug'] + : '*'; + self::$cache[$key][] = $row; + } + } catch (\Throwable $e) { + // Tabelle fehlt (Migration noch nicht gelaufen): dann greift + // eben keine Regel. Kein Grund, die Erfassung scheitern zu lassen. + Logger::warning('Ignore-Regeln nicht ladbar', ['error' => $e->getMessage()]); + self::$cache = []; + } + } + + return array_merge( + self::$cache[$projectSlug] ?? [], + self::$cache['*'] ?? [] + ); + } + + /** Vermerkt einen Treffer fuer die Statistik in der Verwaltungsansicht. */ + public function recordMatch(int $ruleId): void + { + try { + $stmt = $this->db->prepare(' + UPDATE bugtracker_ignore_rules + SET match_count = match_count + 1, last_match_at = UTC_TIMESTAMP() + WHERE id = :id + '); + $stmt->execute([':id' => $ruleId]); + } catch (\Throwable $e) { + Logger::warning('Treffer der Ignore-Regel nicht vermerkt', ['rule_id' => $ruleId]); + } + } + + // ------------------------------------------------------------------ + // Verwaltung + // ------------------------------------------------------------------ + + /** @return list> */ + public function all(): array + { + $stmt = $this->db->query(' + SELECT r.*, + (SELECT COUNT(*) FROM bugtracker_items i WHERE i.ignore_rule_id = r.id) AS item_count + FROM bugtracker_ignore_rules r + ORDER BY r.enabled DESC, r.last_match_at DESC, r.id DESC + '); + return $stmt->fetchAll() ?: []; + } + + public function create( + ?string $projectSlug, + string $matchType, + string $pattern, + string $reason, + ?int $alertOnRate, + string $createdBy + ): int { + $pattern = trim($pattern); + $reason = trim($reason); + + if ($pattern === '') { + throw new \InvalidArgumentException('Das Suchmuster darf nicht leer sein.'); + } + if ($reason === '') { + throw new \InvalidArgumentException('Bitte begruenden, warum dieser Fehler harmlos ist.'); + } + if (!in_array($matchType, self::MATCH_TYPES, true)) { + $matchType = 'contains'; + } + + // Ungueltige Ausdruecke sollen beim Anlegen auffallen, nicht spaeter + // still bei jedem eingehenden Fehler. + if ($matchType === 'regex' && @preg_match('/' . str_replace('/', '\/', $pattern) . '/i', '') === false) { + throw new \InvalidArgumentException('Der regulaere Ausdruck ist ungueltig.'); + } + + $stmt = $this->db->prepare(' + INSERT INTO bugtracker_ignore_rules + (project_slug, match_type, pattern, reason, alert_on_rate, enabled, created_by, created_at) + VALUES (:slug, :match_type, :pattern, :reason, :rate, 1, :created_by, UTC_TIMESTAMP()) + '); + $stmt->execute([ + ':slug' => $projectSlug !== null && $projectSlug !== '' && $projectSlug !== 'all' ? $projectSlug : null, + ':match_type' => $matchType, + ':pattern' => $pattern, + ':reason' => $reason, + ':rate' => $alertOnRate !== null && $alertOnRate > 0 ? $alertOnRate : null, + ':created_by' => $createdBy, + ]); + + self::$cache = null; + + return (int)$this->db->lastInsertId(); + } + + public function setEnabled(int $ruleId, bool $enabled): bool + { + $stmt = $this->db->prepare('UPDATE bugtracker_ignore_rules SET enabled = :enabled WHERE id = :id'); + $stmt->execute([':enabled' => $enabled ? 1 : 0, ':id' => $ruleId]); + + self::$cache = null; + + return $stmt->rowCount() > 0; + } + + public function delete(int $ruleId): bool + { + $stmt = $this->db->prepare('DELETE FROM bugtracker_ignore_rules WHERE id = :id'); + $stmt->execute([':id' => $ruleId]); + + self::$cache = null; + + return $stmt->rowCount() > 0; + } + + /** + * Wendet eine neu angelegte Regel rueckwirkend an: passende offene Items + * werden stummgeschaltet. Ohne das muesste man sie einzeln nachpflegen. + * + * @return int Anzahl der betroffenen Items + */ + public function applyRetroactively(int $ruleId): int + { + $stmt = $this->db->prepare('SELECT * FROM bugtracker_ignore_rules WHERE id = :id'); + $stmt->execute([':id' => $ruleId]); + $rule = $stmt->fetch(); + + if (!is_array($rule)) { + return 0; + } + + $where = ['status IN ("open","planned","in_progress")']; + $params = []; + + if ($rule['project_slug'] !== null && $rule['project_slug'] !== '') { + $where[] = 'project_slug = :slug'; + $params[':slug'] = $rule['project_slug']; + } + + $candidates = $this->db->prepare( + 'SELECT id, title, error_message FROM bugtracker_items WHERE ' . implode(' AND ', $where) . ' LIMIT 2000' + ); + $candidates->execute($params); + + $affected = 0; + $update = $this->db->prepare(' + UPDATE bugtracker_items + SET status = "ignored", ignore_rule_id = :rule_id + WHERE id = :id + '); + + foreach ($candidates->fetchAll() ?: [] as $item) { + $haystack = trim(($item['error_message'] ?? '') . "\n" . ($item['title'] ?? '')); + if ($haystack === '' || !self::ruleMatches($rule, null, $haystack)) { + continue; + } + + $update->execute([':rule_id' => $ruleId, ':id' => (int)$item['id']]); + $affected++; + } + + return $affected; + } +} diff --git a/src/Modules/Watchdog/Evaluator.php b/src/Modules/Watchdog/Evaluator.php index b0f1fad..229d7dc 100644 --- a/src/Modules/Watchdog/Evaluator.php +++ b/src/Modules/Watchdog/Evaluator.php @@ -9,6 +9,9 @@ use Deploymentcenter\Modules\Bugtracker\BugRepo; use Deploymentcenter\Modules\Notify\WebhookDispatcher; use PDO; +// MetricStore und MonitorRepo liegen im selben Namensraum und werden +// automatisch geladen. + /** * Watchdog-Evaluator. * @@ -50,8 +53,21 @@ final class Evaluator $candidates = $monitorRepo->getEvaluationCandidates(); + // Zustaende aller Monitore, damit die Abhaengigkeitspruefung ohne + // weitere Abfragen auskommt. + $stateBySource = []; + $parentBySource = []; + foreach ($candidates as $monitor) { + $source = (string)$monitor['source']; + $stateBySource[$source] = (string)$monitor['state']; + $parentBySource[$source] = !empty($monitor['parent_source']) + ? (string)$monitor['parent_source'] + : null; + } + $changes = []; $checked = 0; + $suppressed = []; foreach ($candidates as $monitor) { $checked++; @@ -66,6 +82,10 @@ final class Evaluator $reason = self::reasonFor($monitor, $target); $monitorRepo->setState((int)$monitor['id'], $target, $reason); + // Zustandswechsel im Bild mitfuehren, damit ein spaeter geprueftes + // Kind den frisch gefallenen Parent bereits sieht. + $stateBySource[(string)$monitor['source']] = $target; + $eventLog->logEvent( (string)$monitor['source'], (string)$monitor['instance'], @@ -76,13 +96,34 @@ final class Evaluator $reason ); + // Faellt ein Hypervisor aus, sind seine VMs zwangslaeufig auch weg. + // Ohne diese Pruefung erzeugt ein einzelner Ausfall so viele + // Meldungen, wie Kinder daran haengen - bei einem Node mit zwoelf + // VMs also dreizehn Alarme fuer ein Problem. + $blockingParent = self::findDownAncestor( + (string)$monitor['source'], + $parentBySource, + $stateBySource + ); + $changes[] = [ - 'source' => $monitor['source'], - 'from' => $current, - 'to' => $target, - 'reason' => $reason, + 'source' => $monitor['source'], + 'from' => $current, + 'to' => $target, + 'reason' => $reason, + 'suppressed' => $blockingParent, ]; + $monitorRepo->setAlertSuppression((int)$monitor['id'], $blockingParent); + + if ($blockingParent !== null) { + $suppressed[] = [ + 'source' => $monitor['source'], + 'parent' => $blockingParent, + ]; + continue; + } + // Stummgeschaltete Monitore erscheinen im Dashboard, loesen aber // keine Benachrichtigung aus. if (empty($monitor['is_muted'])) { @@ -99,6 +140,12 @@ final class Evaluator Logger::warning('Lease-Bereinigung fehlgeschlagen', ['error' => $e->getMessage()]); } + // Alten Metrik-Verlauf abraeumen (hoechstens einmal pro Stunde). + $purgedMetrics = 0; + if (self::shouldPurgeMetrics($db)) { + $purgedMetrics = (new MetricStore($db))->purge(); + } + $durationMs = (int)round((microtime(true) - $started) * 1000); self::recordRun($db, $durationMs, count($changes)); @@ -110,11 +157,73 @@ final class Evaluator 'checked' => $checked, 'changed' => count($changes), 'changes' => $changes, + 'suppressed' => $suppressed, 'released_leases' => $releasedLeases, + 'purged_metrics' => $purgedMetrics, 'duration_ms' => $durationMs, ]; } + /** + * Sucht den naechsten Vorfahren, der selbst unten ist. + * + * @param array $parentBySource + * @param array $stateBySource + * @return string|null Name des ausgefallenen Vorfahren, oder null + */ + private static function findDownAncestor( + string $source, + array $parentBySource, + array $stateBySource + ): ?string { + $seen = []; + $current = $parentBySource[$source] ?? null; + + // Tiefenbegrenzung und Zyklusschutz: ein falsch gesetzter Parent darf + // keine Endlosschleife ausloesen. + while ($current !== null && !isset($seen[$current]) && count($seen) < 10) { + $seen[$current] = true; + + $parentState = $stateBySource[$current] ?? null; + if ($parentState === 'down' || $parentState === 'error') { + return $current; + } + + $current = $parentBySource[$current] ?? null; + } + + return null; + } + + /** Der Verlauf wird hoechstens stuendlich bereinigt, nicht bei jedem Lauf. */ + private static function shouldPurgeMetrics(PDO $db): bool + { + try { + $stmt = $db->query(" + SELECT last_run_utc FROM watchdog_cron_jobs WHERE name = 'metrics_cleanup' + "); + $lastRun = $stmt !== false ? $stmt->fetchColumn() : false; + + if ($lastRun === false || $lastRun === null) { + $due = true; + } else { + $due = (time() - (int)strtotime((string)$lastRun . ' UTC')) > 3600; + } + + if ($due) { + $db->prepare(' + INSERT INTO watchdog_cron_jobs (name, interval_sec, last_run_utc, enabled) + VALUES ("metrics_cleanup", 86400, UTC_TIMESTAMP(), 1) + ON DUPLICATE KEY UPDATE last_run_utc = UTC_TIMESTAMP() + ')->execute(); + } + + return $due; + } catch (\Throwable $e) { + return false; + } + } + /** * Ermittelt den Zustand, den ein Monitor haben sollte. * null bedeutet: keine Aenderung noetig. diff --git a/src/Modules/Watchdog/MetricStore.php b/src/Modules/Watchdog/MetricStore.php new file mode 100644 index 0000000..1677e1c --- /dev/null +++ b/src/Modules/Watchdog/MetricStore.php @@ -0,0 +1,270 @@ +db = $db; + } + + /** + * Nimmt die numerischen Werte eines Heartbeats auf. + * Nicht numerische Werte werden uebergangen. + * + * @param mixed $metrics + */ + public function record(string $source, string $instance, $metrics): int + { + if (!is_array($metrics) && !is_object($metrics)) { + return 0; + } + + $flat = self::flatten((array)$metrics); + if ($flat === []) { + return 0; + } + + try { + $stmt = $this->db->prepare(' + INSERT INTO watchdog_metrics (source, instance, metric_key, metric_value, recorded_utc) + VALUES (:source, :instance, :metric_key, :metric_value, UTC_TIMESTAMP()) + '); + + $written = 0; + foreach ($flat as $key => $value) { + if ($written >= self::MAX_KEYS_PER_BEAT) { + break; + } + + $stmt->execute([ + ':source' => mb_substr($source, 0, 100), + ':instance' => mb_substr($instance, 0, 100), + ':metric_key' => mb_substr($key, 0, 64), + ':metric_value' => $value, + ]); + $written++; + } + + return $written; + } catch (\Throwable $e) { + // Der Verlauf ist Beiwerk; ein Heartbeat darf daran nicht scheitern. + Logger::warning('Metriken nicht gespeichert', [ + 'source' => $source, + 'error' => $e->getMessage(), + ]); + return 0; + } + } + + /** + * Verlauf einer Metrik, auf Zeitfenster verdichtet. + * + * @return list + */ + public function history( + string $source, + string $metricKey, + int $hours = 24, + string $instance = 'default', + int $bucketMinutes = 15 + ): array { + $hours = max(1, min($hours, 24 * self::RETENTION_DAYS)); + $bucketMinutes = max(1, min($bucketMinutes, 1440)); + + try { + // Zeitstempel auf das Raster runden, damit gleichmaessige + // Stuetzstellen entstehen. + $stmt = $this->db->prepare(' + SELECT + FROM_UNIXTIME(FLOOR(UNIX_TIMESTAMP(recorded_utc) / (' . $bucketMinutes . ' * 60)) + * (' . $bucketMinutes . ' * 60)) AS bucket, + AVG(metric_value) AS avg_value, + MIN(metric_value) AS min_value, + MAX(metric_value) AS max_value, + COUNT(*) AS samples + FROM watchdog_metrics + WHERE source = :source + AND instance = :instance + AND metric_key = :metric_key + AND recorded_utc > (UTC_TIMESTAMP() - INTERVAL ' . $hours . ' HOUR) + GROUP BY bucket + ORDER BY bucket ASC + '); + $stmt->execute([ + ':source' => $source, + ':instance' => $instance, + ':metric_key' => $metricKey, + ]); + + $out = []; + foreach ($stmt->fetchAll() ?: [] as $row) { + $out[] = [ + 'bucket' => (string)$row['bucket'], + 'avg' => (float)$row['avg_value'], + 'min' => (float)$row['min_value'], + 'max' => (float)$row['max_value'], + 'samples' => (int)$row['samples'], + ]; + } + + return $out; + } catch (\Throwable $e) { + Logger::warning('Metrik-Verlauf nicht abrufbar', ['error' => $e->getMessage()]); + return []; + } + } + + /** + * Welche Metriken liefert dieser Monitor ueberhaupt? + * + * @return list + */ + public function keysFor(string $source, string $instance = 'default'): array + { + try { + $stmt = $this->db->prepare(' + SELECT DISTINCT metric_key + FROM watchdog_metrics + WHERE source = :source AND instance = :instance + AND recorded_utc > (UTC_TIMESTAMP() - INTERVAL 7 DAY) + ORDER BY metric_key ASC + '); + $stmt->execute([':source' => $source, ':instance' => $instance]); + return $stmt->fetchAll(PDO::FETCH_COLUMN) ?: []; + } catch (\Throwable $e) { + return []; + } + } + + /** + * Vergleicht den aktuellen Wert mit dem eigenen Verlauf. + * + * Dieser Ansatz braucht kein Projektwissen: Statt fester Schwellwerte je + * Anwendung wird gemeldet, was deutlich vom bisherigen Verhalten desselben + * Monitors abweicht. + * + * @return array{deviates:bool,current:float,baseline:float,factor:float}|null + */ + public function deviation(string $source, string $metricKey, string $instance = 'default', float $factor = 3.0): ?array + { + try { + $stmt = $this->db->prepare(' + SELECT + (SELECT metric_value FROM watchdog_metrics + WHERE source = :s1 AND instance = :i1 AND metric_key = :k1 + ORDER BY recorded_utc DESC LIMIT 1) AS current_value, + (SELECT AVG(metric_value) FROM watchdog_metrics + WHERE source = :s2 AND instance = :i2 AND metric_key = :k2 + AND recorded_utc BETWEEN (UTC_TIMESTAMP() - INTERVAL 7 DAY) + AND (UTC_TIMESTAMP() - INTERVAL 1 HOUR)) AS baseline_value + '); + $stmt->execute([ + ':s1' => $source, ':i1' => $instance, ':k1' => $metricKey, + ':s2' => $source, ':i2' => $instance, ':k2' => $metricKey, + ]); + $row = $stmt->fetch(); + + if (!is_array($row) || $row['current_value'] === null || $row['baseline_value'] === null) { + return null; + } + + $current = (float)$row['current_value']; + $baseline = (float)$row['baseline_value']; + + if (abs($baseline) < 0.0001) { + return null; + } + + $ratio = $current / $baseline; + + return [ + 'deviates' => $ratio >= $factor || $ratio <= (1 / $factor), + 'current' => $current, + 'baseline' => $baseline, + 'factor' => $ratio, + ]; + } catch (\Throwable $e) { + return null; + } + } + + /** Entfernt Werte, die aelter als die Aufbewahrungsfrist sind. */ + public function purge(): int + { + try { + $stmt = $this->db->prepare( + 'DELETE FROM watchdog_metrics + WHERE recorded_utc < (UTC_TIMESTAMP() - INTERVAL ' . self::RETENTION_DAYS . ' DAY) + LIMIT 50000' + ); + $stmt->execute(); + return $stmt->rowCount(); + } catch (\Throwable $e) { + Logger::warning('Metrik-Bereinigung fehlgeschlagen', ['error' => $e->getMessage()]); + return 0; + } + } + + /** + * Verschachtelte Metriken flach klopfen: {"cpu":{"load":1.2}} -> "cpu.load". + * + * @return array + */ + private static function flatten(array $metrics, string $prefix = '', int $depth = 0): array + { + if ($depth > 3) { + return []; + } + + $out = []; + foreach ($metrics as $key => $value) { + if (!is_string($key) && !is_int($key)) { + continue; + } + + $name = $prefix === '' ? (string)$key : $prefix . '.' . $key; + + if (is_array($value)) { + $out += self::flatten($value, $name, $depth + 1); + continue; + } + + if (is_bool($value)) { + $out[$name] = $value ? 1.0 : 0.0; + continue; + } + + if (is_numeric($value)) { + $out[$name] = (float)$value; + } + } + + return $out; + } +} diff --git a/src/Modules/Watchdog/MonitorRepo.php b/src/Modules/Watchdog/MonitorRepo.php index db97d76..8e2d4c5 100644 --- a/src/Modules/Watchdog/MonitorRepo.php +++ b/src/Modules/Watchdog/MonitorRepo.php @@ -72,6 +72,10 @@ final class MonitorRepo /** * Nimmt einen Heartbeat entgegen und legt den Monitor bei Bedarf an. */ + /** + * @param mixed $metrics + * @param mixed $checks Gesundheitszustand, den die Anwendung selbst ermittelt hat. + */ public function upsertHeartbeat( string $source, string $instance, @@ -81,18 +85,35 @@ final class MonitorRepo string $status, ?string $message, ?string $groupKey = null, - ?string $os = null + ?string $os = null, + $checks = null ): array { $metricsJson = (is_array($metrics) || is_object($metrics)) ? json_encode($metrics, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) : null; + // Die Anwendung meldet ihren Gesundheitszustand selbst mit. Das + // Deploymentcenter interpretiert die Namen der Pruefungen nicht - es + // liest nur ok und message. Damit muss auf der Zielmaschine kein Port + // geoeffnet werden, und jede Anwendung entscheidet selbst, was bei ihr + // "gesund" bedeutet. + $failing = self::failingChecks($checks); + $healthJson = (is_array($checks) || is_object($checks)) + ? json_encode($checks, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) + : null; + $state = match ($status) { 'ok' => 'up', 'warning' => 'warning', default => 'down', }; + // Eine fehlgeschlagene Pruefung stuft einen als "ok" gemeldeten + // Heartbeat herab: der Prozess laeuft, tut aber nicht, was er soll. + if ($failing !== [] && $state === 'up') { + $state = 'warning'; + } + $previous = $this->getMonitor($source, $instance); $previousState = $previous !== null ? (string)$previous['state'] : null; @@ -102,12 +123,12 @@ final class MonitorRepo $stmt = $this->db->prepare(' INSERT INTO watchdog_monitors ( source, instance, type, state, last_state_change_utc, expected_interval_sec, - last_seen_utc, last_status, last_message, metrics_json, group_key, os, - created_utc, updated_utc + last_seen_utc, last_status, last_message, metrics_json, health_json, + failing_checks, group_key, os, created_utc, updated_utc ) VALUES ( :source, :instance, :type, :state, UTC_TIMESTAMP(), :interval, - UTC_TIMESTAMP(), :last_status, :message, :metrics, :group_key, :os, - UTC_TIMESTAMP(), UTC_TIMESTAMP() + UTC_TIMESTAMP(), :last_status, :message, :metrics, :health, + :failing, :group_key, :os, UTC_TIMESTAMP(), UTC_TIMESTAMP() ) ON DUPLICATE KEY UPDATE -- Reihenfolge ist relevant: MySQL wertet die Zuweisungen von @@ -121,6 +142,8 @@ final class MonitorRepo last_status = VALUES(last_status), last_message = VALUES(last_message), metrics_json = VALUES(metrics_json), + health_json = COALESCE(VALUES(health_json), health_json), + failing_checks = VALUES(failing_checks), group_key = COALESCE(VALUES(group_key), group_key), os = COALESCE(VALUES(os), os), updated_utc = VALUES(updated_utc) @@ -135,6 +158,8 @@ final class MonitorRepo ':last_status' => in_array($status, ['ok', 'warning', 'error'], true) ? $status : 'error', ':message' => $message, ':metrics' => $metricsJson, + ':health' => $healthJson, + ':failing' => $failing !== [] ? mb_substr(implode(', ', $failing), 0, 255) : null, ':group_key' => $groupKey, ':os' => $os, ]); @@ -146,10 +171,52 @@ final class MonitorRepo $monitor['_previous_state'] = $previousState; $monitor['_state_changed'] = $previousState !== null && $previousState !== $state; + $monitor['_failing_checks'] = $failing; return $monitor; } + /** + * Ermittelt die Namen aller fehlgeschlagenen Pruefungen. + * + * Erwartetes Format, das die Anwendung mitschickt: + * { "db": { "ok": true }, "feed": { "ok": false, "message": "..." } } + * + * Akzeptiert zur Bequemlichkeit auch { "db": true, "feed": false }. + * + * @param mixed $checks + * @return list + */ + public static function failingChecks($checks): array + { + if (!is_array($checks) && !is_object($checks)) { + return []; + } + + $failing = []; + foreach ((array)$checks as $name => $check) { + if (!is_string($name)) { + continue; + } + + if (is_bool($check)) { + if (!$check) { + $failing[] = $name; + } + continue; + } + + if (is_array($check) || is_object($check)) { + $data = (array)$check; + if (array_key_exists('ok', $data) && !filter_var($data['ok'], FILTER_VALIDATE_BOOLEAN)) { + $failing[] = $name; + } + } + } + + return $failing; + } + /** * Legt einen Monitor manuell an (ohne Heartbeat). * @@ -357,6 +424,23 @@ final class MonitorRepo return $stmt->rowCount() > 0; } + /** + * Vermerkt, dass die Alarmierung dieses Monitors unterdrueckt wird, weil + * ein uebergeordnetes System ausgefallen ist. Der Zustand bleibt sichtbar, + * nur die Benachrichtigung entfaellt. + */ + public function setAlertSuppression(int $id, ?string $blockingParent): bool + { + $stmt = $this->db->prepare(' + UPDATE watchdog_monitors + SET alert_suppressed_by = :parent + WHERE id = :id + '); + $stmt->execute([':parent' => $blockingParent, ':id' => $id]); + + return $stmt->rowCount() > 0; + } + public function setMaintenance(string $source, string $instance, ?string $untilUtc): bool { $stmt = $this->db->prepare('