diff --git a/.gitignore b/.gitignore index fc951dd5..50ce1c70 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ /vendor/ *.iml /.idea/ +benchmark.csv composer.lock composer.phar .phpunit.result.cache diff --git a/docker-compose.yml b/docker-compose.yml index c6a794b0..fb8ef342 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,6 +5,8 @@ services: - php-fpm ports: - 8080:80 + environment: + - REDIS_HOST=redis php-fpm: build: php-fpm/ diff --git a/examples/flush_adapter.php b/examples/flush_adapter.php index 1c00eab7..037f9142 100644 --- a/examples/flush_adapter.php +++ b/examples/flush_adapter.php @@ -16,6 +16,8 @@ $adapter = new Prometheus\Storage\APCng(); } elseif ($adapterName === 'in-memory') { $adapter = new Prometheus\Storage\InMemory(); +} elseif ($adapterName === 'redistxn') { + $adapter = new Prometheus\Storage\RedisTxn(['host' => $_SERVER['REDIS_HOST'] ?? '127.0.0.1']); } $adapter->wipeStorage(); diff --git a/examples/metrics.php b/examples/metrics.php index 9c0fdb80..051caf38 100644 --- a/examples/metrics.php +++ b/examples/metrics.php @@ -17,6 +17,8 @@ $adapter = new Prometheus\Storage\APCng(); } elseif ($adapter === 'in-memory') { $adapter = new Prometheus\Storage\InMemory(); +} elseif ($adapter === 'redistxn') { + $adapter = new Prometheus\Storage\RedisTxn(['host' => $_SERVER['REDIS_HOST'] ?? '127.0.0.1']); } $registry = new CollectorRegistry($adapter); $renderer = new RenderTextFormat(); diff --git a/examples/some_counter.php b/examples/some_counter.php index c7426ce8..05ad9429 100644 --- a/examples/some_counter.php +++ b/examples/some_counter.php @@ -16,6 +16,8 @@ $adapter = new Prometheus\Storage\APCng(); } elseif ($adapter === 'in-memory') { $adapter = new Prometheus\Storage\InMemory(); +} elseif ($adapter === 'redistxn') { + $adapter = new Prometheus\Storage\RedisTxn(['host' => $_SERVER['REDIS_HOST'] ?? '127.0.0.1']); } $registry = new CollectorRegistry($adapter); diff --git a/examples/some_gauge.php b/examples/some_gauge.php index 9e8b3da2..05b8175e 100644 --- a/examples/some_gauge.php +++ b/examples/some_gauge.php @@ -19,6 +19,8 @@ $adapter = new Prometheus\Storage\APCng(); } elseif ($adapter === 'in-memory') { $adapter = new Prometheus\Storage\InMemory(); +} elseif ($adapter === 'redistxn') { + $adapter = new Prometheus\Storage\RedisTxn(['host' => $_SERVER['REDIS_HOST'] ?? '127.0.0.1']); } $registry = new CollectorRegistry($adapter); diff --git a/examples/some_histogram.php b/examples/some_histogram.php index 2f1a5f98..8970c849 100644 --- a/examples/some_histogram.php +++ b/examples/some_histogram.php @@ -18,6 +18,8 @@ $adapter = new Prometheus\Storage\APCng(); } elseif ($adapter === 'in-memory') { $adapter = new Prometheus\Storage\InMemory(); +} elseif ($adapter === 'redistxn') { + $adapter = new Prometheus\Storage\RedisTxn(['host' => $_SERVER['REDIS_HOST'] ?? '127.0.0.1']); } $registry = new CollectorRegistry($adapter); diff --git a/examples/some_summary.php b/examples/some_summary.php index 363f9190..8dabaadc 100644 --- a/examples/some_summary.php +++ b/examples/some_summary.php @@ -18,6 +18,8 @@ $adapter = new Prometheus\Storage\APCng(); } elseif ($adapter === 'in-memory') { $adapter = new Prometheus\Storage\InMemory(); +} elseif ($adapter === 'redistxn') { + $adapter = new Prometheus\Storage\RedisTxn(['host' => $_SERVER['REDIS_HOST'] ?? '127.0.0.1']); } $registry = new CollectorRegistry($adapter); diff --git a/php-fpm/Dockerfile b/php-fpm/Dockerfile index f0d50190..7a65252b 100644 --- a/php-fpm/Dockerfile +++ b/php-fpm/Dockerfile @@ -1,7 +1,14 @@ FROM php:8.1-fpm -RUN pecl install redis && docker-php-ext-enable redis -RUN pecl install apcu && docker-php-ext-enable apcu +RUN apt-get -y install git libzip-dev zip unzip php-zip +RUN pecl install redis && docker-php-ext-enable redis \ + && pecl install apcu && docker-php-ext-enable apcu \ + && pecl install zip && docker-php-ext-enable zip +RUN cd /var/www/html \ + && curl -sS https://getcomposer.org/installer -o composer-setup.php \ + && php composer-setup.php \ + && rm composer-setup.php \ + && composer.phar install COPY www.conf /usr/local/etc/php-fpm.d/ COPY docker-php-ext-apcu-cli.ini /usr/local/etc/php/conf.d/ diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 88981644..68244721 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -25,6 +25,7 @@ Performance + Benchmark diff --git a/src/Prometheus/Storage/RedisTxn.php b/src/Prometheus/Storage/RedisTxn.php new file mode 100644 index 00000000..d8c42660 --- /dev/null +++ b/src/Prometheus/Storage/RedisTxn.php @@ -0,0 +1,300 @@ + '127.0.0.1', + 'port' => 6379, + 'timeout' => 0.1, + 'read_timeout' => '10', + 'persistent_connections' => false, + 'password' => 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 + */ + public function __construct(array $options = []) + { + $this->options = array_merge(self::$defaultOptions, $options); + $this->redis = new \Redis(); + } + + /** + * @param \Redis $redis + * @return self + * @throws StorageException + */ + public static function fromExistingConnection(\Redis $redis): self + { + if ($redis->isConnected() === false) { + throw new StorageException('Connection to Redis server not established'); + } + + $self = new self(); + $self->connectionInitialized = true; + $self->redis = $redis; + + return $self; + } + + /** + * @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( + <<ensureOpenConnection(); + + // Collect all metrics + $counters = $this->collectCounters(); + $histograms = $this->collectHistograms(); + $gauges = $this->collectGauges(); + $summaries = $this->collectSummaries(); + return array_merge( + $counters, + $histograms, + $gauges, + $summaries + ); + } + + /** + * @throws StorageException + */ + private function ensureOpenConnection(): void + { + if ($this->connectionInitialized === true) { + return; + } + + $this->connectToServer(); + + if ($this->options['password'] !== null) { + $this->redis->auth($this->options['password']); + } + + 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("Can't connect to Redis server", 0); + } + } catch (\RedisException $e) { + throw new StorageException("Can't connect to Redis server", 0, $e); + } + } + + /** + * @inheritDoc + */ + public function updateHistogram(array $data): void + { + // Ensure Redis connection + $this->ensureOpenConnection(); + + // Update metric + $updater = new HistogramUpdater($this->redis); + $updater->update($data); + } + + /** + * @inheritDoc + */ + public function updateSummary(array $data): void + { + // Ensure Redis connection + $this->ensureOpenConnection(); + + // Update metric + $updater = new SummaryUpdater($this->redis); + $updater->update($data); + } + + /** + * @inheritDoc + */ + public function updateGauge(array $data): void + { + // Ensure Redis connection + $this->ensureOpenConnection(); + + // Update metric + $updater = new GaugeUpdater($this->redis); + $updater->update($data); + } + + /** + * @inheritDoc + */ + public function updateCounter(array $data): void + { + // Ensure Redis connection + $this->ensureOpenConnection(); + + // Update metric + $updater = new CounterUpdater($this->redis); + $updater->update($data); + } + + /** + * @return MetricFamilySamples[] + */ + private function collectHistograms(): array + { + $collector = new HistogramCollecter($this->redis); + return $collector->getMetricFamilySamples(); + } + + /** + * @return MetricFamilySamples[] + */ + private function collectSummaries(): array + { + $collector = new SummaryCollecter($this->redis); + return $collector->getMetricFamilySamples(); + } + + /** + * @return MetricFamilySamples[] + */ + private function collectGauges(): array + { + $collector = new GaugeCollecter($this->redis); + return $collector->getMetricFamilySamples(); + } + + /** + * @return MetricFamilySamples[] + */ + private function collectCounters(): array + { + $collector = new CounterCollecter($this->redis); + return $collector->getMetricFamilySamples(); + } +} diff --git a/src/Prometheus/Storage/RedisTxn/Collecter/AbstractCollecter.php b/src/Prometheus/Storage/RedisTxn/Collecter/AbstractCollecter.php new file mode 100644 index 00000000..c15049d3 --- /dev/null +++ b/src/Prometheus/Storage/RedisTxn/Collecter/AbstractCollecter.php @@ -0,0 +1,57 @@ +helper = new RedisScriptHelper(); + $this->redis = $redis; + } + + /** + * @inheritDoc + */ + public function getHelper(): RedisScriptHelper + { + return $this->helper; + } + + /** + * @inheritDoc + */ + public function getRedis(): Redis + { + return $this->redis; + } + + /** + * @inheritDoc + */ + public function getMetricFamilySamples(): array + { + $metricFamilySamples = []; + $metrics = $this->getMetrics(); + foreach ($metrics as $metric) { + $metricFamilySamples[] = $metric->toMetricFamilySamples(); + } + return $metricFamilySamples; + } +} \ No newline at end of file diff --git a/src/Prometheus/Storage/RedisTxn/Collecter/CollecterInterface.php b/src/Prometheus/Storage/RedisTxn/Collecter/CollecterInterface.php new file mode 100644 index 00000000..aa938fb4 --- /dev/null +++ b/src/Prometheus/Storage/RedisTxn/Collecter/CollecterInterface.php @@ -0,0 +1,36 @@ +getHelper()->getRegistryKey(Counter::TYPE); + $metadataKey = $this->getHelper()->getMetadataKey(Counter::TYPE); + $scriptArgs = [ + $registryKey, + $metadataKey, + ]; + + // Create Redis script + return RedisScript::newBuilder() + ->withScript(self::SCRIPT) + ->withArgs($scriptArgs) + ->withNumKeys($numKeys) + ->build(); + } + + /** + * @inheritDoc + */ + public function getMetrics(): array + { + // Retrieve metrics from Redis + $results = $this->getRedisScript()->eval($this->getRedis()); + + // Collate metrics by metric name + $phpMetrics = []; + $redisMetrics = json_decode($results, true); + foreach ($redisMetrics as $redisMetric) { + // Get metadata + $phpMetadata = json_decode($redisMetric['metadata'], true); + $metadata = MetadataBuilder::fromArray($phpMetadata)->build(); + + // Create or update metric + $metricName = $metadata->getName(); + $builder = $phpMetrics[$metricName] ?? Metric::newScalarMetricBuilder()->withMetadata($metadata); + $builder->withSample($redisMetric['samples'], $metadata->getLabelValues()); + $phpMetrics[$metricName] = $builder; + } + + // Build metrics + $metrics = []; + foreach ($phpMetrics as $_ => $metric) { + $metrics[] = $metric->build(); + } + return $metrics; + } +} \ No newline at end of file diff --git a/src/Prometheus/Storage/RedisTxn/Collecter/GaugeCollecter.php b/src/Prometheus/Storage/RedisTxn/Collecter/GaugeCollecter.php new file mode 100644 index 00000000..3aa036e2 --- /dev/null +++ b/src/Prometheus/Storage/RedisTxn/Collecter/GaugeCollecter.php @@ -0,0 +1,96 @@ +getHelper()->getRegistryKey(Gauge::TYPE); + $metadataKey = $this->getHelper()->getMetadataKey(Gauge::TYPE); + $scriptArgs = [ + $registryKey, + $metadataKey, + ]; + + // Create Redis script + return RedisScript::newBuilder() + ->withScript(self::SCRIPT) + ->withArgs($scriptArgs) + ->withNumKeys($numKeys) + ->build(); + } + + /** + * @inheritDoc + */ + public function getMetrics(): array + { + // Retrieve metrics from Redis + $results = $this->getRedisScript()->eval($this->getRedis()); + + // Collate metrics by metric name + $phpMetrics = []; + $redisMetrics = json_decode($results, true); + foreach ($redisMetrics as $redisMetric) { + // Get metadata + $phpMetadata = json_decode($redisMetric['metadata'], true); + $metadata = MetadataBuilder::fromArray($phpMetadata)->build(); + + // Create or update metric + $metricName = $metadata->getName(); + $builder = $phpMetrics[$metricName] ?? Metric::newScalarMetricBuilder()->withMetadata($metadata); + $builder->withSample($redisMetric['samples'], $metadata->getLabelValues()); + $phpMetrics[$metricName] = $builder; + } + + // Build metrics + $metrics = []; + foreach ($phpMetrics as $_ => $metric) { + $metrics[] = $metric->build(); + } + return $metrics; + } +} diff --git a/src/Prometheus/Storage/RedisTxn/Collecter/HistogramCollecter.php b/src/Prometheus/Storage/RedisTxn/Collecter/HistogramCollecter.php new file mode 100644 index 00000000..5473ddc8 --- /dev/null +++ b/src/Prometheus/Storage/RedisTxn/Collecter/HistogramCollecter.php @@ -0,0 +1,99 @@ +getHelper()->getRegistryKey(Histogram::TYPE); + $metadataKey = $this->getHelper()->getMetadataKey(Histogram::TYPE); + $scriptArgs = [ + $registryKey, + $metadataKey, + ]; + + // Create Redis script + return RedisScript::newBuilder() + ->withScript(self::SCRIPT) + ->withArgs($scriptArgs) + ->withNumKeys($numKeys) + ->build(); + } + + /** + * @inheritDoc + */ + public function getMetrics(): array + { + // Retrieve metrics from Redis + $results = $this->getRedisScript()->eval($this->getRedis()); + + // Collate histogram observations by metric name + $builders = []; + $redisMetrics = json_decode($results, true); + foreach ($redisMetrics as $redisMetric) { + $phpMetadata = json_decode($redisMetric['metadata'], true); + $metadata = MetadataBuilder::fromArray($phpMetadata)->build(); + $builder = $builders[$metadata->getName()] ?? Metric::newHistogramMetricBuilder()->withMetadata($metadata); + $builder->withSamples($redisMetric['samples'], $metadata->getLabelValues()); + $builders[$metadata->getName()] = $builder; + } + + // Build collated histograms into Metric structures + $metrics = []; + foreach ($builders as $builder) { + $metrics[] = $builder->build(); + } + return $metrics; + } +} diff --git a/src/Prometheus/Storage/RedisTxn/Collecter/SummaryCollecter.php b/src/Prometheus/Storage/RedisTxn/Collecter/SummaryCollecter.php new file mode 100644 index 00000000..82dea178 --- /dev/null +++ b/src/Prometheus/Storage/RedisTxn/Collecter/SummaryCollecter.php @@ -0,0 +1,104 @@ + 0 and summaryTtl < currentTime then + local startScore = currentTime - summaryTtl + redis.call("zremrangebyscore", summaryKey, "-inf", startScore) + end + + -- Retrieve the set of remaining metric samples + local numSamples = redis.call('zcard', summaryKey) + local summaryMetadata = {} + local summarySamples = {} + if numSamples > 0 then + -- Configure results + summaryMetadata = redis.call("hget", metadataKey, summaryKey) + summarySamples = redis.call("zrange", summaryKey, startScore, "+inf", "byscore") + else + -- Remove the metric's associated metadata if there are no associated samples remaining + redis.call('srem', summaryRegistryKey, summaryKey) + redis.call('hdel', metadataKey, summaryKey) + redis.call('hdel', metadataKey, ttlFieldName) + end + + -- Add the processed metric to the set of results + result[summaryKey] = {} + result[summaryKey]["metadata"] = summaryMetadata + result[summaryKey]["samples"] = summarySamples +end + +-- Return the set of summary metrics +return cjson.encode(result) +LUA; + + /** + * @inheritDoc + */ + public function getRedisScript(): RedisScript + { + // Create Redis script args + $numKeys = 2; + $registryKey = $this->getHelper()->getRegistryKey(Summary::TYPE); + $metadataKey = $this->getHelper()->getMetadataKey(Summary::TYPE); + $currentTime = time(); + $scriptArgs = [ + $registryKey, + $metadataKey, + $currentTime + ]; + + // Create Redis script + return RedisScript::newBuilder() + ->withScript(self::SCRIPT) + ->withArgs($scriptArgs) + ->withNumKeys($numKeys) + ->build(); + } + + /** + * @inheritDoc + */ + public function getMetrics(): array + { + // Retrieve metrics from Redis + $results = $this->getRedisScript()->eval($this->getRedis()); + + // Format metrics as MetricFamilySamples + $metrics = []; + $redisMetrics = json_decode($results, true); + foreach ($redisMetrics as $redisMetric) { + $metrics[] = Metric::newSummaryMetricBuilder() + ->withMetadata($redisMetric['metadata']) + ->withSamples($redisMetric['samples']) + ->build(); + } + return $metrics; + } +} diff --git a/src/Prometheus/Storage/RedisTxn/Metric/HistogramMetricBuilder.php b/src/Prometheus/Storage/RedisTxn/Metric/HistogramMetricBuilder.php new file mode 100644 index 00000000..769596c2 --- /dev/null +++ b/src/Prometheus/Storage/RedisTxn/Metric/HistogramMetricBuilder.php @@ -0,0 +1,126 @@ +metadata = $metadata; + return $this; + } + + /** + * @param array $samples + * @param array $labelValues + * @return HistogramMetricBuilder + */ + public function withSamples(array $samples, array $labelValues): HistogramMetricBuilder + { + $jsonLabelValues = json_encode($labelValues); + $this->samples[$jsonLabelValues] = $this->processSamples($samples, $labelValues); + return $this; + } + + /** + * @return Metric + */ + public function build(): Metric + { + // Validate + $this->validate(); + + // Natural sort samples by label values + ksort($this->samples, SORT_NATURAL); + + // Flatten observation samples into a single collection + $samples = []; + foreach ($this->samples as $observation) { + foreach ($observation as $observationSample) { + $samples[] = $observationSample->toArray(); + } + } + + // Return metric + return new Metric($this->metadata, $samples); + } + + /** + * @return void + * @throws InvalidArgumentException + */ + private function validate(): void + { + if ($this->metadata === null) { + throw new InvalidArgumentException('Summary metadata field is required.'); + } + + if ($this->samples === null) { + throw new InvalidArgumentException('Summary samples field is required.'); + } + } + + /** + * @param array $sourceSamples + * @param array $labelValues + * @return Sample[] + */ + private function processSamples(array $sourceSamples, array $labelValues): array + { + // Return value + $samples = []; + + // Calculate bucket samples + $bucketSamples = 0.0; + foreach ($this->metadata->getBuckets() as $bucket) { + $bucketSamples += floatval($sourceSamples[$bucket] ?? 0.0); + $name = $this->metadata->getName() . "_bucket"; + $samples[] = Sample::newBuilder() + ->withName($name) + ->withLabelNames(["le"]) + ->withLabelValues(array_merge($labelValues, [$bucket])) + ->withValue($bucketSamples) + ->build(); + } + + // Calculate bucket count + $name = $this->metadata->getName() . "_count"; + $samples[] = Sample::newBuilder() + ->withName($name) + ->withLabelNames([]) + ->withLabelValues($labelValues) + ->withValue($sourceSamples['count'] ?? 0) + ->build(); + + // Calculate bucket sum + $name = $this->metadata->getName() . "_sum"; + $samples[] = Sample::newBuilder() + ->withName($name) + ->withLabelNames([]) + ->withLabelValues($labelValues) + ->withValue($sourceSamples['sum'] ?? 0) + ->build(); + + // Return processed samples + return $samples; + } +} \ No newline at end of file diff --git a/src/Prometheus/Storage/RedisTxn/Metric/Metadata.php b/src/Prometheus/Storage/RedisTxn/Metric/Metadata.php new file mode 100644 index 00000000..beb5ae2e --- /dev/null +++ b/src/Prometheus/Storage/RedisTxn/Metric/Metadata.php @@ -0,0 +1,226 @@ +name = $name; + $this->type = $type; + $this->help = $help; + $this->labelNames = $labelNames; + $this->labelValues = $labelValues; + $this->maxAgeSeconds = $maxAgeSeconds; + $this->buckets = $buckets; + $this->quantiles = $quantiles; + $this->command = $command; + } + + /** + * Prometheus metric name. + * + * @return string + */ + public function getName(): string + { + return $this->name; + } + + /** + * Prometheus metric type. + * + * @return string + */ + public function getType(): string + { + return $this->type; + } + + /** + * Prometheus metric help description. + * + * @internal Optional. + * @return string + */ + public function getHelp(): string + { + return $this->help; + } + + /** + * Prometheus metric label names. + * + * Note that each label introduces a degree of cardinality for a given metric. + * + * @internal Optional. It is permissible to have no label names. + * @return string[] + */ + public function getLabelNames(): array + { + return $this->labelNames; + } + + /** + * Prometheus metric label values. + * + * Note that each label value should correspond to a label name. + * + * @internal Optional. + * @return mixed[] + */ + public function getLabelValues(): array + { + return $this->labelValues; + } + + /** + * Prometheus metric label values encoded for storage in Redis. + * + * This property is used internally by the storage adapter and is not served to a Prometheus scraper. Instead, + * the scraper receives the result from the {@see Metadata::getLabelValues()} accessor. + * + * @return string + */ + public function getLabelValuesEncoded(): string + { + return base64_encode(json_encode($this->labelValues)); + } + + /** + * Prometheus metric time-to-live (TTL) in seconds. + * + * This property is used internally by the storage adapter to enforce a TTL for metrics stored in Redis. + * + * @return int + */ + public function getMaxAgeSeconds(): int + { + return $this->maxAgeSeconds; + } + + /** + * @return float[] + */ + public function getBuckets(): array + { + return $this->buckets; + } + + /** + * Prometheus metric metadata that describes the set of quantiles to report for a summary-type metric. + * + * @return array + */ + public function getQuantiles(): array + { + return $this->quantiles; + } + + /** + * @return int + */ + public function getCommand(): int + { + return $this->command; + } + + /** + * Represents this data structure as a JSON object. + * + * @return string + */ + public function toJson(): string + { + return json_encode([ + 'name' => $this->getName(), + 'type' => $this->getType(), + 'help' => $this->getHelp(), + 'labelNames' => $this->getLabelNames(), + 'labelValues' => $this->getLabelValuesEncoded(), + 'maxAgeSeconds' => $this->getMaxAgeSeconds(), + 'buckets' => $this->getBuckets(), + 'quantiles' => $this->getQuantiles(), + 'command' => $this->getCommand(), + ]); + } +} diff --git a/src/Prometheus/Storage/RedisTxn/Metric/MetadataBuilder.php b/src/Prometheus/Storage/RedisTxn/Metric/MetadataBuilder.php new file mode 100644 index 00000000..4216f569 --- /dev/null +++ b/src/Prometheus/Storage/RedisTxn/Metric/MetadataBuilder.php @@ -0,0 +1,212 @@ +withName($metadata['name']) + ->withType($metadata['type']) + ->withHelp($metadata['help'] ?? null) + ->withLabelNames($metadata['labelNames'] ?? null) + ->withLabelValues($metadata['labelValues'] ?? null) + ->withMaxAgeSeconds($metadata['maxAgeSeconds'] ?? null) + ->withBuckets($metadata['buckets'] ?? null) + ->withQuantiles($metadata['quantiles'] ?? null) + ->withCommand($metadata['command'] ?? null); + } + + /** + * @param string $name + * @return MetadataBuilder + */ + public function withName(string $name): MetadataBuilder + { + $this->name = $name; + return $this; + } + + /** + * @param string $type + * @return MetadataBuilder + */ + public function withType(string $type): MetadataBuilder + { + $this->type = $type; + return $this; + } + + /** + * @param string|null $help + * @return MetadataBuilder + */ + public function withHelp(?string $help): MetadataBuilder + { + $this->help = $help; + return $this; + } + + /** + * @param string[]|null $labelNames + * @return MetadataBuilder + */ + public function withLabelNames(?array $labelNames): MetadataBuilder + { + $this->labelNames = $labelNames; + return $this; + } + + /** + * @param string|array|null $labelValues + * @return MetadataBuilder + */ + public function withLabelValues($labelValues): MetadataBuilder + { + if (($labelValues === null) || is_array($labelValues)) { + $this->labelValues = $labelValues; + } else { + // See Metadata::getLabelNamesEncoded() for the inverse operation on this data. + $this->labelValues = json_decode(base64_decode($labelValues)); + } + return $this; + } + + /** + * @param int|null $maxAgeSeconds + * @return MetadataBuilder + */ + public function withMaxAgeSeconds(?int $maxAgeSeconds): MetadataBuilder + { + $this->maxAgeSeconds = $maxAgeSeconds; + return $this; + } + + /** + * @param float[]|null $buckets + * @return MetadataBuilder + */ + public function withBuckets(?array $buckets): MetadataBuilder + { + $this->buckets = $buckets; + if ($buckets !== null) { + // Stringify bucket keys + // NOTE: We do this because PHP implicitly truncates floats to int values when used as associative array keys. + $this->buckets = array_map(function ($key) { + return strval($key); + }, $this->buckets); + + // Add +Inf bucket + if (!in_array('+Inf', $this->buckets)) { + $this->buckets[] = '+Inf'; + } + } + return $this; + } + + /** + * @param float[]|null $quantiles + * @return MetadataBuilder + */ + public function withQuantiles(?array $quantiles): MetadataBuilder + { + $this->quantiles = $quantiles; + return $this; + } + + /** + * @param int|null $command + * @return MetadataBuilder + */ + public function withCommand(?int $command): MetadataBuilder + { + $this->command = $command; + return $this; + } + + /** + * @return Metadata + */ + public function build(): Metadata + { + $this->validate(); + return new Metadata( + $this->name, + $this->type ?? '', + $this->help ?? '', + $this->labelNames ?? [], + $this->labelValues ?? [], + $this->maxAgeSeconds ?? 0, + $this->buckets ?? [], + $this->quantiles ?? [], + $this->command ?? Adapter::COMMAND_INCREMENT_FLOAT + ); + } + + /** + * @return void + * @throws InvalidArgumentException + */ + private function validate(): void + { + if ($this->name === null) { + throw new InvalidArgumentException('Metadata name field is required'); + } + } +} diff --git a/src/Prometheus/Storage/RedisTxn/Metric/Metric.php b/src/Prometheus/Storage/RedisTxn/Metric/Metric.php new file mode 100644 index 00000000..4d422e3a --- /dev/null +++ b/src/Prometheus/Storage/RedisTxn/Metric/Metric.php @@ -0,0 +1,83 @@ +metadata = $metadata; + $this->samples = $samples; + } + + /** + * @return Metadata + */ + public function getMetadata(): Metadata + { + return $this->metadata; + } + + /** + * @return MetricFamilySamples + */ + public function toMetricFamilySamples(): MetricFamilySamples + { + return new MetricFamilySamples([ + 'name' => $this->metadata->getName(), + 'help' => $this->metadata->getHelp(), + 'type' => $this->metadata->getType(), + 'labelNames' => $this->metadata->getLabelNames(), + 'maxAgeSeconds' => $this->metadata->getMaxAgeSeconds(), + 'quantiles' => $this->metadata->getQuantiles() ?? [], + 'samples' => $this->samples, + ]); + } +} diff --git a/src/Prometheus/Storage/RedisTxn/Metric/Sample.php b/src/Prometheus/Storage/RedisTxn/Metric/Sample.php new file mode 100644 index 00000000..d8c93467 --- /dev/null +++ b/src/Prometheus/Storage/RedisTxn/Metric/Sample.php @@ -0,0 +1,112 @@ +name = $name; + $this->labelNames = $labelNames; + $this->labelValues = $labelValues; + $this->value = $value; + } + + /** + * @return string + */ + public function getName(): string + { + return $this->name; + } + + /** + * @return array|string[] + */ + public function getLabelNames(): array + { + return $this->labelNames; + } + + /** + * @return array + */ + public function getLabelValues(): array + { + return $this->labelValues; + } + + /** + * @return float|int + */ + public function getValue() + { + return $this->value; + } + + /** + * Represents this structure as a PHP associative array. + * + * This array generally conforms to the expectations of the {@see \Prometheus\MetricFamilySamples} structure. + * + * @return array + */ + public function toArray(): array + { + return [ + 'name' => $this->name, + 'labelNames' => $this->labelNames, + 'labelValues' => $this->labelValues, + 'value' => $this->value, + ]; + } +} diff --git a/src/Prometheus/Storage/RedisTxn/Metric/SampleBuilder.php b/src/Prometheus/Storage/RedisTxn/Metric/SampleBuilder.php new file mode 100644 index 00000000..e730cd99 --- /dev/null +++ b/src/Prometheus/Storage/RedisTxn/Metric/SampleBuilder.php @@ -0,0 +1,102 @@ +name = $name; + return $this; + } + + /** + * @param string[] $labelNames + * @return SampleBuilder + */ + public function withLabelNames(array $labelNames): SampleBuilder + { + $this->labelNames = $labelNames; + return $this; + } + + /** + * @param float[]|int[] $labelValues + * @return SampleBuilder + */ + public function withLabelValues(array $labelValues): SampleBuilder + { + $this->labelValues = $labelValues; + return $this; + } + + /** + * @param float|int $value + * @return SampleBuilder + */ + public function withValue($value): SampleBuilder + { + $this->value = floatval($value) && (floatval($value) != intval($value)) + ? floatval($value) + : intval($value); + return $this; + } + + /** + * @return Sample + */ + public function build(): Sample + { + $this->validate(); + return new Sample( + $this->name, + $this->labelNames ?? [], + $this->labelValues ?? [], + $this->value + ); + } + + /** + * @return void + * @throws InvalidArgumentException + */ + private function validate(): void + { + if ($this->name === null) { + throw new InvalidArgumentException('Sample name field is required'); + } + + if ($this->value === null) { + throw new InvalidArgumentException('Sample name field is required'); + } + } +} diff --git a/src/Prometheus/Storage/RedisTxn/Metric/ScalarMetricBuilder.php b/src/Prometheus/Storage/RedisTxn/Metric/ScalarMetricBuilder.php new file mode 100644 index 00000000..3cfa4c07 --- /dev/null +++ b/src/Prometheus/Storage/RedisTxn/Metric/ScalarMetricBuilder.php @@ -0,0 +1,94 @@ +metadata = $metadata; + return $this; + } + + /** + * @param string $sample + * @param array $labelValues + * @return ScalarMetricBuilder + */ + public function withSample(string $sample, array $labelValues): ScalarMetricBuilder + { + $sample = $this->coerceSampleType($sample); + $jsonLabelValues = json_encode($labelValues); + $this->samples[$jsonLabelValues] = $this->toSample($sample, $labelValues); + return $this; + } + + /** + * @return Metric + */ + public function build(): Metric + { + $this->validate(); + ksort($this->samples); + return new Metric($this->metadata, $this->samples); + } + + /** + * @return void + * @throws InvalidArgumentException + */ + private function validate(): void + { + if ($this->metadata === null) { + throw new InvalidArgumentException('Summary metadata field is required.'); + } + + if ($this->samples === null) { + throw new InvalidArgumentException('Summary samples field is required.'); + } + } + + + /** + * @param float|int $sourceSample + * @param array $labelValues + * @return array + */ + private function toSample($sourceSample, array $labelValues): array + { + return Sample::newBuilder() + ->withName($this->metadata->getName()) + ->withLabelNames([]) + ->withLabelValues($labelValues) + ->withValue($sourceSample) + ->build() + ->toArray(); + } + + /** + * @param string $sample + * @return float|int + */ + private function coerceSampleType(string $sample) + { + return (floatval($sample) && floatval($sample) != intval($sample)) + ? floatval($sample) + : intval($sample); + } +} \ No newline at end of file diff --git a/src/Prometheus/Storage/RedisTxn/Metric/SummaryMetricBuilder.php b/src/Prometheus/Storage/RedisTxn/Metric/SummaryMetricBuilder.php new file mode 100644 index 00000000..d6d8203e --- /dev/null +++ b/src/Prometheus/Storage/RedisTxn/Metric/SummaryMetricBuilder.php @@ -0,0 +1,135 @@ +metadata = MetadataBuilder::fromArray($metadata)->build(); + return $this; + } + + /** + * @param array $samples + * @return SummaryMetricBuilder + */ + public function withSamples(array $samples): SummaryMetricBuilder + { + $this->samples = $this->processSamples($samples); + return $this; + } + + /** + * @return Metric + */ + public function build(): Metric + { + $this->validate(); + return new Metric($this->metadata, $this->samples); + } + + /** + * @return void + * @throws InvalidArgumentException + */ + private function validate(): void + { + if ($this->metadata === null) { + throw new InvalidArgumentException('Summary metadata field is required.'); + } + + if ($this->samples === null) { + throw new InvalidArgumentException('Summary samples field is required.'); + } + } + + /** + * Calculates the configured quantiles, count, and sum for a summary metric given a set of observed values. + * + * @param array $sourceSamples + * @return array + */ + private function processSamples(array $sourceSamples): array + { + // Return value + $samples = []; + + // Coerce sample values to numeric type and strip off their unique suffixes + // + // NOTE: When we persist a summary metric sample into Redis, we write it into a Redis sorted set. + // We append the current time in microseconds as a suffix on the observed value to make each observed value + // durable and unique in the sorted set in accordance with best-practice guidelines described in the article, + // "Redis Best Practices: Sorted Set Time Series" [1]. + // + // See RedisTxn::updateSummary() for the complementary part of this operation. + // + // [1] https://redis.com/redis-best-practices/time-series/sorted-set-time-series/ + $typedSamples = array_map(function ($sample) { + $tokens = explode(':', $sample); + $sample = $tokens[0]; + return doubleval($sample); + }, $sourceSamples); + + // Sort samples to calculate quantiles + sort($typedSamples); + + // Calculate quantiles + $math = new Math(); + foreach ($this->metadata->getQuantiles() as $quantile) { + $value = $math->quantile($typedSamples, $quantile); + $labelValues = array_merge($this->metadata->getLabelValues(), [$quantile]); + $samples[] = Sample::newBuilder() + ->withName($this->metadata->getName()) + ->withLabelNames(['quantile']) + ->withLabelValues($labelValues) + ->withValue($value) + ->build() + ->toArray(); + } + + // Calculate count + $samples[] = Sample::newBuilder() + ->withName($this->metadata->getName() . '_count') + ->withLabelNames([]) + ->withLabelValues($this->metadata->getLabelValues()) + ->withValue(count($typedSamples)) + ->build() + ->toArray(); + + // Calculate sum + $samples[] = Sample::newBuilder() + ->withName($this->metadata->getName() . '_sum') + ->withLabelNames([]) + ->withLabelValues($this->metadata->getLabelValues()) + ->withValue(array_sum($typedSamples)) + ->build() + ->toArray(); + + // Return processed samples + return $samples; + } +} diff --git a/src/Prometheus/Storage/RedisTxn/RedisScript/RedisScript.php b/src/Prometheus/Storage/RedisTxn/RedisScript/RedisScript.php new file mode 100644 index 00000000..f4a847aa --- /dev/null +++ b/src/Prometheus/Storage/RedisTxn/RedisScript/RedisScript.php @@ -0,0 +1,84 @@ +script = $script; + $this->args = $args; + $this->numKeys = $numKeys; + } + + /** + * @return string + */ + public function getScript(): string + { + return $this->script; + } + + /** + * @return array + */ + public function getArgs(): array + { + return $this->args; + } + + /** + * @return int + */ + public function getNumKeys(): int + { + return $this->numKeys; + } + + /** + * @param Redis $redis + * @return mixed + */ + public function eval(Redis $redis) + { + return $redis->eval( + $this->getScript(), + $this->getArgs(), + $this->getNumKeys() + ); + } +} \ No newline at end of file diff --git a/src/Prometheus/Storage/RedisTxn/RedisScript/RedisScriptBuilder.php b/src/Prometheus/Storage/RedisTxn/RedisScript/RedisScriptBuilder.php new file mode 100644 index 00000000..a37b58f8 --- /dev/null +++ b/src/Prometheus/Storage/RedisTxn/RedisScript/RedisScriptBuilder.php @@ -0,0 +1,77 @@ +script = $script; + return $this; + } + + /** + * @param array $args + * @return RedisScriptBuilder + */ + public function withArgs(array $args): RedisScriptBuilder + { + $this->args = $args; + return $this; + } + + /** + * @param int $numKeys + * @return RedisScriptBuilder + */ + public function withNumKeys(int $numKeys): RedisScriptBuilder + { + $this->numKeys = $numKeys; + return $this; + } + + /** + * @return RedisScript + */ + public function build(): RedisScript + { + $this->validate(); + return new RedisScript( + $this->script, + $this->args ?? [], + $this->numKeys ?? 0 + ); + } + + /** + * @return void + * @throws InvalidArgumentException + */ + private function validate(): void + { + if ($this->script === null) { + throw new InvalidArgumentException('A Redis script is required.'); + } + } +} diff --git a/src/Prometheus/Storage/RedisTxn/RedisScript/RedisScriptHelper.php b/src/Prometheus/Storage/RedisTxn/RedisScript/RedisScriptHelper.php new file mode 100644 index 00000000..c697ec5f --- /dev/null +++ b/src/Prometheus/Storage/RedisTxn/RedisScript/RedisScriptHelper.php @@ -0,0 +1,79 @@ +getType(); + $name = $metadata->getName(); + $labelValues = $metadata->getLabelValuesEncoded(); + $keyPrefix = self::PREFIX . $type . self::PROMETHEUS_METRIC_KEYS_SUFFIX; + return implode(':', [$keyPrefix, $name, $labelValues]); + } + + /** + * @param int $cmd + * @return string + */ + public function getRedisCommand(int $cmd): string + { + switch ($cmd) { + case Adapter::COMMAND_INCREMENT_INTEGER: + return 'incrby'; + case Adapter::COMMAND_INCREMENT_FLOAT: + return 'incrbyfloat'; + case Adapter::COMMAND_SET: + return 'set'; + default: + throw new InvalidArgumentException("Unknown command"); + } + } + + /** + * @return int + */ + public function getDefautlTtl(): int + { + return self::DEFAULT_TTL_SECONDS; + } +} \ No newline at end of file diff --git a/src/Prometheus/Storage/RedisTxn/Updater/AbstractUpdater.php b/src/Prometheus/Storage/RedisTxn/Updater/AbstractUpdater.php new file mode 100644 index 00000000..62444787 --- /dev/null +++ b/src/Prometheus/Storage/RedisTxn/Updater/AbstractUpdater.php @@ -0,0 +1,52 @@ +helper = new RedisScriptHelper(); + $this->redis = $redis; + } + + /** + * @inheritDoc + */ + public function getHelper(): RedisScriptHelper + { + return $this->helper; + } + + /** + * @inheritDoc + */ + public function getRedis(): Redis + { + return $this->redis; + } + + /** + * @inheritDoc + */ + public function update(array $data) + { + return $this->getRedisScript($data)->eval($this->getRedis()); + } +} \ No newline at end of file diff --git a/src/Prometheus/Storage/RedisTxn/Updater/CounterUpdater.php b/src/Prometheus/Storage/RedisTxn/Updater/CounterUpdater.php new file mode 100644 index 00000000..a5ad1c8d --- /dev/null +++ b/src/Prometheus/Storage/RedisTxn/Updater/CounterUpdater.php @@ -0,0 +1,81 @@ + 0 then + redis.call('expire', metricKey, ttl) + else + redis.call('persist', metricKey) + end + + -- Register metric value + redis.call('sadd', registryKey, metricKey) + + -- Register metric metadata + redis.call('hset', metadataKey, metricKey, metadata) +end + +-- Report script result +return didUpdate +LUA; + + /** + * @inheritDoc + */ + public function getRedisScript(array $data): RedisScript + { + // Prepare metadata + $metadata = MetadataBuilder::fromArray($data)->build(); + + // Create Redis keys + $metricKey = $this->getHelper()->getMetricKey($metadata); + $registryKey = $this->getHelper()->getRegistryKey($metadata->getType()); + $metadataKey = $this->getHelper()->getMetadataKey($metadata->getType()); + + // Prepare script input + $command = $this->getHelper()->getRedisCommand($metadata->getCommand()); + $value = $data['value']; + $ttl = $metadata->getMaxAgeSeconds() ?? $this->getHelper()->getDefautlTtl(); + $scriptArgs = [ + $registryKey, + $metadataKey, + $metricKey, + $metadata->toJson(), + $command, + $value, + $ttl + ]; + + // Return script + return RedisScript::newBuilder() + ->withScript(self::SCRIPT) + ->withArgs($scriptArgs) + ->withNumKeys(3) + ->build(); + } +} \ No newline at end of file diff --git a/src/Prometheus/Storage/RedisTxn/Updater/GaugeUpdater.php b/src/Prometheus/Storage/RedisTxn/Updater/GaugeUpdater.php new file mode 100644 index 00000000..c7eaf5d2 --- /dev/null +++ b/src/Prometheus/Storage/RedisTxn/Updater/GaugeUpdater.php @@ -0,0 +1,87 @@ + 0 then + redis.call('expire', metricKey, ttl) + else + redis.call('persist', metricKey) + end + + -- Register metric value + redis.call('sadd', registryKey, metricKey) + + -- Register metric metadata + redis.call('hset', metadataKey, metricKey, metadata) +end + +-- Report script result +return didUpdate +LUA; + + /** + * @inheritDoc + */ + public function getRedisScript(array $data): RedisScript + { + // Prepare metadata + $metadata = MetadataBuilder::fromArray($data)->build(); + + // Create Redis keys + $registryKey = $this->getHelper()->getRegistryKey($metadata->getType()); + $metadataKey = $this->getHelper()->getMetadataKey($metadata->getType()); + $metricKey = $this->getHelper()->getMetricKey($metadata); + + // Prepare script input + $command = $this->getHelper()->getRedisCommand($metadata->getCommand()); + $value = $data['value']; + $ttl = $metadata->getMaxAgeSeconds() ?? $this->getHelper()->getDefautlTtl(); + $scriptArgs = [ + $registryKey, + $metadataKey, + $metricKey, + $metadata->toJson(), + $command, + $value, + $ttl + ]; + + // Return script + return RedisScript::newBuilder() + ->withScript(self::SCRIPT) + ->withArgs($scriptArgs) + ->withNumKeys(3) + ->build(); + } +} diff --git a/src/Prometheus/Storage/RedisTxn/Updater/HistogramUpdater.php b/src/Prometheus/Storage/RedisTxn/Updater/HistogramUpdater.php new file mode 100644 index 00000000..eedec96c --- /dev/null +++ b/src/Prometheus/Storage/RedisTxn/Updater/HistogramUpdater.php @@ -0,0 +1,99 @@ + value +-- if didUpdate ~= true then +-- return false +-- end + +-- Update metric count +local result = redis.call("hincrby", metricKey, 'count', 1) + +-- Update bucket count +result = redis.call("hincrby", metricKey, bucket, 1) +didUpdate = result >= 1 +-- if didUpdate ~= true then +-- return false +-- end + +-- Set metric TTL +-- if ttl > 0 then +-- redis.call('expire', metricKey, ttl) +-- else +-- redis.call('persist', metricKey) +-- end + +-- Register metric key +redis.call('sadd', registryKey, metricKey) + +-- Register metric metadata +redis.call('hset', metadataKey, metricKey, metadata) + +-- Report script result +return true +LUA; + + /** + * @inheritDoc + */ + public function getRedisScript(array $data): RedisScript + { + // Prepare metadata + $metadata = MetadataBuilder::fromArray($data)->build(); + + // Create Redis keys + $metricKey = $this->getHelper()->getMetricKey($metadata); + $registryKey = $this->getHelper()->getRegistryKey($metadata->getType()); + $metadataKey = $this->getHelper()->getMetadataKey($metadata->getType()); + + // Determine minimum eligible bucket + $value = floatval($data['value']); + $targetBucket = '+Inf'; + foreach ($metadata->getBuckets() as $bucket) { + if ($value <= $bucket) { + $targetBucket = $bucket; + break; + } + } + + // Prepare script input + $ttl = $metadata->getMaxAgeSeconds() ?? $this->getHelper()->getDefautlTtl(); + $scriptArgs = [ + $registryKey, + $metadataKey, + $metricKey, + $metadata->toJson(), + $targetBucket, + $value, + $ttl + ]; + + // Return script + return RedisScript::newBuilder() + ->withScript(self::SCRIPT) + ->withArgs($scriptArgs) + ->withNumKeys(3) + ->build(); + } +} diff --git a/src/Prometheus/Storage/RedisTxn/Updater/SummaryUpdater.php b/src/Prometheus/Storage/RedisTxn/Updater/SummaryUpdater.php new file mode 100644 index 00000000..255f604b --- /dev/null +++ b/src/Prometheus/Storage/RedisTxn/Updater/SummaryUpdater.php @@ -0,0 +1,80 @@ +build(); + + // Create Redis keys + $registryKey = $this->getHelper()->getRegistryKey($metadata->getType()); + $metadataKey = $this->getHelper()->getMetadataKey($metadata->getType()); + $metricKey = $this->getHelper()->getMetricKey($metadata); + + // Get summary sample + // + // NOTE: When we persist a summary metric sample into Redis, we write it into a Redis sorted set. + // We append the current time in microseconds as a suffix on the observed value to make each observed value + // durable and unique in the sorted set in accordance with best-practice guidelines described in the article, + // "Redis Best Practices: Sorted Set Time Series" [1]. + // + // See MetricBuilder::processSamples() for the complementary part of this operation. + // + // [1] https://redis.com/redis-best-practices/time-series/sorted-set-time-series/ + $value = implode(':', [$data['value'], microtime(true)]); + + // Prepare script input + $currentTime = time(); + $ttl = $metadata->getMaxAgeSeconds(); + $scriptArgs = [ + $registryKey, + $metadataKey, + $metricKey, + $metadata->toJson(), + $value, + $currentTime, + $ttl, + ]; + + // Return script + return RedisScript::newBuilder() + ->withScript(self::SCRIPT) + ->withArgs($scriptArgs) + ->withNumKeys(3) + ->build(); + } +} diff --git a/src/Prometheus/Storage/RedisTxn/Updater/UpdaterInterface.php b/src/Prometheus/Storage/RedisTxn/Updater/UpdaterInterface.php new file mode 100644 index 00000000..9d7050e5 --- /dev/null +++ b/src/Prometheus/Storage/RedisTxn/Updater/UpdaterInterface.php @@ -0,0 +1,32 @@ +withAdapterType($adapter) + ->withMetricType($metric) + ->withReportType(ReportType::CSV) + ->withNumKeys($numKeys) + ->withNumSamples($numSamples) + ->build(); + + // Sanity check test structure + $this->assertEquals($adapter, $testCase->getAdapterType()); + $this->assertEquals($metric, $testCase->getMetricType()); + $this->assertEquals($numKeys, $testCase->getNumKeys()); + $this->assertEquals($numSamples, $testCase->getNumSamples()); + + // Record results + $result = $testCase->execute(); + file_put_contents(self::RESULT_FILENAME, $result->report() . PHP_EOL, FILE_APPEND); + } +} diff --git a/tests/Test/Benchmark/MetricType.php b/tests/Test/Benchmark/MetricType.php new file mode 100644 index 00000000..e4009497 --- /dev/null +++ b/tests/Test/Benchmark/MetricType.php @@ -0,0 +1,33 @@ +adapterType = $adapterType; + $this->metricType = $metricType; + $this->reportType = $reportType; + $this->numKeys = $numKeys; + $this->numSamples = $numSamples; + $this->getRegistry(); + } + + /** + * @return int + */ + public function getAdapterType(): int + { + return $this->adapterType; + } + + /** + * @return int + */ + public function getMetricType(): int + { + return $this->metricType; + } + + /** + * @return int + */ + public function getNumKeys(): int + { + return $this->numKeys; + } + + /** + * @return int + */ + public function getNumSamples(): int + { + return $this->numSamples; + } + + /** + * @return TestCaseResult + */ + public function execute(): TestCaseResult + { + // Setup test + $this->executeSeed(); + + // Create result builder + $builder = TestCaseResult::newBuilder() + ->withAdapterType($this->adapterType) + ->withMetricType($this->metricType) + ->withNumKeys($this->numKeys) + ->withReportType($this->reportType); + + // Run render tests + for ($i = 0; $i < $this->numSamples; $i++) { + $result = $this->executeRender(); + $builder->withRenderResult($result); + } + + // Run write tests + for ($i = 0; $i < $this->numSamples; $i++) { + $result = $this->executeWrite(); + $builder->withWriteResult($result); + } + + // Build result + return $builder->build(); + } + + /** + * @return Adapter + */ + private function getAdapter(): Adapter + { + if ($this->adapter === null) { + switch ($this->adapterType) { + case AdapterType::REDIS: + $config = $this->getRedisConfig(); + $this->adapter = new Redis($config); + break; + case AdapterType::REDISNG: + $config = $this->getRedisConfig(); + $this->adapter = new RedisNg($config); + break; + case AdapterType::REDISTXN: + $config = $this->getRedisConfig(); + $this->adapter = new RedisTxn($config); + break; + default: + break; + } + } + return $this->adapter; + } + + /** + * @return RegistryInterface + */ + private function getRegistry(): RegistryInterface + { + if ($this->registry === null) { + $this->registry = new CollectorRegistry($this->getAdapter(), false); + } + return $this->registry; + } + + /** + * @return array + */ + private function getRedisConfig(): array + { + return [ + 'host' => $_SERVER['REDIS_HOST'] ?? self::REDIS_HOST, + 'port' => self::REDIS_PORT, + 'database' => self::REDIS_DB, + ]; + } + + /** + * @return void + */ + private function executeSeed(): void + { + $this->getAdapter()->wipeStorage(); + for ($i = 0; $i < $this->numKeys; $i++) { + $this->emitMetric(); + } + } + + /** + * @return float + * @throws Exception + */ + private function executeWrite(): float + { + // Write test key + $start = microtime(true); + $this->emitMetric(); + return microtime(true) - $start; + } + + /** + * @return float + */ + private function executeRender(): float + { + $start = microtime(true); + $this->render(); + return microtime(true) - $start; + } + + /** + * @return string + * @throws MetricsRegistrationException + * @throws Exception + */ + private function emitMetric(): string + { + $key = ''; + $registry = $this->getRegistry(); + switch ($this->metricType) { + case MetricType::COUNTER: + $key = uniqid('counter_', false); + $registry->getOrRegisterCounter(self::DEFAULT_METRIC_NAMESPACE, $key, self::DEFAULT_METRIC_HELP)->inc(); + break; + case MetricType::GAUGE: + $key = uniqid('gauge_', false); + $registry->getOrRegisterGauge(self::DEFAULT_METRIC_NAMESPACE, $key, self::DEFAULT_METRIC_HELP)->inc();; + break; + case MetricType::HISTOGRAM: + $key = uniqid('histogram_', false); + $value = random_int(1, PHP_INT_MAX); + $registry->getOrRegisterHistogram(self::DEFAULT_METRIC_NAMESPACE, $key, self::DEFAULT_METRIC_HELP)->observe($value); + break; + case MetricType::SUMMARY: + $key = uniqid('summary_', false); + $value = random_int(1, PHP_INT_MAX); + $registry->getOrRegisterSummary(self::DEFAULT_METRIC_NAMESPACE, $key, self::DEFAULT_METRIC_HELP)->observe($value); + break; + } + return $key; + } + + /** + * @return string + */ + private function render(): string + { + $renderer = new RenderTextFormat(); + return $renderer->render($this->getRegistry()->getMetricFamilySamples()); + } +} diff --git a/tests/Test/Benchmark/TestCaseBuilder.php b/tests/Test/Benchmark/TestCaseBuilder.php new file mode 100644 index 00000000..5bba584f --- /dev/null +++ b/tests/Test/Benchmark/TestCaseBuilder.php @@ -0,0 +1,115 @@ +adapterType = $adapterType; + return $this; + } + + /** + * @param int $metricType + * @return TestCaseBuilder + */ + public function withMetricType(int $metricType): TestCaseBuilder + { + $this->metricType = $metricType; + return $this; + } + + /** + * @param int $reportType + * @return TestCaseResultBuilder + */ + public function withReportType(int $reportType): TestCaseBuilder + { + $this->reportType = $reportType; + return $this; + } + + /** + * @param int $numKeys + * @return $this + */ + public function withNumKeys(int $numKeys): TestCaseBuilder + { + $this->numKeys = $numKeys; + return $this; + } + + /** + * @param int $numSamples + * @return $this + */ + public function withNumSamples(int $numSamples): TestCaseBuilder + { + $this->numSamples = $numSamples; + return $this; + } + + /** + * @return BenchmarkTestCase + * @throws InvalidArgumentException + */ + public function build(): BenchmarkTestCase + { + $this->validate(); + return new BenchmarkTestCase( + $this->adapterType, + $this->metricType, + $this->reportType ?? ReportType::CSV, + $this->numKeys ?? BenchmarkTestCase::DEFAULT_NUM_KEYS, + $this->numSamples ?? BenchmarkTestCase::DEFAULT_NUM_SAMPLES + ); + } + + /** + * @return void + * @throws InvalidArgumentException + */ + private function validate(): void + { + if ($this->adapterType === null) { + throw new InvalidArgumentException('Missing adapter type'); + } + + if ($this->metricType === null) { + throw new InvalidArgumentException('Missing metric type'); + } + } +} diff --git a/tests/Test/Benchmark/TestCaseResult.php b/tests/Test/Benchmark/TestCaseResult.php new file mode 100644 index 00000000..7a0c28a7 --- /dev/null +++ b/tests/Test/Benchmark/TestCaseResult.php @@ -0,0 +1,188 @@ +adapterType = $adapterType; + $this->metricType = $metricType; + $this->reportType = $reportType; + $this->numKeys = $numKeys; + $this->updateResults = $updateResults; + $this->collectResults = $collectResults; + } + + /** + * @return string + */ + public function report(): string + { + assert(count($this->updateResults) === count($this->collectResults)); + + sort($this->updateResults); + sort($this->collectResults); + + return ($this->reportType === ReportType::CSV) + ? $this->toCsv() + : $this->toJson(); + } + + private function toCsv(): string + { + return implode(',', [ + AdapterType::toString($this->adapterType), + MetricType::toString($this->metricType), + $this->numKeys, + count($this->updateResults), + $this->quantile($this->updateResults, 0.50), + $this->quantile($this->updateResults, 0.75), + $this->quantile($this->updateResults, 0.95), + $this->quantile($this->updateResults, 0.99), + min($this->updateResults), + max($this->updateResults), + array_sum($this->updateResults) / count($this->updateResults), + $this->quantile($this->collectResults, 0.50), + $this->quantile($this->collectResults, 0.75), + $this->quantile($this->collectResults, 0.95), + $this->quantile($this->collectResults, 0.99), + min($this->collectResults), + max($this->collectResults), + array_sum($this->collectResults) / count($this->collectResults), + ]); + } + + /** + * @return string + */ + private function toJson(): string + { + return json_encode([ + 'adapter' => AdapterType::toString($this->adapterType), + 'metric' => MetricType::toString($this->metricType), + 'num-keys' => $this->numKeys, + 'num-samples' => count($this->updateResults), + 'tests' => [ + 'write' => [ + '50' => $this->quantile($this->updateResults, 0.50), + '75' => $this->quantile($this->updateResults, 0.75), + '95' => $this->quantile($this->updateResults, 0.95), + '99' => $this->quantile($this->updateResults, 0.99), + 'min' => min($this->updateResults), + 'max' => max($this->updateResults), + 'avg' => array_sum($this->updateResults) / count($this->updateResults), + ], + 'render' => [ + '50' => $this->quantile($this->collectResults, 0.50), + '75' => $this->quantile($this->collectResults, 0.75), + '95' => $this->quantile($this->collectResults, 0.95), + '99' => $this->quantile($this->collectResults, 0.99), + 'min' => min($this->collectResults), + 'max' => max($this->collectResults), + 'avg' => array_sum($this->collectResults) / count($this->collectResults), + ], + ], + ]); + } + + /** + * @param array $data + * @param float $quantile + * @return float + */ + private function quantile(array $data, float $quantile): float + { + $count = count($data); + if ($count === 0) { + return 0; + } + + $j = floor($count * $quantile); + $r = $count * $quantile - $j; + if (0.0 === $r) { + return $data[$j - 1]; + } + return $data[$j]; + } +} diff --git a/tests/Test/Benchmark/TestCaseResultBuilder.php b/tests/Test/Benchmark/TestCaseResultBuilder.php new file mode 100644 index 00000000..bcc43c47 --- /dev/null +++ b/tests/Test/Benchmark/TestCaseResultBuilder.php @@ -0,0 +1,111 @@ +adapterType = $adapterType; + return $this; + } + + /** + * @param int $metricType + * @return TestCaseResultBuilder + */ + public function withMetricType(int $metricType): TestCaseResultBuilder + { + $this->metricType = $metricType; + return $this; + } + + /** + * @param int $reportType + * @return TestCaseResultBuilder + */ + public function withReportType(int $reportType): TestCaseResultBuilder + { + $this->reportType = $reportType; + return $this; + } + + /** + * @param int $numKeys + * @return TestCaseResultBuilder + */ + public function withNumKeys(int $numKeys): TestCaseResultBuilder + { + $this->numKeys = $numKeys; + return $this; + } + + /** + * @param float $result + * @return TestCaseResultBuilder + */ + public function withWriteResult(float $result): TestCaseResultBuilder + { + $this->writeResults[] = $result; + return $this; + } + + /** + * @param float $result + * @return TestCaseResultBuilder + */ + public function withRenderResult(float $result): TestCaseResultBuilder + { + $this->renderResults[] = $result; + return $this; + } + + /** + * @return TestCaseResult + */ + public function build(): TestCaseResult + { + return new TestCaseResult( + $this->adapterType, + $this->metricType, + $this->reportType, + $this->numKeys, + $this->writeResults, + $this->renderResults + ); + } +} diff --git a/tests/Test/Benchmark/TestType.php b/tests/Test/Benchmark/TestType.php new file mode 100644 index 00000000..67d3d0ab --- /dev/null +++ b/tests/Test/Benchmark/TestType.php @@ -0,0 +1,9 @@ +adapter = new RedisTxn(['host' => REDIS_HOST]); + $this->adapter->wipeStorage(); + } +} diff --git a/tests/Test/Prometheus/RedisTxn/CounterTest.php b/tests/Test/Prometheus/RedisTxn/CounterTest.php new file mode 100644 index 00000000..cbd84f07 --- /dev/null +++ b/tests/Test/Prometheus/RedisTxn/CounterTest.php @@ -0,0 +1,21 @@ +adapter = new RedisTxn(['host' => REDIS_HOST]); + $this->adapter->wipeStorage(); + } +} diff --git a/tests/Test/Prometheus/RedisTxn/CounterWithPrefixTest.php b/tests/Test/Prometheus/RedisTxn/CounterWithPrefixTest.php new file mode 100644 index 00000000..67d5e715 --- /dev/null +++ b/tests/Test/Prometheus/RedisTxn/CounterWithPrefixTest.php @@ -0,0 +1,26 @@ +connect(REDIS_HOST); + + $connection->setOption(\Redis::OPT_PREFIX, 'prefix:'); + + $this->adapter = RedisTxn::fromExistingConnection($connection); + $this->adapter->wipeStorage(); + } +} diff --git a/tests/Test/Prometheus/RedisTxn/GaugeTest.php b/tests/Test/Prometheus/RedisTxn/GaugeTest.php new file mode 100644 index 00000000..119d4513 --- /dev/null +++ b/tests/Test/Prometheus/RedisTxn/GaugeTest.php @@ -0,0 +1,21 @@ +adapter = new RedisTxn(['host' => REDIS_HOST]); + $this->adapter->wipeStorage(); + } +} diff --git a/tests/Test/Prometheus/RedisTxn/GaugeWithPrefixTest.php b/tests/Test/Prometheus/RedisTxn/GaugeWithPrefixTest.php new file mode 100644 index 00000000..178bc35d --- /dev/null +++ b/tests/Test/Prometheus/RedisTxn/GaugeWithPrefixTest.php @@ -0,0 +1,26 @@ +connect(REDIS_HOST); + + $connection->setOption(\Redis::OPT_PREFIX, 'prefix:'); + + $this->adapter = RedisTxn::fromExistingConnection($connection); + $this->adapter->wipeStorage(); + } +} diff --git a/tests/Test/Prometheus/RedisTxn/HistogramTest.php b/tests/Test/Prometheus/RedisTxn/HistogramTest.php new file mode 100644 index 00000000..d18db180 --- /dev/null +++ b/tests/Test/Prometheus/RedisTxn/HistogramTest.php @@ -0,0 +1,21 @@ +adapter = new RedisTxn(['host' => REDIS_HOST]); + $this->adapter->wipeStorage(); + } +} diff --git a/tests/Test/Prometheus/RedisTxn/HistogramWithPrefixTest.php b/tests/Test/Prometheus/RedisTxn/HistogramWithPrefixTest.php new file mode 100644 index 00000000..b198d410 --- /dev/null +++ b/tests/Test/Prometheus/RedisTxn/HistogramWithPrefixTest.php @@ -0,0 +1,26 @@ +connect(REDIS_HOST); + + $connection->setOption(\Redis::OPT_PREFIX, 'prefix:'); + + $this->adapter = RedisTxn::fromExistingConnection($connection); + $this->adapter->wipeStorage(); + } +} diff --git a/tests/Test/Prometheus/RedisTxn/SummaryTest.php b/tests/Test/Prometheus/RedisTxn/SummaryTest.php new file mode 100644 index 00000000..4b15746f --- /dev/null +++ b/tests/Test/Prometheus/RedisTxn/SummaryTest.php @@ -0,0 +1,21 @@ +adapter = new RedisTxn(['host' => REDIS_HOST]); + $this->adapter->wipeStorage(); + } +} diff --git a/tests/Test/Prometheus/RedisTxn/SummaryWithPrefixTest.php b/tests/Test/Prometheus/RedisTxn/SummaryWithPrefixTest.php new file mode 100644 index 00000000..9a5ec9ef --- /dev/null +++ b/tests/Test/Prometheus/RedisTxn/SummaryWithPrefixTest.php @@ -0,0 +1,26 @@ +connect(REDIS_HOST); + + $connection->setOption(\Redis::OPT_PREFIX, 'prefix:'); + + $this->adapter = RedisTxn::fromExistingConnection($connection); + $this->adapter->wipeStorage(); + } +} diff --git a/tests/Test/Prometheus/Storage/RedisTxnTest.php b/tests/Test/Prometheus/Storage/RedisTxnTest.php new file mode 100644 index 00000000..b1e929a0 --- /dev/null +++ b/tests/Test/Prometheus/Storage/RedisTxnTest.php @@ -0,0 +1,150 @@ +redisConnection = new \Redis(); + $this->redisConnection->connect(REDIS_HOST); + $this->redisConnection->flushAll(); + } + + /** + * @test + */ + public function itShouldThrowAnExceptionOnConnectionFailure(): void + { + $redis = new RedisTxn(['host' => '/dev/null']); + + $this->expectException(StorageException::class); + $this->expectExceptionMessage("Can't connect to Redis server"); + + $redis->collect(); + $redis->wipeStorage(); + } + + /** + * @test + */ + public function itShouldThrowExceptionWhenInjectedRedisIsNotConnected(): void + { + $connection = new \Redis(); + + self::expectException(StorageException::class); + self::expectExceptionMessage('Connection to Redis server not established'); + + RedisTxn::fromExistingConnection($connection); + } + + /** + * @test + */ + public function itShouldNotClearWholeRedisOnFlush(): void + { + $this->redisConnection->set('not a prometheus metric key', 'data'); + + $redis = RedisTxn::fromExistingConnection($this->redisConnection); + $registry = new CollectorRegistry($redis); + + // ensure flush is working correctly on large number of metrics + 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); + } + $redis->wipeStorage(); + + $redisKeys = $this->redisConnection->keys("*"); + self::assertThat( + $redisKeys, + self::equalTo([ + 'not a prometheus metric key' + ]) + ); + } + + /** + * @test + */ + public function itShouldOnlyConnectOnceOnSubsequentCalls(): void + { + $clientId = $this->redisConnection->rawCommand('client', 'id'); + $expectedClientId = 'id=' . ($clientId + 1) . ' '; + $notExpectedClientId = 'id=' . ($clientId + 2) . ' '; + + $redis = new RedisTxn(['host' => REDIS_HOST]); + + $redis->collect(); + + self::assertStringContainsString( + $expectedClientId, + $this->redisConnection->rawCommand('client', 'list') + ); + self::assertStringNotContainsString( + $notExpectedClientId, + $this->redisConnection->rawCommand('client', 'list') + ); + + $redis->collect(); + + self::assertStringContainsString( + $expectedClientId, + $this->redisConnection->rawCommand('client', 'list') + ); + self::assertStringNotContainsString( + $notExpectedClientId, + $this->redisConnection->rawCommand('client', 'list') + ); + } + + /** + * @test + */ + public function itShouldOnlyConnectOnceForInjectedRedisConnectionOnSubsequentCalls(): void + { + $clientId = $this->redisConnection->rawCommand('client', 'id'); + $expectedClientId = 'id=' . $clientId . ' '; + $notExpectedClientId = 'id=' . ($clientId + 1) . ' '; + + $redis = RedisTxn::fromExistingConnection($this->redisConnection); + + $redis->collect(); + + self::assertStringContainsString( + $expectedClientId, + $this->redisConnection->rawCommand('client', 'list') + ); + self::assertStringNotContainsString( + $notExpectedClientId, + $this->redisConnection->rawCommand('client', 'list') + ); + + $redis->collect(); + + self::assertStringContainsString( + $expectedClientId, + $this->redisConnection->rawCommand('client', 'list') + ); + self::assertStringNotContainsString( + $notExpectedClientId, + $this->redisConnection->rawCommand('client', 'list') + ); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 12456d29..7f9207f9 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -15,5 +15,6 @@ $loader = require $autoload; $loader->add('Test\\Prometheus', __DIR__); $loader->add('Test\\Performance', __DIR__); +$loader->add('Test\\Benchmark', __DIR__); define('REDIS_HOST', isset($_ENV['REDIS_HOST']) ? $_ENV['REDIS_HOST'] : '127.0.0.1');