Files
Deploymentcenter/src/Modules/Watchdog/MetricStore.php
T
Deploymentcenter BotandClaude Opus 5 60e34b29f6 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>
2026-08-07 21:55:23 +02:00

271 lines
8.8 KiB
PHP

<?php
declare(strict_types=1);
namespace Deploymentcenter\Modules\Watchdog;
use Deploymentcenter\Core\Logger;
use PDO;
/**
* Verlauf der Heartbeat-Metriken.
*
* Bisher wurde metrics_json bei jedem Heartbeat ueberschrieben - es gab immer
* nur den letzten Moment. Damit laesst sich "die Platte laeuft seit drei Tagen
* voll" nicht erkennen, sondern nur "die Platte ist voll".
*
* Bewusst schmal gehalten: Das hier ist die Datengrundlage fuer Sparklines und
* Abweichungsalarme im Dashboard, keine Zeitreihendatenbank. Wer echte Analyse
* braucht, ist mit Prometheus besser bedient.
*/
final class MetricStore
{
/** Aufbewahrung der Rohwerte in Tagen. */
private const RETENTION_DAYS = 14;
/** Maximale Anzahl Metriken je Heartbeat - schuetzt vor Ausreissern. */
private const MAX_KEYS_PER_BEAT = 25;
private PDO $db;
public function __construct(PDO $db)
{
$this->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<array{bucket:string,avg:float,min:float,max:float,samples:int}>
*/
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<string>
*/
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<string,float>
*/
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;
}
}