diff --git a/ProcessMaker/Cache/Monitoring/CacheMetricsDecorator.php b/ProcessMaker/Cache/Monitoring/CacheMetricsDecorator.php new file mode 100644 index 0000000000..9808158ccf --- /dev/null +++ b/ProcessMaker/Cache/Monitoring/CacheMetricsDecorator.php @@ -0,0 +1,172 @@ +cache = $cache; + $this->metrics = $metrics; + } + + /** + * Create a cache key for screen data + * + * @param int $processId Process ID + * @param int $processVersionId Process version ID + * @param string $language Language code + * @param int $screenId Screen ID + * @param int $screenVersionId Screen version ID + * @return string Generated cache key + * @throws \RuntimeException If underlying cache doesn't support createKey + */ + public function createKey(int $processId, int $processVersionId, string $language, int $screenId, int $screenVersionId): string + { + if ($this->cache instanceof ScreenCacheInterface) { + return $this->cache->createKey($processId, $processVersionId, $language, $screenId, $screenVersionId); + } + + throw new \RuntimeException('Underlying cache implementation does not support createKey method'); + } + + /** + * Get a value from the cache + * + * Records timing and hit/miss metrics for the get operation + * + * @param string $key Cache key + * @param mixed $default Default value if key not found + * @return mixed Cached value or default + */ + public function get(string $key, mixed $default = null): mixed + { + $startTime = microtime(true); + $value = $this->cache->get($key, $default); + $endTime = microtime(true); + $duration = $endTime - $startTime; + + if ($value === $default) { + $this->metrics->recordMiss($key, $duration); + } else { + $this->metrics->recordHit($key, $duration); + } + + return $value; + } + + /** + * Store a value in the cache + * + * Records metrics about the size of stored data + * + * @param string $key Cache key + * @param mixed $value Value to cache + * @param null|int|\DateInterval $ttl Optional TTL + * @return bool True if successful + */ + public function set(string $key, mixed $value, null|int|\DateInterval $ttl = null): bool + { + $result = $this->cache->set($key, $value, $ttl); + + if ($result) { + // Calculate approximate size in bytes + $size = $this->calculateSize($value); + $this->metrics->recordWrite($key, $size); + } + + return $result; + } + + /** + * Delete a value from the cache + * + * @param string $key Cache key + * @return bool True if successful + */ + public function delete(string $key): bool + { + return $this->cache->delete($key); + } + + /** + * Clear all values from the cache + * + * @return bool True if successful + */ + public function clear(): bool + { + return $this->cache->clear(); + } + + /** + * Check if a key exists in the cache + * + * @param string $key Cache key + * @return bool True if key exists + */ + public function has(string $key): bool + { + return $this->cache->has($key); + } + + /** + * Check if a key is missing from the cache + * + * @param string $key Cache key + * @return bool True if key is missing + */ + public function missing(string $key): bool + { + return $this->cache->missing($key); + } + + /** + * Calculate the approximate size in bytes of a value + * + * @param mixed $value Value to calculate size for + * @return int Size in bytes + */ + protected function calculateSize(mixed $value): int + { + if (is_string($value)) { + return strlen($value); + } + + if (is_array($value) || is_object($value)) { + return strlen(serialize($value)); + } + + if (is_int($value)) { + return PHP_INT_SIZE; + } + + if (is_float($value)) { + return 8; // typical double size + } + + if (is_bool($value)) { + return 1; + } + + return 0; // for null or other types + } +} diff --git a/ProcessMaker/Cache/Monitoring/CacheMetricsInterface.php b/ProcessMaker/Cache/Monitoring/CacheMetricsInterface.php new file mode 100644 index 0000000000..6632c6a7f9 --- /dev/null +++ b/ProcessMaker/Cache/Monitoring/CacheMetricsInterface.php @@ -0,0 +1,93 @@ +hincrby($baseKey, self::HITS_KEY, 1); + $pipe->rpush($baseKey . ':' . self::HIT_TIMES_KEY, $microtime); + $pipe->ltrim($baseKey . ':' . self::HIT_TIMES_KEY, -100, -1); + }); + } + + /** + * Record a cache miss + * + * @param string $key Cache key + * @param float $microtime Time taken in microseconds + */ + public function recordMiss(string $key, $microtime): void + { + $baseKey = self::METRICS_PREFIX . $key; + Redis::pipeline(function ($pipe) use ($baseKey, $microtime) { + $pipe->hincrby($baseKey, self::MISSES_KEY, 1); + $pipe->rpush($baseKey . ':' . self::MISS_TIMES_KEY, $microtime); + $pipe->ltrim($baseKey . ':' . self::MISS_TIMES_KEY, -100, -1); + }); + } + + /** + * Record a cache write operation + * + * @param string $key Cache key + * @param int $size Size in bytes + */ + public function recordWrite(string $key, int $size): void + { + $baseKey = self::METRICS_PREFIX . $key; + Redis::pipeline(function ($pipe) use ($baseKey, $size) { + $pipe->hset($baseKey, self::MEMORY_KEY, $size); + $pipe->hset($baseKey, self::LAST_WRITE_KEY, microtime(true)); + }); + } + + /** + * Get hit rate for a specific key + * + * @param string $key Cache key + * @return float Hit rate percentage (0-1) + */ + public function getHitRate(string $key): float + { + $baseKey = self::METRICS_PREFIX . $key; + $hits = (int) Redis::hget($baseKey, self::HITS_KEY) ?: 0; + $misses = (int) Redis::hget($baseKey, self::MISSES_KEY) ?: 0; + $total = $hits + $misses; + + return $total > 0 ? $hits / $total : 0; + } + + /** + * Get miss rate for a specific key + * + * @param string $key Cache key + * @return float Miss rate percentage (0-1) + */ + public function getMissRate(string $key): float + { + $baseKey = self::METRICS_PREFIX . $key; + $hits = (int) Redis::hget($baseKey, self::HITS_KEY) ?: 0; + $misses = (int) Redis::hget($baseKey, self::MISSES_KEY) ?: 0; + $total = $hits + $misses; + + return $total > 0 ? $misses / $total : 0; + } + + /** + * Get average hit time for a specific key + * + * @param string $key Cache key + * @return float Average hit time in seconds + */ + public function getHitAvgTime(string $key): float + { + $times = Redis::lrange(self::METRICS_PREFIX . $key . ':' . self::HIT_TIMES_KEY, 0, -1); + if (empty($times)) { + return 0; + } + + return array_sum(array_map('floatval', $times)) / count($times); + } + + /** + * Get average miss time for a specific key + * + * @param string $key Cache key + * @return float Average miss time in seconds + */ + public function getMissAvgTime(string $key): float + { + $times = Redis::lrange(self::METRICS_PREFIX . $key . ':' . self::MISS_TIMES_KEY, 0, -1); + if (empty($times)) { + return 0; + } + + return array_sum(array_map('floatval', $times)) / count($times); + } + + /** + * Get top accessed keys + * + * @param int $count Number of keys to return + * @return array Top keys with their metrics + */ + public function getTopKeys(int $count = 5): array + { + $keys = Redis::keys(self::METRICS_PREFIX . '*'); + $metrics = []; + + foreach ($keys as $redisKey) { + if (str_contains($redisKey, ':' . self::HIT_TIMES_KEY) || + str_contains($redisKey, ':' . self::MISS_TIMES_KEY)) { + continue; + } + + $key = str_replace(self::METRICS_PREFIX, '', $redisKey); + $hits = (int) Redis::hget($redisKey, self::HITS_KEY) ?: 0; + $misses = (int) Redis::hget($redisKey, self::MISSES_KEY) ?: 0; + $total = $hits + $misses; + + if ($total > 0) { + $metrics[$key] = [ + 'key' => $key, + 'hits' => $hits, + 'misses' => $misses, + 'total_accesses' => $total, + 'hit_ratio' => $hits / $total, + 'miss_ratio' => $misses / $total, + 'avg_hit_time' => $this->getHitAvgTime($key), + 'avg_miss_time' => $this->getMissAvgTime($key), + 'memory_usage' => $this->getMemoryUsage($key)['current_size'], + 'last_write' => Redis::hget($redisKey, self::LAST_WRITE_KEY), + ]; + } + } + + uasort($metrics, fn ($a, $b) => $b['total_accesses'] <=> $a['total_accesses']); + + return array_slice($metrics, 0, $count, true); + } + + /** + * Get memory usage for a specific key + * + * @param string $key Cache key + * @return array Memory usage statistics + */ + public function getMemoryUsage(string $key): array + { + $baseKey = self::METRICS_PREFIX . $key; + $currentSize = (int) Redis::hget($baseKey, self::MEMORY_KEY) ?: 0; + $lastWrite = Redis::hget($baseKey, self::LAST_WRITE_KEY); + + return [ + 'current_size' => $currentSize, + 'last_write' => $lastWrite ? (float) $lastWrite : null, + ]; + } + + /** + * Reset all metrics + */ + public function resetMetrics(): void + { + $keys = Redis::keys(self::METRICS_PREFIX . '*'); + if (!empty($keys)) { + Redis::del(...$keys); + } + } + + /** + * Get summary of all metrics + * + * @return array Summary statistics + */ + public function getSummary(): array + { + $keys = Redis::keys(self::METRICS_PREFIX . '*'); + $metrics = []; + $totalHits = 0; + $totalMisses = 0; + $totalMemory = 0; + $totalHitTime = 0; + $totalMissTime = 0; + $keyCount = 0; + + foreach ($keys as $redisKey) { + if (str_contains($redisKey, ':' . self::HIT_TIMES_KEY) || + str_contains($redisKey, ':' . self::MISS_TIMES_KEY)) { + continue; + } + + $key = str_replace(self::METRICS_PREFIX, '', $redisKey); + $hits = (int) Redis::hget($redisKey, self::HITS_KEY) ?: 0; + $misses = (int) Redis::hget($redisKey, self::MISSES_KEY) ?: 0; + $memory = (int) Redis::hget($redisKey, self::MEMORY_KEY) ?: 0; + + $totalHits += $hits; + $totalMisses += $misses; + $totalMemory += $memory; + $totalHitTime += $this->getHitAvgTime($key); + $totalMissTime += $this->getMissAvgTime($key); + + $metrics[$key] = [ + 'hits' => $hits, + 'misses' => $misses, + 'hit_ratio' => $hits + $misses > 0 ? $hits / ($hits + $misses) : 0, + 'avg_hit_time' => $this->getHitAvgTime($key), + 'avg_miss_time' => $this->getMissAvgTime($key), + 'memory_usage' => $memory, + ]; + + $keyCount++; + } + + $total = $totalHits + $totalMisses; + + return [ + 'keys' => $metrics, + 'overall_hit_ratio' => $total > 0 ? $totalHits / $total : 0, + 'overall_miss_ratio' => $total > 0 ? $totalMisses / $total : 0, + 'avg_hit_time' => $keyCount > 0 ? $totalHitTime / $keyCount : 0, + 'avg_miss_time' => $keyCount > 0 ? $totalMissTime / $keyCount : 0, + 'total_memory_usage' => $totalMemory, + 'total_keys' => $keyCount, + ]; + } +} diff --git a/ProcessMaker/Cache/Screens/ScreenCacheFactory.php b/ProcessMaker/Cache/Screens/ScreenCacheFactory.php index a383a7a899..e02270c056 100644 --- a/ProcessMaker/Cache/Screens/ScreenCacheFactory.php +++ b/ProcessMaker/Cache/Screens/ScreenCacheFactory.php @@ -3,6 +3,8 @@ namespace ProcessMaker\Cache\Screens; use Illuminate\Support\Facades\Config; +use ProcessMaker\Cache\Monitoring\CacheMetricsDecorator; +use ProcessMaker\Cache\Monitoring\RedisMetricsManager; use ProcessMaker\Cache\Screens\ScreenCacheManager; use ProcessMaker\Managers\ScreenCompiledManager; @@ -31,15 +33,18 @@ public static function create(): ScreenCacheInterface return self::$testInstance; } + // Create the appropriate cache implementation $manager = Config::get('screens.cache.manager', 'legacy'); + $cache = $manager === 'new' + ? app(ScreenCacheManager::class) + : new LegacyScreenCacheAdapter(app()->make(ScreenCompiledManager::class)); - if ($manager === 'new') { - return app(ScreenCacheManager::class); + // If already wrapped with metrics decorator, return as is + if ($cache instanceof CacheMetricsDecorator) { + return $cache; } - // Get the concrete ScreenCompiledManager instance from the container - $compiledManager = app()->make(ScreenCompiledManager::class); - - return new LegacyScreenCacheAdapter($compiledManager); + // Wrap with metrics decorator if not already wrapped + return new CacheMetricsDecorator($cache, app()->make(RedisMetricsManager::class)); } } diff --git a/ProcessMaker/Console/Commands/CacheMetricsClearCommand.php b/ProcessMaker/Console/Commands/CacheMetricsClearCommand.php new file mode 100644 index 0000000000..0046859679 --- /dev/null +++ b/ProcessMaker/Console/Commands/CacheMetricsClearCommand.php @@ -0,0 +1,33 @@ +metrics = $metrics; + } + + public function handle() + { + if (!$this->confirm('Are you sure you want to clear all cache metrics? This action cannot be undone.')) { + $this->info('Operation cancelled.'); + + return 0; + } + $this->info('Clearing all cache metrics data...'); + $this->metrics->resetMetrics(); + $this->info('Cache metrics data cleared successfully!'); + } +} diff --git a/ProcessMaker/Console/Commands/CacheMetricsCommand.php b/ProcessMaker/Console/Commands/CacheMetricsCommand.php new file mode 100644 index 0000000000..87394681b8 --- /dev/null +++ b/ProcessMaker/Console/Commands/CacheMetricsCommand.php @@ -0,0 +1,213 @@ +metrics = $metrics; + } + + public function handle() + { + $key = $this->option('key'); + $type = $this->option('type'); + $format = $this->option('format'); + + if ($key) { + $this->displayKeyMetrics($key, $format); + } else { + $this->displaySummary($type, $format); + } + + return 0; + } + + protected function displayKeyMetrics(string $key, string $format): void + { + $hitRate = $this->metrics->getHitRate($key); + $missRate = $this->metrics->getMissRate($key); + $avgHitTime = $this->metrics->getHitAvgTime($key); + $avgMissTime = $this->metrics->getMissAvgTime($key); + $memory = $this->metrics->getMemoryUsage($key); + + $data = [ + 'key' => $key, + 'hit_rate' => $hitRate, + 'miss_rate' => $missRate, + 'avg_hit_time' => $avgHitTime, + 'avg_miss_time' => $avgMissTime, + 'memory_usage' => $memory, + ]; + + if ($format === 'json') { + $this->line(json_encode($data, JSON_PRETTY_PRINT)); + + return; + } + + $this->info("Cache Metrics for key: {$key}"); + $this->newLine(); + + $table = new Table($this->output); + $table->setHeaders(['Metric', 'Value']); + $table->setRows([ + ['Hit Rate', $this->formatPercentage($hitRate)], + ['Miss Rate', $this->formatPercentage($missRate)], + ['Avg Hit Time', sprintf('%.4f sec', $avgHitTime)], + ['Avg Miss Time', sprintf('%.4f sec', $avgMissTime)], + ['Memory Usage', $this->formatBytes($memory['current_size'])], + ['Last Write', $memory['last_write'] ? date('Y-m-d H:i:s', $memory['last_write']) : 'Never'], + ]); + $table->render(); + } + + protected function displaySummary(?string $type, string $format): void + { + $summary = $this->metrics->getSummary(); + + if ($type) { + $summary = $this->filterByType($summary, $type); + } + + if ($format === 'json') { + $this->line(json_encode($summary, JSON_PRETTY_PRINT)); + + return; + } + + $this->info('Cache Performance Summary'); + $this->newLine(); + + // Overall Statistics + $table = new Table($this->output); + $table->setHeaders(['Metric', 'Value']); + $table->setRows([ + ['Total Keys', number_format($summary['total_keys'])], + ['Overall Hit Ratio', $this->formatPercentage($summary['overall_hit_ratio'])], + ['Overall Miss Ratio', $this->formatPercentage($summary['overall_miss_ratio'])], + ['Average Hit Time', sprintf('%.4f sec', $summary['avg_hit_time'])], + ['Average Miss Time', sprintf('%.4f sec', $summary['avg_miss_time'])], + ['Total Memory Usage', $this->formatBytes($summary['total_memory_usage'])], + ]); + $table->render(); + + // Display key details if there are any + if (!empty($summary['keys'])) { + $this->newLine(); + $this->info('Key Details'); + $this->newLine(); + + $table = new Table($this->output); + $table->setHeaders([ + 'Key', + 'Hits', + 'Hit Ratio', + 'Avg Time', + 'Memory', + 'Status', + ]); + + foreach ($summary['keys'] as $key => $metrics) { + $table->addRow([ + $key, + number_format($metrics['hits']), + $this->formatPercentage($metrics['hit_ratio']), + sprintf('%.4f sec', $metrics['avg_hit_time']), + $this->formatBytes($metrics['memory_usage']), + $this->getPerformanceStatus($metrics['hit_ratio']), + ]); + } + + $table->render(); + } + } + + protected function filterByType(array $summary, string $type): array + { + $filtered = [ + 'keys' => [], + 'total_keys' => 0, + 'overall_hit_ratio' => 0, + 'overall_miss_ratio' => 0, + 'avg_hit_time' => 0, + 'avg_miss_time' => 0, + 'total_memory_usage' => 0, + ]; + + foreach ($summary['keys'] as $key => $metrics) { + if ($this->getKeyType($key) === $type) { + $filtered['keys'][$key] = $metrics; + $filtered['total_keys']++; + $filtered['total_memory_usage'] += $metrics['memory_usage']; + $filtered['avg_hit_time'] += $metrics['avg_hit_time']; + $filtered['avg_miss_time'] += $metrics['avg_miss_time']; + } + } + + if ($filtered['total_keys'] > 0) { + $filtered['avg_hit_time'] /= $filtered['total_keys']; + $filtered['avg_miss_time'] /= $filtered['total_keys']; + } + + return $filtered; + } + + protected function getKeyType(string $key): string + { + if (str_starts_with($key, 'screen_')) { + return 'legacy'; + } + if (str_starts_with($key, 'pid_')) { + return 'new'; + } + + return 'unknown'; + } + + protected function formatPercentage(float $value): string + { + return sprintf('%.2f%%', $value * 100); + } + + protected function formatBytes(int $bytes): string + { + $units = ['B', 'KB', 'MB', 'GB']; + $bytes = max($bytes, 0); + $pow = floor(($bytes ? log($bytes) : 0) / log(1024)); + $pow = min($pow, count($units) - 1); + $bytes /= pow(1024, $pow); + + return sprintf('%.2f %s', $bytes, $units[$pow]); + } + + protected function getPerformanceStatus(float $hitRatio): string + { + if ($hitRatio >= 0.95) { + return '✅ Excellent'; + } + if ($hitRatio >= 0.80) { + return '✓ Good'; + } + if ($hitRatio >= 0.60) { + return '⚠️ Fair'; + } + + return '❌ Poor'; + } +} diff --git a/ProcessMaker/Console/Commands/CacheMetricsPopulateCommand.php b/ProcessMaker/Console/Commands/CacheMetricsPopulateCommand.php new file mode 100644 index 0000000000..95cf3a1f43 --- /dev/null +++ b/ProcessMaker/Console/Commands/CacheMetricsPopulateCommand.php @@ -0,0 +1,111 @@ +metrics = $metrics; + } + + public function handle() + { + $numKeys = (int) $this->option('keys'); + $type = $this->option('type'); + + $this->info("Populating {$numKeys} fake cache metrics..."); + + // Reset existing metrics + $this->metrics->resetMetrics(); + + // Generate keys based on type + $keys = $this->generateKeys($numKeys, $type); + + // Populate metrics for each key + foreach ($keys as $key) { + $this->populateKeyMetrics($key); + $this->output->write('.'); + } + + $this->newLine(); + $this->info('Done! You can now run cache:metrics to see the data.'); + } + + protected function generateKeys(int $count, string $type): array + { + $keys = []; + $prefixes = $this->getPrefixes($type); + + for ($i = 0; $i < $count; $i++) { + $prefix = $prefixes[array_rand($prefixes)]; + $id = rand(1, 1000); + + if ($prefix === 'screen_') { + $keys[] = "screen_{$id}_version_" . rand(1, 5); + } else { + $keys[] = "pid_{$id}_{$id}_en_sid_{$id}_" . rand(1, 5); + } + } + + return $keys; + } + + protected function getPrefixes(string $type): array + { + return match ($type) { + 'legacy' => ['screen_'], + 'new' => ['pid_'], + default => ['screen_', 'pid_'], + }; + } + + protected function populateKeyMetrics(string $key): void + { + // Generate random number of hits (0-1000) + $hits = rand(0, 1000); + + // Generate random number of misses (0-200) + $misses = rand(0, 200); + + // Record hits + for ($i = 0; $i < $hits; $i++) { + $time = $this->generateHitTime(); + $this->metrics->recordHit($key, $time); + } + + // Record misses + for ($i = 0; $i < $misses; $i++) { + $time = $this->generateMissTime(); + $this->metrics->recordMiss($key, $time); + } + + // Record memory usage (10KB - 1MB) + $size = rand(10 * 1024, 1024 * 1024); + $this->metrics->recordWrite($key, $size); + } + + protected function generateHitTime(): float + { + // Generate hit time between 0.001 and 0.1 seconds + return rand(1000, 100000) / 1000000; + } + + protected function generateMissTime(): float + { + // Generate miss time between 0.1 and 1 second + return rand(100000, 1000000) / 1000000; + } +} diff --git a/ProcessMaker/Console/Commands/CacheMetricsSummaryCommand.php b/ProcessMaker/Console/Commands/CacheMetricsSummaryCommand.php new file mode 100644 index 0000000000..11d65c1cdf --- /dev/null +++ b/ProcessMaker/Console/Commands/CacheMetricsSummaryCommand.php @@ -0,0 +1,169 @@ +metrics = $metrics; + } + + public function handle() + { + $days = (int) $this->option('days'); + $type = $this->option('type'); + $format = $this->option('format'); + + $summary = $this->metrics->getSummary(); + $topKeys = $this->metrics->getTopKeys(10); + + if ($format === 'json') { + $this->outputJson($summary, $topKeys); + + return; + } + + $this->outputTable($summary, $topKeys); + } + + protected function outputJson(array $summary, array $topKeys): void + { + $data = [ + 'summary' => $summary, + 'top_keys' => $topKeys, + 'generated_at' => now()->toIso8601String(), + ]; + + $this->line(json_encode($data, JSON_PRETTY_PRINT)); + } + + protected function outputTable(array $summary, array $topKeys): void + { + // Overall Statistics + $this->info('Cache Performance Summary'); + $this->newLine(); + + $table = new Table($this->output); + $table->setHeaders(['Metric', 'Value']); + $table->setRows([ + ['Total Keys', number_format($summary['total_keys'])], + ['Overall Hit Ratio', $this->formatPercentage($summary['overall_hit_ratio'])], + ['Overall Miss Ratio', $this->formatPercentage($summary['overall_miss_ratio'])], + ['Average Hit Time', sprintf('%.4f sec', $summary['avg_hit_time'])], + ['Average Miss Time', sprintf('%.4f sec', $summary['avg_miss_time'])], + ['Total Memory Usage', $this->formatBytes($summary['total_memory_usage'])], + ]); + $table->render(); + + // Performance Insights + $this->newLine(); + $this->info('Performance Insights'); + $this->newLine(); + + $insights = $this->generateInsights($summary); + foreach ($insights as $insight) { + $this->line(" • {$insight}"); + } + + // Top Keys + $this->newLine(); + $this->info('Top 10 Most Accessed Keys'); + $this->newLine(); + + $table = new Table($this->output); + $table->setHeaders([ + 'Key', + 'Hits', + 'Hit Ratio', + 'Avg Time', + 'Memory', + 'Status', + ]); + + foreach ($topKeys as $key => $metrics) { + $table->addRow([ + $key, + number_format($metrics['hits']), + $this->formatPercentage($metrics['hit_ratio']), + sprintf('%.4f sec', $metrics['avg_hit_time']), + $this->formatBytes($metrics['memory_usage']), + $this->getPerformanceStatus($metrics['hit_ratio']), + ]); + } + + $table->render(); + } + + protected function generateInsights(array $summary): array + { + $insights = []; + + // Hit ratio insights + if ($summary['overall_hit_ratio'] < 0.5) { + $insights[] = 'Low hit ratio indicates potential cache configuration issues'; + } elseif ($summary['overall_hit_ratio'] > 0.9) { + $insights[] = 'Excellent hit ratio shows effective cache utilization'; + } + + // Response time insights + if ($summary['avg_miss_time'] > $summary['avg_hit_time'] * 10) { + $insights[] = 'High miss penalty suggests optimization opportunities'; + } + + // Memory usage insights + $avgMemoryPerKey = $summary['total_memory_usage'] / ($summary['total_keys'] ?: 1); + if ($avgMemoryPerKey > 1024 * 1024) { // 1MB per key + $insights[] = 'High average memory usage per key'; + } + + return $insights; + } + + protected function formatPercentage(float $value): string + { + return sprintf('%.2f%%', $value * 100); + } + + protected function formatBytes(int $bytes): string + { + $units = ['B', 'KB', 'MB', 'GB']; + $bytes = max($bytes, 0); + $pow = floor(($bytes ? log($bytes) : 0) / log(1024)); + $pow = min($pow, count($units) - 1); + $bytes /= pow(1024, $pow); + + return sprintf('%.2f %s', $bytes, $units[$pow]); + } + + protected function getPerformanceStatus(float $hitRatio): string + { + if ($hitRatio >= 0.95) { + return '✅ Excellent'; + } + if ($hitRatio >= 0.80) { + return '✓ Good'; + } + if ($hitRatio >= 0.60) { + return '⚠️ Fair'; + } + + return '❌ Poor'; + } +} diff --git a/ProcessMaker/Console/Kernel.php b/ProcessMaker/Console/Kernel.php index b4bb48cf3e..30ac716641 100644 --- a/ProcessMaker/Console/Kernel.php +++ b/ProcessMaker/Console/Kernel.php @@ -41,6 +41,9 @@ protected function schedule(Schedule $schedule) $schedule->command('processmaker:sync-screen-templates --queue') ->daily(); + + $schedule->command('cache:metrics --format=json > storage/logs/processmaker-cache-metrics.json') + ->daily(); } /** diff --git a/ProcessMaker/Providers/CacheServiceProvider.php b/ProcessMaker/Providers/CacheServiceProvider.php new file mode 100644 index 0000000000..e1f6282f68 --- /dev/null +++ b/ProcessMaker/Providers/CacheServiceProvider.php @@ -0,0 +1,94 @@ +app->singleton(RedisMetricsManager::class); + $this->app->bind(CacheMetricsInterface::class, RedisMetricsManager::class); + + // Register screen cache with metrics + $this->app->singleton(ScreenCacheManager::class, function ($app) { + $cache = new ScreenCacheManager( + $app['cache'], + $app->make(ScreenCompiledManager::class) + ); + + return new CacheMetricsDecorator( + $cache, + $app->make(RedisMetricsManager::class) + ); + }); + + // Register settings cache with metrics + $this->app->singleton(SettingCacheManager::class, function ($app) { + $cache = new SettingCacheManager($app['cache']); + + return new CacheMetricsDecorator( + $cache, + $app->make(RedisMetricsManager::class) + ); + }); + + // Register legacy screen cache with metrics + $this->app->bind(LegacyScreenCacheAdapter::class, function ($app) { + $cache = new LegacyScreenCacheAdapter( + $app->make(ScreenCompiledManager::class) + ); + + return new CacheMetricsDecorator( + $cache, + $app->make(RedisMetricsManager::class) + ); + }); + + // Update the screen cache factory to use the metrics-enabled instances + $this->app->extend(ScreenCacheFactory::class, function ($factory, $app) { + return new class($app) extends ScreenCacheFactory { + protected $app; + + public function __construct($app) + { + $this->app = $app; + } + + public static function create(): ScreenCacheInterface + { + $manager = config('screens.cache.manager', 'legacy'); + + if ($manager === 'new') { + return app(ScreenCacheManager::class); + } + + return app(LegacyScreenCacheAdapter::class); + } + }; + }); + } + + public function boot(): void + { + // Register the metrics commands + if ($this->app->runningInConsole()) { + $this->commands([ + \ProcessMaker\Console\Commands\CacheMetricsCommand::class, + \ProcessMaker\Console\Commands\CacheMetricsSummaryCommand::class, + \ProcessMaker\Console\Commands\CacheMetricsPopulateCommand::class, + \ProcessMaker\Console\Commands\CacheMetricsClearCommand::class, + ]); + } + } +} diff --git a/config/screens.php b/config/screens.php index 4635f58c3c..10b838f944 100644 --- a/config/screens.php +++ b/config/screens.php @@ -19,5 +19,20 @@ // Default TTL for cached screens (24 hours) 'ttl' => env('SCREEN_CACHE_TTL', 86400), + + // Cache metrics configuration + 'metrics' => [ + // Enable or disable cache metrics + 'enabled' => env('CACHE_METRICS_ENABLED', true), + + // Maximum number of timing samples to keep per key + 'max_samples' => env('CACHE_METRICS_MAX_SAMPLES', 100), + + // Redis prefix for metrics data + 'redis_prefix' => env('CACHE_METRICS_PREFIX', 'cache:metrics:'), + + // How long to keep metrics data (in seconds) + 'ttl' => env('CACHE_METRICS_TTL', 86400), // 24 hours + ], ], ]; diff --git a/docs/cache-monitoring.md b/docs/cache-monitoring.md new file mode 100644 index 0000000000..a6ec09973d --- /dev/null +++ b/docs/cache-monitoring.md @@ -0,0 +1,77 @@ +# ProcessMaker Cache Monitoring System + +## Overview +The ProcessMaker Cache Monitoring System is a comprehensive solution for tracking and analyzing cache performance metrics in real-time. It uses Redis as a backend store for metrics and provides detailed insights into cache usage patterns, hit/miss rates, and memory consumption. + +## Current Implementation + +### Architecture +1. **Core Components**: + - `CacheMetricsInterface`: Defines the contract for metrics collection + - `RedisMetricsManager`: Implements metrics storage using Redis + - `CacheMetricsDecorator`: Wraps cache implementations to collect metrics + +2. **Key Features**: + - Real-time metrics collection + - Hit/miss rate tracking + - Response time monitoring + - Memory usage tracking + - Top keys analysis + - Performance insights + +3. **Storage Strategy**: + - Uses Redis hash structures for counters + - Maintains rolling lists for timing data + - Prefix-based key organization + - Automatic cleanup of old data + +### Metrics Collected +- Cache hits and misses +- Response times for hits/misses +- Memory usage per key +- Last write timestamps +- Access patterns +- Overall performance statistics + + +## Why Redis? +The current Redis-based implementation was chosen because: +1. **Performance**: Redis provides fast read/write operations +2. **Data Structures**: Native support for counters and lists +3. **TTL Support**: Automatic expiration of old metrics +4. **Scalability**: Easy to scale horizontally +5. **Integration**: Already used in ProcessMaker's infrastructure + +## Usage Examples + +### Basic Monitoring +```php +// Get hit rate for a specific key +$hitRate = $metrics->getHitRate('my_cache_key'); + +// Get memory usage +$usage = $metrics->getMemoryUsage('my_cache_key'); + +// Get performance summary +$summary = $metrics->getSummary(); +``` + +### Performance Analysis +```php +// Get top accessed keys +$topKeys = $metrics->getTopKeys(10); + +// Analyze response times +$avgHitTime = $metrics->getHitAvgTime('my_cache_key'); +$avgMissTime = $metrics->getMissAvgTime('my_cache_key'); +``` + +## Future Improvements +1. **Aggregation**: Add support for metric aggregation by time periods +2. **Sampling**: Implement sampling for high-traffic scenarios +3. **Alerts**: Add threshold-based alerting system +4. **Visualization**: Integrate with monitoring dashboards +5. **Custom Metrics**: Allow adding custom metrics per cache type + +## Conclusion +The Redis-based monitoring system provides a good balance between performance, functionality, and maintainability. While there are alternatives available, the current implementation meets ProcessMaker's requirements for real-time cache monitoring with minimal overhead. \ No newline at end of file diff --git a/tests/unit/ProcessMaker/Cache/Monitoring/CacheMetricsDecoratorTest.php b/tests/unit/ProcessMaker/Cache/Monitoring/CacheMetricsDecoratorTest.php new file mode 100644 index 0000000000..3865cd3df0 --- /dev/null +++ b/tests/unit/ProcessMaker/Cache/Monitoring/CacheMetricsDecoratorTest.php @@ -0,0 +1,225 @@ +cache = Mockery::mock(CacheInterface::class); + $this->metrics = Mockery::mock(CacheMetricsInterface::class); + + // Create decorator with mocks + $this->decorator = new CacheMetricsDecorator($this->cache, $this->metrics); + } + + public function testGetWithHit() + { + // Setup expectations for cache hit + $this->cache->shouldReceive('get') + ->once() + ->with($this->testKey, null) + ->andReturn($this->testValue); + + $this->metrics->shouldReceive('recordHit') + ->once() + ->withArgs(function ($key, $time) { + return $key === $this->testKey && is_float($time); + }); + + // Execute and verify + $result = $this->decorator->get($this->testKey); + $this->assertEquals($this->testValue, $result); + } + + public function testGetWithMiss() + { + $default = 'default_value'; + + // Setup expectations for cache miss + $this->cache->shouldReceive('get') + ->once() + ->with($this->testKey, $default) + ->andReturn($default); + + $this->metrics->shouldReceive('recordMiss') + ->once() + ->withArgs(function ($key, $time) { + return $key === $this->testKey && is_float($time); + }); + + // Execute and verify + $result = $this->decorator->get($this->testKey, $default); + $this->assertEquals($default, $result); + } + + public function testSetSuccess() + { + $ttl = 3600; + + // Setup expectations + $this->cache->shouldReceive('set') + ->once() + ->with($this->testKey, $this->testValue, $ttl) + ->andReturn(true); + + $this->metrics->shouldReceive('recordWrite') + ->once() + ->withArgs(function ($key, $size) { + return $key === $this->testKey && is_int($size) && $size > 0; + }); + + // Execute and verify + $result = $this->decorator->set($this->testKey, $this->testValue, $ttl); + $this->assertTrue($result); + } + + public function testSetFailure() + { + // Setup expectations + $this->cache->shouldReceive('set') + ->once() + ->with($this->testKey, $this->testValue, null) + ->andReturn(false); + + $this->metrics->shouldNotReceive('recordWrite'); + + // Execute and verify + $result = $this->decorator->set($this->testKey, $this->testValue); + $this->assertFalse($result); + } + + public function testDelete() + { + // Setup expectations + $this->cache->shouldReceive('delete') + ->once() + ->with($this->testKey) + ->andReturn(true); + + // Execute and verify + $result = $this->decorator->delete($this->testKey); + $this->assertTrue($result); + } + + public function testClear() + { + // Setup expectations + $this->cache->shouldReceive('clear') + ->once() + ->andReturn(true); + + // Execute and verify + $result = $this->decorator->clear(); + $this->assertTrue($result); + } + + public function testHas() + { + // Setup expectations + $this->cache->shouldReceive('has') + ->once() + ->with($this->testKey) + ->andReturn(true); + + // Execute and verify + $result = $this->decorator->has($this->testKey); + $this->assertTrue($result); + } + + public function testMissing() + { + // Setup expectations + $this->cache->shouldReceive('missing') + ->once() + ->with($this->testKey) + ->andReturn(true); + + // Execute and verify + $result = $this->decorator->missing($this->testKey); + $this->assertTrue($result); + } + + public function testCalculateSizeWithString() + { + $value = 'test'; + $result = $this->invokeCalculateSize($value); + $this->assertEquals(strlen($value), $result); + } + + public function testCalculateSizeWithArray() + { + $value = ['test' => 'value']; + $result = $this->invokeCalculateSize($value); + $this->assertEquals(strlen(serialize($value)), $result); + } + + public function testCalculateSizeWithObject() + { + $value = new \stdClass(); + $value->test = 'value'; + $result = $this->invokeCalculateSize($value); + $this->assertEquals(strlen(serialize($value)), $result); + } + + public function testCalculateSizeWithInteger() + { + $value = 42; + $result = $this->invokeCalculateSize($value); + $this->assertEquals(PHP_INT_SIZE, $result); + } + + public function testCalculateSizeWithFloat() + { + $value = 3.14; + $result = $this->invokeCalculateSize($value); + $this->assertEquals(8, $result); + } + + public function testCalculateSizeWithBoolean() + { + $value = true; + $result = $this->invokeCalculateSize($value); + $this->assertEquals(1, $result); + } + + public function testCalculateSizeWithNull() + { + $value = null; + $result = $this->invokeCalculateSize($value); + $this->assertEquals(0, $result); + } + + protected function invokeCalculateSize($value) + { + $method = new \ReflectionMethod(CacheMetricsDecorator::class, 'calculateSize'); + $method->setAccessible(true); + + return $method->invoke($this->decorator, $value); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} diff --git a/tests/unit/ProcessMaker/Cache/Monitoring/RedisMetricsManagerTest.php b/tests/unit/ProcessMaker/Cache/Monitoring/RedisMetricsManagerTest.php new file mode 100644 index 0000000000..04f30acba7 --- /dev/null +++ b/tests/unit/ProcessMaker/Cache/Monitoring/RedisMetricsManagerTest.php @@ -0,0 +1,187 @@ +metrics = new RedisMetricsManager(); + + // Get metrics prefix using reflection + $reflection = new \ReflectionClass(RedisMetricsManager::class); + $this->metricsPrefix = $reflection->getConstant('METRICS_PREFIX'); + + // Clear any existing metrics before each test + $this->metrics->resetMetrics(); + } + + public function testRecordHit() + { + $time = 0.1; + $this->metrics->recordHit($this->testKey, $time); + + $baseKey = $this->metricsPrefix . $this->testKey; + $hits = Redis::hget($baseKey, 'hits'); + $times = Redis::lrange($baseKey . ':hit_times', 0, -1); + + $this->assertEquals(1, $hits); + $this->assertCount(1, $times); + $this->assertEquals($time, (float) $times[0]); + } + + public function testRecordMiss() + { + $time = 0.2; + $this->metrics->recordMiss($this->testKey, $time); + + $baseKey = $this->metricsPrefix . $this->testKey; + $misses = Redis::hget($baseKey, 'misses'); + $times = Redis::lrange($baseKey . ':miss_times', 0, -1); + + $this->assertEquals(1, $misses); + $this->assertCount(1, $times); + $this->assertEquals($time, (float) $times[0]); + } + + public function testRecordWrite() + { + $size = 1024; + $this->metrics->recordWrite($this->testKey, $size); + + $baseKey = $this->metricsPrefix . $this->testKey; + $storedSize = Redis::hget($baseKey, 'memory'); + $lastWrite = Redis::hget($baseKey, 'last_write'); + + $this->assertEquals($size, $storedSize); + $this->assertNotNull($lastWrite); + $this->assertIsNumeric($lastWrite); + } + + public function testGetHitRate() + { + // Record 2 hits and 1 miss + $this->metrics->recordHit($this->testKey, 0.1); + $this->metrics->recordHit($this->testKey, 0.1); + $this->metrics->recordMiss($this->testKey, 0.2); + + $hitRate = $this->metrics->getHitRate($this->testKey); + $this->assertEqualsWithDelta(2 / 3, $hitRate, 0.0001); + } + + public function testGetMissRate() + { + // Record 2 hits and 1 miss + $this->metrics->recordHit($this->testKey, 0.1); + $this->metrics->recordHit($this->testKey, 0.1); + $this->metrics->recordMiss($this->testKey, 0.2); + + $missRate = $this->metrics->getMissRate($this->testKey); + $this->assertEqualsWithDelta(1 / 3, $missRate, 0.0001); + } + + public function testGetHitAvgTime() + { + $this->metrics->recordHit($this->testKey, 0.1); + $this->metrics->recordHit($this->testKey, 0.3); + + $avgTime = $this->metrics->getHitAvgTime($this->testKey); + $this->assertEqualsWithDelta(0.2, $avgTime, 0.0001); + } + + public function testGetMissAvgTime() + { + $this->metrics->recordMiss($this->testKey, 0.2); + $this->metrics->recordMiss($this->testKey, 0.4); + + $avgTime = $this->metrics->getMissAvgTime($this->testKey); + $this->assertEqualsWithDelta(0.3, $avgTime, 0.0001); + } + + public function testGetTopKeys() + { + // Setup test data + $this->metrics->recordHit('key1', 0.1); + $this->metrics->recordHit('key1', 0.1); + $this->metrics->recordMiss('key1', 0.2); + $this->metrics->recordWrite('key1', 1000); + + $this->metrics->recordHit('key2', 0.1); + $this->metrics->recordWrite('key2', 500); + + $topKeys = $this->metrics->getTopKeys(2); + + $this->assertCount(2, $topKeys); + $this->assertEquals('key1', $topKeys['key1']['key']); + $this->assertEquals(3, $topKeys['key1']['total_accesses']); + $this->assertEquals(1000, $topKeys['key1']['memory_usage']); + } + + public function testGetMemoryUsage() + { + $size = 2048; + $this->metrics->recordWrite($this->testKey, $size); + + $usage = $this->metrics->getMemoryUsage($this->testKey); + + $this->assertEquals($size, $usage['current_size']); + $this->assertNotNull($usage['last_write']); + $this->assertIsFloat($usage['last_write']); + } + + public function testResetMetrics() + { + // Add some test data + $this->metrics->recordHit($this->testKey, 0.1); + $this->metrics->recordMiss($this->testKey, 0.2); + $this->metrics->recordWrite($this->testKey, 1024); + + // Reset metrics + $this->metrics->resetMetrics(); + + // Verify all metrics are cleared + $keys = Redis::keys($this->metricsPrefix . '*'); + $this->assertEmpty($keys); + } + + public function testGetSummary() + { + // Setup test data + $this->metrics->recordHit('key1', 0.1); + $this->metrics->recordHit('key1', 0.3); + $this->metrics->recordMiss('key1', 0.2); + $this->metrics->recordWrite('key1', 1000); + + $this->metrics->recordHit('key2', 0.2); + $this->metrics->recordWrite('key2', 500); + + $summary = $this->metrics->getSummary(); + + $this->assertArrayHasKey('keys', $summary); + $this->assertArrayHasKey('overall_hit_ratio', $summary); + $this->assertArrayHasKey('overall_miss_ratio', $summary); + $this->assertArrayHasKey('avg_hit_time', $summary); + $this->assertArrayHasKey('avg_miss_time', $summary); + $this->assertArrayHasKey('total_memory_usage', $summary); + $this->assertEquals(2, $summary['total_keys']); + $this->assertEquals(1500, $summary['total_memory_usage']); + } + + protected function tearDown(): void + { + // Clean up after each test + $this->metrics->resetMetrics(); + parent::tearDown(); + } +} diff --git a/tests/unit/ProcessMaker/Cache/Screens/ScreenCacheFactoryTest.php b/tests/unit/ProcessMaker/Cache/Screens/ScreenCacheFactoryTest.php index 97383b89f7..f97a0d201c 100644 --- a/tests/unit/ProcessMaker/Cache/Screens/ScreenCacheFactoryTest.php +++ b/tests/unit/ProcessMaker/Cache/Screens/ScreenCacheFactoryTest.php @@ -3,6 +3,8 @@ namespace Tests\Unit\ProcessMaker\Cache\Screens; use Illuminate\Support\Facades\Config; +use ProcessMaker\Cache\Monitoring\CacheMetricsDecorator; +use ProcessMaker\Cache\Monitoring\RedisMetricsManager; use ProcessMaker\Cache\Screens\LegacyScreenCacheAdapter; use ProcessMaker\Cache\Screens\ScreenCacheFactory; use ProcessMaker\Cache\Screens\ScreenCacheManager; @@ -11,33 +13,98 @@ class ScreenCacheFactoryTest extends TestCase { - /** @test */ - public function it_creates_new_cache_manager_when_configured() + protected function setUp(): void + { + parent::setUp(); + ScreenCacheFactory::setTestInstance(null); + + // Bind necessary dependencies + $this->app->singleton(ScreenCompiledManager::class); + $this->app->singleton(RedisMetricsManager::class); + } + + public function testCreateNewCacheManager() { Config::set('screens.cache.manager', 'new'); - $cacheHandler = ScreenCacheFactory::create(); + // Create a mock for ScreenCacheManager + $mockManager = $this->createMock(ScreenCacheManager::class); + $this->app->instance(ScreenCacheManager::class, $mockManager); + + $cache = ScreenCacheFactory::create(); + + // Should be wrapped with metrics decorator + $this->assertInstanceOf(CacheMetricsDecorator::class, $cache); - $this->assertInstanceOf(ScreenCacheManager::class, $cacheHandler); + // Get the underlying cache implementation + $reflection = new \ReflectionClass($cache); + $property = $reflection->getProperty('cache'); + $property->setAccessible(true); + $underlyingCache = $property->getValue($cache); + + // Verify it's the new cache manager + $this->assertInstanceOf(ScreenCacheManager::class, $underlyingCache); } - /** @test */ - public function it_creates_legacy_adapter_by_default() + public function testCreateLegacyCacheAdapter() { Config::set('screens.cache.manager', 'legacy'); - $cacheHandler = ScreenCacheFactory::create(); + $cache = ScreenCacheFactory::create(); + + // Should be wrapped with metrics decorator + $this->assertInstanceOf(CacheMetricsDecorator::class, $cache); + + // Get the underlying cache implementation + $reflection = new \ReflectionClass($cache); + $property = $reflection->getProperty('cache'); + $property->setAccessible(true); + $underlyingCache = $property->getValue($cache); - $this->assertInstanceOf(LegacyScreenCacheAdapter::class, $cacheHandler); + // Verify it's the legacy adapter + $this->assertInstanceOf(LegacyScreenCacheAdapter::class, $underlyingCache); } - /** @test */ - public function it_creates_legacy_adapter_when_config_missing() + public function testMetricsIntegrationWithBothAdapters() { - Config::set('screens.cache.manager', null); + // Test with new cache manager + Config::set('screens.cache.manager', 'new'); + + // Create a mock for ScreenCacheManager + $mockManager = $this->createMock(ScreenCacheManager::class); + $this->app->instance(ScreenCacheManager::class, $mockManager); - $cacheHandler = ScreenCacheFactory::create(); + $newCache = ScreenCacheFactory::create(); + $this->verifyMetricsDecorator($newCache, ScreenCacheManager::class); - $this->assertInstanceOf(LegacyScreenCacheAdapter::class, $cacheHandler); + // Test with legacy adapter + Config::set('screens.cache.manager', 'legacy'); + $legacyCache = ScreenCacheFactory::create(); + $this->verifyMetricsDecorator($legacyCache, LegacyScreenCacheAdapter::class); + } + + protected function verifyMetricsDecorator($cache, $expectedCacheClass) + { + // Verify it's a metrics decorator + $this->assertInstanceOf(CacheMetricsDecorator::class, $cache); + + // Get reflection for property access + $reflection = new \ReflectionClass($cache); + + // Check cache implementation + $cacheProperty = $reflection->getProperty('cache'); + $cacheProperty->setAccessible(true); + $this->assertInstanceOf($expectedCacheClass, $cacheProperty->getValue($cache)); + + // Check metrics manager + $metricsProperty = $reflection->getProperty('metrics'); + $metricsProperty->setAccessible(true); + $this->assertInstanceOf(RedisMetricsManager::class, $metricsProperty->getValue($cache)); + } + + protected function tearDown(): void + { + ScreenCacheFactory::setTestInstance(null); + parent::tearDown(); } }