From 86a5c28dd66fc1f1125374bbd85f41934fec1ee8 Mon Sep 17 00:00:00 2001 From: Zlatoslav Desyatnikov Date: Mon, 16 Oct 2023 01:33:47 +0400 Subject: [PATCH] refactor(core): Support zlodes/prometheus-client 2.x BREAKING CHANGE: Configuration changed --- README.md | 19 +- composer.json | 2 +- ...eus-exporter.php => prometheus-client.php} | 10 +- src/Command/ClearMetrics.php | 18 +- src/Command/ListMetrics.php | 12 +- src/ServiceProvider.php | 46 +-- src/Storage/RedisStorage.php | 259 ------------- .../RedisStorage/CounterRedisStorage.php | 80 ++++ .../RedisStorage/GaugeRedisStorage.php | 80 ++++ .../RedisStorage/HistogramRedisStorage.php | 157 ++++++++ .../RedisStorage/SummaryRedisStorage.php | 115 ++++++ src/Storage/StorageConfigurator.php | 85 +++++ tests/Commands/ClearMetricsTest.php | 30 +- tests/Http/MetricsExporterControllerTest.php | 4 +- ...edulableCollectorsProvidedByConfigTest.php | 4 +- tests/ServiceProviderTest.php | 44 ++- .../RedisStorage/CounterRedisStorageTest.php | 125 +++++++ .../RedisStorage/GaugeRedisStorageTest.php | 125 +++++++ .../HistogramRedisStorageTest.php | 218 +++++++++++ .../RedisStorage/SummaryRedisStorageTest.php | 159 ++++++++ tests/Storage/RedisStorageTest.php | 354 ------------------ 21 files changed, 1257 insertions(+), 689 deletions(-) rename config/{prometheus-exporter.php => prometheus-client.php} (53%) delete mode 100644 src/Storage/RedisStorage.php create mode 100644 src/Storage/RedisStorage/CounterRedisStorage.php create mode 100644 src/Storage/RedisStorage/GaugeRedisStorage.php create mode 100644 src/Storage/RedisStorage/HistogramRedisStorage.php create mode 100644 src/Storage/RedisStorage/SummaryRedisStorage.php create mode 100644 src/Storage/StorageConfigurator.php create mode 100644 tests/Storage/RedisStorage/CounterRedisStorageTest.php create mode 100644 tests/Storage/RedisStorage/GaugeRedisStorageTest.php create mode 100644 tests/Storage/RedisStorage/HistogramRedisStorageTest.php create mode 100644 tests/Storage/RedisStorage/SummaryRedisStorageTest.php delete mode 100644 tests/Storage/RedisStorageTest.php diff --git a/README.md b/README.md index e7701ec..6029b57 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,9 @@ composer require zlodes/prometheus-client-laravel ### Register a route for the metrics controller -Your application is responsible for metrics route registration. There is a ready to use [controller](src/Http/MetricsExporterController.php). You can configure groups, middleware or prefixes as you want. +Your application is responsible for metrics route registration. +There is a [controller](src/Http/MetricsExporterController.php) ready to use. +You can configure groups, middleware or prefixes as you want. Example: @@ -25,14 +27,15 @@ use Zlodes\PrometheusClient\Laravel\Http\MetricsExporterController; Route::get('/metrics', MetricsExporterController::class); ``` -### Configure a Storage for metrics [optional] +### Configure Storage for metrics [optional] -By-default, it uses [RedisStorage](src/Storage/RedisStorage.php). If you want to use other storage, you can do it easily following these three steps: +By default, it uses Redis storage. +If you want to use other storage, you can do it easily following these three steps: 1. Create a class implements `Storage` interface. 2. Publish a config: ```shell - php artisan vendor:publish --tag=prometheus-exporter + php artisan vendor:publish --tag=prometheus-client ``` 3. Set your `storage` class in the config. @@ -94,6 +97,14 @@ $this->callAfterResolving( | `php artisan metrics:clear` | Clears metrics storage | | `metrics:collect-scheduled` | Runs `ScheduledCollectors`. Using by Scheduler | +## Upgrade guide + +### From 1.x to 2.x + +1. Run `php artisan vendor:publish --tag=prometheus-client` to publish a brand-new config +2. Configure the new config based on the previous one (`prometheus-exporter.php`) +3. Drop legacy config (`prometheus-exporter.php`) + ## Testing ### Run tests diff --git a/composer.json b/composer.json index eadc777..71c76f8 100644 --- a/composer.json +++ b/composer.json @@ -14,7 +14,7 @@ "ext-redis": "*", "laravel/framework": "^9.0 || ^10.0", "webmozart/assert": "^1.11", - "zlodes/prometheus-client": "^1.1.2" + "zlodes/prometheus-client": "2.x-dev" }, "require-dev": { "ergebnis/composer-normalize": "dev-main", diff --git a/config/prometheus-exporter.php b/config/prometheus-client.php similarity index 53% rename from config/prometheus-exporter.php rename to config/prometheus-client.php index d54931d..f7ca3c9 100644 --- a/config/prometheus-exporter.php +++ b/config/prometheus-client.php @@ -9,14 +9,10 @@ 'enabled' => (bool) env('PROMETHEUS_CLIENT_ENABLED', true), /** - * Here you can configure a Storage for metrics - * - * Available options: - * - \Zlodes\PrometheusClient\Storage\InMemoryStorage::class - * - \Zlodes\PrometheusClient\Laravel\Storage\RedisStorage::class - * - Your own storage implements Storage interface + * Here you can configure a Storage for metrics. + * Available options: "null", "in_memory", "redis" */ - 'storage' => \Zlodes\PrometheusClient\Laravel\Storage\RedisStorage::class, + 'storage' => env('PROMETHEUS_CLIENT_STORAGE', 'redis'), /** * Here you can specify a list of your SchedulableCollectors diff --git a/src/Command/ClearMetrics.php b/src/Command/ClearMetrics.php index ee97d7d..657722e 100644 --- a/src/Command/ClearMetrics.php +++ b/src/Command/ClearMetrics.php @@ -5,15 +5,25 @@ namespace Zlodes\PrometheusClient\Laravel\Command; use Illuminate\Console\Command; -use Zlodes\PrometheusClient\Storage\Storage; +use Zlodes\PrometheusClient\Storage\Contracts\CounterStorage; +use Zlodes\PrometheusClient\Storage\Contracts\GaugeStorage; +use Zlodes\PrometheusClient\Storage\Contracts\HistogramStorage; +use Zlodes\PrometheusClient\Storage\Contracts\SummaryStorage; final class ClearMetrics extends Command { protected $signature = 'metrics:clear'; protected $description = 'Drop all the metrics values'; - public function handle(Storage $storage): void - { - $storage->clear(); + public function handle( + CounterStorage $counterStorage, + GaugeStorage $gaugeStorage, + HistogramStorage $histogramStorage, + SummaryStorage $summaryStorage, + ): void { + $counterStorage->clearCounters(); + $gaugeStorage->clearGauges(); + $histogramStorage->clearHistograms(); + $summaryStorage->clearSummaries(); } } diff --git a/src/Command/ListMetrics.php b/src/Command/ListMetrics.php index 6bc8c91..043e033 100644 --- a/src/Command/ListMetrics.php +++ b/src/Command/ListMetrics.php @@ -5,7 +5,6 @@ namespace Zlodes\PrometheusClient\Laravel\Command; use Illuminate\Console\Command; -use JsonException; use Zlodes\PrometheusClient\Registry\Registry; final class ListMetrics extends Command @@ -13,9 +12,6 @@ final class ListMetrics extends Command protected $signature = 'metrics:list'; protected $description = 'Outputs a table with all the registered metrics'; - /** - * @throws JsonException - */ public function handle(Registry $registry): void { $metrics = []; @@ -26,10 +22,9 @@ public function handle(Registry $registry): void $metrics[] = [ $counter, - $metric->getName(), - $metric->getType()->value, - $metric->getHelp(), - json_encode($metric->getInitialLabels(), JSON_THROW_ON_ERROR | JSON_FORCE_OBJECT), + $metric->name, + $metric->getPrometheusType(), + $metric->help, ]; } @@ -38,7 +33,6 @@ public function handle(Registry $registry): void 'Name', 'Type', 'Help', - 'Initial labels', ]; $this->table($tableHeader, $metrics); diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index 6e99b66..af4601b 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -11,7 +11,9 @@ use Webmozart\Assert\Assert; use Zlodes\PrometheusClient\Collector\CollectorFactory; use Zlodes\PrometheusClient\Exporter\Exporter; -use Zlodes\PrometheusClient\Exporter\StoredMetricsExporter; +use Zlodes\PrometheusClient\Exporter\FetcherExporter; +use Zlodes\PrometheusClient\Fetcher\Fetcher; +use Zlodes\PrometheusClient\Fetcher\StoredMetricsFetcher; use Zlodes\PrometheusClient\KeySerialization\JsonSerializer; use Zlodes\PrometheusClient\KeySerialization\Serializer; use Zlodes\PrometheusClient\Laravel\Command\ClearMetrics; @@ -20,28 +22,27 @@ use Zlodes\PrometheusClient\Laravel\ScheduledCollector\SchedulableCollector; use Zlodes\PrometheusClient\Laravel\ScheduledCollector\SchedulableCollectorArrayRegistry; use Zlodes\PrometheusClient\Laravel\ScheduledCollector\SchedulableCollectorRegistry; +use Zlodes\PrometheusClient\Laravel\Storage\StorageConfigurator; use Zlodes\PrometheusClient\Registry\ArrayRegistry; use Zlodes\PrometheusClient\Registry\Registry; -use Zlodes\PrometheusClient\Storage\NullStorage; -use Zlodes\PrometheusClient\Storage\Storage; final class ServiceProvider extends BaseServiceProvider { public function register(): void { - $this->mergeConfigFrom(__DIR__ . '/../config/prometheus-exporter.php', 'prometheus-exporter'); + $this->mergeConfigFrom(__DIR__ . '/../config/prometheus-client.php', 'prometheus-client'); $this->app->singleton(CollectorFactory::class); $this->app->singleton(Registry::class, ArrayRegistry::class); - $this->app->singleton(Exporter::class, StoredMetricsExporter::class); + $this->app->singleton(Fetcher::class, StoredMetricsFetcher::class); + $this->app->singleton(Exporter::class, FetcherExporter::class); $this->app->singleton(Serializer::class, JsonSerializer::class); - $this->registerStorage(); $this->registerSchedulableCollectors(); } - public function boot(): void + public function boot(StorageConfigurator $storageConfigurator): void { if ($this->app->runningInConsole()) { $this->commands([ @@ -51,34 +52,11 @@ public function boot(): void ]); $this->publishes([ - __DIR__ . '/../config/prometheus-exporter.php' => config_path('prometheus-exporter.php'), - ], 'prometheus-exporter'); + __DIR__ . '/../config/prometheus-client.php' => config_path('prometheus-client.php'), + ], 'prometheus-client'); } - } - - private function registerStorage(): void - { - $this->app->singleton(Storage::class, static function (Application $app): Storage { - /** @var Repository $config */ - $config = $app->make(Repository::class); - - $clientEnabled = $config->get('prometheus-exporter.enabled') ?? true; - Assert::boolean($clientEnabled); - if ($clientEnabled === false) { - return new NullStorage(); - } - - /** @psalm-var class-string $storageClass */ - $storageClass = $config->get('prometheus-exporter.storage'); - Assert::true( - is_a($storageClass, Storage::class, true), - 'Config value in prometheus-exporter.storage must be a class-string' - ); - - /** @var Storage */ - return $app->make($storageClass); - }); + $storageConfigurator->configure(); } private function registerSchedulableCollectors(): void @@ -92,7 +70,7 @@ static function (SchedulableCollectorRegistry $registry, Application $app): void $config = $app->make(Repository::class); /** @psalm-var list> $collectors */ - $collectors = $config->get('prometheus-exporter.schedulable_collectors'); + $collectors = $config->get('prometheus-client.schedulable_collectors'); Assert::allStringNotEmpty($collectors); foreach ($collectors as $collectorClass) { diff --git a/src/Storage/RedisStorage.php b/src/Storage/RedisStorage.php deleted file mode 100644 index 1d1b56b..0000000 --- a/src/Storage/RedisStorage.php +++ /dev/null @@ -1,259 +0,0 @@ -fetchGaugeAndCounterMetrics(); - - yield from $this->fetchHistogramMetrics(); - } - - public function clear(): void - { - try { - $this->connection->command('DEL', [self::SIMPLE_HASH_NAME]); - $this->connection->command('DEL', [self::HISTOGRAM_SUM_HASH_NAME]); - $this->connection->command('DEL', [self::HISTOGRAM_COUNT_HASH_NAME]); - - // Using leading asterisk to ignore Laravel Redis connection prefix (like laravel_database_) - $histogramKeyPattern = ['*' . self::HISTOGRAM_HASH_NAME_PREFIX . '*']; - - $this->connection->command('EVAL', [ - "return redis.call('del', unpack(redis.call('keys', ARGV[1])))", - $histogramKeyPattern, - 0, - ]); - } catch (Exception $e) { - throw new StorageWriteException( - "Got clear error. Cannot execute DEL command", - previous: $e - ); - } - } - - public function setValue(MetricValue $value): void - { - try { - $key = $this->serializer->serialize($value->metricNameWithLabels); - } catch (MetricKeySerializationException $e) { - throw new StorageWriteException( - "Got setValue error. Cannot serialize metrics key", - previous: $e - ); - } - - try { - $this->connection->command('HSET', [self::SIMPLE_HASH_NAME, $key, $value->value]); - } catch (Exception $e) { - throw new StorageWriteException( - "Got setValue error. Cannot execute HSET command", - previous: $e - ); - } - } - - public function incrementValue(MetricValue $value): void - { - try { - $key = $this->serializer->serialize($value->metricNameWithLabels); - } catch (MetricKeySerializationException $e) { - throw new StorageWriteException( - "Got incrementValue error. Cannot serialize metrics key", - previous: $e - ); - } - - try { - $this->connection->command('HINCRBYFLOAT', [self::SIMPLE_HASH_NAME, $key, $value->value]); - } catch (Exception $e) { - throw new StorageWriteException( - "Got setValue error. Cannot execute HINCRBYFLOAT command", - previous: $e - ); - } - } - - public function persistHistogram(MetricValue $value, array $buckets): void - { - try { - $key = $this->serializer->serialize($value->metricNameWithLabels); - } catch (MetricKeySerializationException $e) { - throw new StorageWriteException('Cannot serialize metric key', previous: $e); - } - - $metricHashName = self::HISTOGRAM_HASH_NAME_PREFIX . $key; - - $this->ensureHistogramExists($metricHashName, $buckets); - - $bucketsToUpdate = [ - "+Inf", - ]; - - foreach ($buckets as $bucket) { - if ($value->value <= $bucket) { - $bucketsToUpdate[] = (string) $bucket; - } - } - - // TODO: Might be optimized by using EVAL with prepared LUA script. We have to add a performance test for this. - $this->connection->command('HINCRBY', [self::HISTOGRAM_COUNT_HASH_NAME, $key, 1]); - $this->connection->command('HINCRBYFLOAT', [self::HISTOGRAM_SUM_HASH_NAME, $key, $value->value]); - - foreach ($bucketsToUpdate as $bucket) { - $this->connection->command('HINCRBY', [$metricHashName, $bucket, 1]); - } - } - - /** - * @param non-empty-string $metricHashName - * @param non-empty-list $buckets - * - * @return void - */ - private function ensureHistogramExists(string $metricHashName, array $buckets): void - { - $exists = $this->connection->command('EXISTS', [$metricHashName]) === 1; - - if ($exists) { - return; - } - - foreach ($buckets as $bucket) { - $this->connection->command('HSET', [$metricHashName, $bucket, 0]); - } - - $this->connection->command('HSET', [$metricHashName, "+Inf", 0]); - } - - /** - * @return Generator - */ - private function fetchGaugeAndCounterMetrics(): Generator - { - try { - /** @var array $rawHash */ - $rawHash = $this->connection->command('HGETALL', [self::SIMPLE_HASH_NAME]); - } catch (Exception $e) { - throw new StorageReadException( - "Got fetch error. Cannot execute HGETALL command", - previous: $e - ); - } - - foreach ($rawHash as $serializedKey => $value) { - try { - yield new MetricValue( - $this->serializer->unserialize($serializedKey), - (float) $value - ); - } catch (MetricKeyUnserializationException $e) { - throw new StorageReadException( - "Got fetch error. Cannot unserialize metrics key for key: $serializedKey", - previous: $e - ); - } - } - } - - /** - * @return Generator - */ - private function fetchHistogramMetrics(): Generator - { - try { - /** @var array $rawSumHash */ - $rawSumHash = $this->connection->command('HGETALL', [self::HISTOGRAM_SUM_HASH_NAME]); - - /** @var array $rawCountHash */ - $rawCountHash = $this->connection->command('HGETALL', [self::HISTOGRAM_COUNT_HASH_NAME]); - } catch (Exception $e) { - throw new StorageReadException( - "Got fetch error. Cannot execute HGETALL command", - previous: $e - ); - } - - $metricKeys = array_keys($rawSumHash); - - foreach ($metricKeys as $serializedKey) { - try { - $metricNameWithLabels = $this->serializer->unserialize($serializedKey); - } catch (MetricKeyUnserializationException $e) { - throw new StorageReadException( - "Got fetch error. Cannot unserialize metrics key for key: $serializedKey", - previous: $e - ); - } - - $histogramRedisKey = self::HISTOGRAM_HASH_NAME_PREFIX . $serializedKey; - - try { - /** @var array $rawHistogramHash */ - $rawHistogramHash = $this->connection->command('HGETALL', [$histogramRedisKey]); - } catch (Exception $e) { - throw new StorageReadException( - "Got fetch error. Cannot execute HGETALL command", - previous: $e - ); - } - - foreach ($rawHistogramHash as $bucket => $value) { - yield new MetricValue( - new MetricNameWithLabels( - $metricNameWithLabels->metricName, - [ - ...$metricNameWithLabels->labels, - 'le' => (string) $bucket, - ] - ), - (float) $value - ); - } - - yield new MetricValue( - new MetricNameWithLabels( - $metricNameWithLabels->metricName . '_sum', - $metricNameWithLabels->labels - ), - (float) $rawSumHash[$serializedKey] - ); - - yield new MetricValue( - new MetricNameWithLabels( - $metricNameWithLabels->metricName . '_count', - $metricNameWithLabels->labels - ), - (float) $rawCountHash[$serializedKey] - ); - } - } -} diff --git a/src/Storage/RedisStorage/CounterRedisStorage.php b/src/Storage/RedisStorage/CounterRedisStorage.php new file mode 100644 index 0000000..e899573 --- /dev/null +++ b/src/Storage/RedisStorage/CounterRedisStorage.php @@ -0,0 +1,80 @@ +metricKeySerializer->serialize($command->metricNameWithLabels); + } catch (MetricKeySerializationException $e) { + throw new StorageWriteException( + "Got incrementValue error. Cannot serialize metrics key", + previous: $e + ); + } + + try { + $this->connection->command('HINCRBYFLOAT', [self::HASH_NAME, $metricKeyWithLabels, $command->value]); + } catch (Exception $e) { + throw new StorageWriteException( + "Got increment error. Cannot execute HINCRBYFLOAT command", + previous: $e + ); + } + } + + public function fetchCounters(): iterable + { + try { + /** @var array $rawHash */ + $rawHash = $this->connection->command('HGETALL', [self::HASH_NAME]); + } catch (Exception $e) { + throw new StorageReadException( + "Got fetch error. Cannot execute HGETALL command", + previous: $e + ); + } + + foreach ($rawHash as $serializedKey => $value) { + try { + yield new MetricValue( + $this->metricKeySerializer->unserialize($serializedKey), + (float) $value + ); + } catch (MetricKeySerializationException $e) { + throw new StorageReadException( + "Got fetch error. Cannot unserialize metrics key for key: $serializedKey", + previous: $e + ); + } + } + } + + public function clearCounters(): void + { + $this->connection->command('DEL', [self::HASH_NAME]); + } +} diff --git a/src/Storage/RedisStorage/GaugeRedisStorage.php b/src/Storage/RedisStorage/GaugeRedisStorage.php new file mode 100644 index 0000000..4b6a90a --- /dev/null +++ b/src/Storage/RedisStorage/GaugeRedisStorage.php @@ -0,0 +1,80 @@ +metricKeySerializer->serialize($command->metricNameWithLabels); + } catch (MetricKeySerializationException $e) { + throw new StorageWriteException( + "Got incrementValue error. Cannot serialize metrics key", + previous: $e + ); + } + + try { + $this->connection->command('HSET', [self::HASH_NAME, $metricKeyWithLabels, $command->value]); + } catch (Exception $e) { + throw new StorageWriteException( + "Got update error. Cannot execute HSET command", + previous: $e + ); + } + } + + public function fetchGauges(): iterable + { + try { + /** @var array $rawHash */ + $rawHash = $this->connection->command('HGETALL', [self::HASH_NAME]); + } catch (Exception $e) { + throw new StorageReadException( + "Got fetch error. Cannot execute HGETALL command", + previous: $e + ); + } + + foreach ($rawHash as $serializedKey => $value) { + try { + yield new MetricValue( + $this->metricKeySerializer->unserialize($serializedKey), + (float) $value + ); + } catch (MetricKeySerializationException $e) { + throw new StorageReadException( + "Got fetch error. Cannot unserialize metrics key for key: $serializedKey", + previous: $e + ); + } + } + } + + public function clearGauges(): void + { + $this->connection->command('DEL', [self::HASH_NAME]); + } +} diff --git a/src/Storage/RedisStorage/HistogramRedisStorage.php b/src/Storage/RedisStorage/HistogramRedisStorage.php new file mode 100644 index 0000000..2d3f693 --- /dev/null +++ b/src/Storage/RedisStorage/HistogramRedisStorage.php @@ -0,0 +1,157 @@ +metricKeySerializer->serialize($command->metricNameWithLabels); + } catch (MetricKeySerializationException $e) { + throw new StorageWriteException('Cannot serialize metric key', previous: $e); + } + + $metricHashName = self::HISTOGRAM_HASH_NAME_PREFIX . $keyWithLabels; + + $buckets = $command->buckets; + $this->ensureHistogramExists($metricHashName, $buckets); + + $bucketsToUpdate = [ + "+Inf", + ]; + + foreach ($buckets as $bucket) { + if ($command->value <= $bucket) { + $bucketsToUpdate[] = (string) $bucket; + } + } + + // TODO: Might be optimized by using EVAL with prepared LUA script. We have to add a performance test for this. + $this->connection->command('HINCRBY', [self::HISTOGRAM_COUNT_HASH_NAME, $keyWithLabels, 1]); + $this->connection->command('HINCRBYFLOAT', [self::HISTOGRAM_SUM_HASH_NAME, $keyWithLabels, $command->value]); + + foreach ($bucketsToUpdate as $bucket) { + $this->connection->command('HINCRBY', [$metricHashName, $bucket, 1]); + } + } + + public function fetchHistograms(): iterable + { + try { + /** @var array $rawSumHash */ + $rawSumHash = $this->connection->command('HGETALL', [self::HISTOGRAM_SUM_HASH_NAME]); + + /** @var array $rawCountHash */ + $rawCountHash = $this->connection->command('HGETALL', [self::HISTOGRAM_COUNT_HASH_NAME]); + } catch (Exception $e) { + throw new StorageReadException( + "Got fetch error. Cannot execute HGETALL command", + previous: $e + ); + } + + $metricKeys = array_keys($rawSumHash); + + foreach ($metricKeys as $serializedKey) { + try { + $keyWithLabels = $this->metricKeySerializer->unserialize($serializedKey); + } catch (MetricKeyUnserializationException $e) { + throw new StorageReadException( + "Got fetch error. Cannot unserialize metrics key for key: $serializedKey", + previous: $e + ); + } + + $histogramRedisKey = self::HISTOGRAM_HASH_NAME_PREFIX . $serializedKey; + + try { + /** @var array $bucketsWithValues */ + $bucketsWithValues = $this->connection->command('HGETALL', [$histogramRedisKey]); + } catch (Exception $e) { + throw new StorageReadException( + "Got fetch error. Cannot execute HGETALL command", + previous: $e + ); + } + + Assert::notEmpty($bucketsWithValues); + $bucketsWithValues = array_map('floatval', $bucketsWithValues); + + yield new HistogramMetricValue( + $keyWithLabels, + $bucketsWithValues, + (float) $rawSumHash[$serializedKey], + (int) $rawCountHash[$serializedKey] + ); + } + } + + public function clearHistograms(): void + { + try { + $this->connection->command('DEL', [self::HISTOGRAM_SUM_HASH_NAME]); + $this->connection->command('DEL', [self::HISTOGRAM_COUNT_HASH_NAME]); + + // Using leading asterisk to ignore Laravel Redis connection prefix (like laravel_database_) + $histogramKeyPattern = ['*' . self::HISTOGRAM_HASH_NAME_PREFIX . '*']; + + $this->connection->command('EVAL', [ + "return redis.call('del', unpack(redis.call('keys', ARGV[1])))", + $histogramKeyPattern, + 0, + ]); + } catch (Exception $e) { + throw new StorageWriteException( + "Got clear error. Cannot execute DEL command", + previous: $e + ); + } + } + + /** + * @param non-empty-string $metricHashName + * @param non-empty-list $buckets + * + * @return void + */ + private function ensureHistogramExists(string $metricHashName, array $buckets): void + { + $exists = $this->connection->command('EXISTS', [$metricHashName]) === 1; + + if ($exists) { + return; + } + + foreach ($buckets as $bucket) { + $this->connection->command('HSET', [$metricHashName, $bucket, 0]); + } + + $this->connection->command('HSET', [$metricHashName, "+Inf", 0]); + } +} diff --git a/src/Storage/RedisStorage/SummaryRedisStorage.php b/src/Storage/RedisStorage/SummaryRedisStorage.php new file mode 100644 index 0000000..2ed454b --- /dev/null +++ b/src/Storage/RedisStorage/SummaryRedisStorage.php @@ -0,0 +1,115 @@ +metricKeySerializer->serialize($command->metricNameWithLabels); + } catch (MetricKeySerializationException $e) { + throw new StorageWriteException('Cannot serialize metric key', previous: $e); + } + + $metricListName = self::SUMMARY_LIST_NAME_PREFIX . $keyWithLabels; + + $this->connection->command('RPUSH', [$metricListName, $command->value]); + } + + public function fetchSummaries(): iterable + { + try { + $rawKeys = $this->getSummaryKeys(); + } catch (Exception $e) { + throw new StorageReadException( + "Got fetch error. Cannot execute KEYS command", + previous: $e + ); + } + + foreach ($rawKeys as $keyWithPrefix) { + $serializedKey = substr($keyWithPrefix, strlen(self::SUMMARY_LIST_NAME_PREFIX)); + Assert::stringNotEmpty($serializedKey); + + try { + $keyWithLabels = $this->metricKeySerializer->unserialize($serializedKey); + } catch (MetricKeyUnserializationException $e) { + throw new StorageReadException( + "Got fetch error. Cannot unserialize metrics key for key: $serializedKey", + previous: $e + ); + } + + /** @var non-empty-list $elements */ + $elements = $this->connection->command('LRANGE', [$keyWithPrefix, 0, -1]); + + yield new SummaryMetricValue( + $keyWithLabels, + array_map('floatval', $elements) + ); + } + } + + public function clearSummaries(): void + { + try { + foreach ($this->getSummaryKeys() as $key) { + $this->connection->command('DEL', [$key]); + } + } catch (Exception $e) { + throw new StorageWriteException( + "Got clear error. Cannot execute KEYS or EVAL command", + previous: $e + ); + } + } + + /** + * @return list + * + * @throws Exception + */ + private function getSummaryKeys(): array + { + // Using leading asterisk to ignore Laravel Redis connection prefix (like laravel_database_) + $summaryKeysPattern = ['*' . self::SUMMARY_LIST_NAME_PREFIX . '*']; + + /** @var list $rawKeys */ + $rawKeys = $this->connection->command('KEYS', $summaryKeysPattern); + + // Drop prefixes before prefix (like laravel_database_) + return array_map(static function (string $rawKey): string { + $prefixStartPosition = strpos($rawKey, self::SUMMARY_LIST_NAME_PREFIX); + Assert::integer($prefixStartPosition); + + $key = substr($rawKey, $prefixStartPosition); + Assert::stringNotEmpty($key); + + return $key; + }, $rawKeys); + } +} diff --git a/src/Storage/StorageConfigurator.php b/src/Storage/StorageConfigurator.php new file mode 100644 index 0000000..535a1ce --- /dev/null +++ b/src/Storage/StorageConfigurator.php @@ -0,0 +1,85 @@ +> */ + private array $storages = [ + 'null' => [ + GaugeStorage::class => NullStorage::class, + CounterStorage::class => NullStorage::class, + HistogramStorage::class => NullStorage::class, + SummaryStorage::class => NullStorage::class, + ], + 'in_memory' => [ + GaugeStorage::class => InMemoryGaugeStorage::class, + CounterStorage::class => InMemoryCounterStorage::class, + HistogramStorage::class => InMemoryHistogramStorage::class, + SummaryStorage::class => InMemorySummaryStorage::class, + ], + 'redis' => [ + GaugeStorage::class => GaugeRedisStorage::class, + CounterStorage::class => CounterRedisStorage::class, + HistogramStorage::class => HistogramRedisStorage::class, + SummaryStorage::class => SummaryRedisStorage::class, + ], + ]; + + public function __construct( + private readonly Application $app, + private readonly Repository $config, + ) { + } + + public function configure(): void + { + $driverName = $this->getDriverName(); + + $driverConfiguration = $this->storages[$driverName] ?? null; + Assert::notNull($driverConfiguration); + + foreach ($driverConfiguration as $interface => $implementation) { + $this->app->singleton($interface, $implementation); + } + } + + /** + * @return non-empty-string + */ + private function getDriverName(): string + { + $clientEnabled = $this->config->get('prometheus-client.enabled') === true; + + if ($clientEnabled === false) { + return 'null'; + } + + $driverName = $this->config->get('prometheus-client.storage'); + Assert::stringNotEmpty($driverName); + + return $driverName; + } +} diff --git a/tests/Commands/ClearMetricsTest.php b/tests/Commands/ClearMetricsTest.php index 49333ab..3040452 100644 --- a/tests/Commands/ClearMetricsTest.php +++ b/tests/Commands/ClearMetricsTest.php @@ -7,19 +7,39 @@ use Mockery; use Orchestra\Testbench\TestCase; use Zlodes\PrometheusClient\Laravel\ServiceProvider; -use Zlodes\PrometheusClient\Storage\Storage; +use Zlodes\PrometheusClient\Storage\Contracts\CounterStorage; +use Zlodes\PrometheusClient\Storage\Contracts\GaugeStorage; +use Zlodes\PrometheusClient\Storage\Contracts\HistogramStorage; +use Zlodes\PrometheusClient\Storage\Contracts\SummaryStorage; class ClearMetricsTest extends TestCase { public function testCommandRun(): void { $this->app->instance( - Storage::class, - $storageMock = Mockery::mock(Storage::class), + CounterStorage::class, + $counterStorageMock = Mockery::mock(CounterStorage::class), ); - $storageMock - ->expects('clear'); + $this->app->instance( + GaugeStorage::class, + $gaugeStorageMock = Mockery::mock(GaugeStorage::class), + ); + + $this->app->instance( + HistogramStorage::class, + $histogramStorageMock = Mockery::mock(HistogramStorage::class), + ); + + $this->app->instance( + SummaryStorage::class, + $summaryStorageMock = Mockery::mock(SummaryStorage::class), + ); + + $counterStorageMock->expects('clearCounters'); + $gaugeStorageMock->expects('clearGauges'); + $histogramStorageMock->expects('clearHistograms'); + $summaryStorageMock->expects('clearSummaries'); $this ->artisan('metrics:clear') diff --git a/tests/Http/MetricsExporterControllerTest.php b/tests/Http/MetricsExporterControllerTest.php index 7cb8b44..6553d84 100644 --- a/tests/Http/MetricsExporterControllerTest.php +++ b/tests/Http/MetricsExporterControllerTest.php @@ -29,10 +29,10 @@ public function testControllerResponse(): void yield 'baz'; }); - Route::get('/super-metrics', MetricsExporterController::class); + Route::get('/metrics', MetricsExporterController::class); $this - ->get('/super-metrics') + ->get('/metrics') ->assertOk() ->assertHeader('Content-Type', 'text/plain; charset=UTF-8') ->assertSee('foo') diff --git a/tests/ScheduledCollector/SchedulableCollectorsProvidedByConfigTest.php b/tests/ScheduledCollector/SchedulableCollectorsProvidedByConfigTest.php index e0dce21..520eb4d 100644 --- a/tests/ScheduledCollector/SchedulableCollectorsProvidedByConfigTest.php +++ b/tests/ScheduledCollector/SchedulableCollectorsProvidedByConfigTest.php @@ -24,7 +24,7 @@ public function testDefaultConfig(): void public function testConfigWithCorrectValues(): void { config([ - 'prometheus-exporter.schedulable_collectors' => [ + 'prometheus-client.schedulable_collectors' => [ DummySchedulableCollector::class, ], ]); @@ -38,7 +38,7 @@ public function testConfigWithCorrectValues(): void public function testConfigWithWrongValues(): void { config([ - 'prometheus-exporter.schedulable_collectors' => [ + 'prometheus-client.schedulable_collectors' => [ DummySchedulableCollector::class, // valid Model::class, // invalid ], diff --git a/tests/ServiceProviderTest.php b/tests/ServiceProviderTest.php index 2fa0f15..af427ca 100644 --- a/tests/ServiceProviderTest.php +++ b/tests/ServiceProviderTest.php @@ -4,27 +4,42 @@ namespace Zlodes\PrometheusClient\Laravel\Tests; +use Generator; use Illuminate\Console\Scheduling\Event; use Illuminate\Console\Scheduling\Schedule; use Orchestra\Testbench\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; use Zlodes\PrometheusClient\Exporter\Exporter; +use Zlodes\PrometheusClient\Fetcher\Fetcher; use Zlodes\PrometheusClient\KeySerialization\Serializer; use Zlodes\PrometheusClient\Laravel\ScheduledCollector\SchedulableCollectorArrayRegistry; use Zlodes\PrometheusClient\Laravel\ServiceProvider; -use Zlodes\PrometheusClient\Laravel\Storage\RedisStorage; +use Zlodes\PrometheusClient\Laravel\Storage\RedisStorage\CounterRedisStorage; +use Zlodes\PrometheusClient\Laravel\Storage\RedisStorage\GaugeRedisStorage; +use Zlodes\PrometheusClient\Laravel\Storage\RedisStorage\HistogramRedisStorage; +use Zlodes\PrometheusClient\Laravel\Storage\RedisStorage\SummaryRedisStorage; +use Zlodes\PrometheusClient\Laravel\Storage\StorageConfigurator; use Zlodes\PrometheusClient\Registry\Registry; +use Zlodes\PrometheusClient\Storage\Contracts\CounterStorage; +use Zlodes\PrometheusClient\Storage\Contracts\GaugeStorage; +use Zlodes\PrometheusClient\Storage\Contracts\HistogramStorage; +use Zlodes\PrometheusClient\Storage\Contracts\SummaryStorage; use Zlodes\PrometheusClient\Storage\NullStorage; -use Zlodes\PrometheusClient\Storage\Storage; class ServiceProviderTest extends TestCase { public function testBindings(): void { $interfaces = [ - Storage::class, Registry::class, + Fetcher::class, Exporter::class, Serializer::class, + CounterStorage::class, + GaugeStorage::class, + HistogramStorage::class, + SummaryStorage::class, + SchedulableCollectorArrayRegistry::class, ]; @@ -60,18 +75,31 @@ public function testSchedule(): void self::assertTrue($found); } - public function testDefaultStorageIsRedis(): void + #[DataProvider('storageContractsDataProvider')] + public function testDefaultStorageIsRedis(string $contract, string $implementation): void { - $storage = $this->app->make(Storage::class); + $storage = $this->app->make($contract); - self::assertInstanceOf(RedisStorage::class, $storage); + self::assertInstanceOf($implementation, $storage); + } + + public static function storageContractsDataProvider(): Generator + { + yield 'counter' => [CounterStorage::class, CounterRedisStorage::class]; + yield 'gauge' => [GaugeStorage::class, GaugeRedisStorage::class]; + yield 'histogram' => [HistogramStorage::class, HistogramRedisStorage::class]; + yield 'summary' => [SummaryStorage::class, SummaryRedisStorage::class]; } public function testMetricsDisabled(): void { - config()->set('prometheus-exporter.enabled', false); + config()->set('prometheus-client.enabled', false); + + // Run StorageConfigurator to reload bindings + $this->app->make(StorageConfigurator::class) + ->configure(); - $storage = $this->app->make(Storage::class); + $storage = $this->app->make(CounterStorage::class); self::assertInstanceOf(NullStorage::class, $storage); } diff --git a/tests/Storage/RedisStorage/CounterRedisStorageTest.php b/tests/Storage/RedisStorage/CounterRedisStorageTest.php new file mode 100644 index 0000000..bd3c37b --- /dev/null +++ b/tests/Storage/RedisStorage/CounterRedisStorageTest.php @@ -0,0 +1,125 @@ +expects('command') + ->with('HGETALL', Mockery::any()) + ->andThrow(new RedisException('Something went wrong')); + + $this->expectException(StorageReadException::class); + $this->expectExceptionMessage('Cannot execute HGETALL command'); + + [...$storage->fetchCounters()]; + } + + public function testSerializationExceptionWhileFetch(): void + { + $storage = new CounterRedisStorage( + $connectionMock = Mockery::mock(Connection::class), + $serializerMock = Mockery::mock(Serializer::class), + ); + + $connectionMock + ->expects('command') + ->with('HGETALL', Mockery::any()) + ->andReturn(['foo' => 'bar']); + + $serializerMock + ->expects('unserialize') + ->andThrow(new MetricKeySerializationException('Something went wrong')); + + $this->expectException(StorageReadException::class); + $this->expectExceptionMessage('Cannot unserialize metrics key for key: foo'); + + [...$storage->fetchCounters()]; + } + + public function testSerializerExceptionWhileIncrementingValue(): void + { + $storage = new CounterRedisStorage( + Mockery::mock(Connection::class), + $serializerMock = Mockery::mock(Serializer::class), + ); + + $serializerMock + ->expects('serialize') + ->andThrow(new MetricKeySerializationException('Something went wrong')); + + $this->expectException(StorageWriteException::class); + $this->expectExceptionMessage('Cannot serialize metrics key'); + + $storage->incrementCounter( + new IncrementCounter( + new MetricNameWithLabels('foo', []), + 1, + ) + ); + } + + public function testRedisExceptionWhileIncrementingValue(): void + { + $storage = new CounterRedisStorage( + $connectionMock = Mockery::mock(Connection::class), + ); + + $connectionMock + ->expects('command') + ->with('HINCRBYFLOAT', ['metrics_counters', 'foo', 42]) + ->andThrow(new RedisException('Something went wrong')); + + $this->expectException(StorageWriteException::class); + $this->expectExceptionMessage('Cannot execute HINCRBYFLOAT command'); + + $storage->incrementCounter( + new IncrementCounter( + new MetricNameWithLabels('foo', []), + 42, + ) + ); + } + + protected function setUp(): void + { + parent::setUp(); + + /** @var Connection $redis */ + $redis = $this->app->make(Connection::class); + + $redis->command('FLUSHALL'); + } + + protected function createStorage(): CounterRedisStorage + { + return new CounterRedisStorage( + $this->app->make(Connection::class), + ); + } +} diff --git a/tests/Storage/RedisStorage/GaugeRedisStorageTest.php b/tests/Storage/RedisStorage/GaugeRedisStorageTest.php new file mode 100644 index 0000000..e28a042 --- /dev/null +++ b/tests/Storage/RedisStorage/GaugeRedisStorageTest.php @@ -0,0 +1,125 @@ +expects('command') + ->with('HGETALL', Mockery::any()) + ->andThrow(new RedisException('Something went wrong')); + + $this->expectException(StorageReadException::class); + $this->expectExceptionMessage('Cannot execute HGETALL command'); + + [...$storage->fetchGauges()]; + } + + public function testSerializationExceptionWhileFetch(): void + { + $storage = new GaugeRedisStorage( + $connectionMock = Mockery::mock(Connection::class), + $serializerMock = Mockery::mock(Serializer::class), + ); + + $connectionMock + ->expects('command') + ->with('HGETALL', Mockery::any()) + ->andReturn(['foo' => 'bar']); + + $serializerMock + ->expects('unserialize') + ->andThrow(new MetricKeySerializationException('Something went wrong')); + + $this->expectException(StorageReadException::class); + $this->expectExceptionMessage('Cannot unserialize metrics key for key: foo'); + + [...$storage->fetchGauges()]; + } + + public function testSerializerExceptionWhileIncrementingValue(): void + { + $storage = new GaugeRedisStorage( + Mockery::mock(Connection::class), + $serializerMock = Mockery::mock(Serializer::class), + ); + + $serializerMock + ->expects('serialize') + ->andThrow(new MetricKeySerializationException('Something went wrong')); + + $this->expectException(StorageWriteException::class); + $this->expectExceptionMessage('Cannot serialize metrics key'); + + $storage->updateGauge( + new UpdateGauge( + new MetricNameWithLabels('foo', []), + 42, + ) + ); + } + + public function testRedisExceptionWhileIncrementingValue(): void + { + $storage = new GaugeRedisStorage( + $connectionMock = Mockery::mock(Connection::class), + ); + + $connectionMock + ->expects('command') + ->with('HSET', ['metrics_gauges', 'foo', 42]) + ->andThrow(new RedisException('Something went wrong')); + + $this->expectException(StorageWriteException::class); + $this->expectExceptionMessage('Cannot execute HSET command'); + + $storage->updateGauge( + new UpdateGauge( + new MetricNameWithLabels('foo', []), + 42, + ) + ); + } + + protected function setUp(): void + { + parent::setUp(); + + /** @var Connection $redis */ + $redis = $this->app->make(Connection::class); + + $redis->command('FLUSHALL'); + } + + protected function createStorage(): GaugeRedisStorage + { + return new GaugeRedisStorage( + $this->app->make(Connection::class), + ); + } +} diff --git a/tests/Storage/RedisStorage/HistogramRedisStorageTest.php b/tests/Storage/RedisStorage/HistogramRedisStorageTest.php new file mode 100644 index 0000000..e87bc4c --- /dev/null +++ b/tests/Storage/RedisStorage/HistogramRedisStorageTest.php @@ -0,0 +1,218 @@ +expects('serialize') + ->andThrow(new MetricKeySerializationException('Something went wrong')); + + $this->expectException(StorageWriteException::class); + $this->expectExceptionMessage('Cannot serialize metric key'); + + $storage->updateHistogram( + new UpdateHistogram( + new MetricNameWithLabels('foo'), + [0, 1, 2], + 42 + ) + ); + } + + public function testRedisExceptionWhileFetchingSum(): void + { + $storage = new HistogramRedisStorage( + $connectionMock = Mockery::mock(Connection::class), + ); + + $connectionMock + ->expects('command') + ->with('HGETALL', ['metrics_histograms_sum']) + ->andThrow(new RedisException('Something went wrong')); + + $this->expectException(StorageReadException::class); + + [...$storage->fetchHistograms()]; + } + + public function testRedisExceptionWhileFetchingCount(): void + { + $storage = new HistogramRedisStorage( + $connectionMock = Mockery::mock(Connection::class), + ); + + $connectionMock + ->expects('command') + ->with('HGETALL', ['metrics_histograms_sum']) + ->andReturn(['foo' => 100]); + + $connectionMock + ->expects('command') + ->with('HGETALL', ['metrics_histograms_count']) + ->andThrow(new RedisException('Something went wrong')); + + $this->expectException(StorageReadException::class); + + [...$storage->fetchHistograms()]; + } + + public function testSerializerExceptionWhileFetching(): void + { + $storage = new HistogramRedisStorage( + $connectionMock = Mockery::mock(Connection::class), + $serializerMock = Mockery::mock(Serializer::class), + ); + + $connectionMock + ->expects('command') + ->with('HGETALL', ['metrics_histograms_sum']) + ->andReturn(['foo' => 100]); + + $connectionMock + ->expects('command') + ->with('HGETALL', ['metrics_histograms_count']) + ->andReturn(['foo' => 1]); + + $serializerMock + ->expects('unserialize') + ->with('foo') + ->andThrow(new MetricKeyUnserializationException('Something went wrong')); + + $this->expectException(StorageReadException::class); + $this->expectExceptionMessage('Cannot unserialize metrics key for key: foo'); + + [...$storage->fetchHistograms()]; + } + + public function testRedisExceptionWhileFetching(): void + { + $storage = new HistogramRedisStorage( + $connectionMock = Mockery::mock(Connection::class), + ); + + $connectionMock + ->expects('command') + ->with('HGETALL', ['metrics_histograms_sum']) + ->andReturn(['foo' => 100]); + + $connectionMock + ->expects('command') + ->with('HGETALL', ['metrics_histograms_count']) + ->andReturn(['foo' => 1]); + + $connectionMock + ->expects('command') + ->with('HGETALL', ['metrics_histogram_foo']) + ->andThrow(new RedisException('Something went wrong')); + + $this->expectException(StorageReadException::class); + $this->expectExceptionMessage('Cannot execute HGETALL command'); + + [...$storage->fetchHistograms()]; + } + + public function testRedisExceptionWhileCleanupSumDel(): void + { + $storage = new HistogramRedisStorage( + $connectionMock = Mockery::mock(Connection::class), + ); + + $connectionMock + ->expects('command') + ->with('DEL', ['metrics_histograms_sum']) + ->andThrow(new RedisException('Something went wrong')); + + $this->expectException(StorageWriteException::class); + + $storage->clearHistograms(); + } + + public function testRedisExceptionWhileCleanupCountDel(): void + { + $storage = new HistogramRedisStorage( + $connectionMock = Mockery::mock(Connection::class), + ); + + $connectionMock + ->expects('command') + ->with('DEL', ['metrics_histograms_sum']); + + $connectionMock + ->expects('command') + ->with('DEL', ['metrics_histograms_count']) + ->andThrow(new RedisException('Something went wrong')); + + $this->expectException(StorageWriteException::class); + + $storage->clearHistograms(); + } + + public function testRedisExceptionWhileCleanupEval(): void + { + $storage = new HistogramRedisStorage( + $connectionMock = Mockery::mock(Connection::class), + ); + + $connectionMock + ->expects('command') + ->with('DEL', ['metrics_histograms_sum']); + + $connectionMock + ->expects('command') + ->with('DEL', ['metrics_histograms_count']); + + $connectionMock + ->expects('command') + ->with('EVAL', Mockery::any()) + ->andThrow(new RedisException('Something went wrong')); + + $this->expectException(StorageWriteException::class); + + $storage->clearHistograms(); + } + + protected function setUp(): void + { + parent::setUp(); + + /** @var Connection $redis */ + $redis = $this->app->make(Connection::class); + + $redis->command('FLUSHALL'); + } + + protected function createStorage(): HistogramRedisStorage + { + return new HistogramRedisStorage( + $this->app->make(Connection::class), + ); + } +} diff --git a/tests/Storage/RedisStorage/SummaryRedisStorageTest.php b/tests/Storage/RedisStorage/SummaryRedisStorageTest.php new file mode 100644 index 0000000..3a71d41 --- /dev/null +++ b/tests/Storage/RedisStorage/SummaryRedisStorageTest.php @@ -0,0 +1,159 @@ +expects('command') + ->with('KEYS', ['*metrics_summary_*']) + ->andReturn([ + 'laravel_database_metrics_summary_foo', + 'metrics_summary_bar', + ]); + + $connectionMock + ->expects('command') + ->with('DEL', ['metrics_summary_foo']); + + $connectionMock + ->expects('command') + ->with('DEL', ['metrics_summary_bar']); + + $storage->clearSummaries(); + } + + public function testRedisErrorWhileCleanup(): void + { + $storage = new SummaryRedisStorage( + $connectionMock = Mockery::mock(Connection::class), + ); + + $connectionMock + ->expects('command') + ->with('KEYS', ['*metrics_summary_*']) + ->andReturn([ + 'laravel_database_metrics_summary_foo', + ]); + + $connectionMock + ->expects('command') + ->with('DEL', ['metrics_summary_foo']) + ->andThrow(new RedisException('Something went wrong')); + + $this->expectException(StorageWriteException::class); + + $storage->clearSummaries(); + } + + public function testRedisExceptionWhileFetchKeys(): void + { + $storage = new SummaryRedisStorage( + $connectionMock = Mockery::mock(Connection::class), + ); + + $connectionMock + ->expects('command') + ->with('KEYS', ['*metrics_summary_*']) + ->andThrow(new RedisException('Something went wrong')); + + $this->expectException(StorageReadException::class); + + [...$storage->fetchSummaries()]; + } + + public function testSerializationExceptionWhileFetching(): void + { + $storage = new SummaryRedisStorage( + $connectionMock = Mockery::mock(Connection::class), + $serializerMock = Mockery::mock(Serializer::class), + ); + + $connectionMock + ->expects('command') + ->with('KEYS', ['*metrics_summary_*']) + ->andReturn([ + 'laravel_database_metrics_summary_foo', + ]); + + $serializerMock + ->expects('unserialize') + ->with('foo') + ->andThrow(new MetricKeyUnserializationException()); + + $this->expectException(StorageReadException::class); + $this->expectExceptionMessage('Cannot unserialize metrics key for key: foo'); + + [...$storage->fetchSummaries()]; + + [...$storage->fetchSummaries()]; + } + + public function testSerializerExceptionWhileUpdatingHistogram(): void + { + $storage = new SummaryRedisStorage( + Mockery::mock(Connection::class), + $serializerMock = Mockery::mock(Serializer::class), + ); + + $serializerMock + ->expects('serialize') + ->andThrow(new MetricKeySerializationException('Something went wrong')); + + $this->expectException(StorageWriteException::class); + $this->expectExceptionMessage('Cannot serialize metric key'); + + $storage->updateSummary( + new UpdateSummary( + new MetricNameWithLabels('foo'), + 42, + ) + ); + } + + + protected function setUp(): void + { + parent::setUp(); + + /** @var Connection $redis */ + $redis = $this->app->make(Connection::class); + + $redis->command('FLUSHALL'); + } + + protected function createStorage(): SummaryRedisStorage + { + return new SummaryRedisStorage( + $this->app->make(Connection::class), + ); + } +} diff --git a/tests/Storage/RedisStorageTest.php b/tests/Storage/RedisStorageTest.php deleted file mode 100644 index b091640..0000000 --- a/tests/Storage/RedisStorageTest.php +++ /dev/null @@ -1,354 +0,0 @@ -app->make(Connection::class); - - $redis->command('FLUSHALL'); - } - - public function testClear(): void - { - /** @var Connection $redis */ - $redis = $this->app->make(Connection::class); - - $storage = $this->app->make(RedisStorage::class, [ - 'connection' => $redis, - ]); - - $storage->setValue(new MetricValue( - new MetricNameWithLabels('foo', []), - 42, - )); - - $storage->persistHistogram( - new MetricValue( - new MetricNameWithLabels('bar'), - 0.5, - ), - [0.1, 0.2, 0.3] - ); - - $storage->clear(); - - $keys = $redis->command('KEYS', ['*']); - - self::assertEquals([], $keys); - } - - public function testRedisExceptionWhileFetch(): void - { - $storage = new RedisStorage( - $connectionMock = Mockery::mock(Connection::class), - ); - - $connectionMock - ->expects('command') - ->with('HGETALL', Mockery::any()) - ->andThrow(new RedisException('Something went wrong')); - - $this->expectException(StorageReadException::class); - $this->expectExceptionMessage('Cannot execute HGETALL command'); - - iterator_to_array($storage->fetch()); - } - - public function testUnserializationExceptionWhileFetch(): void - { - $storage = new RedisStorage( - $connectionMock = Mockery::mock(Connection::class), - serializer: $serializerMock = Mockery::mock(Serializer::class), - ); - - $connectionMock - ->expects('command') - ->with('HGETALL', Mockery::any()) - ->andReturn([ - 'foo' => 42, - ]); - - $serializerMock - ->expects('unserialize') - ->with('foo') - ->andThrow(new MetricKeyUnserializationException('Something went wrong')); - - $this->expectException(StorageReadException::class); - $this->expectExceptionMessage('Cannot unserialize metrics key for key: foo'); - - iterator_to_array($storage->fetch()); - } - - public function testExceptionWhileFetch(): void - { - $storage = new RedisStorage( - $connectionMock = Mockery::mock(Connection::class), - ); - - $connectionMock - ->expects('command') - ->with('HGETALL', Mockery::any()) - ->andThrow(new RuntimeException('Something went wrong')); - - $this->expectException(StorageReadException::class); - - iterator_to_array($storage->fetch()); - } - - public function testExceptionWhileClear(): void - { - $storage = new RedisStorage( - $connectionMock = Mockery::mock(Connection::class), - ); - - $connectionMock - ->expects('command') - ->with('DEL', Mockery::any()) - ->andThrow(new RuntimeException('Something went wrong')); - - $this->expectException(StorageWriteException::class); - - $storage->clear(); - } - - public function testDenormalizationExceptionWhileIncrementingValue(): void - { - $storage = new RedisStorage( - Mockery::mock(Connection::class), - serializer: $serializerMock = Mockery::mock(Serializer::class), - ); - - $serializerMock - ->expects('serialize') - ->andThrow(new MetricKeySerializationException('Something went wrong')); - - $this->expectException(StorageWriteException::class); - $this->expectExceptionMessage('Cannot serialize metrics key'); - - $storage->incrementValue( - new MetricValue( - new MetricNameWithLabels('foo', []), - 1, - ) - ); - } - - public function testRedisExceptionWhileIncrementingValue(): void - { - $storage = new RedisStorage( - $connectionMock = Mockery::mock(Connection::class), - ); - - $connectionMock - ->expects('command') - ->with('HINCRBYFLOAT', Mockery::any()) - ->andThrow(new RedisException('Something went wrong')); - - $this->expectException(StorageWriteException::class); - $this->expectExceptionMessage('Cannot execute HINCRBYFLOAT command'); - - $storage->incrementValue( - new MetricValue( - new MetricNameWithLabels('foo', []), - 1, - ) - ); - } - - public function testSerializationExceptionWhileSettingValue(): void - { - $storage = new RedisStorage( - Mockery::mock(Connection::class), - serializer: $serializerMock = Mockery::mock(Serializer::class), - ); - - $serializerMock - ->expects('serialize') - ->andThrow(new MetricKeySerializationException('Something went wrong')); - - $this->expectException(StorageWriteException::class); - $this->expectExceptionMessage('Cannot serialize metrics key'); - - $storage->setValue( - new MetricValue( - new MetricNameWithLabels('foo', []), - 1, - ) - ); - } - - public function testRedisExceptionWhileSettingValue(): void - { - $storage = new RedisStorage( - $connectionMock = Mockery::mock(Connection::class), - ); - - $connectionMock - ->expects('command') - ->with('HSET', Mockery::any()) - ->andThrow(new RedisException('Something went wrong')); - - $this->expectException(StorageWriteException::class); - $this->expectExceptionMessage('Cannot execute HSET command'); - - $storage->setValue( - new MetricValue( - new MetricNameWithLabels('foo', []), - 1, - ) - ); - } - - public function testStorageWriteExceptionWhilePersistingOfHistogram(): void - { - $storage = new RedisStorage( - Mockery::mock(Connection::class), - $serializerMock = Mockery::mock(Serializer::class), - ); - - $serializerMock - ->expects('serialize') - ->andThrow(new MetricKeySerializationException('Something went wrong')); - - $this->expectException(StorageWriteException::class); - $this->expectExceptionMessage('Cannot serialize metric key'); - - $storage->persistHistogram( - new MetricValue( - new MetricNameWithLabels('foo', []), - 1, - ), - [0.1, 0.2], - ); - } - - public function testUnserializationExceptionWhileFetchingHistograms(): void - { - $storage = new RedisStorage( - $connectionMock = Mockery::mock(Connection::class), - $serializerMock = Mockery::mock(Serializer::class), - ); - - $connectionMock - ->allows('command') - ->with('HGETALL', Mockery::any()) - ->andReturnUsing(static function (string $command, array $args) { - $data = [ - 'metrics_histograms_sum' => [ - 'non-unserializable-key' => 10, - ], - ]; - - $key = $args[0]; - return $data[$key] ?? []; - }); - - $serializerMock - ->expects('unserialize') - ->with('non-unserializable-key') - ->andThrow(new MetricKeyUnserializationException('Something went wrong')); - - - $this->expectException(StorageReadException::class); - $this->expectExceptionMessage('Cannot unserialize metrics key for key: non-unserializable-key'); - - iterator_to_array($storage->fetch(), false); - } - - public function testRedisExceptionWhileFetchingHistogramBuckets(): void - { - $storage = new RedisStorage( - $connectionMock = Mockery::mock(Connection::class), - ); - - // First call to HGETALL from fetchGaugeAndCounterMetrics - $connectionMock - ->expects('command') - ->with('HGETALL', [RedisStorage::SIMPLE_HASH_NAME]) - ->andReturn([]); - - // Second call to HGETALL from fetchHistogramMetrics (sum) - $connectionMock - ->expects('command') - ->with('HGETALL', [RedisStorage::HISTOGRAM_SUM_HASH_NAME]) - ->andReturn([ - 'foo' => 10, - ]); - - // Third call to HGETALL from fetchHistogramMetrics (count) - $connectionMock - ->expects('command') - ->with('HGETALL', [RedisStorage::HISTOGRAM_COUNT_HASH_NAME]) - ->andReturn([ - 'foo' => 2, - ]); - - // Fourth call to HGETALL from fetchHistogramMetrics (buckets) - $connectionMock - ->expects('command') - ->with('HGETALL', [RedisStorage::HISTOGRAM_HASH_NAME_PREFIX . 'foo']) - ->andThrow(new RedisException('Something went wrong')); - - $this->expectException(StorageReadException::class); - $this->expectExceptionMessage('Cannot execute HGETALL command'); - - iterator_to_array($storage->fetch(), false); - } - - public function testStorageReadExceptionWhileFetchingHistograms(): void - { - $storage = new RedisStorage( - $connectionMock = Mockery::mock(Connection::class), - ); - - // First call to HGETALL from fetchGaugeAndCounterMetrics - $connectionMock - ->expects('command') - ->with('HGETALL', [RedisStorage::SIMPLE_HASH_NAME]) - ->andReturn([]); - - // Second call to HGETALL from fetchHistogramMetrics - $connectionMock - ->expects('command') - ->with('HGETALL', [RedisStorage::HISTOGRAM_SUM_HASH_NAME]) - ->andThrow(new RedisException('Something went wrong')); - - $this->expectException(StorageReadException::class); - $this->expectExceptionMessage('Cannot execute HGETALL command'); - - iterator_to_array($storage->fetch(), false); - } - - private function createStorage(): RedisStorage - { - /** @var RedisStorage */ - return $this->app->make(RedisStorage::class); - } -}