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; } }