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>
271 lines
8.8 KiB
PHP
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;
|
|
}
|
|
}
|