feat(errors, watchdog): Fehler-Stream mit Ignore-Regeln, Metrik-Verlauf, Abhängigkeits-Alarme

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 <noreply@anthropic.com>
This commit is contained in:
Deploymentcenter Bot
2026-08-07 21:55:23 +02:00
co-authored by Claude Opus 5
parent a74c6fd990
commit 60e34b29f6
13 changed files with 2228 additions and 45 deletions
+89 -5
View File
@@ -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<string>
*/
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('