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:
co-authored by
Claude Opus 5
parent
a74c6fd990
commit
60e34b29f6
@@ -0,0 +1,270 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user