|
| 1 | +<?php |
| 2 | +/** |
| 3 | + * This file is part of the phpCacheAdmin. |
| 4 | + * Copyright (c) Róbert Kelčák (https://kelcak.com/) |
| 5 | + */ |
| 6 | + |
| 7 | +declare(strict_types=1); |
| 8 | + |
| 9 | +namespace RobiNN\Pca\Dashboards\Memcached; |
| 10 | + |
| 11 | +use JsonException; |
| 12 | +use PDO; |
| 13 | +use RobiNN\Pca\Config; |
| 14 | +use RobiNN\Pca\Http; |
| 15 | + |
| 16 | +class MemcachedMetrics { |
| 17 | + private PDO $pdo; |
| 18 | + |
| 19 | + private const RATE_COMMANDS = ['get', 'set', 'delete', 'incr', 'decr', 'cas', 'touch', 'flush']; |
| 20 | + private const HIT_RATE_COMMANDS = ['get', 'delete', 'incr', 'decr', 'cas', 'touch']; |
| 21 | + |
| 22 | + public function __construct(private readonly PHPMem $memcached) { |
| 23 | + $hash = md5(Config::get('hash', 'pca')); // This isn't really safe, but it's better than nothing |
| 24 | + $db = __DIR__.'/../../../tmp/memcached_metrics'.$hash.'.db'; |
| 25 | + |
| 26 | + $this->pdo = new PDO('sqlite:'.$db); |
| 27 | + $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); |
| 28 | + |
| 29 | + $schema = <<<SQL |
| 30 | + CREATE TABLE IF NOT EXISTS metrics ( |
| 31 | + id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp INTEGER NOT NULL, |
| 32 | + memory_used INTEGER, memory_limit INTEGER, stored_items INTEGER, connections INTEGER, |
| 33 | + new_connection_rate REAL, eviction_rate REAL, expired_rate REAL, |
| 34 | + hit_rate_overall REAL, hit_rate_get REAL, hit_rate_delete REAL, hit_rate_incr REAL, hit_rate_decr REAL, hit_rate_cas REAL, hit_rate_touch REAL, |
| 35 | + request_rate_overall REAL, request_rate_get REAL, request_rate_set REAL, request_rate_delete REAL, request_rate_incr REAL, request_rate_decr REAL, request_rate_cas REAL, request_rate_touch REAL, request_rate_flush REAL, |
| 36 | + traffic_received_rate REAL, traffic_sent_rate REAL, |
| 37 | + cumulative_total_connections INTEGER, cumulative_evictions INTEGER, cumulative_expired_unfetched INTEGER, |
| 38 | + cumulative_bytes_read INTEGER, cumulative_bytes_written INTEGER, |
| 39 | + cumulative_cmd_get INTEGER, cumulative_cmd_set INTEGER, cumulative_cmd_delete INTEGER, cumulative_cmd_incr INTEGER, cumulative_cmd_decr INTEGER, cumulative_cmd_cas INTEGER, cumulative_cmd_touch INTEGER, cumulative_cmd_flush INTEGER |
| 40 | + ) |
| 41 | + SQL; |
| 42 | + |
| 43 | + $this->pdo->exec($schema); |
| 44 | + } |
| 45 | + |
| 46 | + /** |
| 47 | + * @throws MemcachedException |
| 48 | + */ |
| 49 | + public function collectAndRespond(): string { |
| 50 | + $stats = $this->memcached->getServerStats(); |
| 51 | + |
| 52 | + if (empty($stats)) { |
| 53 | + throw new MemcachedException('Failed to retrieve Memcached stats.'); |
| 54 | + } |
| 55 | + |
| 56 | + $metrics = $this->calculateMetrics($stats); |
| 57 | + $this->insertMetrics($metrics); |
| 58 | + $recent_data = $this->fetchRecentMetrics(); |
| 59 | + $formatted_data = $this->formatDataForResponse($recent_data); |
| 60 | + |
| 61 | + header('Content-Type: application/json'); |
| 62 | + header('Cache-Control: no-cache, must-revalidate'); |
| 63 | + |
| 64 | + try { |
| 65 | + return json_encode($formatted_data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); |
| 66 | + } catch (JsonException $e) { |
| 67 | + return $e->getMessage(); |
| 68 | + } |
| 69 | + } |
| 70 | + |
| 71 | + /** |
| 72 | + * @param array<string, mixed> $stats Raw stats from Memcached. |
| 73 | + * |
| 74 | + * @return array<string, mixed> |
| 75 | + */ |
| 76 | + private function calculateMetrics(array $stats): array { |
| 77 | + /** @var array<string, mixed>|false $last_point */ |
| 78 | + $last_point = $this->pdo->query('SELECT * FROM metrics ORDER BY id DESC LIMIT 1')->fetch(PDO::FETCH_ASSOC); |
| 79 | + |
| 80 | + $time_diff = ($last_point && isset($last_point['timestamp'])) ? time() - (int) $last_point['timestamp'] : 60; |
| 81 | + $time_diff = max($time_diff, 1); |
| 82 | + |
| 83 | + $calculate_rate = static function (int|float $current_val, ?int $last_val) use ($time_diff): float { |
| 84 | + if ($last_val === null || $current_val < $last_val) { |
| 85 | + return 0.0; |
| 86 | + } |
| 87 | + |
| 88 | + return round(($current_val - $last_val) / $time_diff, 2); |
| 89 | + }; |
| 90 | + |
| 91 | + $calculate_hit_rate = static function (array $stats, string $cmd): float { |
| 92 | + $hits = $stats[$cmd.'_hits'] ?? 0; |
| 93 | + $misses = $stats[$cmd.'_misses'] ?? 0; |
| 94 | + $total = $hits + $misses; |
| 95 | + |
| 96 | + return $total > 0 ? round(($hits / $total) * 100, 2) : 0.0; |
| 97 | + }; |
| 98 | + |
| 99 | + $command_rates = []; |
| 100 | + |
| 101 | + foreach (self::RATE_COMMANDS as $cmd) { |
| 102 | + $command_rates['request_rate_'.$cmd] = $calculate_rate( |
| 103 | + $stats['cmd_'.$cmd] ?? 0, |
| 104 | + $last_point['cumulative_cmd_'.$cmd] ?? null |
| 105 | + ); |
| 106 | + } |
| 107 | + |
| 108 | + $command_rates['request_rate_overall'] = array_sum($command_rates); |
| 109 | + |
| 110 | + $hit_rates = []; |
| 111 | + $total_hits = $total_misses = 0; |
| 112 | + |
| 113 | + foreach (self::HIT_RATE_COMMANDS as $cmd) { |
| 114 | + $hit_rates['hit_rate_'.$cmd] = $calculate_hit_rate($stats, $cmd); |
| 115 | + $total_hits += $stats[$cmd.'_hits'] ?? 0; |
| 116 | + $total_misses += $stats[$cmd.'_misses'] ?? 0; |
| 117 | + } |
| 118 | + |
| 119 | + $hit_rates['hit_rate_overall'] = ($total_hits + $total_misses > 0) ? round(($total_hits / ($total_hits + $total_misses)) * 100, 2) : 0.0; |
| 120 | + |
| 121 | + $metrics_to_insert = array_merge($hit_rates, $command_rates, [ |
| 122 | + 'timestamp' => time(), |
| 123 | + 'memory_used' => $stats['bytes'] ?? 0, |
| 124 | + 'memory_limit' => $stats['limit_maxbytes'] ?? 0, |
| 125 | + 'stored_items' => $stats['curr_items'] ?? 0, |
| 126 | + 'connections' => $stats['curr_connections'] ?? 0, |
| 127 | + 'new_connection_rate' => $calculate_rate($stats['total_connections'] ?? 0, $last_point['cumulative_total_connections'] ?? null), |
| 128 | + 'eviction_rate' => $calculate_rate($stats['evictions'] ?? 0, $last_point['cumulative_evictions'] ?? null), |
| 129 | + 'expired_rate' => $calculate_rate($stats['expired_unfetched'] ?? 0, $last_point['cumulative_expired_unfetched'] ?? null), |
| 130 | + 'traffic_received_rate' => $calculate_rate($stats['bytes_read'] ?? 0, $last_point['cumulative_bytes_read'] ?? null), |
| 131 | + 'traffic_sent_rate' => $calculate_rate($stats['bytes_written'] ?? 0, $last_point['cumulative_bytes_written'] ?? null), |
| 132 | + 'cumulative_total_connections' => $stats['total_connections'] ?? 0, |
| 133 | + 'cumulative_evictions' => $stats['evictions'] ?? 0, |
| 134 | + 'cumulative_expired_unfetched' => $stats['expired_unfetched'] ?? 0, |
| 135 | + 'cumulative_bytes_read' => $stats['bytes_read'] ?? 0, |
| 136 | + 'cumulative_bytes_written' => $stats['bytes_written'] ?? 0, |
| 137 | + ]); |
| 138 | + |
| 139 | + foreach (self::RATE_COMMANDS as $cmd) { |
| 140 | + $metrics_to_insert['cumulative_cmd_'.$cmd] = $stats['cmd_'.$cmd] ?? 0; |
| 141 | + } |
| 142 | + |
| 143 | + return $metrics_to_insert; |
| 144 | + } |
| 145 | + |
| 146 | + /** |
| 147 | + * @param array<string, mixed> $metrics |
| 148 | + */ |
| 149 | + private function insertMetrics(array $metrics): void { |
| 150 | + $columns = implode(', ', array_keys($metrics)); |
| 151 | + $placeholders = rtrim(str_repeat('?, ', count($metrics)), ', '); |
| 152 | + $sql = "INSERT INTO metrics ($columns) VALUES ($placeholders)"; |
| 153 | + |
| 154 | + $stmt = $this->pdo->prepare($sql); |
| 155 | + $stmt->execute(array_values($metrics)); |
| 156 | + } |
| 157 | + |
| 158 | + /** |
| 159 | + * @return array<int, array<string, mixed>> |
| 160 | + */ |
| 161 | + private function fetchRecentMetrics(): array { |
| 162 | + $max_data_points_to_return = Http::get('points', Config::get('metricstab', 1440)); |
| 163 | + |
| 164 | + $stmt = $this->pdo->prepare('SELECT * FROM metrics ORDER BY id DESC LIMIT :limit'); |
| 165 | + $stmt->bindValue(':limit', $max_data_points_to_return, PDO::PARAM_INT); |
| 166 | + $stmt->execute(); |
| 167 | + $results = $stmt->fetchAll(PDO::FETCH_ASSOC); |
| 168 | + |
| 169 | + return array_reverse($results); |
| 170 | + } |
| 171 | + |
| 172 | + /** |
| 173 | + * @param array<int, array<string, mixed>> $db_rows |
| 174 | + * |
| 175 | + * @return array<int, array<string, mixed>> |
| 176 | + */ |
| 177 | + private function formatDataForResponse(array $db_rows): array { |
| 178 | + $formatted_results = []; |
| 179 | + |
| 180 | + foreach ($db_rows as $row) { |
| 181 | + $formatted_results[] = [ |
| 182 | + 'timestamp' => date('Y-m-d H:i:s', (int) $row['timestamp']), |
| 183 | + 'unix_timestamp' => (int) $row['timestamp'], |
| 184 | + 'hit_rates' => [ |
| 185 | + 'overall' => $row['hit_rate_overall'], 'get' => $row['hit_rate_get'], 'delete' => $row['hit_rate_delete'], |
| 186 | + 'incr' => $row['hit_rate_incr'], 'decr' => $row['hit_rate_decr'], 'cas' => $row['hit_rate_cas'], 'touch' => $row['hit_rate_touch'], |
| 187 | + ], |
| 188 | + 'request_rates' => [ |
| 189 | + 'overall' => $row['request_rate_overall'], 'get' => $row['request_rate_get'], 'set' => $row['request_rate_set'], |
| 190 | + 'delete' => $row['request_rate_delete'], 'incr' => $row['request_rate_incr'], 'decr' => $row['request_rate_decr'], |
| 191 | + 'cas' => $row['request_rate_cas'], 'touch' => $row['request_rate_touch'], 'flush' => $row['request_rate_flush'], |
| 192 | + ], |
| 193 | + 'traffic' => [ |
| 194 | + 'received_rate' => $row['traffic_received_rate'], |
| 195 | + 'sent_rate' => $row['traffic_sent_rate'], |
| 196 | + ], |
| 197 | + 'memory_used' => $row['memory_used'], |
| 198 | + 'memory_limit' => $row['memory_limit'], |
| 199 | + 'stored_items' => $row['stored_items'], |
| 200 | + 'eviction_rate' => $row['eviction_rate'], |
| 201 | + 'expired_rate' => $row['expired_rate'], |
| 202 | + 'connections' => $row['connections'], |
| 203 | + 'new_connection_rate' => $row['new_connection_rate'], |
| 204 | + ]; |
| 205 | + } |
| 206 | + |
| 207 | + return $formatted_results; |
| 208 | + } |
| 209 | +} |
0 commit comments