diff --git a/.github/workflows/blackbox.yml b/.github/workflows/blackbox.yml index afdce3c4..225f2c15 100644 --- a/.github/workflows/blackbox.yml +++ b/.github/workflows/blackbox.yml @@ -39,5 +39,7 @@ jobs: run: docker compose run phpunit env ADAPTER=apc vendor/bin/phpunit tests/Test/ - name: Run Blackbox with APCng run: docker compose run phpunit env ADAPTER=apcng vendor/bin/phpunit tests/Test/ - - name: Run Blackbox with Redis + - name: Run Blackbox with PHPRedis run: docker compose run phpunit env ADAPTER=redis vendor/bin/phpunit tests/Test/ + - name: Run Blackbox with Predis + run: docker compose run phpunit env ADAPTER=predis vendor/bin/phpunit tests/Test/ diff --git a/README.md b/README.md index f189b082..925a2843 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,8 @@ If using Redis, we recommend running a local Redis instance next to your PHP wor ## How does it work? Usually PHP worker processes don't share any state. -You can pick from four adapters. -Redis, APC, APCng, or an in-memory adapter. +You can pick from five adapters. +Redis, Predis, APC, APCng, or an in-memory adapter. While the first needs a separate binary running, the second and third just need the [APC](https://pecl.php.net/package/APCU) extension to be installed. If you don't need persistent metrics between requests (e.g. a long running cron job or script) the in-memory adapter might be suitable to use. ## Installation @@ -24,6 +24,7 @@ composer require promphp/prometheus_client_php ## Usage A simple counter: + ```php \Prometheus\CollectorRegistry::getDefault() ->getOrRegisterCounter('', 'some_quick_counter', 'just a quick measurement') @@ -31,6 +32,7 @@ A simple counter: ``` Write some enhanced metrics: + ```php $registry = \Prometheus\CollectorRegistry::getDefault(); @@ -48,6 +50,7 @@ $summary->observe(5, ['blue']); ``` Manually register and retrieve metrics (these steps are combined in the `getOrRegister...` methods): + ```php $registry = \Prometheus\CollectorRegistry::getDefault(); @@ -60,6 +63,7 @@ $counterB->incBy(2, ['red']); ``` Expose the metrics: + ```php $registry = \Prometheus\CollectorRegistry::getDefault(); @@ -71,6 +75,7 @@ echo $result; ``` Change the Redis options (the example shows the defaults): + ```php \Prometheus\Storage\Redis::setDefaultOptions( [ @@ -84,7 +89,23 @@ Change the Redis options (the example shows the defaults): ); ``` +Using the Predis storage (requires `predis/predis`): + +```php +$registry = new CollectorRegistry(new \Prometheus\Storage\Predis()); +``` + +Or with an existing connection: + +```php +$client = new \Predis\Client(['host' => '127.0.0.1']); +$registry = new CollectorRegistry(\Prometheus\Storage\Predis::fromExistingConnection($client)); +``` + +> **Note:** Using `Redis::setPrefix()` and `Predis::setPrefix()` share the same prefix. Using both adapters with different prefixes in the same application is not supported. + Using the InMemory storage: + ```php $registry = new CollectorRegistry(new InMemory()); @@ -96,14 +117,17 @@ $result = $renderer->render($registry->getMetricFamilySamples()); ``` Using the APC or APCng storage: + ```php $registry = new CollectorRegistry(new APCng()); or $registry = new CollectorRegistry(new APC()); ``` + (see the `README.APCng.md` file for more details) Using the PDO storage: + ```php $registry = new CollectorRegistry(new \PDO('mysql:host=localhost;dbname=prometheus', 'username', 'password')); or @@ -113,11 +137,13 @@ $registry = new CollectorRegistry(new \PDO('sqlite::memory:')); ### Advanced Usage #### Advanced Histogram Usage + On passing an empty array for the bucket parameter on instantiation, a set of default buckets will be used instead. Whilst this is a good base for a typical web application, there is named constructor to assist in the generation of exponential / geometric buckets. Eg: + ``` Histogram::exponentialBuckets(0.05, 1.5, 10); ``` @@ -127,7 +153,9 @@ This will start your buckets with a value of 0.05, grow them by a factor of 1.5 Also look at the [examples](examples). #### PushGateway Support -As of Version 2.0.0 this library doesn't support the Prometheus PushGateway anymore because we want to have this package as small als possible. If you need Prometheus PushGateway support, you could use the companion library: https://github.com/PromPHP/prometheus_push_gateway_php + +As of Version 2.0.0 this library doesn't support the Prometheus PushGateway anymore because we want to have this package as small als possible. If you need Prometheus PushGateway support, you could use the companion library: + ``` composer require promphp/prometheus_push_gateway_php ``` @@ -143,11 +171,13 @@ composer require promphp/prometheus_push_gateway_php * Redis Start a Redis instance: + ``` docker-compose up redis ``` Run the tests: + ``` composer install @@ -159,9 +189,11 @@ composer install ## Black box testing Just start the nginx, fpm & Redis setup with docker-compose: + ``` docker-compose up ``` + Pick the adapter you want to test. ``` @@ -173,11 +205,13 @@ docker-compose run phpunit env ADAPTER=redis vendor/bin/phpunit tests/Test/ ## Performance testing This currently tests the APC and APCng adapters head-to-head and reports if the APCng adapter is slower for any actions. + ``` phpunit vendor/bin/phpunit tests/Test/ --group Performance ``` The test can also be run inside a container. + ``` docker-compose up docker-compose run phpunit vendor/bin/phpunit tests/Test/ --group Performance diff --git a/composer.json b/composer.json index fd5e5948..ebe65fbf 100644 --- a/composer.json +++ b/composer.json @@ -25,11 +25,13 @@ "phpstan/phpstan-phpunit": "^1.1.0", "phpstan/phpstan-strict-rules": "^1.1.0", "phpunit/phpunit": "^9.4", + "predis/predis": "^2.3", "squizlabs/php_codesniffer": "^3.6", "symfony/polyfill-apcu": "^1.6" }, "suggest": { "ext-redis": "Required if using Redis.", + "predis/predis": "Required if using Predis.", "ext-apc": "Required if using APCu.", "ext-pdo": "Required if using PDO.", "promphp/prometheus_push_gateway_php": "An easy client for using Prometheus PushGateway.", diff --git a/examples/flush_adapter.php b/examples/flush_adapter.php index 1c00eab7..712e2bec 100644 --- a/examples/flush_adapter.php +++ b/examples/flush_adapter.php @@ -10,6 +10,8 @@ define('REDIS_HOST', $_SERVER['REDIS_HOST'] ?? '127.0.0.1'); $adapter = new Prometheus\Storage\Redis(['host' => REDIS_HOST]); +} elseif ($adapterName === 'predis') { + $adapter = new Prometheus\Storage\Predis(['host' => $_SERVER['REDIS_HOST'] ?? '127.0.0.1']); } elseif ($adapterName === 'apc') { $adapter = new Prometheus\Storage\APC(); } elseif ($adapterName === 'apcng') { diff --git a/examples/metrics.php b/examples/metrics.php index 9c0fdb80..844c2b24 100644 --- a/examples/metrics.php +++ b/examples/metrics.php @@ -11,6 +11,8 @@ if ($adapter === 'redis') { Redis::setDefaultOptions(['host' => $_SERVER['REDIS_HOST'] ?? '127.0.0.1']); $adapter = new Prometheus\Storage\Redis(); +} elseif ($adapter === 'predis') { + $adapter = new Prometheus\Storage\Predis(['host' => $_SERVER['REDIS_HOST'] ?? '127.0.0.1']); } elseif ($adapter === 'apc') { $adapter = new Prometheus\Storage\APC(); } elseif ($adapter === 'apcng') { diff --git a/examples/some_counter.php b/examples/some_counter.php index c7426ce8..b861a18b 100644 --- a/examples/some_counter.php +++ b/examples/some_counter.php @@ -10,6 +10,8 @@ if ($adapter === 'redis') { Redis::setDefaultOptions(['host' => $_SERVER['REDIS_HOST'] ?? '127.0.0.1']); $adapter = new Prometheus\Storage\Redis(); +} elseif ($adapter === 'predis') { + $adapter = new Prometheus\Storage\Predis(['host' => $_SERVER['REDIS_HOST'] ?? '127.0.0.1']); } elseif ($adapter === 'apc') { $adapter = new Prometheus\Storage\APC(); } elseif ($adapter === 'apcng') { diff --git a/examples/some_gauge.php b/examples/some_gauge.php index 9e8b3da2..a0a5894c 100644 --- a/examples/some_gauge.php +++ b/examples/some_gauge.php @@ -5,7 +5,6 @@ use Prometheus\CollectorRegistry; use Prometheus\Storage\Redis; - error_log('c=' . $_GET['c']); $adapter = $_GET['adapter']; @@ -13,6 +12,8 @@ if ($adapter === 'redis') { Redis::setDefaultOptions(['host' => $_SERVER['REDIS_HOST'] ?? '127.0.0.1']); $adapter = new Prometheus\Storage\Redis(); +} elseif ($adapter === 'predis') { + $adapter = new Prometheus\Storage\Predis(['host' => $_SERVER['REDIS_HOST'] ?? '127.0.0.1']); } elseif ($adapter === 'apc') { $adapter = new Prometheus\Storage\APC(); } elseif ($adapter === 'apcng') { diff --git a/examples/some_histogram.php b/examples/some_histogram.php index 2f1a5f98..b2d9135b 100644 --- a/examples/some_histogram.php +++ b/examples/some_histogram.php @@ -12,6 +12,8 @@ if ($adapter === 'redis') { Redis::setDefaultOptions(['host' => $_SERVER['REDIS_HOST'] ?? '127.0.0.1']); $adapter = new Prometheus\Storage\Redis(); +} elseif ($adapter === 'predis') { + $adapter = new Prometheus\Storage\Predis(['host' => $_SERVER['REDIS_HOST'] ?? '127.0.0.1']); } elseif ($adapter === 'apc') { $adapter = new Prometheus\Storage\APC(); } elseif ($adapter === 'apcng') { diff --git a/examples/some_summary.php b/examples/some_summary.php index 363f9190..34b30ce0 100644 --- a/examples/some_summary.php +++ b/examples/some_summary.php @@ -12,6 +12,8 @@ if ($adapter === 'redis') { Redis::setDefaultOptions(['host' => $_SERVER['REDIS_HOST'] ?? '127.0.0.1']); $adapter = new Prometheus\Storage\Redis(); +} elseif ($adapter === 'predis') { + $adapter = new Prometheus\Storage\Predis(['host' => $_SERVER['REDIS_HOST'] ?? '127.0.0.1']); } elseif ($adapter === 'apc') { $adapter = new Prometheus\Storage\APC(); } elseif ($adapter === 'apcng') { diff --git a/src/Prometheus/Storage/AbstractRedis.php b/src/Prometheus/Storage/AbstractRedis.php new file mode 100644 index 00000000..35bbde7a --- /dev/null +++ b/src/Prometheus/Storage/AbstractRedis.php @@ -0,0 +1,632 @@ +wipeStorage(); + } + + /** + * {@inheritDoc} + */ + public function wipeStorage(): void + { + $this->redis->ensureOpenConnection(); + + $searchPattern = ''; + + $globalPrefix = $this->redis->getPrefix(); + if ($globalPrefix !== null) { + $searchPattern .= $globalPrefix; + } + + $searchPattern .= self::$prefix; + $searchPattern .= '*'; + + $this->redis->eval( + <<<'LUA' +redis.replicate_commands() +local cursor = "0" +repeat + local results = redis.call('SCAN', cursor, 'MATCH', ARGV[1]) + cursor = results[1] + for _, key in ipairs(results[2]) do + redis.call('DEL', key) + end +until cursor == "0" +LUA + , + [$searchPattern], + 0 + ); + } + + /** + * @param mixed[] $data + */ + protected function metaKey(array $data): string + { + return implode(':', [ + $data['name'], + 'meta', + ]); + } + + /** + * @param mixed[] $data + */ + protected function valueKey(array $data): string + { + return implode(':', [ + $data['name'], + $this->encodeLabelValues($data['labelValues']), + 'value', + ]); + } + + /** + * @return MetricFamilySamples[] + * + * @throws StorageException + */ + public function collect(bool $sortMetrics = true): array + { + $this->redis->ensureOpenConnection(); + $metrics = $this->collectHistograms(); + $metrics = array_merge($metrics, $this->collectGauges($sortMetrics)); + $metrics = array_merge($metrics, $this->collectCounters($sortMetrics)); + $metrics = array_merge($metrics, $this->collectSummaries()); + + return array_map( + function (array $metric): MetricFamilySamples { + return new MetricFamilySamples($metric); + }, + $metrics + ); + } + + /** + * @param mixed[] $data + * + * @throws StorageException + */ + public function updateHistogram(array $data): void + { + $this->redis->ensureOpenConnection(); + $bucketToIncrease = '+Inf'; + foreach ($data['buckets'] as $bucket) { + if ($data['value'] <= $bucket) { + $bucketToIncrease = $bucket; + break; + } + } + $metaData = $data; + unset($metaData['value'], $metaData['labelValues']); + + $this->redis->eval( + <<<'LUA' +local result = redis.call('hIncrByFloat', KEYS[1], ARGV[1], ARGV[3]) +redis.call('hIncrBy', KEYS[1], ARGV[2], 1) +if tonumber(result) >= tonumber(ARGV[3]) then + redis.call('hSet', KEYS[1], '__meta', ARGV[4]) + redis.call('sAdd', KEYS[2], KEYS[1]) +end +return result +LUA + , + [ + $this->toMetricKey($data), + self::$prefix . Histogram::TYPE . self::PROMETHEUS_METRIC_KEYS_SUFFIX, + json_encode(['b' => 'sum', 'labelValues' => $data['labelValues']]), + json_encode(['b' => $bucketToIncrease, 'labelValues' => $data['labelValues']]), + $data['value'], + json_encode($metaData), + ], + 2 + ); + } + + /** + * @param mixed[] $data + * + * @throws StorageException + */ + public function updateSummary(array $data): void + { + $this->redis->ensureOpenConnection(); + + // store meta + $summaryKey = self::$prefix . Summary::TYPE . self::PROMETHEUS_METRIC_KEYS_SUFFIX; + $metaKey = $summaryKey . ':' . $this->metaKey($data); + $json = json_encode($this->metaData($data)); + if ($json === false) { + throw new RuntimeException(json_last_error_msg()); + } + $this->redis->setNx($metaKey, $json); + + // store value key + $valueKey = $summaryKey . ':' . $this->valueKey($data); + $json = json_encode($this->encodeLabelValues($data['labelValues'])); + if ($json === false) { + throw new RuntimeException(json_last_error_msg()); + } + $this->redis->setNx($valueKey, $json); + + // trick to handle uniqid collision + $done = false; + while (! $done) { + $sampleKey = $valueKey . ':' . uniqid('', true); + $done = $this->redis->set($sampleKey, $data['value'], ['NX', 'EX' => $data['maxAgeSeconds']]); + } + } + + /** + * @param mixed[] $data + * + * @throws StorageException + */ + public function updateGauge(array $data): void + { + $this->redis->ensureOpenConnection(); + $metaData = $data; + unset($metaData['value'], $metaData['labelValues'], $metaData['command']); + $this->redis->eval( + <<<'LUA' +local result = redis.call(ARGV[1], KEYS[1], ARGV[2], ARGV[3]) + +if ARGV[1] == 'hSet' then + if result == 1 then + redis.call('hSet', KEYS[1], '__meta', ARGV[4]) + redis.call('sAdd', KEYS[2], KEYS[1]) + end +else + if result == ARGV[3] then + redis.call('hSet', KEYS[1], '__meta', ARGV[4]) + redis.call('sAdd', KEYS[2], KEYS[1]) + end +end +LUA + , + [ + $this->toMetricKey($data), + self::$prefix . Gauge::TYPE . self::PROMETHEUS_METRIC_KEYS_SUFFIX, + $this->getRedisCommand($data['command']), + json_encode($data['labelValues']), + $data['value'], + json_encode($metaData), + ], + 2 + ); + } + + /** + * @param mixed[] $data + * + * @throws StorageException + */ + public function updateCounter(array $data): void + { + $this->redis->ensureOpenConnection(); + $metaData = $data; + unset($metaData['value'], $metaData['labelValues'], $metaData['command']); + $this->redis->eval( + <<<'LUA' +local result = redis.call(ARGV[1], KEYS[1], ARGV[3], ARGV[2]) +local added = redis.call('sAdd', KEYS[2], KEYS[1]) +if added == 1 then + redis.call('hMSet', KEYS[1], '__meta', ARGV[4]) +end +return result +LUA + , + [ + $this->toMetricKey($data), + self::$prefix . Counter::TYPE . self::PROMETHEUS_METRIC_KEYS_SUFFIX, + $this->getRedisCommand($data['command']), + $data['value'], + json_encode($data['labelValues']), + json_encode($metaData), + ], + 2 + ); + } + + /** + * @param mixed[] $data + * @return mixed[] + */ + protected function metaData(array $data): array + { + $metricsMetaData = $data; + unset($metricsMetaData['value'], $metricsMetaData['command'], $metricsMetaData['labelValues']); + + return $metricsMetaData; + } + + /** + * @return mixed[] + */ + protected function collectHistograms(): array + { + $keys = $this->redis->sMembers(self::$prefix . Histogram::TYPE . self::PROMETHEUS_METRIC_KEYS_SUFFIX); + sort($keys); + $histograms = []; + foreach ($keys as $key) { + $raw = $this->redis->hGetAll($this->removePrefixFromKey($key)); + if (! isset($raw['__meta'])) { + continue; + } + $histogram = json_decode($raw['__meta'], true); + unset($raw['__meta']); + $histogram['samples'] = []; + + // Add the Inf bucket so we can compute it later on + $histogram['buckets'][] = '+Inf'; + + $allLabelValues = []; + foreach (array_keys($raw) as $k) { + $d = json_decode($k, true); + if ($d['b'] == 'sum') { + continue; + } + $allLabelValues[] = $d['labelValues']; + } + if (json_last_error() !== JSON_ERROR_NONE) { + $this->throwMetricJsonException($key); + } + + // We need set semantics. + // This is the equivalent of array_unique but for arrays of arrays. + $allLabelValues = array_map('unserialize', array_unique(array_map('serialize', $allLabelValues))); + sort($allLabelValues); + + foreach ($allLabelValues as $labelValues) { + // Fill up all buckets. + // If the bucket doesn't exist fill in values from + // the previous one. + $acc = 0; + foreach ($histogram['buckets'] as $bucket) { + $bucketKey = json_encode(['b' => $bucket, 'labelValues' => $labelValues]); + if (! isset($raw[$bucketKey])) { + $histogram['samples'][] = [ + 'name' => $histogram['name'] . '_bucket', + 'labelNames' => ['le'], + 'labelValues' => array_merge($labelValues, [$bucket]), + 'value' => $acc, + ]; + } else { + $acc += $raw[$bucketKey]; + $histogram['samples'][] = [ + 'name' => $histogram['name'] . '_bucket', + 'labelNames' => ['le'], + 'labelValues' => array_merge($labelValues, [$bucket]), + 'value' => $acc, + ]; + } + } + + // Add the count + $histogram['samples'][] = [ + 'name' => $histogram['name'] . '_count', + 'labelNames' => [], + 'labelValues' => $labelValues, + 'value' => $acc, + ]; + + // Add the sum + $histogram['samples'][] = [ + 'name' => $histogram['name'] . '_sum', + 'labelNames' => [], + 'labelValues' => $labelValues, + 'value' => $raw[json_encode(['b' => 'sum', 'labelValues' => $labelValues])], + ]; + } + $histograms[] = $histogram; + } + + return $histograms; + } + + protected function removePrefixFromKey(string $key): string + { + $prefix = $this->redis->getPrefix(); + + if ($prefix === null) { + return $key; + } + + return substr($key, strlen($prefix)); + } + + /** + * @return mixed[] + */ + protected function collectSummaries(): array + { + $math = new Math(); + $summaryKey = self::$prefix . Summary::TYPE . self::PROMETHEUS_METRIC_KEYS_SUFFIX; + $keys = $this->redis->keys($summaryKey . ':*:meta'); + + $summaries = []; + foreach ($keys as $metaKeyWithPrefix) { + $metaKey = $this->removePrefixFromKey($metaKeyWithPrefix); + $rawSummary = $this->redis->get($metaKey); + if ($rawSummary === false) { + continue; + } + $summary = json_decode($rawSummary, true); + $metaData = $summary; + $data = [ + 'name' => $metaData['name'], + 'help' => $metaData['help'], + 'type' => $metaData['type'], + 'labelNames' => $metaData['labelNames'], + 'maxAgeSeconds' => $metaData['maxAgeSeconds'], + 'quantiles' => $metaData['quantiles'], + 'samples' => [], + ]; + + $values = $this->redis->keys($summaryKey . ':' . $metaData['name'] . ':*:value'); + foreach ($values as $valueKeyWithPrefix) { + $valueKey = $this->removePrefixFromKey($valueKeyWithPrefix); + $rawValue = $this->redis->get($valueKey); + if ($rawValue === false) { + continue; + } + $value = json_decode($rawValue, true); + $encodedLabelValues = $value; + $decodedLabelValues = $this->decodeLabelValues($encodedLabelValues); + + $samples = []; + $sampleValues = $this->redis->keys($summaryKey . ':' . $metaData['name'] . ':' . $encodedLabelValues . ':value:*'); + foreach ($sampleValues as $sampleValueWithPrefix) { + $sampleValue = $this->removePrefixFromKey($sampleValueWithPrefix); + $samples[] = (float) $this->redis->get($sampleValue); + } + + if (count($samples) === 0) { + try { + $this->redis->del($valueKey); + } catch (RedisClientException $e) { + // ignore if we can't delete the key + } + + continue; + } + + // Compute quantiles + sort($samples); + foreach ($data['quantiles'] as $quantile) { + $data['samples'][] = [ + 'name' => $metaData['name'], + 'labelNames' => ['quantile'], + 'labelValues' => array_merge($decodedLabelValues, [$quantile]), + 'value' => $math->quantile($samples, $quantile), + ]; + } + + // Add the count + $data['samples'][] = [ + 'name' => $metaData['name'] . '_count', + 'labelNames' => [], + 'labelValues' => $decodedLabelValues, + 'value' => count($samples), + ]; + + // Add the sum + $data['samples'][] = [ + 'name' => $metaData['name'] . '_sum', + 'labelNames' => [], + 'labelValues' => $decodedLabelValues, + 'value' => array_sum($samples), + ]; + } + + if (count($data['samples']) > 0) { + $summaries[] = $data; + } else { + try { + $this->redis->del($metaKey); + } catch (RedisClientException $e) { + // ignore if we can't delete the key + } + } + } + + return $summaries; + } + + /** + * @return mixed[] + */ + protected function collectGauges(bool $sortMetrics = true): array + { + $keys = $this->redis->sMembers(self::$prefix . Gauge::TYPE . self::PROMETHEUS_METRIC_KEYS_SUFFIX); + sort($keys); + $gauges = []; + foreach ($keys as $key) { + $raw = $this->redis->hGetAll($this->removePrefixFromKey($key)); + if (! isset($raw['__meta'])) { + continue; + } + $gauge = json_decode($raw['__meta'], true); + unset($raw['__meta']); + $gauge['samples'] = []; + foreach ($raw as $k => $value) { + $gauge['samples'][] = [ + 'name' => $gauge['name'], + 'labelNames' => [], + 'labelValues' => json_decode($k, true), + 'value' => $value, + ]; + if (json_last_error() !== JSON_ERROR_NONE) { + $this->throwMetricJsonException($key, $gauge['name']); + } + } + + if ($sortMetrics) { + usort($gauge['samples'], function ($a, $b): int { + return strcmp(implode('', $a['labelValues']), implode('', $b['labelValues'])); + }); + } + + $gauges[] = $gauge; + } + + return $gauges; + } + + /** + * @return mixed[] + * + * @throws MetricJsonException + */ + protected function collectCounters(bool $sortMetrics = true): array + { + $keys = $this->redis->sMembers(self::$prefix . Counter::TYPE . self::PROMETHEUS_METRIC_KEYS_SUFFIX); + sort($keys); + $counters = []; + foreach ($keys as $key) { + $raw = $this->redis->hGetAll($this->removePrefixFromKey($key)); + if (! isset($raw['__meta'])) { + continue; + } + $counter = json_decode($raw['__meta'], true); + + unset($raw['__meta']); + $counter['samples'] = []; + foreach ($raw as $k => $value) { + $counter['samples'][] = [ + 'name' => $counter['name'], + 'labelNames' => [], + 'labelValues' => json_decode($k, true), + 'value' => $value, + ]; + + if (json_last_error() !== JSON_ERROR_NONE) { + $this->throwMetricJsonException($key, $counter['name']); + } + } + + if ($sortMetrics) { + usort($counter['samples'], function ($a, $b): int { + return strcmp(implode('', $a['labelValues']), implode('', $b['labelValues'])); + }); + } + + $counters[] = $counter; + } + + return $counters; + } + + protected function getRedisCommand(int $cmd): string + { + switch ($cmd) { + case Adapter::COMMAND_INCREMENT_INTEGER: + return 'hIncrBy'; + case Adapter::COMMAND_INCREMENT_FLOAT: + return 'hIncrByFloat'; + case Adapter::COMMAND_SET: + return 'hSet'; + default: + throw new InvalidArgumentException('Unknown command'); + } + } + + /** + * @param mixed[] $data + */ + protected function toMetricKey(array $data): string + { + return implode(':', [self::$prefix, $data['type'], $data['name']]); + } + + /** + * @param mixed[] $values + * + * @throws RuntimeException + */ + protected function encodeLabelValues(array $values): string + { + $json = json_encode($values); + if ($json === false) { + throw new RuntimeException(json_last_error_msg()); + } + + return base64_encode($json); + } + + /** + * @return mixed[] + * + * @throws RuntimeException + */ + protected function decodeLabelValues(string $values): array + { + $json = base64_decode($values, true); + if ($json === false) { + throw new RuntimeException('Cannot base64 decode label values'); + } + $decodedValues = json_decode($json, true); + if ($decodedValues === false) { + throw new RuntimeException(json_last_error_msg()); + } + + return $decodedValues; + } + + /** + * @throws MetricJsonException + */ + protected function throwMetricJsonException(string $redisKey, ?string $metricName = null): void + { + $metricName = $metricName ?? 'unknown'; + $message = 'Json error: ' . json_last_error_msg() . ' redis key : ' . $redisKey . ' metric name: ' . $metricName; + throw new MetricJsonException($message, 0, null, $metricName); + } +} diff --git a/src/Prometheus/Storage/Predis.php b/src/Prometheus/Storage/Predis.php new file mode 100644 index 00000000..fbdc7ff9 --- /dev/null +++ b/src/Prometheus/Storage/Predis.php @@ -0,0 +1,80 @@ + 'tcp', + 'host' => '127.0.0.1', + 'port' => 6379, + 'timeout' => 0.1, + 'read_write_timeout' => 10, + 'persistent' => false, + 'password' => null, + 'username' => null, + ]; + + /** + * @var mixed[] + */ + private static $defaultOptions = [ + 'prefix' => '', + 'throw_errors' => true, + ]; + + /** + * @var mixed[] + */ + private $parameters = []; + + /** + * @var mixed[] + */ + private $options = []; + + /** + * Predis constructor. + * + * @param mixed[] $parameters + * @param mixed[] $options + */ + public function __construct(array $parameters = [], array $options = []) + { + $this->parameters = array_merge(self::$defaultParameters, $parameters); + $this->options = array_merge(self::$defaultOptions, $options); + $this->redis = PredisClient::create($this->parameters, $this->options); + } + + public static function fromExistingConnection(Client $client): self + { + $self = new self(); + $self->redis = PredisClient::fromExistingConnection($client); + + return $self; + } + + /** + * @param mixed[] $parameters + */ + public static function setDefaultParameters(array $parameters): void + { + self::$defaultParameters = array_merge(self::$defaultParameters, $parameters); + } + + /** + * @param mixed[] $options + */ + public static function setDefaultOptions(array $options): void + { + self::$defaultOptions = array_merge(self::$defaultOptions, $options); + } +} diff --git a/src/Prometheus/Storage/Redis.php b/src/Prometheus/Storage/Redis.php index 730fac87..78b0e375 100644 --- a/src/Prometheus/Storage/Redis.php +++ b/src/Prometheus/Storage/Redis.php @@ -4,21 +4,11 @@ namespace Prometheus\Storage; -use InvalidArgumentException; -use Prometheus\Counter; -use Prometheus\Exception\MetricJsonException; use Prometheus\Exception\StorageException; -use Prometheus\Gauge; -use Prometheus\Histogram; -use Prometheus\Math; -use Prometheus\MetricFamilySamples; -use Prometheus\Summary; -use RuntimeException; +use Prometheus\Storage\RedisClients\PHPRedis; -class Redis implements Adapter +class Redis extends AbstractRedis { - const PROMETHEUS_METRIC_KEYS_SUFFIX = '_METRIC_KEYS'; - /** * @var mixed[] */ @@ -32,39 +22,23 @@ class Redis implements Adapter 'user' => null, ]; - /** - * @var string - */ - private static $prefix = 'PROMETHEUS_'; - /** * @var mixed[] */ private $options = []; - /** - * @var \Redis - */ - private $redis; - - /** - * @var boolean - */ - private $connectionInitialized = false; - /** * Redis constructor. - * @param mixed[] $options + * + * @param mixed[] $options */ public function __construct(array $options = []) { $this->options = array_merge(self::$defaultOptions, $options); - $this->redis = new \Redis(); + $this->redis = PHPRedis::create($this->options); } /** - * @param \Redis $redis - * @return self * @throws StorageException */ public static function fromExistingConnection(\Redis $redis): self @@ -74,682 +48,16 @@ public static function fromExistingConnection(\Redis $redis): self } $self = new self(); - $self->connectionInitialized = true; - $self->redis = $redis; + $self->redis = PHPRedis::fromExistingConnection($redis, self::$defaultOptions); return $self; } /** - * @param mixed[] $options + * @param mixed[] $options */ public static function setDefaultOptions(array $options): void { self::$defaultOptions = array_merge(self::$defaultOptions, $options); } - - /** - * @param string $prefix - */ - public static function setPrefix(string $prefix): void - { - self::$prefix = $prefix; - } - - /** - * @throws StorageException - * @deprecated use replacement method wipeStorage from Adapter interface - */ - public function flushRedis(): void - { - $this->wipeStorage(); - } - - /** - * @inheritDoc - */ - public function wipeStorage(): void - { - $this->ensureOpenConnection(); - - $searchPattern = ""; - - $globalPrefix = $this->redis->getOption(\Redis::OPT_PREFIX); - // @phpstan-ignore-next-line false positive, phpstan thinks getOptions returns int - if (is_string($globalPrefix)) { - $searchPattern .= $globalPrefix; - } - - $searchPattern .= self::$prefix; - $searchPattern .= '*'; - - $this->redis->eval( - <<encodeLabelValues($data['labelValues']), - 'value' - ]); - } - - /** - * @return MetricFamilySamples[] - * @throws StorageException - */ - public function collect(bool $sortMetrics = true): array - { - $this->ensureOpenConnection(); - $metrics = $this->collectHistograms(); - $metrics = array_merge($metrics, $this->collectGauges($sortMetrics)); - $metrics = array_merge($metrics, $this->collectCounters($sortMetrics)); - $metrics = array_merge($metrics, $this->collectSummaries()); - return array_map( - function (array $metric): MetricFamilySamples { - return new MetricFamilySamples($metric); - }, - $metrics - ); - } - - /** - * @throws StorageException - */ - private function ensureOpenConnection(): void - { - if ($this->connectionInitialized === true) { - return; - } - - $this->connectToServer(); - $authParams = []; - - if (isset($this->options['user']) && $this->options['user'] !== '') { - $authParams[] = $this->options['user']; - } - - if (isset($this->options['password'])) { - $authParams[] = $this->options['password']; - } - - if ($authParams !== []) { - $this->redis->auth($authParams); - } - - if (isset($this->options['database'])) { - $this->redis->select($this->options['database']); - } - - $this->redis->setOption(\Redis::OPT_READ_TIMEOUT, $this->options['read_timeout']); - - $this->connectionInitialized = true; - } - - /** - * @throws StorageException - */ - private function connectToServer(): void - { - try { - $connection_successful = false; - if ($this->options['persistent_connections'] !== false) { - $connection_successful = $this->redis->pconnect( - $this->options['host'], - (int)$this->options['port'], - (float)$this->options['timeout'] - ); - } else { - $connection_successful = $this->redis->connect($this->options['host'], (int)$this->options['port'], (float)$this->options['timeout']); - } - if (!$connection_successful) { - throw new StorageException( - sprintf("Can't connect to Redis server. %s", $this->redis->getLastError()), - 0 - ); - } - } catch (\RedisException $e) { - throw new StorageException( - sprintf("Can't connect to Redis server. %s", $e->getMessage()), - $e->getCode(), - $e - ); - } - } - - /** - * @param mixed[] $data - * @throws StorageException - */ - public function updateHistogram(array $data): void - { - $this->ensureOpenConnection(); - $bucketToIncrease = '+Inf'; - foreach ($data['buckets'] as $bucket) { - if ($data['value'] <= $bucket) { - $bucketToIncrease = $bucket; - break; - } - } - $metaData = $data; - unset($metaData['value'], $metaData['labelValues']); - - $this->redis->eval( - <<= tonumber(ARGV[3]) then - redis.call('hSet', KEYS[1], '__meta', ARGV[4]) - redis.call('sAdd', KEYS[2], KEYS[1]) -end -return result -LUA - , - [ - $this->toMetricKey($data), - self::$prefix . Histogram::TYPE . self::PROMETHEUS_METRIC_KEYS_SUFFIX, - json_encode(['b' => 'sum', 'labelValues' => $data['labelValues']]), - json_encode(['b' => $bucketToIncrease, 'labelValues' => $data['labelValues']]), - $data['value'], - json_encode($metaData), - ], - 2 - ); - } - - /** - * @param mixed[] $data - * @throws StorageException - */ - public function updateSummary(array $data): void - { - $this->ensureOpenConnection(); - - // store meta - $summaryKey = self::$prefix . Summary::TYPE . self::PROMETHEUS_METRIC_KEYS_SUFFIX; - $metaKey = $summaryKey . ':' . $this->metaKey($data); - $json = json_encode($this->metaData($data)); - if (false === $json) { - throw new RuntimeException(json_last_error_msg()); - } - $this->redis->setNx($metaKey, $json);/** @phpstan-ignore-line */ - - - // store value key - $valueKey = $summaryKey . ':' . $this->valueKey($data); - $json = json_encode($this->encodeLabelValues($data['labelValues'])); - if (false === $json) { - throw new RuntimeException(json_last_error_msg()); - } - $this->redis->setNx($valueKey, $json);/** @phpstan-ignore-line */ - - // trick to handle uniqid collision - $done = false; - while (!$done) { - $sampleKey = $valueKey . ':' . uniqid('', true); - $done = $this->redis->set($sampleKey, $data['value'], ['NX', 'EX' => $data['maxAgeSeconds']]); - } - } - - /** - * @param mixed[] $data - * @throws StorageException - */ - public function updateGauge(array $data): void - { - $this->ensureOpenConnection(); - $metaData = $data; - unset($metaData['value'], $metaData['labelValues'], $metaData['command']); - $this->redis->eval( - <<toMetricKey($data), - self::$prefix . Gauge::TYPE . self::PROMETHEUS_METRIC_KEYS_SUFFIX, - $this->getRedisCommand($data['command']), - json_encode($data['labelValues']), - $data['value'], - json_encode($metaData), - ], - 2 - ); - } - - /** - * @param mixed[] $data - * @throws StorageException - */ - public function updateCounter(array $data): void - { - $this->ensureOpenConnection(); - $metaData = $data; - unset($metaData['value'], $metaData['labelValues'], $metaData['command']); - $this->redis->eval( - <<toMetricKey($data), - self::$prefix . Counter::TYPE . self::PROMETHEUS_METRIC_KEYS_SUFFIX, - $this->getRedisCommand($data['command']), - $data['value'], - json_encode($data['labelValues']), - json_encode($metaData), - ], - 2 - ); - } - - - /** - * @param mixed[] $data - * @return mixed[] - */ - private function metaData(array $data): array - { - $metricsMetaData = $data; - unset($metricsMetaData['value'], $metricsMetaData['command'], $metricsMetaData['labelValues']); - return $metricsMetaData; - } - - /** - * @return mixed[] - */ - private function collectHistograms(): array - { - $keys = $this->redis->sMembers(self::$prefix . Histogram::TYPE . self::PROMETHEUS_METRIC_KEYS_SUFFIX); - sort($keys); - $histograms = []; - foreach ($keys as $key) { - $raw = $this->redis->hGetAll(ltrim($key, $this->redis->_prefix(''))); - if (!isset($raw['__meta'])) { - continue; - } - $histogram = json_decode($raw['__meta'], true); - unset($raw['__meta']); - $histogram['samples'] = []; - - // Add the Inf bucket so we can compute it later on - $histogram['buckets'][] = '+Inf'; - - $allLabelValues = []; - foreach (array_keys($raw) as $k) { - $d = json_decode($k, true); - if ($d['b'] == 'sum') { - continue; - } - $allLabelValues[] = $d['labelValues']; - } - if (json_last_error() !== JSON_ERROR_NONE) { - $this->throwMetricJsonException($key); - } - - // We need set semantics. - // This is the equivalent of array_unique but for arrays of arrays. - $allLabelValues = array_map("unserialize", array_unique(array_map("serialize", $allLabelValues))); - sort($allLabelValues); - - foreach ($allLabelValues as $labelValues) { - // Fill up all buckets. - // If the bucket doesn't exist fill in values from - // the previous one. - $acc = 0; - foreach ($histogram['buckets'] as $bucket) { - $bucketKey = json_encode(['b' => $bucket, 'labelValues' => $labelValues]); - if (!isset($raw[$bucketKey])) { - $histogram['samples'][] = [ - 'name' => $histogram['name'] . '_bucket', - 'labelNames' => ['le'], - 'labelValues' => array_merge($labelValues, [$bucket]), - 'value' => $acc, - ]; - } else { - $acc += $raw[$bucketKey]; - $histogram['samples'][] = [ - 'name' => $histogram['name'] . '_bucket', - 'labelNames' => ['le'], - 'labelValues' => array_merge($labelValues, [$bucket]), - 'value' => $acc, - ]; - } - } - - // Add the count - $histogram['samples'][] = [ - 'name' => $histogram['name'] . '_count', - 'labelNames' => [], - 'labelValues' => $labelValues, - 'value' => $acc, - ]; - - // Add the sum - $histogram['samples'][] = [ - 'name' => $histogram['name'] . '_sum', - 'labelNames' => [], - 'labelValues' => $labelValues, - 'value' => $raw[json_encode(['b' => 'sum', 'labelValues' => $labelValues])], - ]; - } - $histograms[] = $histogram; - } - return $histograms; - } - - /** - * @param string $key - * - * @return string - */ - private function removePrefixFromKey(string $key): string - { - // @phpstan-ignore-next-line false positive, phpstan thinks getOptions returns int - if ($this->redis->getOption(\Redis::OPT_PREFIX) === null) { - return $key; - } - // @phpstan-ignore-next-line false positive, phpstan thinks getOptions returns int - return substr($key, strlen($this->redis->getOption(\Redis::OPT_PREFIX))); - } - - /** - * @return mixed[] - */ - private function collectSummaries(): array - { - $math = new Math(); - $summaryKey = self::$prefix . Summary::TYPE . self::PROMETHEUS_METRIC_KEYS_SUFFIX; - $keys = $this->redis->keys($summaryKey . ':*:meta'); - - $summaries = []; - foreach ($keys as $metaKeyWithPrefix) { - $metaKey = $this->removePrefixFromKey($metaKeyWithPrefix); - $rawSummary = $this->redis->get($metaKey); - if ($rawSummary === false) { - continue; - } - $summary = json_decode($rawSummary, true); - $metaData = $summary; - $data = [ - 'name' => $metaData['name'], - 'help' => $metaData['help'], - 'type' => $metaData['type'], - 'labelNames' => $metaData['labelNames'], - 'maxAgeSeconds' => $metaData['maxAgeSeconds'], - 'quantiles' => $metaData['quantiles'], - 'samples' => [], - ]; - - $values = $this->redis->keys($summaryKey . ':' . $metaData['name'] . ':*:value'); - foreach ($values as $valueKeyWithPrefix) { - $valueKey = $this->removePrefixFromKey($valueKeyWithPrefix); - $rawValue = $this->redis->get($valueKey); - if ($rawValue === false) { - continue; - } - $value = json_decode($rawValue, true); - $encodedLabelValues = $value; - $decodedLabelValues = $this->decodeLabelValues($encodedLabelValues); - - $samples = []; - $sampleValues = $this->redis->keys($summaryKey . ':' . $metaData['name'] . ':' . $encodedLabelValues . ':value:*'); - foreach ($sampleValues as $sampleValueWithPrefix) { - $sampleValue = $this->removePrefixFromKey($sampleValueWithPrefix); - $samples[] = (float)$this->redis->get($sampleValue); - } - - if (count($samples) === 0) { - try { - $this->redis->del($valueKey); - } catch (\RedisException $e) { - // ignore if we can't delete the key - } - continue; - } - - // Compute quantiles - sort($samples); - foreach ($data['quantiles'] as $quantile) { - $data['samples'][] = [ - 'name' => $metaData['name'], - 'labelNames' => ['quantile'], - 'labelValues' => array_merge($decodedLabelValues, [$quantile]), - 'value' => $math->quantile($samples, $quantile), - ]; - } - - // Add the count - $data['samples'][] = [ - 'name' => $metaData['name'] . '_count', - 'labelNames' => [], - 'labelValues' => $decodedLabelValues, - 'value' => count($samples), - ]; - - // Add the sum - $data['samples'][] = [ - 'name' => $metaData['name'] . '_sum', - 'labelNames' => [], - 'labelValues' => $decodedLabelValues, - 'value' => array_sum($samples), - ]; - } - - if (count($data['samples']) > 0) { - $summaries[] = $data; - } else { - try { - $this->redis->del($metaKey); - } catch (\RedisException $e) { - // ignore if we can't delete the key - } - } - } - return $summaries; - } - - /** - * @return mixed[] - */ - private function collectGauges(bool $sortMetrics = true): array - { - $keys = $this->redis->sMembers(self::$prefix . Gauge::TYPE . self::PROMETHEUS_METRIC_KEYS_SUFFIX); - sort($keys); - $gauges = []; - foreach ($keys as $key) { - $raw = $this->redis->hGetAll(ltrim($key, $this->redis->_prefix(''))); - if (!isset($raw['__meta'])) { - continue; - } - $gauge = json_decode($raw['__meta'], true); - unset($raw['__meta']); - $gauge['samples'] = []; - foreach ($raw as $k => $value) { - $gauge['samples'][] = [ - 'name' => $gauge['name'], - 'labelNames' => [], - 'labelValues' => json_decode($k, true), - 'value' => $value, - ]; - if (json_last_error() !== JSON_ERROR_NONE) { - $this->throwMetricJsonException($key, $gauge['name']); - } - } - - if ($sortMetrics) { - usort($gauge['samples'], function ($a, $b): int { - return strcmp(implode("", $a['labelValues']), implode("", $b['labelValues'])); - }); - } - - $gauges[] = $gauge; - } - return $gauges; - } - - /** - * @return mixed[] - * @throws MetricJsonException - */ - private function collectCounters(bool $sortMetrics = true): array - { - $keys = $this->redis->sMembers(self::$prefix . Counter::TYPE . self::PROMETHEUS_METRIC_KEYS_SUFFIX); - sort($keys); - $counters = []; - foreach ($keys as $key) { - $raw = $this->redis->hGetAll(ltrim($key, $this->redis->_prefix(''))); - if (!isset($raw['__meta'])) { - continue; - } - $counter = json_decode($raw['__meta'], true); - - unset($raw['__meta']); - $counter['samples'] = []; - foreach ($raw as $k => $value) { - $counter['samples'][] = [ - 'name' => $counter['name'], - 'labelNames' => [], - 'labelValues' => json_decode($k, true), - 'value' => $value, - ]; - - if (json_last_error() !== JSON_ERROR_NONE) { - $this->throwMetricJsonException($key, $counter['name']); - } - } - - if ($sortMetrics) { - usort($counter['samples'], function ($a, $b): int { - return strcmp(implode("", $a['labelValues']), implode("", $b['labelValues'])); - }); - } - - $counters[] = $counter; - } - return $counters; - } - - /** - * @param int $cmd - * @return string - */ - private function getRedisCommand(int $cmd): string - { - switch ($cmd) { - case Adapter::COMMAND_INCREMENT_INTEGER: - return 'hIncrBy'; - case Adapter::COMMAND_INCREMENT_FLOAT: - return 'hIncrByFloat'; - case Adapter::COMMAND_SET: - return 'hSet'; - default: - throw new InvalidArgumentException("Unknown command"); - } - } - - /** - * @param mixed[] $data - * @return string - */ - private function toMetricKey(array $data): string - { - return implode(':', [self::$prefix, $data['type'], $data['name']]); - } - - /** - * @param mixed[] $values - * @return string - * @throws RuntimeException - */ - private function encodeLabelValues(array $values): string - { - $json = json_encode($values); - if (false === $json) { - throw new RuntimeException(json_last_error_msg()); - } - return base64_encode($json); - } - - /** - * @param string $values - * @return mixed[] - * @throws RuntimeException - */ - private function decodeLabelValues(string $values): array - { - $json = base64_decode($values, true); - if (false === $json) { - throw new RuntimeException('Cannot base64 decode label values'); - } - $decodedValues = json_decode($json, true); - if (false === $decodedValues) { - throw new RuntimeException(json_last_error_msg()); - } - return $decodedValues; - } - - /** - * @param string $redisKey - * @param string|null $metricName - * @return void - * @throws MetricJsonException - */ - private function throwMetricJsonException(string $redisKey, ?string $metricName = null): void - { - $metricName = $metricName ?? 'unknown'; - $message = 'Json error: ' . json_last_error_msg() . ' redis key : ' . $redisKey . ' metric name: ' . $metricName; - throw new MetricJsonException($message, 0, null, $metricName); - } } diff --git a/src/Prometheus/Storage/RedisClients/PHPRedis.php b/src/Prometheus/Storage/RedisClients/PHPRedis.php new file mode 100644 index 00000000..35c7b014 --- /dev/null +++ b/src/Prometheus/Storage/RedisClients/PHPRedis.php @@ -0,0 +1,174 @@ +redis = $redis; + $this->options = $options; + } + + /** + * @param mixed[] $options + */ + public static function create(array $options): self + { + $redis = new \Redis(); + + return new self($redis, $options); + } + + /** + * @param mixed[] $options + */ + public static function fromExistingConnection(\Redis $redis, array $options): self + { + $self = new self($redis, $options); + $self->connectionInitialized = true; + + return $self; + } + + public function getPrefix(): ?string + { + /** @var mixed $prefix */ + $prefix = $this->redis->getOption(\Redis::OPT_PREFIX); + + return is_string($prefix) && $prefix !== '' ? $prefix : null; + } + + public function eval(string $script, array $args = [], int $num_keys = 0): void + { + $this->redis->eval($script, $args, $num_keys); + } + + public function set(string $key, mixed $value, mixed $options = null): bool + { + return $this->redis->set($key, $value, $options); + } + + public function setNx(string $key, mixed $value): void + { + $this->redis->setNx($key, $value); /** @phpstan-ignore-line */ + } + + public function sMembers(string $key): array + { + return $this->redis->sMembers($key); + } + + public function hGetAll(string $key): array|false + { + return $this->redis->hGetAll($key); + } + + public function keys(string $pattern): array + { + return $this->redis->keys($pattern); + } + + public function get(string $key): string|false + { + return $this->redis->get($key); + } + + /** + * @throws RedisClientException + */ + public function del(array|string $key, string ...$other_keys): void + { + try { + $this->redis->del($key, ...$other_keys); + } catch (\RedisException $e) { + throw new RedisClientException($e->getMessage(), $e->getCode(), $e); + } + } + + /** + * @throws StorageException + */ + public function ensureOpenConnection(): void + { + if ($this->connectionInitialized === true) { + return; + } + + $this->connectToServer(); + $authParams = []; + + if (isset($this->options['user']) && $this->options['user'] !== '') { + $authParams[] = $this->options['user']; + } + + if (isset($this->options['password'])) { + $authParams[] = $this->options['password']; + } + + if ($authParams !== []) { + $this->redis->auth($authParams); + } + + if (isset($this->options['database'])) { + $this->redis->select($this->options['database']); + } + + $this->redis->setOption(\Redis::OPT_READ_TIMEOUT, $this->options['read_timeout']); + + $this->connectionInitialized = true; + } + + /** + * @throws StorageException + */ + private function connectToServer(): void + { + try { + $connection_successful = false; + if ($this->options['persistent_connections'] !== false) { + $connection_successful = $this->redis->pconnect( + $this->options['host'], + (int) $this->options['port'], + (float) $this->options['timeout'] + ); + } else { + $connection_successful = $this->redis->connect($this->options['host'], (int) $this->options['port'], (float) $this->options['timeout']); + } + if (! $connection_successful) { + throw new StorageException( + sprintf("Can't connect to Redis server. %s", $this->redis->getLastError()), + 0 + ); + } + } catch (\RedisException $e) { + throw new StorageException( + sprintf("Can't connect to Redis server. %s", $e->getMessage()), + $e->getCode(), + $e, + ); + } + } +} diff --git a/src/Prometheus/Storage/RedisClients/Predis.php b/src/Prometheus/Storage/RedisClients/Predis.php new file mode 100644 index 00000000..3dfe5a92 --- /dev/null +++ b/src/Prometheus/Storage/RedisClients/Predis.php @@ -0,0 +1,122 @@ +client = $client; + } + + /** + * @param mixed[] $parameters + * @param mixed[] $options + * @throws StorageException + */ + public static function create(array $parameters, array $options): self + { + try { + return new self(new Client($parameters, $options)); + } catch (InvalidArgumentException $e) { + throw new StorageException('Invalid Redis client configuration: ' . $e->getMessage(), 0, $e); + } + } + + public static function fromExistingConnection(Client $client): self + { + return new self($client); + } + + public function getPrefix(): ?string + { + $value = $this->client->getOptions()->prefix; + + return $value instanceof \Predis\Command\Processor\KeyPrefixProcessor + ? $value->getPrefix() + : null; + } + + public function eval(string $script, array $args = [], int $num_keys = 0): void + { + $this->client->eval($script, $num_keys, ...$args); + } + + public function set(string $key, mixed $value, mixed $options = null): bool + { + $result = $this->client->set($key, $value, ...$this->flattenFlags($options)); + + return (string) $result === 'OK'; + } + + /** + * @param array $flags + * @return mixed[] + */ + private function flattenFlags(array $flags): array + { + $result = []; + foreach ($flags as $key => $value) { + if (is_int($key)) { + $result[] = $value; + } else { + $result[] = $key; + $result[] = $value; + } + } + + return $result; + } + + public function setNx(string $key, mixed $value): void + { + $this->client->setnx($key, $value); + } + + public function sMembers(string $key): array + { + return $this->client->smembers($key); + } + + public function hGetAll(string $key): array|false + { + return $this->client->hgetall($key); + } + + public function keys(string $pattern): array + { + return $this->client->keys($pattern); + } + + public function get(string $key): string|false + { + return $this->client->get($key) ?? false; + } + + public function del(array|string $key, string ...$other_keys): void + { + $this->client->del($key, ...$other_keys); + } + + /** + * @throws StorageException + */ + public function ensureOpenConnection(): void + { + if (!$this->client->isConnected()) { + try { + $this->client->connect(); + } catch (\Predis\Connection\ConnectionException $e) { + throw new StorageException('Cannot establish Redis Connection:' . $e->getMessage(), 0, $e); + } + } + } +} diff --git a/src/Prometheus/Storage/RedisClients/RedisClient.php b/src/Prometheus/Storage/RedisClients/RedisClient.php new file mode 100644 index 00000000..ae4cb6a5 --- /dev/null +++ b/src/Prometheus/Storage/RedisClients/RedisClient.php @@ -0,0 +1,43 @@ +|false + */ + public function hGetAll(string $key): array|false; + + /** + * @return string[] + */ + public function keys(string $pattern): array; + + public function get(string $key): string|false; + + /** + * @param string|string[] $key + */ + public function del(array|string $key, string ...$other_keys): void; + + public function ensureOpenConnection(): void; +} diff --git a/src/Prometheus/Storage/RedisClients/RedisClientException.php b/src/Prometheus/Storage/RedisClients/RedisClientException.php new file mode 100644 index 00000000..1a811285 --- /dev/null +++ b/src/Prometheus/Storage/RedisClients/RedisClientException.php @@ -0,0 +1,9 @@ +adapter = new Predis(['host' => REDIS_HOST]); + $this->adapter->wipeStorage(); + } +} diff --git a/tests/Test/Prometheus/Predis/CounterTest.php b/tests/Test/Prometheus/Predis/CounterTest.php new file mode 100644 index 00000000..f8508dcf --- /dev/null +++ b/tests/Test/Prometheus/Predis/CounterTest.php @@ -0,0 +1,20 @@ +adapter = new Predis(['host' => REDIS_HOST]); + $this->adapter->wipeStorage(); + } +} diff --git a/tests/Test/Prometheus/Predis/CounterWithPrefixTest.php b/tests/Test/Prometheus/Predis/CounterWithPrefixTest.php new file mode 100644 index 00000000..25e2d01a --- /dev/null +++ b/tests/Test/Prometheus/Predis/CounterWithPrefixTest.php @@ -0,0 +1,20 @@ +adapter = new Predis(['host' => REDIS_HOST], ['prefix' => 'prefix:']); + $this->adapter->wipeStorage(); + } +} diff --git a/tests/Test/Prometheus/Predis/GaugeTest.php b/tests/Test/Prometheus/Predis/GaugeTest.php new file mode 100644 index 00000000..e84e8142 --- /dev/null +++ b/tests/Test/Prometheus/Predis/GaugeTest.php @@ -0,0 +1,20 @@ +adapter = new Predis(['host' => REDIS_HOST]); + $this->adapter->wipeStorage(); + } +} diff --git a/tests/Test/Prometheus/Predis/GaugeWithPrefixTest.php b/tests/Test/Prometheus/Predis/GaugeWithPrefixTest.php new file mode 100644 index 00000000..d5a895ec --- /dev/null +++ b/tests/Test/Prometheus/Predis/GaugeWithPrefixTest.php @@ -0,0 +1,20 @@ +adapter = new Predis(['host' => REDIS_HOST], ['prefix' => 'prefix:']); + $this->adapter->wipeStorage(); + } +} diff --git a/tests/Test/Prometheus/Predis/HistogramTest.php b/tests/Test/Prometheus/Predis/HistogramTest.php new file mode 100644 index 00000000..381ed196 --- /dev/null +++ b/tests/Test/Prometheus/Predis/HistogramTest.php @@ -0,0 +1,20 @@ +adapter = new Predis(['host' => REDIS_HOST]); + $this->adapter->wipeStorage(); + } +} diff --git a/tests/Test/Prometheus/Predis/HistogramWithPrefixTest.php b/tests/Test/Prometheus/Predis/HistogramWithPrefixTest.php new file mode 100644 index 00000000..d4029a0c --- /dev/null +++ b/tests/Test/Prometheus/Predis/HistogramWithPrefixTest.php @@ -0,0 +1,20 @@ +adapter = new Predis(['host' => REDIS_HOST], ['prefix' => 'prefix:']); + $this->adapter->wipeStorage(); + } +} diff --git a/tests/Test/Prometheus/Predis/SummaryTest.php b/tests/Test/Prometheus/Predis/SummaryTest.php new file mode 100644 index 00000000..58fb4796 --- /dev/null +++ b/tests/Test/Prometheus/Predis/SummaryTest.php @@ -0,0 +1,20 @@ +adapter = new Predis(['host' => REDIS_HOST]); + $this->adapter->wipeStorage(); + } +} diff --git a/tests/Test/Prometheus/Predis/SummaryWithPrefixTest.php b/tests/Test/Prometheus/Predis/SummaryWithPrefixTest.php new file mode 100644 index 00000000..54ffd31f --- /dev/null +++ b/tests/Test/Prometheus/Predis/SummaryWithPrefixTest.php @@ -0,0 +1,20 @@ +adapter = new Predis(['host' => REDIS_HOST], ['prefix' => 'prefix:']); + $this->adapter->wipeStorage(); + } +} diff --git a/tests/Test/Prometheus/Redis/SummaryTest.php b/tests/Test/Prometheus/Redis/SummaryTest.php index d6b0c596..1446f65a 100644 --- a/tests/Test/Prometheus/Redis/SummaryTest.php +++ b/tests/Test/Prometheus/Redis/SummaryTest.php @@ -18,9 +18,4 @@ public function configureAdapter(): void $this->adapter = new Redis(['host' => REDIS_HOST]); $this->adapter->wipeStorage(); } - /** @test */ - public function itShouldObserveWithLabels(): void - { - parent::itShouldObserveWithLabels(); // TODO: Change the autogenerated stub - } } diff --git a/tests/Test/Prometheus/Storage/PredisTest.php b/tests/Test/Prometheus/Storage/PredisTest.php new file mode 100644 index 00000000..82a63f03 --- /dev/null +++ b/tests/Test/Prometheus/Storage/PredisTest.php @@ -0,0 +1,61 @@ +predisConnection = new Client(['host' => REDIS_HOST]); + $this->predisConnection->flushall(); + } + + /** + * @test + */ + public function itShouldThrowAnExceptionOnConnectionFailure(): void + { + $predis = new Predis(['host' => '/dev/null']); + + $this->expectException(StorageException::class); + $this->expectExceptionMessage('Cannot establish Redis Connection'); + + $predis->wipeStorage(); + } + + /** + * @test + */ + public function itShouldNotClearWholeRedisOnFlush(): void + { + $this->predisConnection->set('not a prometheus metric key', 'data'); + + $predis = Predis::fromExistingConnection($this->predisConnection); + $registry = new CollectorRegistry($predis); + + for ($i = 0; $i < 1000; $i++) { + $registry->getOrRegisterCounter('namespace', "counter_$i", 'counter help')->inc(); + $registry->getOrRegisterGauge('namespace', "gauge_$i", 'gauge help')->inc(); + $registry->getOrRegisterHistogram('namespace', "histogram_$i", 'histogram help')->observe(1); + } + $predis->wipeStorage(); + + $redisKeys = $this->predisConnection->keys('*'); + self::assertThat( + $redisKeys, + self::equalTo(['not a prometheus metric key']) + ); + } +} diff --git a/tests/Test/Prometheus/Storage/RedisTest.php b/tests/Test/Prometheus/Storage/RedisTest.php index 90ca2d79..f8b3aa6d 100644 --- a/tests/Test/Prometheus/Storage/RedisTest.php +++ b/tests/Test/Prometheus/Storage/RedisTest.php @@ -74,7 +74,7 @@ public function itShouldNotClearWholeRedisOnFlush(): void self::assertThat( $redisKeys, self::equalTo([ - 'not a prometheus metric key' + 'not a prometheus metric key', ]) ); }