lthn.io/app/Core/Storage/StorageMetrics.php
Claude 41a90cbff8
feat: lthn.io API serving live chain data
Fixed: basePath self→static binding, namespace detection, event wiring,
SQLite cache, file cache driver. All Mod Boot classes converted to
$listens pattern for lifecycle event discovery.

Working endpoints:
- /v1/explorer/info — live chain height, difficulty, aliases
- /v1/explorer/stats — formatted chain statistics
- /v1/names/directory — alias directory grouped by type
- /v1/names/available/{name} — name availability check
- /v1/names/lookup/{name} — name details

Co-Authored-By: Charon <charon@lethean.io>
2026-04-03 17:17:42 +01:00

532 lines
14 KiB
PHP

<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Storage;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Support\Facades\Log;
/**
* Collects and reports storage metrics for monitoring.
*
* Tracks cache hits, misses, latency, and fallback activations to provide
* insights into storage layer health and performance.
*
* ## Metrics Collected
*
* - **cache.hit**: Successful cache retrievals
* - **cache.miss**: Failed cache lookups
* - **cache.write**: Cache write operations
* - **cache.delete**: Cache deletion operations
* - **cache.latency**: Operation timing in milliseconds
* - **fallback.activated**: Fallback to database triggered
* - **circuit.opened**: Circuit breaker opened
* - **circuit.closed**: Circuit breaker recovered
*
* ## Usage
*
* ```php
* $metrics = app(StorageMetrics::class);
*
* // Record a cache hit with timing
* $start = microtime(true);
* $value = $cache->get($key);
* $metrics->recordHit('redis', microtime(true) - $start);
*
* // Get collected metrics
* $stats = $metrics->getStats();
*
* // Flush metrics (e.g., to external service)
* $metrics->flush(fn($stats) => $statsd->gauge('cache', $stats));
* ```
*/
class StorageMetrics
{
/**
* In-memory metrics buffer.
*
* @var array<string, array<string, int|float>>
*/
protected array $metrics = [];
/**
* Timing samples for latency calculations.
*
* @var array<string, array<float>>
*/
protected array $latencies = [];
/**
* Maximum latency samples to keep per driver.
*/
protected int $maxLatencySamples;
/**
* Whether metrics collection is enabled.
*/
protected bool $enabled;
public function __construct(
protected ?Dispatcher $events = null
) {
$this->enabled = (bool) config('core.storage.metrics.enabled', true);
$this->maxLatencySamples = (int) config('core.storage.metrics.max_samples', 1000);
$this->initializeMetrics();
}
/**
* Record a cache hit.
*/
public function recordHit(string $driver, float $durationSeconds = 0.0): void
{
if (! $this->enabled) {
return;
}
$this->doIncrement($driver, 'hits');
$this->recordLatency($driver, $durationSeconds * 1000);
}
/**
* Record a cache miss.
*/
public function recordMiss(string $driver, float $durationSeconds = 0.0): void
{
if (! $this->enabled) {
return;
}
$this->doIncrement($driver, 'misses');
$this->recordLatency($driver, $durationSeconds * 1000);
}
/**
* Record a cache write operation.
*/
public function recordWrite(string $driver, float $durationSeconds = 0.0): void
{
if (! $this->enabled) {
return;
}
$this->doIncrement($driver, 'writes');
$this->recordLatency($driver, $durationSeconds * 1000);
}
/**
* Record a cache delete operation.
*/
public function recordDelete(string $driver, float $durationSeconds = 0.0): void
{
if (! $this->enabled) {
return;
}
$this->doIncrement($driver, 'deletes');
$this->recordLatency($driver, $durationSeconds * 1000);
}
/**
* Record a fallback activation.
*/
public function recordFallbackActivation(string $driver, string $reason = ''): void
{
if (! $this->enabled) {
return;
}
$this->doIncrement($driver, 'fallback_activations');
$this->log('warning', 'Storage fallback activated', [
'driver' => $driver,
'reason' => $reason,
]);
}
/**
* Record a circuit breaker state change.
*/
public function recordCircuitChange(string $driver, string $oldState, string $newState): void
{
if (! $this->enabled) {
return;
}
if ($newState === CircuitBreaker::STATE_OPEN) {
$this->doIncrement($driver, 'circuit_opens');
} elseif ($newState === CircuitBreaker::STATE_CLOSED && $oldState !== CircuitBreaker::STATE_CLOSED) {
$this->doIncrement($driver, 'circuit_closes');
}
$this->log('info', 'Circuit breaker state change', [
'driver' => $driver,
'old_state' => $oldState,
'new_state' => $newState,
]);
}
/**
* Increment a custom metric counter.
*
* Allows external code to record custom metrics beyond the standard
* hit/miss/write/delete metrics.
*/
public function increment(string $driver, string $metric, int $amount = 1): void
{
$this->doIncrement($driver, $metric, $amount);
}
/**
* Record an error.
*/
public function recordError(string $driver, string $operation, \Throwable $error): void
{
if (! $this->enabled) {
return;
}
$this->doIncrement($driver, 'errors');
$this->log('error', 'Storage operation error', [
'driver' => $driver,
'operation' => $operation,
'error' => $error->getMessage(),
'exception' => get_class($error),
]);
}
/**
* Get metrics for all drivers.
*
* @return array<string, array<string, mixed>>
*/
public function getStats(): array
{
$stats = [];
foreach ($this->metrics as $driver => $driverMetrics) {
$latency = $this->calculateLatencyStats($driver);
$stats[$driver] = array_merge($driverMetrics, [
'hit_rate' => $this->calculateHitRate($driver),
'latency_avg_ms' => $latency['avg'],
'latency_p95_ms' => $latency['p95'],
'latency_p99_ms' => $latency['p99'],
]);
}
return $stats;
}
/**
* Get metrics for a specific driver.
*
* @return array<string, mixed>
*/
public function getDriverStats(string $driver): array
{
$allStats = $this->getStats();
return $allStats[$driver] ?? [];
}
/**
* Flush metrics to an external handler.
*
* @param callable(array<string, array<string, mixed>>): void $handler
*/
public function flush(callable $handler): void
{
$stats = $this->getStats();
$handler($stats);
$this->reset();
}
/**
* Reset all metrics.
*/
public function reset(): void
{
$this->initializeMetrics();
$this->latencies = [];
}
/**
* Enable or disable metrics collection.
*/
public function setEnabled(bool $enabled): void
{
$this->enabled = $enabled;
}
/**
* Check if metrics collection is enabled.
*/
public function isEnabled(): bool
{
return $this->enabled;
}
/**
* Get cache hit rate for a specific driver.
*
* Returns the percentage of cache hits vs total lookups (hits + misses).
* This is a key metric for understanding cache effectiveness.
*
* @param string $driver The cache driver name (redis, database, file, array)
* @return float Hit rate as a percentage (0.0 to 100.0)
*/
public function getHitRate(string $driver): float
{
return $this->calculateHitRate($driver);
}
/**
* Get cache hit rates for all drivers.
*
* Returns an array of driver => hit rate percentage.
* Useful for monitoring dashboards and alerting.
*
* @return array<string, float> Map of driver name to hit rate percentage
*/
public function getAllHitRates(): array
{
$rates = [];
foreach (array_keys($this->metrics) as $driver) {
$rates[$driver] = $this->calculateHitRate($driver);
}
return $rates;
}
/**
* Get detailed hit/miss statistics for a driver.
*
* Returns an array with hits, misses, total lookups, and hit rate.
* Provides more context than just the hit rate percentage.
*
* @param string $driver The cache driver name
* @return array{hits: int, misses: int, total: int, hit_rate: float}
*/
public function getHitRateDetails(string $driver): array
{
$hits = $this->metrics[$driver]['hits'] ?? 0;
$misses = $this->metrics[$driver]['misses'] ?? 0;
$total = $hits + $misses;
return [
'hits' => $hits,
'misses' => $misses,
'total' => $total,
'hit_rate' => $this->calculateHitRate($driver),
];
}
/**
* Check if the hit rate is below a threshold (indicating potential issues).
*
* Useful for alerting when cache effectiveness drops.
*
* @param string $driver The cache driver name
* @param float $threshold Minimum acceptable hit rate percentage (default: 50.0)
* @param int $minSamples Minimum samples required before alerting (default: 100)
* @return bool True if hit rate is below threshold and we have enough samples
*/
public function isHitRateLow(string $driver, float $threshold = 50.0, int $minSamples = 100): bool
{
$hits = $this->metrics[$driver]['hits'] ?? 0;
$misses = $this->metrics[$driver]['misses'] ?? 0;
$total = $hits + $misses;
// Don't alert if we don't have enough samples
if ($total < $minSamples) {
return false;
}
return $this->calculateHitRate($driver) < $threshold;
}
/**
* Get a summary of cache health across all drivers.
*
* Returns a structured summary suitable for monitoring endpoints.
*
* @param float $warnThreshold Hit rate below this triggers warning (default: 70.0)
* @param float $criticalThreshold Hit rate below this triggers critical (default: 50.0)
* @return array{
* overall_status: string,
* drivers: array<string, array{hit_rate: float, status: string, hits: int, misses: int}>
* }
*/
public function getCacheHealthSummary(float $warnThreshold = 70.0, float $criticalThreshold = 50.0): array
{
$drivers = [];
$worstStatus = 'healthy';
foreach (array_keys($this->metrics) as $driver) {
$details = $this->getHitRateDetails($driver);
// Determine status
$status = 'healthy';
if ($details['total'] > 0) {
if ($details['hit_rate'] < $criticalThreshold) {
$status = 'critical';
} elseif ($details['hit_rate'] < $warnThreshold) {
$status = 'warning';
}
} else {
$status = 'no_data';
}
// Track worst status
if ($status === 'critical') {
$worstStatus = 'critical';
} elseif ($status === 'warning' && $worstStatus !== 'critical') {
$worstStatus = 'warning';
}
$drivers[$driver] = [
'hit_rate' => $details['hit_rate'],
'status' => $status,
'hits' => $details['hits'],
'misses' => $details['misses'],
];
}
return [
'overall_status' => $worstStatus,
'drivers' => $drivers,
];
}
/**
* Initialize metric counters.
*/
protected function initializeMetrics(): void
{
$this->metrics = [
'redis' => $this->getDefaultMetrics(),
'database' => $this->getDefaultMetrics(),
'file' => $this->getDefaultMetrics(),
'array' => $this->getDefaultMetrics(),
];
}
/**
* Get default metric structure.
*
* @return array<string, int>
*/
protected function getDefaultMetrics(): array
{
return [
'hits' => 0,
'misses' => 0,
'writes' => 0,
'deletes' => 0,
'errors' => 0,
'fallback_activations' => 0,
'circuit_opens' => 0,
'circuit_closes' => 0,
];
}
/**
* Internal metric counter increment.
*/
protected function doIncrement(string $driver, string $metric, int $amount = 1): void
{
if (! isset($this->metrics[$driver])) {
$this->metrics[$driver] = $this->getDefaultMetrics();
}
if (! isset($this->metrics[$driver][$metric])) {
$this->metrics[$driver][$metric] = 0;
}
$this->metrics[$driver][$metric] += $amount;
}
/**
* Record a latency sample.
*/
protected function recordLatency(string $driver, float $durationMs): void
{
if (! isset($this->latencies[$driver])) {
$this->latencies[$driver] = [];
}
$this->latencies[$driver][] = $durationMs;
// Trim samples if we exceed max
if (count($this->latencies[$driver]) > $this->maxLatencySamples) {
$this->latencies[$driver] = array_slice(
$this->latencies[$driver],
-$this->maxLatencySamples
);
}
}
/**
* Calculate hit rate for a driver.
*/
protected function calculateHitRate(string $driver): float
{
$hits = $this->metrics[$driver]['hits'] ?? 0;
$misses = $this->metrics[$driver]['misses'] ?? 0;
$total = $hits + $misses;
if ($total === 0) {
return 0.0;
}
return round($hits / $total * 100, 2);
}
/**
* Calculate latency statistics for a driver.
*
* @return array<string, float>
*/
protected function calculateLatencyStats(string $driver): array
{
$samples = $this->latencies[$driver] ?? [];
if (empty($samples)) {
return ['avg' => 0.0, 'p95' => 0.0, 'p99' => 0.0];
}
sort($samples);
$count = count($samples);
$avg = round(array_sum($samples) / $count, 2);
$p95 = round($samples[(int) floor($count * 0.95)] ?? 0, 2);
$p99 = round($samples[(int) floor($count * 0.99)] ?? 0, 2);
return ['avg' => $avg, 'p95' => $p95, 'p99' => $p99];
}
/**
* Log a message if logging is enabled.
*
* @param array<string, mixed> $context
*/
protected function log(string $level, string $message, array $context = []): void
{
if (! config('core.storage.metrics.log_enabled', true)) {
return;
}
Log::log($level, "[StorageMetrics] {$message}", $context);
}
}