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');