'source', 'type' => 'type', 'group_key' => 'group_key', 'parent_source' => 'parent_source', 'os' => 'os', 'notes' => 'notes', 'url' => 'url', 'icon' => 'icon', 'expected_interval_sec' => 'expected_interval_sec', 'is_muted' => 'is_muted', 'expect_running' => 'expect_running', ]; private PDO $db; public function __construct(PDO $db) { $this->db = $db; } /** @return list> */ public function getAllMonitors(): array { $stmt = $this->db->query(' SELECT * FROM watchdog_monitors ORDER BY COALESCE(group_key, "zzz") ASC, source ASC '); return $stmt->fetchAll() ?: []; } public function getMonitor(string $source, string $instance = 'default'): ?array { $stmt = $this->db->prepare('SELECT * FROM watchdog_monitors WHERE source = :source AND instance = :instance'); $stmt->execute([':source' => $source, ':instance' => $instance]); $row = $stmt->fetch(); return is_array($row) ? $row : null; } /** * 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, string $type, int $intervalSec, $metrics, string $status, ?string $message, ?string $groupKey = null, ?string $os = null, $checks = null, ?string $appVersion = null, ?string $projectSlug = 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; // "stopped" und "maintenance" sind angekuendigte Zustaende: der Dienst // wurde bewusst beendet bzw. gewartet. Der Evaluator laesst solche // Monitore in Ruhe, statt sie als Ausfall zu melden. Ohne diese beiden // Werte gab es keinen Weg, ein geplantes Herunterfahren mitzuteilen - // jeder saubere Shutdown erzeugte kurz darauf einen Fehlalarm. $state = match ($status) { 'ok' => 'up', 'warning' => 'warning', 'stopped' => 'stopped', 'maintenance' => 'maintenance', 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'; } // last_status kennt nur ok/warning/error; angekuendigte Zustaende // gelten dort als unauffaellig. $lastStatus = match ($status) { 'ok', 'stopped', 'maintenance' => 'ok', 'warning' => 'warning', default => 'error', }; $previous = $this->getMonitor($source, $instance); $previousState = $previous !== null ? (string)$previous['state'] : null; $type = in_array($type, self::TYPES, true) ? $type : 'heartbeat'; $intervalSec = max(10, min($intervalSec, 86400)); $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, health_json, failing_checks, group_key, os, app_version, project_slug, created_utc, updated_utc ) VALUES ( :source, :instance, :type, :state, UTC_TIMESTAMP(), :interval, UTC_TIMESTAMP(), :last_status, :message, :metrics, :health, :failing, :group_key, :os, :app_version, :project_slug, UTC_TIMESTAMP(), UTC_TIMESTAMP() ) ON DUPLICATE KEY UPDATE -- Reihenfolge ist relevant: MySQL wertet die Zuweisungen von -- links nach rechts aus. last_state_change_utc muss den alten -- Wert von state sehen, also vor dessen Zuweisung stehen. last_state_change_utc = IF(state <> VALUES(state), UTC_TIMESTAMP(), last_state_change_utc), state = VALUES(state), down_since_utc = IF(VALUES(state) = "up", NULL, down_since_utc), expected_interval_sec = VALUES(expected_interval_sec), last_seen_utc = VALUES(last_seen_utc), 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), -- COALESCE, damit ein Agent, der die Version nicht mitschickt, -- eine frueher gemeldete nicht loescht. app_version = COALESCE(VALUES(app_version), app_version), -- Ebenso das Projekt: Ein Ping mit einem ungebundenen Token -- (etwa ueber den Shared Key oder ein Alt-Token aus -- watchdog_agent_tokens) darf eine bekannte Zuordnung nicht -- wieder loeschen. Zuordnen kann nur, wer sie kennt. project_slug = COALESCE(VALUES(project_slug), project_slug), updated_utc = VALUES(updated_utc) '); $stmt->execute([ ':source' => $source, ':instance' => $instance, ':type' => $type, ':state' => $state, ':interval' => $intervalSec, ':last_status' => $lastStatus, ':message' => $message, ':metrics' => $metricsJson, ':health' => $healthJson, ':failing' => $failing !== [] ? mb_substr(implode(', ', $failing), 0, 255) : null, ':group_key' => $groupKey, ':os' => $os, ':app_version' => $appVersion !== null ? mb_substr($appVersion, 0, 64) : null, // Zu welchem Projekt gehoert dieser Monitor? // // Die Spalte gibt es seit Migration 014, geschrieben hat sie hier // nie jemand. Ein Heartbeat legte den Monitor also mit // project_slug NULL an, und jeder daraus abgeleitete Befund fiel // auf das Scheinprojekt "default" zurueck (FindingRepo). Wirkung: // dc_findings(project_slug="ibkrtrader") lieferte null Befunde, // waehrend ein deviation.self-Befund mit elf Vorkommnissen // bereitstand - nur unter einem Projekt, das es nicht gibt. // // Die Zuordnung war die ganze Zeit bekannt: Das Ping-Token ist an // ein Projekt gebunden, und authorizeSource() liest die Bindung // ohnehin. Sie wurde nur nicht weitergereicht. (#404) ':project_slug' => $projectSlug !== null ? mb_substr($projectSlug, 0, 64) : null, ]); $monitor = $this->getMonitor($source, $instance); if ($monitor === null) { throw new RuntimeException('Monitor konnte nicht gespeichert werden: ' . $source); } $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 */ 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). * * @param array $fields */ public function createMonitor(string $source, array $fields = []): array { $stmt = $this->db->prepare(' INSERT INTO watchdog_monitors ( source, instance, type, state, expected_interval_sec, group_key, parent_source, os, created_utc, updated_utc ) VALUES ( :source, "default", :type, "unknown", :interval, :group_key, :parent_source, :os, UTC_TIMESTAMP(), UTC_TIMESTAMP() ) ON DUPLICATE KEY UPDATE expected_interval_sec = VALUES(expected_interval_sec), group_key = VALUES(group_key), parent_source = VALUES(parent_source), os = COALESCE(VALUES(os), os), updated_utc = UTC_TIMESTAMP() '); $type = (string)($fields['type'] ?? 'heartbeat'); $stmt->execute([ ':source' => $source, ':type' => in_array($type, self::TYPES, true) ? $type : 'heartbeat', ':interval' => max(10, min((int)($fields['expected_interval_sec'] ?? 60), 86400)), ':group_key' => self::nullIfEmpty($fields['group_key'] ?? null), ':parent_source' => self::nullIfEmpty($fields['parent_source'] ?? null), ':os' => self::nullIfEmpty($fields['os'] ?? null), ]); return $this->getMonitor($source) ?? []; } /** * Aktualisiert einen Monitor. Es werden ausschliesslich die in $fields * enthaltenen Spalten geschrieben - alles andere bleibt unangetastet. * * @param array $fields */ public function updateMonitor(string $oldSource, string $instance, array $fields): bool { $monitor = $this->getMonitor($oldSource, $instance); if ($monitor === null) { return false; } $newSource = isset($fields['source']) ? trim((string)$fields['source']) : $oldSource; if ($newSource === '') { $newSource = $oldSource; } $renaming = $newSource !== $oldSource; if ($renaming) { $conflict = $this->getMonitor($newSource, $instance); if ($conflict !== null) { throw new RuntimeException( sprintf('Ein Monitor namens "%s" existiert bereits.', $newSource) ); } } $set = []; $params = [':old_source' => $oldSource, ':instance' => $instance]; foreach (self::UPDATABLE as $key => $column) { if (!array_key_exists($key, $fields)) { continue; } $value = $fields[$key]; if ($column === 'expected_interval_sec') { $value = max(10, min((int)$value, 86400)); } elseif ($column === 'is_muted' || $column === 'expect_running') { $value = !empty($value) ? 1 : 0; } elseif ($column === 'type') { if (!in_array((string)$value, self::TYPES, true)) { continue; } } elseif ($column === 'source') { $value = $newSource; } else { $value = self::nullIfEmpty($value); } $set[] = $column . ' = :f_' . $key; $params[':f_' . $key] = $value; } if ($set === []) { return true; } $set[] = 'updated_utc = UTC_TIMESTAMP()'; // Umbenennung und alle abhaengigen Aktualisierungen als eine Einheit. $ownTransaction = !$this->db->inTransaction(); if ($ownTransaction) { $this->db->beginTransaction(); } try { $stmt = $this->db->prepare( 'UPDATE watchdog_monitors SET ' . implode(', ', $set) . ' WHERE source = :old_source AND instance = :instance' ); $stmt->execute($params); if ($renaming) { $this->cascadeRename($oldSource, $newSource); } if ($ownTransaction) { $this->db->commit(); } } catch (\Throwable $e) { if ($ownTransaction && $this->db->inTransaction()) { $this->db->rollBack(); } throw $e; } return true; } /** Zieht Kinder, Agent-Tokens und Event-Log auf den neuen Namen um. */ private function cascadeRename(string $oldSource, string $newSource): void { $updates = [ 'UPDATE watchdog_monitors SET parent_source = :new WHERE parent_source = :old', 'UPDATE watchdog_agent_tokens SET monitor_source = :new WHERE monitor_source = :old', 'UPDATE watchdog_event_log SET source = :new WHERE source = :old', ]; foreach ($updates as $sql) { $this->db->prepare($sql)->execute([':new' => $newSource, ':old' => $oldSource]); } Logger::info('Monitor umbenannt', ['from' => $oldSource, 'to' => $newSource]); } public function setParentSource(string $source, ?string $parentSource, string $instance = 'default'): bool { // Ein Monitor darf nicht sein eigener Parent sein. if ($parentSource !== null && trim($parentSource) === $source) { $parentSource = null; } $stmt = $this->db->prepare(' UPDATE watchdog_monitors SET parent_source = :parent, updated_utc = UTC_TIMESTAMP() WHERE source = :source AND instance = :instance '); $stmt->execute([ ':parent' => self::nullIfEmpty($parentSource), ':source' => $source, ':instance' => $instance, ]); return $stmt->rowCount() > 0; } /** * Setzt den Zustand eines Monitors und vermerkt den Wechselzeitpunkt. */ public function setState(int $id, string $state, ?string $reason = null): bool { if (!in_array($state, self::STATES, true)) { return false; } $isDown = in_array($state, ['down', 'error'], true); $isUp = $state === 'up'; // Reihenfolge beachten: last_state_change_utc und down_since_utc muessen // den alten Wert von state sehen, stehen daher vor dessen Zuweisung. $stmt = $this->db->prepare(' UPDATE watchdog_monitors SET last_state_change_utc = IF(state <> :state_cmp, UTC_TIMESTAMP(), last_state_change_utc), down_since_utc = CASE WHEN :is_down = 1 AND down_since_utc IS NULL THEN UTC_TIMESTAMP() WHEN :is_up = 1 THEN NULL ELSE down_since_utc END, state = :state, metric_state_reason = :reason, updated_utc = UTC_TIMESTAMP() WHERE id = :id '); $stmt->execute([ ':state_cmp' => $state, ':is_down' => $isDown ? 1 : 0, ':is_up' => $isUp ? 1 : 0, ':state' => $state, ':reason' => $reason, ':id' => $id, ]); 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(' UPDATE watchdog_monitors SET state = :state, suppress_until_utc = :until, updated_utc = UTC_TIMESTAMP() WHERE source = :source AND instance = :instance '); $stmt->execute([ ':state' => $untilUtc !== null ? 'maintenance' : 'unknown', ':until' => $untilUtc, ':source' => $source, ':instance' => $instance, ]); return $stmt->rowCount() > 0; } public function deleteMonitor(string $source, string $instance = 'default'): bool { $ownTransaction = !$this->db->inTransaction(); if ($ownTransaction) { $this->db->beginTransaction(); } try { // Kinder auf Top-Level heben, damit keine Waisen entstehen. $this->db->prepare('UPDATE watchdog_monitors SET parent_source = NULL WHERE parent_source = :source') ->execute([':source' => $source]); // Zugehoerige Agent-Tokens entwerten. $this->db->prepare('UPDATE watchdog_agent_tokens SET revoked = 1 WHERE monitor_source = :source') ->execute([':source' => $source]); $stmt = $this->db->prepare('DELETE FROM watchdog_monitors WHERE source = :source AND instance = :instance'); $stmt->execute([':source' => $source, ':instance' => $instance]); $deleted = $stmt->rowCount() > 0; if ($ownTransaction) { $this->db->commit(); } return $deleted; } catch (\Throwable $e) { if ($ownTransaction && $this->db->inTransaction()) { $this->db->rollBack(); } throw $e; } } /** * Monitore, die der Evaluator pruefen muss. * * @return list> */ public function getEvaluationCandidates(): array { $stmt = $this->db->query(' SELECT * FROM watchdog_monitors WHERE state <> "maintenance" AND (suppress_until_utc IS NULL OR suppress_until_utc < UTC_TIMESTAMP()) '); return $stmt->fetchAll() ?: []; } /** @param mixed $value */ private static function nullIfEmpty($value): ?string { if ($value === null || is_array($value) || is_object($value)) { return null; } $value = trim((string)$value); return $value === '' ? null : $value; } }