├── src └── Prometheus │ ├── Exception │ ├── StorageException.php │ ├── MetricsRegistrationException.php │ ├── MetricNotFoundException.php │ └── MetricJsonException.php │ ├── RendererInterface.php │ ├── Math.php │ ├── Storage │ ├── Adapter.php │ ├── InMemory.php │ ├── APC.php │ ├── RedisNg.php │ ├── Redis.php │ ├── PDO.php │ └── APCng.php │ ├── Counter.php │ ├── Sample.php │ ├── MetricFamilySamples.php │ ├── Gauge.php │ ├── Collector.php │ ├── RenderTextFormat.php │ ├── Summary.php │ ├── Histogram.php │ ├── RegistryInterface.php │ └── CollectorRegistry.php ├── phpstan.neon.dist ├── composer.json ├── README.md ├── README.APCng.md └── LICENSE /src/Prometheus/Exception/StorageException.php: -------------------------------------------------------------------------------- 1 | metricName = $metricName; 22 | } 23 | 24 | public function getMetricName(): ?string 25 | { 26 | return $this->metricName; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Prometheus/Storage/Adapter.php: -------------------------------------------------------------------------------- 1 | incBy(1, $labels); 27 | } 28 | 29 | /** 30 | * @param int|float $count e.g. 2 31 | * @param string[] $labels e.g. ['status', 'opcode'] 32 | */ 33 | public function incBy($count, array $labels = []): void 34 | { 35 | $this->assertLabelsAreDefinedCorrectly($labels); 36 | 37 | $this->storageAdapter->updateCounter( 38 | [ 39 | 'name' => $this->getName(), 40 | 'help' => $this->getHelp(), 41 | 'type' => $this->getType(), 42 | 'labelNames' => $this->getLabelNames(), 43 | 'labelValues' => $labels, 44 | 'value' => $count, 45 | 'command' => is_float($count) ? Adapter::COMMAND_INCREMENT_FLOAT : Adapter::COMMAND_INCREMENT_INTEGER, 46 | ] 47 | ); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Prometheus/Sample.php: -------------------------------------------------------------------------------- 1 | name = $data['name']; 38 | $this->labelNames = (array) $data['labelNames']; 39 | $this->labelValues = (array) $data['labelValues']; 40 | $this->value = $data['value']; 41 | } 42 | 43 | /** 44 | * @return string 45 | */ 46 | public function getName(): string 47 | { 48 | return $this->name; 49 | } 50 | 51 | /** 52 | * @return string[] 53 | */ 54 | public function getLabelNames(): array 55 | { 56 | return $this->labelNames; 57 | } 58 | 59 | /** 60 | * @return mixed[] 61 | */ 62 | public function getLabelValues(): array 63 | { 64 | return $this->labelValues; 65 | } 66 | 67 | /** 68 | * @return string 69 | */ 70 | public function getValue(): string 71 | { 72 | if (is_float($this->value) && is_infinite($this->value)) { 73 | return $this->value > 0 ? '+Inf' : '-Inf'; 74 | } 75 | return (string) $this->value; 76 | } 77 | 78 | /** 79 | * @return bool 80 | */ 81 | public function hasLabelNames(): bool 82 | { 83 | return $this->labelNames !== []; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "promphp/prometheus_client_php", 3 | "description": "Prometheus instrumentation library for PHP applications.", 4 | "type": "library", 5 | "license": "Apache-2.0", 6 | "authors": [ 7 | { 8 | "name": "Lukas Kämmerling", 9 | "email": "kontakt@lukas-kaemmerling.de" 10 | } 11 | ], 12 | "replace": { 13 | "jimdo/prometheus_client_php": "*", 14 | "endclothing/prometheus_client_php": "*", 15 | "lkaemmerling/prometheus_client_php": "*" 16 | }, 17 | "require": { 18 | "php": "^7.4|^8.0", 19 | "ext-json": "*" 20 | }, 21 | "require-dev": { 22 | "guzzlehttp/guzzle": "^6.3|^7.0", 23 | "phpstan/extension-installer": "^1.0", 24 | "phpstan/phpstan": "^1.5.4", 25 | "phpstan/phpstan-phpunit": "^1.1.0", 26 | "phpstan/phpstan-strict-rules": "^1.1.0", 27 | "phpunit/phpunit": "^9.4", 28 | "squizlabs/php_codesniffer": "^3.6", 29 | "symfony/polyfill-apcu": "^1.6" 30 | }, 31 | "suggest": { 32 | "ext-redis": "Required if using Redis.", 33 | "ext-apc": "Required if using APCu.", 34 | "ext-pdo": "Required if using PDO.", 35 | "promphp/prometheus_push_gateway_php": "An easy client for using Prometheus PushGateway.", 36 | "symfony/polyfill-apcu": "Required if you use APCu on PHP8.0+" 37 | }, 38 | "autoload": { 39 | "psr-4": { 40 | "Prometheus\\": "src/Prometheus/" 41 | } 42 | }, 43 | "autoload-dev": { 44 | "psr-0": { 45 | "Test\\Prometheus\\": "tests/" 46 | } 47 | }, 48 | "extra": { 49 | "branch-alias": { 50 | "dev-master": "1.0-dev" 51 | } 52 | }, 53 | "config": { 54 | "sort-packages": true, 55 | "allow-plugins": { 56 | "phpstan/extension-installer": true 57 | } 58 | }, 59 | "prefer-stable": true 60 | } 61 | -------------------------------------------------------------------------------- /src/Prometheus/MetricFamilySamples.php: -------------------------------------------------------------------------------- 1 | name = $data['name']; 40 | $this->type = $data['type']; 41 | $this->help = $data['help']; 42 | $this->labelNames = $data['labelNames']; 43 | if (isset($data['samples'])) { 44 | foreach ($data['samples'] as $sampleData) { 45 | $this->samples[] = new Sample($sampleData); 46 | } 47 | } 48 | } 49 | 50 | /** 51 | * @return string 52 | */ 53 | public function getName(): string 54 | { 55 | return $this->name; 56 | } 57 | 58 | /** 59 | * @return string 60 | */ 61 | public function getType(): string 62 | { 63 | return $this->type; 64 | } 65 | 66 | /** 67 | * @return string 68 | */ 69 | public function getHelp(): string 70 | { 71 | return $this->help; 72 | } 73 | 74 | /** 75 | * @return Sample[] 76 | */ 77 | public function getSamples(): array 78 | { 79 | return $this->samples; 80 | } 81 | 82 | /** 83 | * @return string[] 84 | */ 85 | public function getLabelNames(): array 86 | { 87 | return $this->labelNames; 88 | } 89 | 90 | /** 91 | * @return bool 92 | */ 93 | public function hasLabelNames(): bool 94 | { 95 | return $this->labelNames !== []; 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/Prometheus/Gauge.php: -------------------------------------------------------------------------------- 1 | assertLabelsAreDefinedCorrectly($labels); 20 | 21 | $this->storageAdapter->updateGauge( 22 | [ 23 | 'name' => $this->getName(), 24 | 'help' => $this->getHelp(), 25 | 'type' => $this->getType(), 26 | 'labelNames' => $this->getLabelNames(), 27 | 'labelValues' => $labels, 28 | 'value' => $value, 29 | 'command' => Adapter::COMMAND_SET, 30 | ] 31 | ); 32 | } 33 | 34 | /** 35 | * @return string 36 | */ 37 | public function getType(): string 38 | { 39 | return self::TYPE; 40 | } 41 | 42 | /** 43 | * @param string[] $labels 44 | */ 45 | public function inc(array $labels = []): void 46 | { 47 | $this->incBy(1, $labels); 48 | } 49 | 50 | /** 51 | * @param int|float $value 52 | * @param string[] $labels 53 | */ 54 | public function incBy($value, array $labels = []): void 55 | { 56 | $this->assertLabelsAreDefinedCorrectly($labels); 57 | 58 | $this->storageAdapter->updateGauge( 59 | [ 60 | 'name' => $this->getName(), 61 | 'help' => $this->getHelp(), 62 | 'type' => $this->getType(), 63 | 'labelNames' => $this->getLabelNames(), 64 | 'labelValues' => $labels, 65 | 'value' => $value, 66 | 'command' => Adapter::COMMAND_INCREMENT_FLOAT, 67 | ] 68 | ); 69 | } 70 | 71 | /** 72 | * @param string[] $labels 73 | */ 74 | public function dec(array $labels = []): void 75 | { 76 | $this->decBy(1, $labels); 77 | } 78 | 79 | /** 80 | * @param int|float $value 81 | * @param string[] $labels 82 | */ 83 | public function decBy($value, array $labels = []): void 84 | { 85 | $this->incBy(-$value, $labels); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/Prometheus/Collector.php: -------------------------------------------------------------------------------- 1 | storageAdapter = $storageAdapter; 45 | $metricName = ($namespace !== '' ? $namespace . '_' : '') . $name; 46 | self::assertValidMetricName($metricName); 47 | $this->name = $metricName; 48 | $this->help = $help; 49 | foreach ($labels as $label) { 50 | self::assertValidLabel($label); 51 | } 52 | $this->labels = $labels; 53 | } 54 | 55 | /** 56 | * @return string 57 | */ 58 | abstract public function getType(): string; 59 | 60 | /** 61 | * @return string 62 | */ 63 | public function getName(): string 64 | { 65 | return $this->name; 66 | } 67 | 68 | /** 69 | * @return string[] 70 | */ 71 | public function getLabelNames(): array 72 | { 73 | return $this->labels; 74 | } 75 | 76 | /** 77 | * @return string 78 | */ 79 | public function getHelp(): string 80 | { 81 | return $this->help; 82 | } 83 | 84 | /** 85 | * @return string 86 | */ 87 | public function getKey(): string 88 | { 89 | return sha1($this->getName() . serialize($this->getLabelNames())); 90 | } 91 | 92 | /** 93 | * @param string[] $labels 94 | */ 95 | protected function assertLabelsAreDefinedCorrectly(array $labels): void 96 | { 97 | if (count($labels) !== count($this->labels)) { 98 | throw new InvalidArgumentException(sprintf('Labels are not defined correctly: %s', print_r($labels, true))); 99 | } 100 | } 101 | 102 | /** 103 | * @param string $metricName 104 | */ 105 | public static function assertValidMetricName(string $metricName): void 106 | { 107 | if (preg_match(self::RE_METRIC_NAME, $metricName) !== 1) { 108 | throw new InvalidArgumentException("Invalid metric name: '" . $metricName . "'"); 109 | } 110 | } 111 | 112 | /** 113 | * @param string $label 114 | */ 115 | public static function assertValidLabel(string $label): void 116 | { 117 | if (preg_match(self::RE_LABEL_NAME, $label) !== 1) { 118 | throw new InvalidArgumentException("Invalid label name: '" . $label . "'"); 119 | } else if (strpos($label, "__") === 0) { 120 | throw new InvalidArgumentException("Can't used a reserved label name: '" . $label . "'"); 121 | } 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/Prometheus/RenderTextFormat.php: -------------------------------------------------------------------------------- 1 | getName(), $b->getName()); 23 | }); 24 | 25 | $lines = []; 26 | foreach ($metrics as $metric) { 27 | $lines[] = "# HELP " . $metric->getName() . " {$metric->getHelp()}"; 28 | $lines[] = "# TYPE " . $metric->getName() . " {$metric->getType()}"; 29 | foreach ($metric->getSamples() as $sample) { 30 | try { 31 | $lines[] = $this->renderSample($metric, $sample); 32 | } catch (Throwable $e) { 33 | // Redis and RedisNg allow samples with mismatching labels to be stored, which could cause ValueError 34 | // to be thrown when rendering. If this happens, users can decide whether to ignore the error or not. 35 | // These errors will normally disappear after the storage is flushed. 36 | if (!$silent) { 37 | throw $e; 38 | } 39 | 40 | $lines[] = "# Error: {$e->getMessage()}"; 41 | $lines[] = "# Labels: " . json_encode(array_merge($metric->getLabelNames(), $sample->getLabelNames())); 42 | $lines[] = "# Values: " . json_encode(array_merge($sample->getLabelValues())); 43 | } 44 | } 45 | } 46 | return implode("\n", $lines) . "\n"; 47 | } 48 | 49 | /** 50 | * @param MetricFamilySamples $metric 51 | * @param Sample $sample 52 | * @return string 53 | */ 54 | private function renderSample(MetricFamilySamples $metric, Sample $sample): string 55 | { 56 | $labelNames = $metric->getLabelNames(); 57 | if ($metric->hasLabelNames() || $sample->hasLabelNames()) { 58 | $escapedLabels = $this->escapeAllLabels($metric, $labelNames, $sample); 59 | return $sample->getName() . '{' . implode(',', $escapedLabels) . '} ' . $sample->getValue(); 60 | } 61 | return $sample->getName() . ' ' . $sample->getValue(); 62 | } 63 | 64 | /** 65 | * @param string $v 66 | * @return string 67 | */ 68 | private function escapeLabelValue(string $v): string 69 | { 70 | return str_replace(["\\", "\n", "\""], ["\\\\", "\\n", "\\\""], $v); 71 | } 72 | 73 | /** 74 | * @param MetricFamilySamples $metric 75 | * @param string[] $labelNames 76 | * @param Sample $sample 77 | * 78 | * @return string[] 79 | */ 80 | private function escapeAllLabels(MetricFamilySamples $metric, array $labelNames, Sample $sample): array 81 | { 82 | $escapedLabels = []; 83 | 84 | $labels = array_combine(array_merge($labelNames, $sample->getLabelNames()), $sample->getLabelValues()); 85 | 86 | if ($labels === false) { 87 | throw new RuntimeException('Unable to combine labels for metric named ' . $metric->getName()); 88 | } 89 | 90 | foreach ($labels as $labelName => $labelValue) { 91 | $escapedLabels[] = $labelName . '="' . $this->escapeLabelValue((string)$labelValue) . '"'; 92 | } 93 | 94 | return $escapedLabels; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/Prometheus/Summary.php: -------------------------------------------------------------------------------- 1 | = $quantiles[$i + 1]) { 55 | throw new InvalidArgumentException( 56 | "Summary quantiles must be in increasing order: " . 57 | $quantiles[$i] . " >= " . $quantiles[$i + 1] 58 | ); 59 | } 60 | } 61 | 62 | foreach ($quantiles as $quantile) { 63 | if ($quantile <= 0 || $quantile >= 1) { 64 | throw new InvalidArgumentException("Quantile $quantile invalid: Expected number between 0 and 1."); 65 | } 66 | } 67 | 68 | if ($maxAgeSeconds <= 0) { 69 | throw new InvalidArgumentException("maxAgeSeconds $maxAgeSeconds invalid: Expected number greater than 0."); 70 | } 71 | 72 | if (count(array_intersect(self::RESERVED_LABELS, $labels)) > 0) { 73 | throw new InvalidArgumentException("Summary cannot have a label named " . implode(', ', self::RESERVED_LABELS) . "."); 74 | } 75 | $this->quantiles = $quantiles; 76 | $this->maxAgeSeconds = $maxAgeSeconds; 77 | } 78 | 79 | /** 80 | * List of default quantiles suitable for typical web application latency metrics 81 | * 82 | * @return float[] 83 | */ 84 | public static function getDefaultQuantiles(): array 85 | { 86 | return [ 87 | 0.01, 88 | 0.05, 89 | 0.5, 90 | 0.95, 91 | 0.99, 92 | ]; 93 | } 94 | 95 | /** 96 | * @param float $value e.g. 123.0 97 | * @param string[] $labels e.g. ['status', 'opcode'] 98 | */ 99 | public function observe(float $value, array $labels = []): void 100 | { 101 | $this->assertLabelsAreDefinedCorrectly($labels); 102 | 103 | $this->storageAdapter->updateSummary( 104 | [ 105 | 'value' => $value, 106 | 'name' => $this->getName(), 107 | 'help' => $this->getHelp(), 108 | 'type' => $this->getType(), 109 | 'labelNames' => $this->getLabelNames(), 110 | 'labelValues' => $labels, 111 | 'maxAgeSeconds' => $this->maxAgeSeconds, 112 | 'quantiles' => $this->quantiles, 113 | ] 114 | ); 115 | } 116 | 117 | /** 118 | * @return string 119 | */ 120 | public function getType(): string 121 | { 122 | return self::TYPE; 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/Prometheus/Histogram.php: -------------------------------------------------------------------------------- 1 | = $buckets[$i + 1]) { 47 | throw new InvalidArgumentException( 48 | "Histogram buckets must be in increasing order: " . 49 | $buckets[$i] . " >= " . $buckets[$i + 1] 50 | ); 51 | } 52 | } 53 | if (in_array('le', $labels, true)) { 54 | throw new \InvalidArgumentException("Histogram cannot have a label named 'le'."); 55 | } 56 | $this->buckets = $buckets; 57 | } 58 | 59 | /** 60 | * List of default buckets suitable for typical web application latency metrics 61 | * 62 | * @return float[] 63 | */ 64 | public static function getDefaultBuckets(): array 65 | { 66 | return [ 67 | 0.005, 68 | 0.01, 69 | 0.025, 70 | 0.05, 71 | 0.075, 72 | 0.1, 73 | 0.25, 74 | 0.5, 75 | 0.75, 76 | 1.0, 77 | 2.5, 78 | 5.0, 79 | 7.5, 80 | 10.0, 81 | ]; 82 | } 83 | 84 | /** 85 | * @param float $start 86 | * @param float $growthFactor 87 | * @param int $numberOfBuckets 88 | * 89 | * @return float[] 90 | */ 91 | public static function exponentialBuckets(float $start, float $growthFactor, int $numberOfBuckets): array 92 | { 93 | if ($numberOfBuckets < 1) { 94 | throw new InvalidArgumentException('Number of buckets must be a positive integer'); 95 | } 96 | 97 | if ($start <= 0) { 98 | throw new InvalidArgumentException('The starting position of a set of buckets must be a positive integer'); 99 | } 100 | 101 | if ($growthFactor <= 1) { 102 | throw new InvalidArgumentException('The growth factor must greater than 1'); 103 | } 104 | 105 | $buckets = []; 106 | 107 | for ($i = 0; $i < $numberOfBuckets; $i++) { 108 | $buckets[$i] = $start; 109 | $start *= $growthFactor; 110 | } 111 | 112 | return $buckets; 113 | } 114 | 115 | /** 116 | * @param float $value e.g. 123.0 117 | * @param string[] $labels e.g. ['status', 'opcode'] 118 | */ 119 | public function observe(float $value, array $labels = []): void 120 | { 121 | $this->assertLabelsAreDefinedCorrectly($labels); 122 | 123 | $this->storageAdapter->updateHistogram( 124 | [ 125 | 'value' => $value, 126 | 'name' => $this->getName(), 127 | 'help' => $this->getHelp(), 128 | 'type' => $this->getType(), 129 | 'labelNames' => $this->getLabelNames(), 130 | 'labelValues' => $labels, 131 | 'buckets' => $this->buckets, 132 | ] 133 | ); 134 | } 135 | 136 | /** 137 | * @return string 138 | */ 139 | public function getType(): string 140 | { 141 | return self::TYPE; 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/Prometheus/RegistryInterface.php: -------------------------------------------------------------------------------- 1 | getOrRegisterCounter('', 'some_quick_counter', 'just a quick measurement') 29 | ->inc(); 30 | ``` 31 | 32 | Write some enhanced metrics: 33 | ```php 34 | $registry = \Prometheus\CollectorRegistry::getDefault(); 35 | 36 | $counter = $registry->getOrRegisterCounter('test', 'some_counter', 'it increases', ['type']); 37 | $counter->incBy(3, ['blue']); 38 | 39 | $gauge = $registry->getOrRegisterGauge('test', 'some_gauge', 'it sets', ['type']); 40 | $gauge->set(2.5, ['blue']); 41 | 42 | $histogram = $registry->getOrRegisterHistogram('test', 'some_histogram', 'it observes', ['type'], [0.1, 1, 2, 3.5, 4, 5, 6, 7, 8, 9]); 43 | $histogram->observe(3.5, ['blue']); 44 | 45 | $summary = $registry->getOrRegisterSummary('test', 'some_summary', 'it observes a sliding window', ['type'], 84600, [0.01, 0.05, 0.5, 0.95, 0.99]); 46 | $summary->observe(5, ['blue']); 47 | ``` 48 | 49 | Manually register and retrieve metrics (these steps are combined in the `getOrRegister...` methods): 50 | ```php 51 | $registry = \Prometheus\CollectorRegistry::getDefault(); 52 | 53 | $counterA = $registry->registerCounter('test', 'some_counter', 'it increases', ['type']); 54 | $counterA->incBy(3, ['blue']); 55 | 56 | // once a metric is registered, it can be retrieved using e.g. getCounter: 57 | $counterB = $registry->getCounter('test', 'some_counter') 58 | $counterB->incBy(2, ['red']); 59 | ``` 60 | 61 | Expose the metrics: 62 | ```php 63 | $registry = \Prometheus\CollectorRegistry::getDefault(); 64 | 65 | $renderer = new RenderTextFormat(); 66 | $result = $renderer->render($registry->getMetricFamilySamples()); 67 | 68 | header('Content-type: ' . RenderTextFormat::MIME_TYPE); 69 | echo $result; 70 | ``` 71 | 72 | Change the Redis options (the example shows the defaults): 73 | ```php 74 | \Prometheus\Storage\Redis::setDefaultOptions( 75 | [ 76 | 'host' => '127.0.0.1', 77 | 'port' => 6379, 78 | 'password' => null, 79 | 'timeout' => 0.1, // in seconds 80 | 'read_timeout' => '10', // in seconds 81 | 'persistent_connections' => false 82 | ] 83 | ); 84 | ``` 85 | 86 | Using the InMemory storage: 87 | ```php 88 | $registry = new CollectorRegistry(new InMemory()); 89 | 90 | $counter = $registry->registerCounter('test', 'some_counter', 'it increases', ['type']); 91 | $counter->incBy(3, ['blue']); 92 | 93 | $renderer = new RenderTextFormat(); 94 | $result = $renderer->render($registry->getMetricFamilySamples()); 95 | ``` 96 | 97 | Using the APC or APCng storage: 98 | ```php 99 | $registry = new CollectorRegistry(new APCng()); 100 | or 101 | $registry = new CollectorRegistry(new APC()); 102 | ``` 103 | (see the `README.APCng.md` file for more details) 104 | 105 | Using the PDO storage: 106 | ```php 107 | $registry = new CollectorRegistry(new \PDO('mysql:host=localhost;dbname=prometheus', 'username', 'password')); 108 | or 109 | $registry = new CollectorRegistry(new \PDO('sqlite::memory:')); 110 | ``` 111 | 112 | ### Advanced Usage 113 | 114 | #### Advanced Histogram Usage 115 | On passing an empty array for the bucket parameter on instantiation, a set of default buckets will be used instead. 116 | Whilst this is a good base for a typical web application, there is named constructor to assist in the generation of 117 | exponential / geometric buckets. 118 | 119 | Eg: 120 | ``` 121 | Histogram::exponentialBuckets(0.05, 1.5, 10); 122 | ``` 123 | 124 | This will start your buckets with a value of 0.05, grow them by a factor of 1.5 per bucket across a set of 10 buckets. 125 | 126 | Also look at the [examples](examples). 127 | 128 | #### PushGateway Support 129 | As of Version 2.0.0 this library doesn't support the Prometheus PushGateway anymore because we want to have this package as small als possible. If you need Prometheus PushGateway support, you could use the companion library: https://github.com/PromPHP/prometheus_push_gateway_php 130 | ``` 131 | composer require promphp/prometheus_push_gateway_php 132 | ``` 133 | 134 | ## Development 135 | 136 | ### Dependencies 137 | 138 | * PHP ^7.2 | ^8.0 139 | * PHP Redis extension 140 | * PHP APCu extension 141 | * [Composer](https://getcomposer.org/doc/00-intro.md#installation-linux-unix-osx) 142 | * Redis 143 | 144 | Start a Redis instance: 145 | ``` 146 | docker-compose up redis 147 | ``` 148 | 149 | Run the tests: 150 | ``` 151 | composer install 152 | 153 | # when Redis is not listening on localhost: 154 | # export REDIS_HOST=192.168.59.100 155 | ./vendor/bin/phpunit 156 | ``` 157 | 158 | ## Black box testing 159 | 160 | Just start the nginx, fpm & Redis setup with docker-compose: 161 | ``` 162 | docker-compose up 163 | ``` 164 | Pick the adapter you want to test. 165 | 166 | ``` 167 | docker-compose run phpunit env ADAPTER=apc vendor/bin/phpunit tests/Test/ 168 | docker-compose run phpunit env ADAPTER=apcng vendor/bin/phpunit tests/Test/ 169 | docker-compose run phpunit env ADAPTER=redis vendor/bin/phpunit tests/Test/ 170 | ``` 171 | 172 | ## Performance testing 173 | 174 | This currently tests the APC and APCng adapters head-to-head and reports if the APCng adapter is slower for any actions. 175 | ``` 176 | phpunit vendor/bin/phpunit tests/Test/ --group Performance 177 | ``` 178 | 179 | The test can also be run inside a container. 180 | ``` 181 | docker-compose up 182 | docker-compose run phpunit vendor/bin/phpunit tests/Test/ --group Performance 183 | ``` 184 | -------------------------------------------------------------------------------- /README.APCng.md: -------------------------------------------------------------------------------- 1 | 2 | ## Using either the APC or APCng storage engine: 3 | ```php 4 | $registry = new CollectorRegistry(new APCng()); 5 | // or... 6 | $registry = new CollectorRegistry(new APC()); 7 | 8 | // then... 9 | $counter = $registry->registerCounter('test', 'some_counter', 'it increases', ['type']); 10 | $counter->incBy(3, ['blue']); 11 | 12 | $renderer = new RenderTextFormat(); 13 | $result = $renderer->render($registry->getMetricFamilySamples()); 14 | ``` 15 | 16 | ## Performance comparions vs the original APC engine 17 | The difference between `APC` and `APCng` is that `APCng` is re-designed for servers which have millions of entries in their APCu cache and/or receive hundreds to thousands of requests per second. Several key data structures in the original `APC` engine require repeated scans of the entire keyspace, which is far too slow and CPU-intensive for a busy server when APCu contains more than a few thousand keys. `APCng` avoids these scans for the most part, the trade-off being creation of new metrics is slightly slower than it is with the `APC` engine, while other operations are approximately the same speed, and collecting metrics to report is 1-2 orders of magnitude faster when APCu contains 10,000+ keys. 18 | In general, if your APCu cache contains over 1000 keys, consider using the `APCng` engine. 19 | In my testing, on a system with 100,000 keys in APCu and 500 Prometheus metrics being tracked, rendering all metrics took 35.7 seconds with the `APC` engine, but only 0.6 seconds with the `APCng` engine. Even with a tiny cache (50 metrics / 1000 APC keys), `APCng` is over 2.5x faster generating reports. As the number of APCu keys and/or number of tracked metrics increases, `APCng`'s speed advantage grows. 20 | The following table compares `APC` and `APCng` processing time for a series of operations, including creating each metric, incrementing each metric, the wipeStorage() call, and the collect() call, which is used to render the page that Prometheus scrapes. Lower numbers are better! Increment is the most frequently used operation, followed by collect, which happens every time Prometheus scrapes the server. Create and wipe are relatively infrequent operations. 21 | 22 | | Configuration | Create (ms) | Increment (ms) | WipeStorage (ms) | Collect (ms) | Collect speedup over APC | 23 | |--------------------------------|------------:|---------------:|-----------------:|-------------:|-------------------------:| 24 | | APC 1k keys / 50 metrics | n/t | n/t | n/t | 29.0 | - | 25 | | APC 10k keys / 50 metrics | 9.2 | 0.7 | 1.1 | 131.9 | - | 26 | | APC 100k keys / 50 metrics | 9.3 | 1.3 | 11.9 | 3474.1 | - | 27 | | APC 1M keys / 50 metrics | 12.7 | 1.4 | 19.2 | 4805.8 | - | 28 | | APC 1k keys / 500 metrics | n/t | n/t | n/t | 806.5 | - | 29 | | APC 10k keys / 500 metrics | 26.7 | 9.3 | 4.2 | 1770.9 | - | 30 | | APC 100k keys / 500 metrics | 44.8 | 13.1 | 16.6 | 35758.3 | - | 31 | | APC 1M keys / 500 metrics | 39.9 | 25.9 | 22.9 | 46489.1 | - | 32 | | APC 1k keys / 2500 metrics | n/t | n/t | n/t | n/t | n/t | 33 | | APC 10k keys / 2500 metrics | 196.7 | 95.1 | 17.6 | 24689.5 | - | 34 | | APC 100k keys / 2500 metrics | 182.6 | 82.0 | 34.4 | 216526.5 | - | 35 | | APC 1M keys / 2500 metrics | 172.7 | 93.3 | 38.3 | 270596.3 | - | 36 | | | | | | | | 37 | | APCng 1k keys / 50 metrics | n/t | n/t | n/t | 11.1 | 2.6x | 38 | | APCng 10k keys / 50 metrics | 8.6 | 0.6 | 1.3 | 15.2 | 8.6x | 39 | | APCng 100k keys / 50 metrics | 10.1 | 1.0 | 11.7 | 69.7 | 49.8x | 40 | | APCng 1M keys / 50 metrics | 10.4 | 1.3 | 17.3 | 100.4 | 47.9x | 41 | | APCng 1k keys / 500 metrics | n/t | n/t | n/t | 108.3 | 7.4x | 42 | | APCng 10k keys / 500 metrics | 25.2 | 7.2 | 5.9 | 118.6 | 14.9x | 43 | | APCng 100k keys / 500 metrics | 55.0 | 12.3 | 18.6 | 603.9 | 59.2x | 44 | | APCng 1M keys / 500 metrics | 39.9 | 14.1 | 22.9 | 904.2 | 51.4x | 45 | | APCng 1k keys / 2500 metrics | n/t | n/t | n/t | n/t | n/t | 46 | | APCng 10k keys / 2500 metrics | 181.3 | 80.3 | 17.9 | 978.8 | 25.2x | 47 | | APCng 100k keys / 2500 metrics | 274.7 | 84.0 | 34.6 | 4092.4 | 52.9x | 48 | | APCng 1M keys / 2500 metrics | 187.8 | 87.7 | 40.7 | 5396.4 | 50.1x | 49 | 50 | The suite of engine-performance tests can be automatically executed by running `docker-compose run phpunit vendor/bin/phpunit tests/Test --group Performance`. This set of tests in not part of the default unit tests which get run, since they take quite a while to complete. Any significant change to the APC or APCng code should be followed by a performance-test run to quantify the before/after impact of the change. Currently this is triggered manually, but it could be automated as part of a Github workflow. 51 | 52 | ## Known limitations 53 | One thing to note, the current implementation of the `Summary` observer should be avoided on busy servers. This is true for both the `APC` and `APCng` storage engines. The reason is simple: each observation (call to increment, set, etc) results in a new item being written to APCu. The default TTL for these items is 600 seconds. On a busy server that might be getting 1000 requests/second, that results in 600,000 APC cache items continually churning in and out of existence. This can put some interesting pressure on APCu, which could lead to rapid fragmentation of APCu memory. Definitely test before deploying in production. 54 | 55 | For a future project, the existing algorithm that stores one new key per observation could be replaced with a sampling-style algorithm (`t-digest`) that only stores a handful of keys, and updates their weights for each request. This is considerably less likely to fragment APCu memory over time. 56 | 57 | Neither the `APC` or `APCng` engine performs particularly well once more than ~1000 Prometheus metrics are being tracked. Of course, "good performance" is subjective, and partially based on how often you scrape for data. If you only scrape every five minutes, then spending 4 seconds waiting for collect() might be perfectly acceptable. On the other hand, if you scrape every 2 seconds, you'll want collect() to be as fast as possible. 58 | 59 | ## How it works under the covers 60 | Without going into excruciating detail (you can read the source for that!), the general idea is to remove calls to APCUIterator() whenever possible. In particular, nested calls to APCUIterator are horrible, since APCUIterator scales O(n) where n is the number of keys in APCu. This means the busier your server is, the slower these calls will run. Summary is the worst: it has APCUIterator calls nested three deep, leading to O(n^3) running-time. 61 | 62 | The approach `APCng` takes is to keep a "metadata cache" which stores an array of all the metadata keys, so instead of doing a scan of APCu looking for all matching keys, we just need to retrieve one key, deserialize it (which turns out to be slow), and retrieve all the metadata keys listed in the array. Once we've done that, there is some fancy handwaving which is used to deterministically generate possible sub-keys for each metadata item, based on LabelNames, etc. Not all of these keys exist, but it's quicker to attempt to fetch them and fail, then it is to run another APCUIterator looking for a specific pattern. 63 | 64 | Summaries, as mentioned before, have a third nested APCUIterator in them, looking for all readings w/o expired TTLs that match a pattern. Again, slow. Instead, we store a "map", similar to the metadata cache, but this one is temporally-keyed: one key per second, which lists how many samples were collected in that second. Once this is done, an expensive APCUIterator match is no longer needed, as all possible keys can be deterministically generated and checked, by retrieving each key for the past 600 seconds (if it exists), extracting the sample-count from the key, and then generating all the APCu keys which would refer to each observed sample. 65 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2016 Jimdo GmbH 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /src/Prometheus/CollectorRegistry.php: -------------------------------------------------------------------------------- 1 | storageAdapter = $storageAdapter; 58 | if ($registerDefaultMetrics) { 59 | $this->registerDefaultMetrics(); 60 | } 61 | } 62 | 63 | /** 64 | * @return CollectorRegistry 65 | */ 66 | public static function getDefault(): CollectorRegistry 67 | { 68 | return self::$defaultRegistry ?? (self::$defaultRegistry = new self(new Redis())); /** @phpstan-ignore-line */ 69 | } 70 | 71 | /** 72 | * Removes all previously stored metrics from underlying storage adapter 73 | * 74 | * @return void 75 | */ 76 | public function wipeStorage(): void 77 | { 78 | $this->storageAdapter->wipeStorage(); 79 | } 80 | 81 | /** 82 | * @return MetricFamilySamples[] 83 | */ 84 | public function getMetricFamilySamples(bool $sortMetrics = true): array 85 | { 86 | return $this->storageAdapter->collect($sortMetrics); /** @phpstan-ignore-line */ 87 | } 88 | 89 | /** 90 | * @param string $namespace e.g. cms 91 | * @param string $name e.g. duration_seconds 92 | * @param string $help e.g. The duration something took in seconds. 93 | * @param string[] $labels e.g. ['controller', 'action'] 94 | * 95 | * @return Gauge 96 | * @throws MetricsRegistrationException 97 | */ 98 | public function registerGauge(string $namespace, string $name, string $help, array $labels = []): Gauge 99 | { 100 | $metricIdentifier = self::metricIdentifier($namespace, $name); 101 | if (isset($this->gauges[$metricIdentifier])) { 102 | throw new MetricsRegistrationException("Metric ` . $metricIdentifier . ` already registered"); 103 | } 104 | $this->gauges[$metricIdentifier] = new Gauge( 105 | $this->storageAdapter, 106 | $namespace, 107 | $name, 108 | $help, 109 | $labels 110 | ); 111 | return $this->gauges[$metricIdentifier]; 112 | } 113 | 114 | /** 115 | * @param string $namespace 116 | * @param string $name 117 | * 118 | * @return Gauge 119 | * @throws MetricNotFoundException 120 | */ 121 | public function getGauge(string $namespace, string $name): Gauge 122 | { 123 | $metricIdentifier = self::metricIdentifier($namespace, $name); 124 | if (!isset($this->gauges[$metricIdentifier])) { 125 | throw new MetricNotFoundException("Metric not found:" . $metricIdentifier); 126 | } 127 | return $this->gauges[$metricIdentifier]; 128 | } 129 | 130 | /** 131 | * @param string $namespace e.g. cms 132 | * @param string $name e.g. duration_seconds 133 | * @param string $help e.g. The duration something took in seconds. 134 | * @param string[] $labels e.g. ['controller', 'action'] 135 | * 136 | * @return Gauge 137 | * @throws MetricsRegistrationException 138 | */ 139 | public function getOrRegisterGauge(string $namespace, string $name, string $help, array $labels = []): Gauge 140 | { 141 | try { 142 | $gauge = $this->getGauge($namespace, $name); 143 | } catch (MetricNotFoundException $e) { 144 | $gauge = $this->registerGauge($namespace, $name, $help, $labels); 145 | } 146 | return $gauge; 147 | } 148 | 149 | /** 150 | * @param string $namespace e.g. cms 151 | * @param string $name e.g. requests 152 | * @param string $help e.g. The number of requests made. 153 | * @param string[] $labels e.g. ['controller', 'action'] 154 | * 155 | * @return Counter 156 | * @throws MetricsRegistrationException 157 | */ 158 | public function registerCounter(string $namespace, string $name, string $help, array $labels = []): Counter 159 | { 160 | $metricIdentifier = self::metricIdentifier($namespace, $name); 161 | if (isset($this->counters[$metricIdentifier])) { 162 | throw new MetricsRegistrationException("Metric ` . $metricIdentifier . ` already registered"); 163 | } 164 | $this->counters[$metricIdentifier] = new Counter( 165 | $this->storageAdapter, 166 | $namespace, 167 | $name, 168 | $help, 169 | $labels 170 | ); 171 | return $this->counters[self::metricIdentifier($namespace, $name)]; 172 | } 173 | 174 | /** 175 | * @param string $namespace 176 | * @param string $name 177 | * 178 | * @return Counter 179 | * @throws MetricNotFoundException 180 | */ 181 | public function getCounter(string $namespace, string $name): Counter 182 | { 183 | $metricIdentifier = self::metricIdentifier($namespace, $name); 184 | if (!isset($this->counters[$metricIdentifier])) { 185 | throw new MetricNotFoundException("Metric not found:" . $metricIdentifier); 186 | } 187 | return $this->counters[self::metricIdentifier($namespace, $name)]; 188 | } 189 | 190 | /** 191 | * @param string $namespace e.g. cms 192 | * @param string $name e.g. requests 193 | * @param string $help e.g. The number of requests made. 194 | * @param string[] $labels e.g. ['controller', 'action'] 195 | * 196 | * @return Counter 197 | * @throws MetricsRegistrationException 198 | */ 199 | public function getOrRegisterCounter(string $namespace, string $name, string $help, array $labels = []): Counter 200 | { 201 | try { 202 | $counter = $this->getCounter($namespace, $name); 203 | } catch (MetricNotFoundException $e) { 204 | $counter = $this->registerCounter($namespace, $name, $help, $labels); 205 | } 206 | return $counter; 207 | } 208 | 209 | /** 210 | * @param string $namespace e.g. cms 211 | * @param string $name e.g. duration_seconds 212 | * @param string $help e.g. A histogram of the duration in seconds. 213 | * @param string[] $labels e.g. ['controller', 'action'] 214 | * @param float[]|null $buckets e.g. [100.0, 200.0, 300.0] 215 | * 216 | * @return Histogram 217 | * @throws MetricsRegistrationException 218 | */ 219 | public function registerHistogram( 220 | string $namespace, 221 | string $name, 222 | string $help, 223 | array $labels = [], 224 | ?array $buckets = null 225 | ): Histogram { 226 | $metricIdentifier = self::metricIdentifier($namespace, $name); 227 | if (isset($this->histograms[$metricIdentifier])) { 228 | throw new MetricsRegistrationException("Metric ` . $metricIdentifier . ` already registered"); 229 | } 230 | $this->histograms[$metricIdentifier] = new Histogram( 231 | $this->storageAdapter, 232 | $namespace, 233 | $name, 234 | $help, 235 | $labels, 236 | $buckets 237 | ); 238 | return $this->histograms[$metricIdentifier]; 239 | } 240 | 241 | /** 242 | * @param string $namespace 243 | * @param string $name 244 | * 245 | * @return Histogram 246 | * @throws MetricNotFoundException 247 | */ 248 | public function getHistogram(string $namespace, string $name): Histogram 249 | { 250 | $metricIdentifier = self::metricIdentifier($namespace, $name); 251 | if (!isset($this->histograms[$metricIdentifier])) { 252 | throw new MetricNotFoundException("Metric not found:" . $metricIdentifier); 253 | } 254 | return $this->histograms[self::metricIdentifier($namespace, $name)]; 255 | } 256 | 257 | /** 258 | * @param string $namespace e.g. cms 259 | * @param string $name e.g. duration_seconds 260 | * @param string $help e.g. A histogram of the duration in seconds. 261 | * @param string[] $labels e.g. ['controller', 'action'] 262 | * @param float[]|null $buckets e.g. [100.0, 200.0, 300.0] 263 | * 264 | * @return Histogram 265 | * @throws MetricsRegistrationException 266 | */ 267 | public function getOrRegisterHistogram( 268 | string $namespace, 269 | string $name, 270 | string $help, 271 | array $labels = [], 272 | ?array $buckets = null 273 | ): Histogram { 274 | try { 275 | $histogram = $this->getHistogram($namespace, $name); 276 | } catch (MetricNotFoundException $e) { 277 | $histogram = $this->registerHistogram($namespace, $name, $help, $labels, $buckets); 278 | } 279 | return $histogram; 280 | } 281 | 282 | 283 | /** 284 | * @param string $namespace e.g. cms 285 | * @param string $name e.g. duration_seconds 286 | * @param string $help e.g. A summary of the duration in seconds. 287 | * @param string[] $labels e.g. ['controller', 'action'] 288 | * @param int $maxAgeSeconds e.g. 604800 289 | * @param float[]|null $quantiles e.g. [0.01, 0.5, 0.99] 290 | * 291 | * @return Summary 292 | * @throws MetricsRegistrationException 293 | */ 294 | public function registerSummary( 295 | string $namespace, 296 | string $name, 297 | string $help, 298 | array $labels = [], 299 | int $maxAgeSeconds = 600, 300 | ?array $quantiles = null 301 | ): Summary { 302 | $metricIdentifier = self::metricIdentifier($namespace, $name); 303 | if (isset($this->summaries[$metricIdentifier])) { 304 | throw new MetricsRegistrationException("Metric ` . $metricIdentifier . ` already registered"); 305 | } 306 | $this->summaries[$metricIdentifier] = new Summary( 307 | $this->storageAdapter, 308 | $namespace, 309 | $name, 310 | $help, 311 | $labels, 312 | $maxAgeSeconds, 313 | $quantiles 314 | ); 315 | return $this->summaries[$metricIdentifier]; 316 | } 317 | 318 | /** 319 | * @param string $namespace 320 | * @param string $name 321 | * 322 | * @return Summary 323 | * @throws MetricNotFoundException 324 | */ 325 | public function getSummary(string $namespace, string $name): Summary 326 | { 327 | $metricIdentifier = self::metricIdentifier($namespace, $name); 328 | if (!isset($this->summaries[$metricIdentifier])) { 329 | throw new MetricNotFoundException("Metric not found:" . $metricIdentifier); 330 | } 331 | return $this->summaries[self::metricIdentifier($namespace, $name)]; 332 | } 333 | 334 | /** 335 | * @param string $namespace e.g. cms 336 | * @param string $name e.g. duration_seconds 337 | * @param string $help e.g. A summary of the duration in seconds. 338 | * @param string[] $labels e.g. ['controller', 'action'] 339 | * @param int $maxAgeSeconds e.g. 604800 340 | * @param float[]|null $quantiles e.g. [0.01, 0.5, 0.99] 341 | * 342 | * @return Summary 343 | * @throws MetricsRegistrationException 344 | */ 345 | public function getOrRegisterSummary( 346 | string $namespace, 347 | string $name, 348 | string $help, 349 | array $labels = [], 350 | int $maxAgeSeconds = 600, 351 | ?array $quantiles = null 352 | ): Summary { 353 | try { 354 | $summary = $this->getSummary($namespace, $name); 355 | } catch (MetricNotFoundException $e) { 356 | $summary = $this->registerSummary($namespace, $name, $help, $labels, $maxAgeSeconds, $quantiles); 357 | } 358 | return $summary; 359 | } 360 | 361 | /** 362 | * @param string $namespace 363 | * @param string $name 364 | * 365 | * @return string 366 | */ 367 | private static function metricIdentifier(string $namespace, string $name): string 368 | { 369 | return $namespace . ":" . $name; 370 | } 371 | 372 | private function registerDefaultMetrics(): void 373 | { 374 | $this->defaultGauges['php_info_gauge'] = $this->getOrRegisterGauge( 375 | "", 376 | "php_info", 377 | "Information about the PHP environment.", 378 | ["version"] 379 | ); 380 | $this->defaultGauges['php_info_gauge']->set(1, [PHP_VERSION]); 381 | } 382 | } 383 | -------------------------------------------------------------------------------- /src/Prometheus/Storage/InMemory.php: -------------------------------------------------------------------------------- 1 | internalCollect($this->counters, $sortMetrics); 39 | $metrics = array_merge($metrics, $this->internalCollect($this->gauges, $sortMetrics)); 40 | $metrics = array_merge($metrics, $this->collectHistograms()); 41 | $metrics = array_merge($metrics, $this->collectSummaries()); 42 | return $metrics; 43 | } 44 | 45 | /** 46 | * @deprecated use replacement method wipeStorage from Adapter interface 47 | */ 48 | public function flushMemory(): void 49 | { 50 | $this->wipeStorage(); 51 | } 52 | 53 | /** 54 | * @inheritDoc 55 | */ 56 | public function wipeStorage(): void 57 | { 58 | $this->counters = []; 59 | $this->gauges = []; 60 | $this->histograms = []; 61 | $this->summaries = []; 62 | } 63 | 64 | /** 65 | * @return MetricFamilySamples[] 66 | */ 67 | protected function collectHistograms(): array 68 | { 69 | $histograms = []; 70 | foreach ($this->histograms as $histogram) { 71 | $metaData = $histogram['meta']; 72 | $data = [ 73 | 'name' => $metaData['name'], 74 | 'help' => $metaData['help'], 75 | 'type' => $metaData['type'], 76 | 'labelNames' => $metaData['labelNames'], 77 | 'buckets' => $metaData['buckets'], 78 | ]; 79 | 80 | // Add the Inf bucket so we can compute it later on 81 | $data['buckets'][] = '+Inf'; 82 | 83 | $histogramBuckets = []; 84 | foreach ($histogram['samples'] as $key => $value) { 85 | $parts = explode(':', $key); 86 | $labelValues = $parts[2]; 87 | $bucket = $parts[3]; 88 | // Key by labelValues 89 | $histogramBuckets[$labelValues][$bucket] = $value; 90 | } 91 | 92 | // Compute all buckets 93 | $labels = array_keys($histogramBuckets); 94 | sort($labels); 95 | foreach ($labels as $labelValues) { 96 | $acc = 0; 97 | $decodedLabelValues = $this->decodeLabelValues($labelValues); 98 | foreach ($data['buckets'] as $bucket) { 99 | $bucket = (string)$bucket; 100 | if (!isset($histogramBuckets[$labelValues][$bucket])) { 101 | $data['samples'][] = [ 102 | 'name' => $metaData['name'] . '_bucket', 103 | 'labelNames' => ['le'], 104 | 'labelValues' => array_merge($decodedLabelValues, [$bucket]), 105 | 'value' => $acc, 106 | ]; 107 | } else { 108 | $acc += $histogramBuckets[$labelValues][$bucket]; 109 | $data['samples'][] = [ 110 | 'name' => $metaData['name'] . '_' . 'bucket', 111 | 'labelNames' => ['le'], 112 | 'labelValues' => array_merge($decodedLabelValues, [$bucket]), 113 | 'value' => $acc, 114 | ]; 115 | } 116 | } 117 | 118 | // Add the count 119 | $data['samples'][] = [ 120 | 'name' => $metaData['name'] . '_count', 121 | 'labelNames' => [], 122 | 'labelValues' => $decodedLabelValues, 123 | 'value' => $acc, 124 | ]; 125 | 126 | // Add the sum 127 | $data['samples'][] = [ 128 | 'name' => $metaData['name'] . '_sum', 129 | 'labelNames' => [], 130 | 'labelValues' => $decodedLabelValues, 131 | 'value' => $histogramBuckets[$labelValues]['sum'], 132 | ]; 133 | } 134 | $histograms[] = new MetricFamilySamples($data); 135 | } 136 | return $histograms; 137 | } 138 | 139 | /** 140 | * @return MetricFamilySamples[] 141 | */ 142 | protected function collectSummaries(): array 143 | { 144 | $math = new Math(); 145 | $summaries = []; 146 | foreach ($this->summaries as $metaKey => &$summary) { 147 | $metaData = $summary['meta']; 148 | $data = [ 149 | 'name' => $metaData['name'], 150 | 'help' => $metaData['help'], 151 | 'type' => $metaData['type'], 152 | 'labelNames' => $metaData['labelNames'], 153 | 'maxAgeSeconds' => $metaData['maxAgeSeconds'], 154 | 'quantiles' => $metaData['quantiles'], 155 | 'samples' => [], 156 | ]; 157 | 158 | foreach ($summary['samples'] as $key => &$values) { 159 | $parts = explode(':', $key); 160 | $labelValues = $parts[2]; 161 | $decodedLabelValues = $this->decodeLabelValues($labelValues); 162 | 163 | // Remove old data 164 | $values = array_filter($values, function (array $value) use ($data): bool { 165 | return time() - $value['time'] <= $data['maxAgeSeconds']; 166 | }); 167 | if (count($values) === 0) { 168 | unset($summary['samples'][$key]); 169 | continue; 170 | } 171 | 172 | // Compute quantiles 173 | usort($values, function (array $value1, array $value2) { 174 | if ($value1['value'] === $value2['value']) { 175 | return 0; 176 | } 177 | return ($value1['value'] < $value2['value']) ? -1 : 1; 178 | }); 179 | 180 | foreach ($data['quantiles'] as $quantile) { 181 | $data['samples'][] = [ 182 | 'name' => $metaData['name'], 183 | 'labelNames' => ['quantile'], 184 | 'labelValues' => array_merge($decodedLabelValues, [$quantile]), 185 | 'value' => $math->quantile(array_column($values, 'value'), $quantile), 186 | ]; 187 | } 188 | 189 | // Add the count 190 | $data['samples'][] = [ 191 | 'name' => $metaData['name'] . '_count', 192 | 'labelNames' => [], 193 | 'labelValues' => $decodedLabelValues, 194 | 'value' => count($values), 195 | ]; 196 | 197 | // Add the sum 198 | $data['samples'][] = [ 199 | 'name' => $metaData['name'] . '_sum', 200 | 'labelNames' => [], 201 | 'labelValues' => $decodedLabelValues, 202 | 'value' => array_sum(array_column($values, 'value')), 203 | ]; 204 | } 205 | if (count($data['samples']) > 0) { 206 | $summaries[] = new MetricFamilySamples($data); 207 | } else { 208 | unset($this->summaries[$metaKey]); 209 | } 210 | } 211 | return $summaries; 212 | } 213 | 214 | /** 215 | * @param mixed[] $metrics 216 | * @return MetricFamilySamples[] 217 | */ 218 | protected function internalCollect(array $metrics, bool $sortMetrics = true): array 219 | { 220 | $result = []; 221 | foreach ($metrics as $metric) { 222 | $metaData = $metric['meta']; 223 | $data = [ 224 | 'name' => $metaData['name'], 225 | 'help' => $metaData['help'], 226 | 'type' => $metaData['type'], 227 | 'labelNames' => $metaData['labelNames'], 228 | 'samples' => [], 229 | ]; 230 | foreach ($metric['samples'] as $key => $value) { 231 | $parts = explode(':', $key); 232 | $labelValues = $parts[2]; 233 | $data['samples'][] = [ 234 | 'name' => $metaData['name'], 235 | 'labelNames' => [], 236 | 'labelValues' => $this->decodeLabelValues($labelValues), 237 | 'value' => $value, 238 | ]; 239 | } 240 | 241 | if ($sortMetrics) { 242 | $this->sortSamples($data['samples']); 243 | } 244 | 245 | $result[] = new MetricFamilySamples($data); 246 | } 247 | return $result; 248 | } 249 | 250 | /** 251 | * @param mixed[] $data 252 | * @return void 253 | */ 254 | public function updateHistogram(array $data): void 255 | { 256 | // Initialize the sum 257 | $metaKey = $this->metaKey($data); 258 | if (array_key_exists($metaKey, $this->histograms) === false) { 259 | $this->histograms[$metaKey] = [ 260 | 'meta' => $this->metaData($data), 261 | 'samples' => [], 262 | ]; 263 | } 264 | $sumKey = $this->histogramBucketValueKey($data, 'sum'); 265 | if (array_key_exists($sumKey, $this->histograms[$metaKey]['samples']) === false) { 266 | $this->histograms[$metaKey]['samples'][$sumKey] = 0; 267 | } 268 | 269 | $this->histograms[$metaKey]['samples'][$sumKey] += $data['value']; 270 | 271 | 272 | $bucketToIncrease = '+Inf'; 273 | foreach ($data['buckets'] as $bucket) { 274 | if ($data['value'] <= $bucket) { 275 | $bucketToIncrease = $bucket; 276 | break; 277 | } 278 | } 279 | 280 | $bucketKey = $this->histogramBucketValueKey($data, $bucketToIncrease); 281 | if (array_key_exists($bucketKey, $this->histograms[$metaKey]['samples']) === false) { 282 | $this->histograms[$metaKey]['samples'][$bucketKey] = 0; 283 | } 284 | $this->histograms[$metaKey]['samples'][$bucketKey] += 1; 285 | } 286 | 287 | /** 288 | * @param mixed[] $data 289 | * @return void 290 | */ 291 | public function updateSummary(array $data): void 292 | { 293 | $metaKey = $this->metaKey($data); 294 | if (array_key_exists($metaKey, $this->summaries) === false) { 295 | $this->summaries[$metaKey] = [ 296 | 'meta' => $this->metaData($data), 297 | 'samples' => [], 298 | ]; 299 | } 300 | 301 | $valueKey = $this->valueKey($data); 302 | if (array_key_exists($valueKey, $this->summaries[$metaKey]['samples']) === false) { 303 | $this->summaries[$metaKey]['samples'][$valueKey] = []; 304 | } 305 | 306 | $this->summaries[$metaKey]['samples'][$valueKey][] = [ 307 | 'time' => time(), 308 | 'value' => $data['value'], 309 | ]; 310 | } 311 | 312 | /** 313 | * @param mixed[] $data 314 | */ 315 | public function updateGauge(array $data): void 316 | { 317 | $metaKey = $this->metaKey($data); 318 | $valueKey = $this->valueKey($data); 319 | if (array_key_exists($metaKey, $this->gauges) === false) { 320 | $this->gauges[$metaKey] = [ 321 | 'meta' => $this->metaData($data), 322 | 'samples' => [], 323 | ]; 324 | } 325 | if (array_key_exists($valueKey, $this->gauges[$metaKey]['samples']) === false) { 326 | $this->gauges[$metaKey]['samples'][$valueKey] = 0; 327 | } 328 | if ($data['command'] === Adapter::COMMAND_SET) { 329 | $this->gauges[$metaKey]['samples'][$valueKey] = $data['value']; 330 | } else { 331 | $this->gauges[$metaKey]['samples'][$valueKey] += $data['value']; 332 | } 333 | } 334 | 335 | /** 336 | * @param mixed[] $data 337 | */ 338 | public function updateCounter(array $data): void 339 | { 340 | $metaKey = $this->metaKey($data); 341 | $valueKey = $this->valueKey($data); 342 | if (array_key_exists($metaKey, $this->counters) === false) { 343 | $this->counters[$metaKey] = [ 344 | 'meta' => $this->metaData($data), 345 | 'samples' => [], 346 | ]; 347 | } 348 | if (array_key_exists($valueKey, $this->counters[$metaKey]['samples']) === false) { 349 | $this->counters[$metaKey]['samples'][$valueKey] = 0; 350 | } 351 | if ($data['command'] === Adapter::COMMAND_SET) { 352 | $this->counters[$metaKey]['samples'][$valueKey] = 0; 353 | } else { 354 | $this->counters[$metaKey]['samples'][$valueKey] += $data['value']; 355 | } 356 | } 357 | 358 | /** 359 | * @param mixed[] $data 360 | * @param string|int $bucket 361 | * 362 | * @return string 363 | */ 364 | protected function histogramBucketValueKey(array $data, $bucket): string 365 | { 366 | return implode(':', [ 367 | $data['type'], 368 | $data['name'], 369 | $this->encodeLabelValues($data['labelValues']), 370 | $bucket, 371 | ]); 372 | } 373 | 374 | /** 375 | * @param mixed[] $data 376 | * 377 | * @return string 378 | */ 379 | protected function metaKey(array $data): string 380 | { 381 | return implode(':', [ 382 | $data['type'], 383 | $data['name'], 384 | 'meta' 385 | ]); 386 | } 387 | 388 | /** 389 | * @param mixed[] $data 390 | * 391 | * @return string 392 | */ 393 | protected function valueKey(array $data): string 394 | { 395 | return implode(':', [ 396 | $data['type'], 397 | $data['name'], 398 | $this->encodeLabelValues($data['labelValues']), 399 | 'value' 400 | ]); 401 | } 402 | 403 | /** 404 | * @param mixed[] $data 405 | * 406 | * @return mixed[] 407 | */ 408 | protected function metaData(array $data): array 409 | { 410 | $metricsMetaData = $data; 411 | unset($metricsMetaData['value'], $metricsMetaData['command'], $metricsMetaData['labelValues']); 412 | return $metricsMetaData; 413 | } 414 | 415 | /** 416 | * @param mixed[] $samples 417 | */ 418 | protected function sortSamples(array &$samples): void 419 | { 420 | usort($samples, function ($a, $b): int { 421 | return strcmp(implode("", $a['labelValues']), implode("", $b['labelValues'])); 422 | }); 423 | } 424 | 425 | /** 426 | * @param mixed[] $values 427 | * @return string 428 | * @throws RuntimeException 429 | */ 430 | protected function encodeLabelValues(array $values): string 431 | { 432 | $json = json_encode($values); 433 | if (false === $json) { 434 | throw new RuntimeException(json_last_error_msg()); 435 | } 436 | return base64_encode($json); 437 | } 438 | 439 | /** 440 | * @param string $values 441 | * @return mixed[] 442 | * @throws RuntimeException 443 | */ 444 | protected function decodeLabelValues(string $values): array 445 | { 446 | $json = base64_decode($values, true); 447 | if (false === $json) { 448 | throw new RuntimeException('Cannot base64 decode label values'); 449 | } 450 | $decodedValues = json_decode($json, true); 451 | if (false === $decodedValues) { 452 | throw new RuntimeException(json_last_error_msg()); 453 | } 454 | return $decodedValues; 455 | } 456 | } 457 | -------------------------------------------------------------------------------- /src/Prometheus/Storage/APC.php: -------------------------------------------------------------------------------- 1 | prometheusPrefix = $prometheusPrefix; 38 | } 39 | 40 | /** 41 | * @return MetricFamilySamples[] 42 | */ 43 | public function collect(bool $sortMetrics = true): array 44 | { 45 | $metrics = $this->collectHistograms(); 46 | $metrics = array_merge($metrics, $this->collectGauges($sortMetrics)); 47 | $metrics = array_merge($metrics, $this->collectCounters($sortMetrics)); 48 | $metrics = array_merge($metrics, $this->collectSummaries()); 49 | return $metrics; 50 | } 51 | 52 | /** 53 | * @param mixed[] $data 54 | */ 55 | public function updateHistogram(array $data): void 56 | { 57 | // Initialize the sum 58 | $sumKey = $this->histogramBucketValueKey($data, 'sum'); 59 | if (!apcu_exists($sumKey)) { 60 | $new = apcu_add($sumKey, $this->toBinaryRepresentationAsInteger(0)); 61 | 62 | // If sum does not exist, assume a new histogram and store the metadata 63 | if ($new) { 64 | apcu_store($this->metaKey($data), json_encode($this->metaData($data))); 65 | } 66 | } 67 | 68 | // Atomically increment the sum 69 | // Taken from https://github.com/prometheus/client_golang/blob/66058aac3a83021948e5fb12f1f408ff556b9037/prometheus/value.go#L91 70 | $done = false; 71 | while (!$done) { 72 | $old = apcu_fetch($sumKey); 73 | if ($old !== false) { 74 | $done = apcu_cas($sumKey, $old, $this->toBinaryRepresentationAsInteger($this->fromBinaryRepresentationAsInteger($old) + $data['value'])); 75 | } else { 76 | $new = apcu_add($sumKey, $this->toBinaryRepresentationAsInteger(0)); 77 | 78 | // If sum does not exist, assume a new histogram and store the metadata 79 | if ($new) { 80 | apcu_store($this->metaKey($data), json_encode($this->metaData($data))); 81 | } 82 | } 83 | } 84 | 85 | // Figure out in which bucket the observation belongs 86 | $bucketToIncrease = '+Inf'; 87 | foreach ($data['buckets'] as $bucket) { 88 | if ($data['value'] <= $bucket) { 89 | $bucketToIncrease = $bucket; 90 | break; 91 | } 92 | } 93 | 94 | // Initialize and increment the bucket 95 | $bucketKey = $this->histogramBucketValueKey($data, $bucketToIncrease); 96 | if (!apcu_exists($bucketKey)) { 97 | apcu_add($bucketKey, 0); 98 | } 99 | apcu_inc($bucketKey); 100 | } 101 | 102 | /** 103 | * @param mixed[] $data 104 | */ 105 | public function updateSummary(array $data): void 106 | { 107 | // store meta 108 | $metaKey = $this->metaKey($data); 109 | if (!apcu_exists($metaKey)) { 110 | apcu_add($metaKey, $this->metaData($data)); 111 | } 112 | 113 | // store value key 114 | $valueKey = $this->valueKey($data); 115 | if (!apcu_exists($valueKey)) { 116 | apcu_add($valueKey, $this->encodeLabelValues($data['labelValues'])); 117 | } 118 | 119 | // trick to handle uniqid collision 120 | $done = false; 121 | while (!$done) { 122 | $sampleKey = $valueKey . ':' . uniqid('', true); 123 | $done = apcu_add($sampleKey, $data['value'], $data['maxAgeSeconds']); 124 | } 125 | } 126 | 127 | /** 128 | * @param mixed[] $data 129 | */ 130 | public function updateGauge(array $data): void 131 | { 132 | $valueKey = $this->valueKey($data); 133 | $old = apcu_fetch($valueKey); 134 | if ($data['command'] === Adapter::COMMAND_SET) { 135 | $new = $this->toBinaryRepresentationAsInteger($data['value']); 136 | if ($old === false) { 137 | apcu_store($valueKey, $new); 138 | apcu_store($this->metaKey($data), json_encode($this->metaData($data))); 139 | return; 140 | } else { 141 | // Taken from https://github.com/prometheus/client_golang/blob/66058aac3a83021948e5fb12f1f408ff556b9037/prometheus/value.go#L91 142 | while (true) { 143 | if ($old !== false) { 144 | if (apcu_cas($valueKey, $old, $new)) { 145 | return; 146 | } else { 147 | $old = apcu_fetch($valueKey); 148 | } 149 | } else { 150 | // Cache got evicted under our feet? Just consider it a fresh/new insert and move on. 151 | apcu_store($valueKey, $new); 152 | apcu_store($this->metaKey($data), json_encode($this->metaData($data))); 153 | return; 154 | } 155 | } 156 | } 157 | } else { 158 | if ($old === false) { 159 | $new = apcu_add($valueKey, $this->toBinaryRepresentationAsInteger(0)); 160 | if ($new) { 161 | apcu_store($this->metaKey($data), json_encode($this->metaData($data))); 162 | } 163 | } 164 | // Taken from https://github.com/prometheus/client_golang/blob/66058aac3a83021948e5fb12f1f408ff556b9037/prometheus/value.go#L91 165 | $done = false; 166 | while (!$done) { 167 | $old = apcu_fetch($valueKey); 168 | if ($old !== false) { 169 | $done = apcu_cas($valueKey, $old, $this->toBinaryRepresentationAsInteger($this->fromBinaryRepresentationAsInteger($old) + $data['value'])); 170 | } else { 171 | $new = apcu_add($valueKey, $this->toBinaryRepresentationAsInteger(0)); 172 | if ($new) { 173 | apcu_store($this->metaKey($data), json_encode($this->metaData($data))); 174 | } 175 | } 176 | } 177 | } 178 | } 179 | 180 | /** 181 | * @param mixed[] $data 182 | */ 183 | public function updateCounter(array $data): void 184 | { 185 | $valueKey = $this->valueKey($data); 186 | // Check if value key already exists 187 | if (apcu_exists($this->valueKey($data)) === false) { 188 | apcu_add($this->valueKey($data), 0); 189 | apcu_store($this->metaKey($data), json_encode($this->metaData($data))); 190 | } 191 | 192 | // Taken from https://github.com/prometheus/client_golang/blob/66058aac3a83021948e5fb12f1f408ff556b9037/prometheus/value.go#L91 193 | $done = false; 194 | while (!$done) { 195 | $old = apcu_fetch($valueKey); 196 | if ($old !== false) { 197 | $done = apcu_cas($valueKey, $old, $this->toBinaryRepresentationAsInteger($this->fromBinaryRepresentationAsInteger($old) + $data['value'])); 198 | } else { 199 | apcu_add($this->valueKey($data), 0); 200 | apcu_store($this->metaKey($data), json_encode($this->metaData($data))); 201 | } 202 | } 203 | } 204 | 205 | /** 206 | * @deprecated use replacement method wipeStorage from Adapter interface 207 | * 208 | * @return void 209 | */ 210 | public function flushAPC(): void 211 | { 212 | $this->wipeStorage(); 213 | } 214 | 215 | /** 216 | * Removes all previously stored data from apcu 217 | * 218 | * @return void 219 | */ 220 | public function wipeStorage(): void 221 | { 222 | // / / | PCRE expresion boundary 223 | // ^ | match from first character only 224 | // %s: | common prefix substitute with colon suffix 225 | // .+ | at least one additional character 226 | $matchAll = sprintf('/^%s:.+/', $this->prometheusPrefix); 227 | 228 | foreach (new APCuIterator($matchAll) as $key => $value) { 229 | apcu_delete($key); 230 | } 231 | } 232 | 233 | /** 234 | * @param mixed[] $data 235 | * @return string 236 | */ 237 | private function metaKey(array $data): string 238 | { 239 | return implode(':', [$this->prometheusPrefix, $data['type'], $data['name'], 'meta']); 240 | } 241 | 242 | /** 243 | * @param mixed[] $data 244 | * @return string 245 | */ 246 | private function valueKey(array $data): string 247 | { 248 | return implode(':', [ 249 | $this->prometheusPrefix, 250 | $data['type'], 251 | $data['name'], 252 | $this->encodeLabelValues($data['labelValues']), 253 | 'value', 254 | ]); 255 | } 256 | 257 | /** 258 | * @param mixed[] $data 259 | * @param string|int $bucket 260 | * @return string 261 | */ 262 | private function histogramBucketValueKey(array $data, $bucket): string 263 | { 264 | return implode(':', [ 265 | $this->prometheusPrefix, 266 | $data['type'], 267 | $data['name'], 268 | $this->encodeLabelValues($data['labelValues']), 269 | $bucket, 270 | 'value', 271 | ]); 272 | } 273 | 274 | /** 275 | * @param mixed[] $data 276 | * @return mixed[] 277 | */ 278 | private function metaData(array $data): array 279 | { 280 | $metricsMetaData = $data; 281 | unset($metricsMetaData['value'], $metricsMetaData['command'], $metricsMetaData['labelValues']); 282 | return $metricsMetaData; 283 | } 284 | 285 | /** 286 | * @return MetricFamilySamples[] 287 | */ 288 | private function collectCounters(bool $sortMetrics = true): array 289 | { 290 | $counters = []; 291 | foreach (new APCuIterator('/^' . $this->prometheusPrefix . ':counter:.*:meta/') as $counter) { 292 | $metaData = json_decode($counter['value'], true); 293 | $data = [ 294 | 'name' => $metaData['name'], 295 | 'help' => $metaData['help'], 296 | 'type' => $metaData['type'], 297 | 'labelNames' => $metaData['labelNames'], 298 | 'samples' => [], 299 | ]; 300 | foreach (new APCuIterator('/^' . $this->prometheusPrefix . ':counter:' . $metaData['name'] . ':.*:value/') as $value) { 301 | $parts = explode(':', $value['key']); 302 | $labelValues = $parts[3]; 303 | $data['samples'][] = [ 304 | 'name' => $metaData['name'], 305 | 'labelNames' => [], 306 | 'labelValues' => $this->decodeLabelValues($labelValues), 307 | 'value' => $this->fromBinaryRepresentationAsInteger($value['value']), 308 | ]; 309 | } 310 | 311 | if ($sortMetrics) { 312 | $this->sortSamples($data['samples']); 313 | } 314 | 315 | $counters[] = new MetricFamilySamples($data); 316 | } 317 | return $counters; 318 | } 319 | 320 | /** 321 | * @return MetricFamilySamples[] 322 | */ 323 | private function collectGauges(bool $sortMetrics = true): array 324 | { 325 | $gauges = []; 326 | foreach (new APCuIterator('/^' . $this->prometheusPrefix . ':gauge:.*:meta/') as $gauge) { 327 | $metaData = json_decode($gauge['value'], true); 328 | $data = [ 329 | 'name' => $metaData['name'], 330 | 'help' => $metaData['help'], 331 | 'type' => $metaData['type'], 332 | 'labelNames' => $metaData['labelNames'], 333 | 'samples' => [], 334 | ]; 335 | foreach (new APCuIterator('/^' . $this->prometheusPrefix . ':gauge:' . $metaData['name'] . ':.*:value/') as $value) { 336 | $parts = explode(':', $value['key']); 337 | $labelValues = $parts[3]; 338 | $data['samples'][] = [ 339 | 'name' => $metaData['name'], 340 | 'labelNames' => [], 341 | 'labelValues' => $this->decodeLabelValues($labelValues), 342 | 'value' => $this->fromBinaryRepresentationAsInteger($value['value']), 343 | ]; 344 | } 345 | 346 | if ($sortMetrics) { 347 | $this->sortSamples($data['samples']); 348 | } 349 | 350 | $gauges[] = new MetricFamilySamples($data); 351 | } 352 | return $gauges; 353 | } 354 | 355 | /** 356 | * @return MetricFamilySamples[] 357 | */ 358 | private function collectHistograms(): array 359 | { 360 | $histograms = []; 361 | foreach (new APCuIterator('/^' . $this->prometheusPrefix . ':histogram:.*:meta/') as $histogram) { 362 | $metaData = json_decode($histogram['value'], true); 363 | $data = [ 364 | 'name' => $metaData['name'], 365 | 'help' => $metaData['help'], 366 | 'type' => $metaData['type'], 367 | 'labelNames' => $metaData['labelNames'], 368 | 'buckets' => $metaData['buckets'], 369 | ]; 370 | 371 | // Add the Inf bucket so we can compute it later on 372 | $data['buckets'][] = '+Inf'; 373 | 374 | $histogramBuckets = []; 375 | foreach (new APCuIterator('/^' . $this->prometheusPrefix . ':histogram:' . $metaData['name'] . ':.*:value/') as $value) { 376 | $parts = explode(':', $value['key']); 377 | $labelValues = $parts[3]; 378 | $bucket = $parts[4]; 379 | // Key by labelValues 380 | $histogramBuckets[$labelValues][$bucket] = $value['value']; 381 | } 382 | 383 | // Compute all buckets 384 | $labels = array_keys($histogramBuckets); 385 | sort($labels); 386 | foreach ($labels as $labelValues) { 387 | $acc = 0; 388 | $decodedLabelValues = $this->decodeLabelValues($labelValues); 389 | foreach ($data['buckets'] as $bucket) { 390 | $bucket = (string)$bucket; 391 | if (!isset($histogramBuckets[$labelValues][$bucket])) { 392 | $data['samples'][] = [ 393 | 'name' => $metaData['name'] . '_bucket', 394 | 'labelNames' => ['le'], 395 | 'labelValues' => array_merge($decodedLabelValues, [$bucket]), 396 | 'value' => $acc, 397 | ]; 398 | } else { 399 | $acc += $histogramBuckets[$labelValues][$bucket]; 400 | $data['samples'][] = [ 401 | 'name' => $metaData['name'] . '_' . 'bucket', 402 | 'labelNames' => ['le'], 403 | 'labelValues' => array_merge($decodedLabelValues, [$bucket]), 404 | 'value' => $acc, 405 | ]; 406 | } 407 | } 408 | 409 | // Add the count 410 | $data['samples'][] = [ 411 | 'name' => $metaData['name'] . '_count', 412 | 'labelNames' => [], 413 | 'labelValues' => $decodedLabelValues, 414 | 'value' => $acc, 415 | ]; 416 | 417 | // Add the sum 418 | $data['samples'][] = [ 419 | 'name' => $metaData['name'] . '_sum', 420 | 'labelNames' => [], 421 | 'labelValues' => $decodedLabelValues, 422 | 'value' => $this->fromBinaryRepresentationAsInteger($histogramBuckets[$labelValues]['sum'] ?? 0), 423 | ]; 424 | } 425 | $histograms[] = new MetricFamilySamples($data); 426 | } 427 | return $histograms; 428 | } 429 | 430 | /** 431 | * @return MetricFamilySamples[] 432 | */ 433 | private function collectSummaries(): array 434 | { 435 | $math = new Math(); 436 | $summaries = []; 437 | foreach (new APCuIterator('/^' . $this->prometheusPrefix . ':summary:.*:meta/') as $summary) { 438 | $metaData = $summary['value']; 439 | $data = [ 440 | 'name' => $metaData['name'], 441 | 'help' => $metaData['help'], 442 | 'type' => $metaData['type'], 443 | 'labelNames' => $metaData['labelNames'], 444 | 'maxAgeSeconds' => $metaData['maxAgeSeconds'], 445 | 'quantiles' => $metaData['quantiles'], 446 | 'samples' => [], 447 | ]; 448 | 449 | foreach (new APCuIterator('/^' . $this->prometheusPrefix . ':summary:' . $metaData['name'] . ':.*:value$/') as $value) { 450 | $encodedLabelValues = $value['value']; 451 | $decodedLabelValues = $this->decodeLabelValues($encodedLabelValues); 452 | $samples = []; 453 | foreach (new APCuIterator('/^' . $this->prometheusPrefix . ':summary:' . $metaData['name'] . ':' . str_replace('/', '\\/', preg_quote($encodedLabelValues)) . ':value:.*/') as $sample) { 454 | $samples[] = $sample['value']; 455 | } 456 | 457 | if (count($samples) === 0) { 458 | apcu_delete($value['key']); 459 | continue; 460 | } 461 | 462 | // Compute quantiles 463 | sort($samples); 464 | foreach ($data['quantiles'] as $quantile) { 465 | $data['samples'][] = [ 466 | 'name' => $metaData['name'], 467 | 'labelNames' => ['quantile'], 468 | 'labelValues' => array_merge($decodedLabelValues, [$quantile]), 469 | 'value' => $math->quantile($samples, $quantile), 470 | ]; 471 | } 472 | 473 | // Add the count 474 | $data['samples'][] = [ 475 | 'name' => $metaData['name'] . '_count', 476 | 'labelNames' => [], 477 | 'labelValues' => $decodedLabelValues, 478 | 'value' => count($samples), 479 | ]; 480 | 481 | // Add the sum 482 | $data['samples'][] = [ 483 | 'name' => $metaData['name'] . '_sum', 484 | 'labelNames' => [], 485 | 'labelValues' => $decodedLabelValues, 486 | 'value' => array_sum($samples), 487 | ]; 488 | } 489 | 490 | if (count($data['samples']) > 0) { 491 | $summaries[] = new MetricFamilySamples($data); 492 | } else { 493 | apcu_delete($summary['key']); 494 | } 495 | } 496 | return $summaries; 497 | } 498 | 499 | /** 500 | * @param mixed $val 501 | * @return int 502 | * @throws RuntimeException 503 | */ 504 | private function toBinaryRepresentationAsInteger($val): int 505 | { 506 | $packedDouble = pack('d', $val); 507 | if ((bool)$packedDouble !== false) { 508 | $unpackedData = unpack("Q", $packedDouble); 509 | if (is_array($unpackedData)) { 510 | return $unpackedData[1]; 511 | } 512 | } 513 | throw new RuntimeException("Formatting from binary representation to integer did not work"); 514 | } 515 | 516 | /** 517 | * @param mixed $val 518 | * @return float 519 | * @throws RuntimeException 520 | */ 521 | private function fromBinaryRepresentationAsInteger($val): float 522 | { 523 | $packedBinary = pack('Q', $val); 524 | if ((bool)$packedBinary !== false) { 525 | $unpackedData = unpack("d", $packedBinary); 526 | if (is_array($unpackedData)) { 527 | return $unpackedData[1]; 528 | } 529 | } 530 | throw new RuntimeException("Formatting from integer to binary representation did not work"); 531 | } 532 | 533 | /** 534 | * @param mixed[] $samples 535 | */ 536 | private function sortSamples(array &$samples): void 537 | { 538 | usort($samples, function ($a, $b): int { 539 | return strcmp(implode("", $a['labelValues']), implode("", $b['labelValues'])); 540 | }); 541 | } 542 | 543 | /** 544 | * @param mixed[] $values 545 | * @return string 546 | * @throws RuntimeException 547 | */ 548 | private function encodeLabelValues(array $values): string 549 | { 550 | $json = json_encode($values); 551 | if (false === $json) { 552 | throw new RuntimeException(json_last_error_msg()); 553 | } 554 | return base64_encode($json); 555 | } 556 | 557 | /** 558 | * @param string $values 559 | * @return mixed[] 560 | * @throws RuntimeException 561 | */ 562 | private function decodeLabelValues(string $values): array 563 | { 564 | $json = base64_decode($values, true); 565 | if (false === $json) { 566 | throw new RuntimeException('Cannot base64 decode label values'); 567 | } 568 | $decodedValues = json_decode($json, true); 569 | if (false === $decodedValues) { 570 | throw new RuntimeException(json_last_error_msg()); 571 | } 572 | return $decodedValues; 573 | } 574 | } 575 | -------------------------------------------------------------------------------- /src/Prometheus/Storage/RedisNg.php: -------------------------------------------------------------------------------- 1 | '127.0.0.1', 27 | 'port' => 6379, 28 | 'timeout' => 0.1, 29 | 'read_timeout' => '10', 30 | 'persistent_connections' => false, 31 | 'password' => null, 32 | 'user' => null, 33 | ]; 34 | 35 | /** 36 | * @var string 37 | */ 38 | private static $prefix = 'PROMETHEUS_'; 39 | 40 | /** 41 | * @var mixed[] 42 | */ 43 | private $options = []; 44 | 45 | /** 46 | * @var \Redis 47 | */ 48 | private $redis; 49 | 50 | /** 51 | * @var boolean 52 | */ 53 | private $connectionInitialized = false; 54 | 55 | /** 56 | * Redis constructor. 57 | * @param mixed[] $options 58 | */ 59 | public function __construct(array $options = []) 60 | { 61 | $this->options = array_merge(self::$defaultOptions, $options); 62 | $this->redis = new \Redis(); 63 | } 64 | 65 | /** 66 | * @param \Redis $redis 67 | * @return self 68 | * @throws StorageException 69 | */ 70 | public static function fromExistingConnection(\Redis $redis): self 71 | { 72 | if ($redis->isConnected() === false) { 73 | throw new StorageException('Connection to Redis server not established'); 74 | } 75 | 76 | $self = new self(); 77 | $self->connectionInitialized = true; 78 | $self->redis = $redis; 79 | 80 | return $self; 81 | } 82 | 83 | /** 84 | * @param mixed[] $options 85 | */ 86 | public static function setDefaultOptions(array $options): void 87 | { 88 | self::$defaultOptions = array_merge(self::$defaultOptions, $options); 89 | } 90 | 91 | /** 92 | * @param string $prefix 93 | */ 94 | public static function setPrefix(string $prefix): void 95 | { 96 | self::$prefix = $prefix; 97 | } 98 | 99 | /** 100 | * @throws StorageException 101 | * @deprecated use replacement method wipeStorage from Adapter interface 102 | */ 103 | public function flushRedis(): void 104 | { 105 | $this->wipeStorage(); 106 | } 107 | 108 | /** 109 | * @inheritDoc 110 | */ 111 | public function wipeStorage(): void 112 | { 113 | $this->ensureOpenConnection(); 114 | 115 | $searchPattern = ""; 116 | 117 | $globalPrefix = $this->redis->getOption(\Redis::OPT_PREFIX); 118 | // @phpstan-ignore-next-line false positive, phpstan thinks getOptions returns int 119 | if (is_string($globalPrefix)) { 120 | $searchPattern .= $globalPrefix; 121 | } 122 | 123 | $searchPattern .= self::$prefix; 124 | $searchPattern .= '*'; 125 | 126 | $this->redis->eval( 127 | <<encodeLabelValues($data['labelValues']), 167 | 'value' 168 | ]); 169 | } 170 | 171 | /** 172 | * @return MetricFamilySamples[] 173 | * @throws StorageException 174 | */ 175 | public function collect(bool $sortMetrics = true): array 176 | { 177 | $this->ensureOpenConnection(); 178 | $metrics = $this->collectHistograms(); 179 | $metrics = array_merge($metrics, $this->collectGauges($sortMetrics)); 180 | $metrics = array_merge($metrics, $this->collectCounters($sortMetrics)); 181 | $metrics = array_merge($metrics, $this->collectSummaries()); 182 | return array_map( 183 | function (array $metric): MetricFamilySamples { 184 | return new MetricFamilySamples($metric); 185 | }, 186 | $metrics 187 | ); 188 | } 189 | 190 | /** 191 | * @throws StorageException 192 | */ 193 | private function ensureOpenConnection(): void 194 | { 195 | if ($this->connectionInitialized === true) { 196 | return; 197 | } 198 | 199 | $this->connectToServer(); 200 | $authParams = []; 201 | 202 | if (isset($this->options['user']) && $this->options['user'] !== '') { 203 | $authParams[] = $this->options['user']; 204 | } 205 | 206 | if (isset($this->options['password'])) { 207 | $authParams[] = $this->options['password']; 208 | } 209 | 210 | if ($authParams !== []) { 211 | $this->redis->auth($authParams); 212 | } 213 | 214 | if (isset($this->options['database'])) { 215 | $this->redis->select($this->options['database']); 216 | } 217 | 218 | $this->redis->setOption(\Redis::OPT_READ_TIMEOUT, $this->options['read_timeout']); 219 | 220 | $this->connectionInitialized = true; 221 | } 222 | 223 | /** 224 | * @throws StorageException 225 | */ 226 | private function connectToServer(): void 227 | { 228 | try { 229 | $connection_successful = false; 230 | if ($this->options['persistent_connections'] !== false) { 231 | $connection_successful = $this->redis->pconnect( 232 | $this->options['host'], 233 | (int)$this->options['port'], 234 | (float)$this->options['timeout'] 235 | ); 236 | } else { 237 | $connection_successful = $this->redis->connect($this->options['host'], (int)$this->options['port'], (float)$this->options['timeout']); 238 | } 239 | if (!$connection_successful) { 240 | throw new StorageException("Can't connect to Redis server", 0); 241 | } 242 | } catch (\RedisException $e) { 243 | throw new StorageException("Can't connect to Redis server", 0, $e); 244 | } 245 | } 246 | 247 | /** 248 | * @param mixed[] $data 249 | * @throws StorageException 250 | */ 251 | public function updateHistogram(array $data): void 252 | { 253 | $this->ensureOpenConnection(); 254 | $bucketToIncrease = '+Inf'; 255 | foreach ($data['buckets'] as $bucket) { 256 | if ($data['value'] <= $bucket) { 257 | $bucketToIncrease = $bucket; 258 | break; 259 | } 260 | } 261 | $metaData = $data; 262 | unset($metaData['value'], $metaData['labelValues']); 263 | 264 | $this->redis->eval( 265 | <<= tonumber(ARGV[3]) then 269 | redis.call('hSet', KEYS[1], '__meta', ARGV[4]) 270 | redis.call('sAdd', KEYS[2], KEYS[1]) 271 | end 272 | return result 273 | LUA 274 | , 275 | [ 276 | $this->toMetricKey($data), 277 | self::$prefix . Histogram::TYPE . self::PROMETHEUS_METRIC_KEYS_SUFFIX, 278 | json_encode(['b' => 'sum', 'labelValues' => $data['labelValues']]), 279 | json_encode(['b' => $bucketToIncrease, 'labelValues' => $data['labelValues']]), 280 | $data['value'], 281 | json_encode($metaData), 282 | ], 283 | 2 284 | ); 285 | } 286 | 287 | /** 288 | * @param mixed[] $data 289 | * @throws StorageException 290 | */ 291 | public function updateSummary(array $data): void 292 | { 293 | $this->ensureOpenConnection(); 294 | // store meta 295 | $summaryKey = self::$prefix . Summary::TYPE . self::PROMETHEUS_METRIC_KEYS_SUFFIX; 296 | $summaryKeyIndexKey = self::$prefix . Summary::TYPE . self::PROMETHEUS_METRIC_KEYS_SUFFIX . ":keys"; 297 | if (!$this->redis->sIsMember($summaryKeyIndexKey, $summaryKey . ':' . $data["name"])) { 298 | $this->redis->sAdd($summaryKeyIndexKey, $summaryKey . ':' . $data["name"]); 299 | } 300 | 301 | $metaKey = $summaryKey . ':' . $this->metaKey($data); 302 | $json = json_encode($this->metaData($data)); 303 | if (false === $json) { 304 | throw new RuntimeException(json_last_error_msg()); 305 | } 306 | $this->redis->setnx($metaKey, $json); 307 | 308 | // store value key 309 | $valueKey = $summaryKey . ':' . $this->valueKey($data); 310 | 311 | $json = json_encode($this->encodeLabelValues($data['labelValues'])); 312 | if (false === $json) { 313 | throw new RuntimeException(json_last_error_msg()); 314 | } 315 | $this->redis->setnx($valueKey, $json); 316 | 317 | // trick to handle uniqid collision 318 | $done = false; 319 | while (!$done) { 320 | $sampleKey = $valueKey . ':' . uniqid('', true); 321 | $done = $this->redis->set($sampleKey, $data['value'], ['NX', 'EX' => $data['maxAgeSeconds']]); 322 | $this->redis->sAdd($summaryKey . ':' . $data["name"] . ":value:keys", $sampleKey); 323 | } 324 | } 325 | 326 | /** 327 | * @param mixed[] $data 328 | * @throws StorageException 329 | */ 330 | public function updateGauge(array $data): void 331 | { 332 | $this->ensureOpenConnection(); 333 | $metaData = $data; 334 | unset($metaData['value'], $metaData['labelValues'], $metaData['command']); 335 | $this->redis->eval( 336 | <<toMetricKey($data), 354 | self::$prefix . Gauge::TYPE . self::PROMETHEUS_METRIC_KEYS_SUFFIX, 355 | $this->getRedisCommand($data['command']), 356 | json_encode($data['labelValues']), 357 | $data['value'], 358 | json_encode($metaData), 359 | ], 360 | 2 361 | ); 362 | } 363 | 364 | /** 365 | * @param mixed[] $data 366 | * @throws StorageException 367 | */ 368 | public function updateCounter(array $data): void 369 | { 370 | $this->ensureOpenConnection(); 371 | $metaData = $data; 372 | unset($metaData['value'], $metaData['labelValues'], $metaData['command']); 373 | $this->redis->eval( 374 | <<toMetricKey($data), 385 | self::$prefix . Counter::TYPE . self::PROMETHEUS_METRIC_KEYS_SUFFIX, 386 | $this->getRedisCommand($data['command']), 387 | $data['value'], 388 | json_encode($data['labelValues']), 389 | json_encode($metaData), 390 | ], 391 | 2 392 | ); 393 | } 394 | 395 | 396 | /** 397 | * @param mixed[] $data 398 | * @return mixed[] 399 | */ 400 | private function metaData(array $data): array 401 | { 402 | $metricsMetaData = $data; 403 | unset($metricsMetaData['value'], $metricsMetaData['command'], $metricsMetaData['labelValues']); 404 | return $metricsMetaData; 405 | } 406 | 407 | /** 408 | * @return mixed[] 409 | * @throws MetricJsonException 410 | */ 411 | private function collectHistograms(): array 412 | { 413 | $keys = $this->redis->sMembers(self::$prefix . Histogram::TYPE . self::PROMETHEUS_METRIC_KEYS_SUFFIX); 414 | sort($keys); 415 | $histograms = []; 416 | foreach ($keys as $key) { 417 | $raw = $this->redis->hGetAll(ltrim($key, $this->redis->_prefix(''))); 418 | if (!isset($raw['__meta'])) { 419 | continue; 420 | } 421 | $histogram = json_decode($raw['__meta'], true); 422 | unset($raw['__meta']); 423 | $histogram['samples'] = []; 424 | 425 | // Add the Inf bucket so we can compute it later on 426 | $histogram['buckets'][] = '+Inf'; 427 | 428 | $allLabelValues = []; 429 | foreach (array_keys($raw) as $k) { 430 | $d = json_decode($k, true); 431 | if ($d['b'] == 'sum') { 432 | continue; 433 | } 434 | $allLabelValues[] = $d['labelValues']; 435 | } 436 | 437 | if (json_last_error() !== JSON_ERROR_NONE) { 438 | $this->throwMetricJsonException($key); 439 | } 440 | 441 | // We need set semantics. 442 | // This is the equivalent of array_unique but for arrays of arrays. 443 | $allLabelValues = array_map("unserialize", array_unique(array_map("serialize", $allLabelValues))); 444 | sort($allLabelValues); 445 | 446 | foreach ($allLabelValues as $labelValues) { 447 | // Fill up all buckets. 448 | // If the bucket doesn't exist fill in values from 449 | // the previous one. 450 | $acc = 0; 451 | foreach ($histogram['buckets'] as $bucket) { 452 | $bucketKey = json_encode(['b' => $bucket, 'labelValues' => $labelValues]); 453 | if (!isset($raw[$bucketKey])) { 454 | $histogram['samples'][] = [ 455 | 'name' => $histogram['name'] . '_bucket', 456 | 'labelNames' => ['le'], 457 | 'labelValues' => array_merge($labelValues, [$bucket]), 458 | 'value' => $acc, 459 | ]; 460 | } else { 461 | $acc += $raw[$bucketKey]; 462 | $histogram['samples'][] = [ 463 | 'name' => $histogram['name'] . '_bucket', 464 | 'labelNames' => ['le'], 465 | 'labelValues' => array_merge($labelValues, [$bucket]), 466 | 'value' => $acc, 467 | ]; 468 | } 469 | } 470 | 471 | // Add the count 472 | $histogram['samples'][] = [ 473 | 'name' => $histogram['name'] . '_count', 474 | 'labelNames' => [], 475 | 'labelValues' => $labelValues, 476 | 'value' => $acc, 477 | ]; 478 | 479 | // Add the sum 480 | $histogram['samples'][] = [ 481 | 'name' => $histogram['name'] . '_sum', 482 | 'labelNames' => [], 483 | 'labelValues' => $labelValues, 484 | 'value' => $raw[json_encode(['b' => 'sum', 'labelValues' => $labelValues])], 485 | ]; 486 | } 487 | $histograms[] = $histogram; 488 | } 489 | return $histograms; 490 | } 491 | 492 | /** 493 | * @param string $key 494 | * 495 | * @return string 496 | */ 497 | private function removePrefixFromKey(string $key): string /** @phpstan-ignore-line */ 498 | { 499 | // @phpstan-ignore-next-line false positive, phpstan thinks getOptions returns int 500 | if ($this->redis->getOption(\Redis::OPT_PREFIX) === null) { 501 | return $key; 502 | } 503 | // @phpstan-ignore-next-line false positive, phpstan thinks getOptions returns int 504 | return substr($key, strlen($this->redis->getOption(\Redis::OPT_PREFIX))); 505 | } 506 | 507 | /** 508 | * @return mixed[] 509 | */ 510 | private function collectSummaries(): array 511 | { 512 | $math = new Math(); 513 | $summaryKeyIndexKey = self::$prefix . Summary::TYPE . self::PROMETHEUS_METRIC_KEYS_SUFFIX . ":keys"; 514 | 515 | $keys = $this->redis->sMembers($summaryKeyIndexKey); 516 | $summaries = []; 517 | foreach ($keys as $metaKey) { 518 | $rawSummary = $this->redis->get($metaKey . ':meta'); 519 | if ($rawSummary === false) { 520 | continue; 521 | } 522 | $summary = json_decode($rawSummary, true); 523 | $metaData = $summary; 524 | $data = [ 525 | 'name' => $metaData['name'], 526 | 'help' => $metaData['help'], 527 | 'type' => $metaData['type'], 528 | 'labelNames' => $metaData['labelNames'], 529 | 'maxAgeSeconds' => $metaData['maxAgeSeconds'], 530 | 'quantiles' => $metaData['quantiles'], 531 | 'samples' => [], 532 | ]; 533 | $values = $this->redis->sMembers($metaKey . ':value:keys'); 534 | $samples = []; 535 | foreach ($values as $valueKey) { 536 | $rawValue = explode(":", $valueKey); 537 | if ($rawValue === false) { 538 | continue; 539 | } 540 | $encodedLabelValues = $rawValue[2]; 541 | $decodedLabelValues = $this->decodeLabelValues($encodedLabelValues); 542 | 543 | $return = $this->redis->get($valueKey); 544 | if ($return !== false) { 545 | $samples[] = (float)$return; 546 | } 547 | } 548 | if (count($samples) === 0) { 549 | if (isset($valueKey)) { 550 | $this->redis->del($valueKey); 551 | } 552 | 553 | continue; 554 | } 555 | 556 | assert(isset($decodedLabelValues)); 557 | 558 | // Compute quantiles 559 | sort($samples); 560 | foreach ($data['quantiles'] as $quantile) { 561 | $data['samples'][] = [ 562 | 'name' => $metaData['name'], 563 | 'labelNames' => ['quantile'], 564 | 'labelValues' => array_merge($decodedLabelValues, [$quantile]), 565 | 'value' => $math->quantile($samples, $quantile), 566 | ]; 567 | } 568 | 569 | // Add the count 570 | $data['samples'][] = [ 571 | 'name' => $metaData['name'] . '_count', 572 | 'labelNames' => [], 573 | 'labelValues' => $decodedLabelValues, 574 | 'value' => count($samples), 575 | ]; 576 | 577 | // Add the sum 578 | $data['samples'][] = [ 579 | 'name' => $metaData['name'] . '_sum', 580 | 'labelNames' => [], 581 | 'labelValues' => $decodedLabelValues, 582 | 'value' => array_sum($samples), 583 | ]; 584 | 585 | 586 | $summaries[] = $data; 587 | } 588 | return $summaries; 589 | } 590 | 591 | /** 592 | * @return mixed[] 593 | */ 594 | private function collectGauges(bool $sortMetrics = true): array 595 | { 596 | $keys = $this->redis->sMembers(self::$prefix . Gauge::TYPE . self::PROMETHEUS_METRIC_KEYS_SUFFIX); 597 | sort($keys); 598 | $gauges = []; 599 | foreach ($keys as $key) { 600 | $raw = $this->redis->hGetAll(ltrim($key, $this->redis->_prefix(''))); 601 | if (!isset($raw['__meta'])) { 602 | continue; 603 | } 604 | $gauge = json_decode($raw['__meta'], true); 605 | unset($raw['__meta']); 606 | $gauge['samples'] = []; 607 | foreach ($raw as $k => $value) { 608 | $gauge['samples'][] = [ 609 | 'name' => $gauge['name'], 610 | 'labelNames' => [], 611 | 'labelValues' => json_decode($k, true), 612 | 'value' => $value, 613 | ]; 614 | if (json_last_error() !== JSON_ERROR_NONE) { 615 | $this->throwMetricJsonException($key, $gauge['name']); 616 | } 617 | } 618 | 619 | if ($sortMetrics) { 620 | usort($gauge['samples'], function ($a, $b): int { 621 | return strcmp(implode("", $a['labelValues']), implode("", $b['labelValues'])); 622 | }); 623 | } 624 | 625 | $gauges[] = $gauge; 626 | } 627 | return $gauges; 628 | } 629 | 630 | /** 631 | * @return mixed[] 632 | * @throws MetricJsonException 633 | */ 634 | private function collectCounters(bool $sortMetrics = true): array 635 | { 636 | $keys = $this->redis->sMembers(self::$prefix . Counter::TYPE . self::PROMETHEUS_METRIC_KEYS_SUFFIX); 637 | sort($keys); 638 | $counters = []; 639 | foreach ($keys as $key) { 640 | $raw = $this->redis->hGetAll(ltrim($key, $this->redis->_prefix(''))); 641 | if (!isset($raw['__meta'])) { 642 | continue; 643 | } 644 | $counter = json_decode($raw['__meta'], true); 645 | unset($raw['__meta']); 646 | $counter['samples'] = []; 647 | foreach ($raw as $k => $value) { 648 | $counter['samples'][] = [ 649 | 'name' => $counter['name'], 650 | 'labelNames' => [], 651 | 'labelValues' => json_decode($k, true), 652 | 'value' => $value, 653 | ]; 654 | if (json_last_error() !== JSON_ERROR_NONE) { 655 | $this->throwMetricJsonException($key, $counter['name']); 656 | } 657 | } 658 | 659 | if ($sortMetrics) { 660 | usort($counter['samples'], function ($a, $b): int { 661 | return strcmp(implode("", $a['labelValues']), implode("", $b['labelValues'])); 662 | }); 663 | } 664 | 665 | $counters[] = $counter; 666 | } 667 | return $counters; 668 | } 669 | 670 | /** 671 | * @param int $cmd 672 | * @return string 673 | */ 674 | private function getRedisCommand(int $cmd): string 675 | { 676 | switch ($cmd) { 677 | case Adapter::COMMAND_INCREMENT_INTEGER: 678 | return 'hIncrBy'; 679 | case Adapter::COMMAND_INCREMENT_FLOAT: 680 | return 'hIncrByFloat'; 681 | case Adapter::COMMAND_SET: 682 | return 'hSet'; 683 | default: 684 | throw new InvalidArgumentException("Unknown command"); 685 | } 686 | } 687 | 688 | /** 689 | * @param mixed[] $data 690 | * @return string 691 | */ 692 | private function toMetricKey(array $data): string 693 | { 694 | return implode(':', [self::$prefix, $data['type'], $data['name']]); 695 | } 696 | 697 | /** 698 | * @param mixed[] $values 699 | * @return string 700 | * @throws RuntimeException 701 | */ 702 | private function encodeLabelValues(array $values): string 703 | { 704 | $json = json_encode($values); 705 | if (false === $json) { 706 | throw new RuntimeException(json_last_error_msg()); 707 | } 708 | return base64_encode($json); 709 | } 710 | 711 | /** 712 | * @param string $values 713 | * @return mixed[] 714 | * @throws RuntimeException 715 | */ 716 | private function decodeLabelValues(string $values): array 717 | { 718 | $json = base64_decode($values, true); 719 | if (false === $json) { 720 | throw new RuntimeException('Cannot base64 decode label values'); 721 | } 722 | $decodedValues = json_decode($json, true); 723 | if (false === $decodedValues) { 724 | throw new RuntimeException(json_last_error_msg()); 725 | } 726 | return $decodedValues; 727 | } 728 | 729 | /** 730 | * @param string $redisKey 731 | * @param string|null $metricName 732 | * @return void 733 | * @throws MetricJsonException 734 | */ 735 | private function throwMetricJsonException(string $redisKey, ?string $metricName = null): void 736 | { 737 | $metricName = $metricName ?? 'unknown'; 738 | $message = 'Json error: ' . json_last_error_msg() . ' redis key : ' . $redisKey . ' metric name: ' . $metricName; 739 | throw new MetricJsonException($message, 0, null, $metricName); 740 | } 741 | } 742 | -------------------------------------------------------------------------------- /src/Prometheus/Storage/Redis.php: -------------------------------------------------------------------------------- 1 | '127.0.0.1', 27 | 'port' => 6379, 28 | 'timeout' => 0.1, 29 | 'read_timeout' => '10', 30 | 'persistent_connections' => false, 31 | 'password' => null, 32 | 'user' => null, 33 | ]; 34 | 35 | /** 36 | * @var string 37 | */ 38 | private static $prefix = 'PROMETHEUS_'; 39 | 40 | /** 41 | * @var mixed[] 42 | */ 43 | private $options = []; 44 | 45 | /** 46 | * @var \Redis 47 | */ 48 | private $redis; 49 | 50 | /** 51 | * @var boolean 52 | */ 53 | private $connectionInitialized = false; 54 | 55 | /** 56 | * Redis constructor. 57 | * @param mixed[] $options 58 | */ 59 | public function __construct(array $options = []) 60 | { 61 | $this->options = array_merge(self::$defaultOptions, $options); 62 | $this->redis = new \Redis(); 63 | } 64 | 65 | /** 66 | * @param \Redis $redis 67 | * @return self 68 | * @throws StorageException 69 | */ 70 | public static function fromExistingConnection(\Redis $redis): self 71 | { 72 | if ($redis->isConnected() === false) { 73 | throw new StorageException('Connection to Redis server not established'); 74 | } 75 | 76 | $self = new self(); 77 | $self->connectionInitialized = true; 78 | $self->redis = $redis; 79 | 80 | return $self; 81 | } 82 | 83 | /** 84 | * @param mixed[] $options 85 | */ 86 | public static function setDefaultOptions(array $options): void 87 | { 88 | self::$defaultOptions = array_merge(self::$defaultOptions, $options); 89 | } 90 | 91 | /** 92 | * @param string $prefix 93 | */ 94 | public static function setPrefix(string $prefix): void 95 | { 96 | self::$prefix = $prefix; 97 | } 98 | 99 | /** 100 | * @throws StorageException 101 | * @deprecated use replacement method wipeStorage from Adapter interface 102 | */ 103 | public function flushRedis(): void 104 | { 105 | $this->wipeStorage(); 106 | } 107 | 108 | /** 109 | * @inheritDoc 110 | */ 111 | public function wipeStorage(): void 112 | { 113 | $this->ensureOpenConnection(); 114 | 115 | $searchPattern = ""; 116 | 117 | $globalPrefix = $this->redis->getOption(\Redis::OPT_PREFIX); 118 | // @phpstan-ignore-next-line false positive, phpstan thinks getOptions returns int 119 | if (is_string($globalPrefix)) { 120 | $searchPattern .= $globalPrefix; 121 | } 122 | 123 | $searchPattern .= self::$prefix; 124 | $searchPattern .= '*'; 125 | 126 | $this->redis->eval( 127 | <<encodeLabelValues($data['labelValues']), 167 | 'value' 168 | ]); 169 | } 170 | 171 | /** 172 | * @return MetricFamilySamples[] 173 | * @throws StorageException 174 | */ 175 | public function collect(bool $sortMetrics = true): array 176 | { 177 | $this->ensureOpenConnection(); 178 | $metrics = $this->collectHistograms(); 179 | $metrics = array_merge($metrics, $this->collectGauges($sortMetrics)); 180 | $metrics = array_merge($metrics, $this->collectCounters($sortMetrics)); 181 | $metrics = array_merge($metrics, $this->collectSummaries()); 182 | return array_map( 183 | function (array $metric): MetricFamilySamples { 184 | return new MetricFamilySamples($metric); 185 | }, 186 | $metrics 187 | ); 188 | } 189 | 190 | /** 191 | * @throws StorageException 192 | */ 193 | private function ensureOpenConnection(): void 194 | { 195 | if ($this->connectionInitialized === true) { 196 | return; 197 | } 198 | 199 | $this->connectToServer(); 200 | $authParams = []; 201 | 202 | if (isset($this->options['user']) && $this->options['user'] !== '') { 203 | $authParams[] = $this->options['user']; 204 | } 205 | 206 | if (isset($this->options['password'])) { 207 | $authParams[] = $this->options['password']; 208 | } 209 | 210 | if ($authParams !== []) { 211 | $this->redis->auth($authParams); 212 | } 213 | 214 | if (isset($this->options['database'])) { 215 | $this->redis->select($this->options['database']); 216 | } 217 | 218 | $this->redis->setOption(\Redis::OPT_READ_TIMEOUT, $this->options['read_timeout']); 219 | 220 | $this->connectionInitialized = true; 221 | } 222 | 223 | /** 224 | * @throws StorageException 225 | */ 226 | private function connectToServer(): void 227 | { 228 | try { 229 | $connection_successful = false; 230 | if ($this->options['persistent_connections'] !== false) { 231 | $connection_successful = $this->redis->pconnect( 232 | $this->options['host'], 233 | (int)$this->options['port'], 234 | (float)$this->options['timeout'] 235 | ); 236 | } else { 237 | $connection_successful = $this->redis->connect($this->options['host'], (int)$this->options['port'], (float)$this->options['timeout']); 238 | } 239 | if (!$connection_successful) { 240 | throw new StorageException( 241 | sprintf("Can't connect to Redis server. %s", $this->redis->getLastError()), 242 | 0 243 | ); 244 | } 245 | } catch (\RedisException $e) { 246 | throw new StorageException( 247 | sprintf("Can't connect to Redis server. %s", $e->getMessage()), 248 | $e->getCode(), 249 | $e 250 | ); 251 | } 252 | } 253 | 254 | /** 255 | * @param mixed[] $data 256 | * @throws StorageException 257 | */ 258 | public function updateHistogram(array $data): void 259 | { 260 | $this->ensureOpenConnection(); 261 | $bucketToIncrease = '+Inf'; 262 | foreach ($data['buckets'] as $bucket) { 263 | if ($data['value'] <= $bucket) { 264 | $bucketToIncrease = $bucket; 265 | break; 266 | } 267 | } 268 | $metaData = $data; 269 | unset($metaData['value'], $metaData['labelValues']); 270 | 271 | $this->redis->eval( 272 | <<= tonumber(ARGV[3]) then 276 | redis.call('hSet', KEYS[1], '__meta', ARGV[4]) 277 | redis.call('sAdd', KEYS[2], KEYS[1]) 278 | end 279 | return result 280 | LUA 281 | , 282 | [ 283 | $this->toMetricKey($data), 284 | self::$prefix . Histogram::TYPE . self::PROMETHEUS_METRIC_KEYS_SUFFIX, 285 | json_encode(['b' => 'sum', 'labelValues' => $data['labelValues']]), 286 | json_encode(['b' => $bucketToIncrease, 'labelValues' => $data['labelValues']]), 287 | $data['value'], 288 | json_encode($metaData), 289 | ], 290 | 2 291 | ); 292 | } 293 | 294 | /** 295 | * @param mixed[] $data 296 | * @throws StorageException 297 | */ 298 | public function updateSummary(array $data): void 299 | { 300 | $this->ensureOpenConnection(); 301 | 302 | // store meta 303 | $summaryKey = self::$prefix . Summary::TYPE . self::PROMETHEUS_METRIC_KEYS_SUFFIX; 304 | $metaKey = $summaryKey . ':' . $this->metaKey($data); 305 | $json = json_encode($this->metaData($data)); 306 | if (false === $json) { 307 | throw new RuntimeException(json_last_error_msg()); 308 | } 309 | $this->redis->setNx($metaKey, $json);/** @phpstan-ignore-line */ 310 | 311 | 312 | // store value key 313 | $valueKey = $summaryKey . ':' . $this->valueKey($data); 314 | $json = json_encode($this->encodeLabelValues($data['labelValues'])); 315 | if (false === $json) { 316 | throw new RuntimeException(json_last_error_msg()); 317 | } 318 | $this->redis->setNx($valueKey, $json);/** @phpstan-ignore-line */ 319 | 320 | // trick to handle uniqid collision 321 | $done = false; 322 | while (!$done) { 323 | $sampleKey = $valueKey . ':' . uniqid('', true); 324 | $done = $this->redis->set($sampleKey, $data['value'], ['NX', 'EX' => $data['maxAgeSeconds']]); 325 | } 326 | } 327 | 328 | /** 329 | * @param mixed[] $data 330 | * @throws StorageException 331 | */ 332 | public function updateGauge(array $data): void 333 | { 334 | $this->ensureOpenConnection(); 335 | $metaData = $data; 336 | unset($metaData['value'], $metaData['labelValues'], $metaData['command']); 337 | $this->redis->eval( 338 | <<toMetricKey($data), 356 | self::$prefix . Gauge::TYPE . self::PROMETHEUS_METRIC_KEYS_SUFFIX, 357 | $this->getRedisCommand($data['command']), 358 | json_encode($data['labelValues']), 359 | $data['value'], 360 | json_encode($metaData), 361 | ], 362 | 2 363 | ); 364 | } 365 | 366 | /** 367 | * @param mixed[] $data 368 | * @throws StorageException 369 | */ 370 | public function updateCounter(array $data): void 371 | { 372 | $this->ensureOpenConnection(); 373 | $metaData = $data; 374 | unset($metaData['value'], $metaData['labelValues'], $metaData['command']); 375 | $this->redis->eval( 376 | <<toMetricKey($data), 387 | self::$prefix . Counter::TYPE . self::PROMETHEUS_METRIC_KEYS_SUFFIX, 388 | $this->getRedisCommand($data['command']), 389 | $data['value'], 390 | json_encode($data['labelValues']), 391 | json_encode($metaData), 392 | ], 393 | 2 394 | ); 395 | } 396 | 397 | 398 | /** 399 | * @param mixed[] $data 400 | * @return mixed[] 401 | */ 402 | private function metaData(array $data): array 403 | { 404 | $metricsMetaData = $data; 405 | unset($metricsMetaData['value'], $metricsMetaData['command'], $metricsMetaData['labelValues']); 406 | return $metricsMetaData; 407 | } 408 | 409 | /** 410 | * @return mixed[] 411 | */ 412 | private function collectHistograms(): array 413 | { 414 | $keys = $this->redis->sMembers(self::$prefix . Histogram::TYPE . self::PROMETHEUS_METRIC_KEYS_SUFFIX); 415 | sort($keys); 416 | $histograms = []; 417 | foreach ($keys as $key) { 418 | $raw = $this->redis->hGetAll(ltrim($key, $this->redis->_prefix(''))); 419 | if (!isset($raw['__meta'])) { 420 | continue; 421 | } 422 | $histogram = json_decode($raw['__meta'], true); 423 | unset($raw['__meta']); 424 | $histogram['samples'] = []; 425 | 426 | // Add the Inf bucket so we can compute it later on 427 | $histogram['buckets'][] = '+Inf'; 428 | 429 | $allLabelValues = []; 430 | foreach (array_keys($raw) as $k) { 431 | $d = json_decode($k, true); 432 | if ($d['b'] == 'sum') { 433 | continue; 434 | } 435 | $allLabelValues[] = $d['labelValues']; 436 | } 437 | if (json_last_error() !== JSON_ERROR_NONE) { 438 | $this->throwMetricJsonException($key); 439 | } 440 | 441 | // We need set semantics. 442 | // This is the equivalent of array_unique but for arrays of arrays. 443 | $allLabelValues = array_map("unserialize", array_unique(array_map("serialize", $allLabelValues))); 444 | sort($allLabelValues); 445 | 446 | foreach ($allLabelValues as $labelValues) { 447 | // Fill up all buckets. 448 | // If the bucket doesn't exist fill in values from 449 | // the previous one. 450 | $acc = 0; 451 | foreach ($histogram['buckets'] as $bucket) { 452 | $bucketKey = json_encode(['b' => $bucket, 'labelValues' => $labelValues]); 453 | if (!isset($raw[$bucketKey])) { 454 | $histogram['samples'][] = [ 455 | 'name' => $histogram['name'] . '_bucket', 456 | 'labelNames' => ['le'], 457 | 'labelValues' => array_merge($labelValues, [$bucket]), 458 | 'value' => $acc, 459 | ]; 460 | } else { 461 | $acc += $raw[$bucketKey]; 462 | $histogram['samples'][] = [ 463 | 'name' => $histogram['name'] . '_bucket', 464 | 'labelNames' => ['le'], 465 | 'labelValues' => array_merge($labelValues, [$bucket]), 466 | 'value' => $acc, 467 | ]; 468 | } 469 | } 470 | 471 | // Add the count 472 | $histogram['samples'][] = [ 473 | 'name' => $histogram['name'] . '_count', 474 | 'labelNames' => [], 475 | 'labelValues' => $labelValues, 476 | 'value' => $acc, 477 | ]; 478 | 479 | // Add the sum 480 | $histogram['samples'][] = [ 481 | 'name' => $histogram['name'] . '_sum', 482 | 'labelNames' => [], 483 | 'labelValues' => $labelValues, 484 | 'value' => $raw[json_encode(['b' => 'sum', 'labelValues' => $labelValues])], 485 | ]; 486 | } 487 | $histograms[] = $histogram; 488 | } 489 | return $histograms; 490 | } 491 | 492 | /** 493 | * @param string $key 494 | * 495 | * @return string 496 | */ 497 | private function removePrefixFromKey(string $key): string 498 | { 499 | // @phpstan-ignore-next-line false positive, phpstan thinks getOptions returns int 500 | if ($this->redis->getOption(\Redis::OPT_PREFIX) === null) { 501 | return $key; 502 | } 503 | // @phpstan-ignore-next-line false positive, phpstan thinks getOptions returns int 504 | return substr($key, strlen($this->redis->getOption(\Redis::OPT_PREFIX))); 505 | } 506 | 507 | /** 508 | * @return mixed[] 509 | */ 510 | private function collectSummaries(): array 511 | { 512 | $math = new Math(); 513 | $summaryKey = self::$prefix . Summary::TYPE . self::PROMETHEUS_METRIC_KEYS_SUFFIX; 514 | $keys = $this->redis->keys($summaryKey . ':*:meta'); 515 | 516 | $summaries = []; 517 | foreach ($keys as $metaKeyWithPrefix) { 518 | $metaKey = $this->removePrefixFromKey($metaKeyWithPrefix); 519 | $rawSummary = $this->redis->get($metaKey); 520 | if ($rawSummary === false) { 521 | continue; 522 | } 523 | $summary = json_decode($rawSummary, true); 524 | $metaData = $summary; 525 | $data = [ 526 | 'name' => $metaData['name'], 527 | 'help' => $metaData['help'], 528 | 'type' => $metaData['type'], 529 | 'labelNames' => $metaData['labelNames'], 530 | 'maxAgeSeconds' => $metaData['maxAgeSeconds'], 531 | 'quantiles' => $metaData['quantiles'], 532 | 'samples' => [], 533 | ]; 534 | 535 | $values = $this->redis->keys($summaryKey . ':' . $metaData['name'] . ':*:value'); 536 | foreach ($values as $valueKeyWithPrefix) { 537 | $valueKey = $this->removePrefixFromKey($valueKeyWithPrefix); 538 | $rawValue = $this->redis->get($valueKey); 539 | if ($rawValue === false) { 540 | continue; 541 | } 542 | $value = json_decode($rawValue, true); 543 | $encodedLabelValues = $value; 544 | $decodedLabelValues = $this->decodeLabelValues($encodedLabelValues); 545 | 546 | $samples = []; 547 | $sampleValues = $this->redis->keys($summaryKey . ':' . $metaData['name'] . ':' . $encodedLabelValues . ':value:*'); 548 | foreach ($sampleValues as $sampleValueWithPrefix) { 549 | $sampleValue = $this->removePrefixFromKey($sampleValueWithPrefix); 550 | $samples[] = (float)$this->redis->get($sampleValue); 551 | } 552 | 553 | if (count($samples) === 0) { 554 | try { 555 | $this->redis->del($valueKey); 556 | } catch (\RedisException $e) { 557 | // ignore if we can't delete the key 558 | } 559 | continue; 560 | } 561 | 562 | // Compute quantiles 563 | sort($samples); 564 | foreach ($data['quantiles'] as $quantile) { 565 | $data['samples'][] = [ 566 | 'name' => $metaData['name'], 567 | 'labelNames' => ['quantile'], 568 | 'labelValues' => array_merge($decodedLabelValues, [$quantile]), 569 | 'value' => $math->quantile($samples, $quantile), 570 | ]; 571 | } 572 | 573 | // Add the count 574 | $data['samples'][] = [ 575 | 'name' => $metaData['name'] . '_count', 576 | 'labelNames' => [], 577 | 'labelValues' => $decodedLabelValues, 578 | 'value' => count($samples), 579 | ]; 580 | 581 | // Add the sum 582 | $data['samples'][] = [ 583 | 'name' => $metaData['name'] . '_sum', 584 | 'labelNames' => [], 585 | 'labelValues' => $decodedLabelValues, 586 | 'value' => array_sum($samples), 587 | ]; 588 | } 589 | 590 | if (count($data['samples']) > 0) { 591 | $summaries[] = $data; 592 | } else { 593 | try { 594 | $this->redis->del($metaKey); 595 | } catch (\RedisException $e) { 596 | // ignore if we can't delete the key 597 | } 598 | } 599 | } 600 | return $summaries; 601 | } 602 | 603 | /** 604 | * @return mixed[] 605 | */ 606 | private function collectGauges(bool $sortMetrics = true): array 607 | { 608 | $keys = $this->redis->sMembers(self::$prefix . Gauge::TYPE . self::PROMETHEUS_METRIC_KEYS_SUFFIX); 609 | sort($keys); 610 | $gauges = []; 611 | foreach ($keys as $key) { 612 | $raw = $this->redis->hGetAll(ltrim($key, $this->redis->_prefix(''))); 613 | if (!isset($raw['__meta'])) { 614 | continue; 615 | } 616 | $gauge = json_decode($raw['__meta'], true); 617 | unset($raw['__meta']); 618 | $gauge['samples'] = []; 619 | foreach ($raw as $k => $value) { 620 | $gauge['samples'][] = [ 621 | 'name' => $gauge['name'], 622 | 'labelNames' => [], 623 | 'labelValues' => json_decode($k, true), 624 | 'value' => $value, 625 | ]; 626 | if (json_last_error() !== JSON_ERROR_NONE) { 627 | $this->throwMetricJsonException($key, $gauge['name']); 628 | } 629 | } 630 | 631 | if ($sortMetrics) { 632 | usort($gauge['samples'], function ($a, $b): int { 633 | return strcmp(implode("", $a['labelValues']), implode("", $b['labelValues'])); 634 | }); 635 | } 636 | 637 | $gauges[] = $gauge; 638 | } 639 | return $gauges; 640 | } 641 | 642 | /** 643 | * @return mixed[] 644 | * @throws MetricJsonException 645 | */ 646 | private function collectCounters(bool $sortMetrics = true): array 647 | { 648 | $keys = $this->redis->sMembers(self::$prefix . Counter::TYPE . self::PROMETHEUS_METRIC_KEYS_SUFFIX); 649 | sort($keys); 650 | $counters = []; 651 | foreach ($keys as $key) { 652 | $raw = $this->redis->hGetAll(ltrim($key, $this->redis->_prefix(''))); 653 | if (!isset($raw['__meta'])) { 654 | continue; 655 | } 656 | $counter = json_decode($raw['__meta'], true); 657 | 658 | unset($raw['__meta']); 659 | $counter['samples'] = []; 660 | foreach ($raw as $k => $value) { 661 | $counter['samples'][] = [ 662 | 'name' => $counter['name'], 663 | 'labelNames' => [], 664 | 'labelValues' => json_decode($k, true), 665 | 'value' => $value, 666 | ]; 667 | 668 | if (json_last_error() !== JSON_ERROR_NONE) { 669 | $this->throwMetricJsonException($key, $counter['name']); 670 | } 671 | } 672 | 673 | if ($sortMetrics) { 674 | usort($counter['samples'], function ($a, $b): int { 675 | return strcmp(implode("", $a['labelValues']), implode("", $b['labelValues'])); 676 | }); 677 | } 678 | 679 | $counters[] = $counter; 680 | } 681 | return $counters; 682 | } 683 | 684 | /** 685 | * @param int $cmd 686 | * @return string 687 | */ 688 | private function getRedisCommand(int $cmd): string 689 | { 690 | switch ($cmd) { 691 | case Adapter::COMMAND_INCREMENT_INTEGER: 692 | return 'hIncrBy'; 693 | case Adapter::COMMAND_INCREMENT_FLOAT: 694 | return 'hIncrByFloat'; 695 | case Adapter::COMMAND_SET: 696 | return 'hSet'; 697 | default: 698 | throw new InvalidArgumentException("Unknown command"); 699 | } 700 | } 701 | 702 | /** 703 | * @param mixed[] $data 704 | * @return string 705 | */ 706 | private function toMetricKey(array $data): string 707 | { 708 | return implode(':', [self::$prefix, $data['type'], $data['name']]); 709 | } 710 | 711 | /** 712 | * @param mixed[] $values 713 | * @return string 714 | * @throws RuntimeException 715 | */ 716 | private function encodeLabelValues(array $values): string 717 | { 718 | $json = json_encode($values); 719 | if (false === $json) { 720 | throw new RuntimeException(json_last_error_msg()); 721 | } 722 | return base64_encode($json); 723 | } 724 | 725 | /** 726 | * @param string $values 727 | * @return mixed[] 728 | * @throws RuntimeException 729 | */ 730 | private function decodeLabelValues(string $values): array 731 | { 732 | $json = base64_decode($values, true); 733 | if (false === $json) { 734 | throw new RuntimeException('Cannot base64 decode label values'); 735 | } 736 | $decodedValues = json_decode($json, true); 737 | if (false === $decodedValues) { 738 | throw new RuntimeException(json_last_error_msg()); 739 | } 740 | return $decodedValues; 741 | } 742 | 743 | /** 744 | * @param string $redisKey 745 | * @param string|null $metricName 746 | * @return void 747 | * @throws MetricJsonException 748 | */ 749 | private function throwMetricJsonException(string $redisKey, ?string $metricName = null): void 750 | { 751 | $metricName = $metricName ?? 'unknown'; 752 | $message = 'Json error: ' . json_last_error_msg() . ' redis key : ' . $redisKey . ' metric name: ' . $metricName; 753 | throw new MetricJsonException($message, 0, null, $metricName); 754 | } 755 | } 756 | -------------------------------------------------------------------------------- /src/Prometheus/Storage/PDO.php: -------------------------------------------------------------------------------- 1 | getAttribute(\PDO::ATTR_DRIVER_NAME), ['mysql', 'sqlite', 'pgsql'], true)) { 40 | throw new \RuntimeException('Only MySQL and SQLite are supported.'); 41 | } 42 | 43 | $this->database = $database; 44 | $this->prefix = $prefix; 45 | 46 | $this->createTables(); 47 | } 48 | 49 | /** 50 | * @return MetricFamilySamples[] 51 | */ 52 | public function collect(): array 53 | { 54 | $metrics = $this->collectHistograms(); 55 | $metrics = array_merge($metrics, $this->collectGauges()); 56 | $metrics = array_merge($metrics, $this->collectCounters()); 57 | return array_merge($metrics, $this->collectSummaries()); 58 | } 59 | 60 | /** 61 | * @inheritDoc 62 | */ 63 | public function wipeStorage(): void 64 | { 65 | switch ($this->database->getAttribute(\PDO::ATTR_DRIVER_NAME)) { 66 | case 'pgsql': 67 | $this->database->query("DELETE FROM \"{$this->prefix}_metadata\""); 68 | $this->database->query("DELETE FROM \"{$this->prefix}_values\""); 69 | $this->database->query("DELETE FROM \"{$this->prefix}_summaries\""); 70 | $this->database->query("DELETE FROM \"{$this->prefix}_histograms\""); 71 | break; 72 | default: 73 | $this->database->query("DELETE FROM `{$this->prefix}_metadata`"); 74 | $this->database->query("DELETE FROM `{$this->prefix}_values`"); 75 | $this->database->query("DELETE FROM `{$this->prefix}_summaries`"); 76 | $this->database->query("DELETE FROM `{$this->prefix}_histograms`"); 77 | } 78 | } 79 | 80 | public function deleteTables(): void 81 | { 82 | switch ($this->database->getAttribute(\PDO::ATTR_DRIVER_NAME)) { 83 | case 'pgsql': 84 | $this->database->query("DROP TABLE \"{$this->prefix}_metadata\""); 85 | $this->database->query("DROP TABLE \"{$this->prefix}_values\""); 86 | $this->database->query("DROP TABLE \"{$this->prefix}_summaries\""); 87 | $this->database->query("DROP TABLE \"{$this->prefix}_histograms\""); 88 | break; 89 | default: 90 | $this->database->query("DROP TABLE `{$this->prefix}_metadata`"); 91 | $this->database->query("DROP TABLE `{$this->prefix}_values`"); 92 | $this->database->query("DROP TABLE `{$this->prefix}_summaries`"); 93 | $this->database->query("DROP TABLE `{$this->prefix}_histograms`"); 94 | } 95 | } 96 | 97 | /** 98 | * @return MetricFamilySamples[] 99 | */ 100 | protected function collectHistograms(): array 101 | { 102 | $result = []; 103 | 104 | $meta_query = $this->getMetaQuery(); 105 | $meta_query->execute([':type' => Histogram::TYPE]); 106 | 107 | while ($row = $meta_query->fetch(\PDO::FETCH_ASSOC)) { 108 | $data = json_decode($row['metadata'], true); 109 | $data['samples'] = []; 110 | 111 | // Add the Inf bucket, so we can compute it later on. 112 | $data['buckets'][] = '+Inf'; 113 | 114 | switch ($this->database->getAttribute(\PDO::ATTR_DRIVER_NAME)) { 115 | case 'pgsql': 116 | $values_query = $this->database->prepare("SELECT name, labels_hash, labels, value, bucket FROM \"{$this->prefix}_histograms\" WHERE name = :name"); 117 | break; 118 | default: 119 | $values_query = $this->database->prepare("SELECT name, labels_hash, labels, value, bucket FROM `{$this->prefix}_histograms` WHERE name = :name"); 120 | } 121 | 122 | $values_query->execute([':name' => $data['name']]); 123 | 124 | $values = []; 125 | while ($value_row = $values_query->fetch(\PDO::FETCH_ASSOC)) { 126 | $values[$value_row['labels_hash']][] = $value_row; 127 | } 128 | 129 | $histogram_buckets = []; 130 | foreach ($values as $_hash => $value) { 131 | foreach ($value as $bucket_value) { 132 | $histogram_buckets[$bucket_value['labels']][$bucket_value['bucket']] = $bucket_value['value']; 133 | } 134 | } 135 | 136 | // Compute all buckets 137 | $labels = array_keys($histogram_buckets); 138 | sort($labels); 139 | foreach ($labels as $label_values) { 140 | $acc = 0; 141 | $decoded_values = json_decode($label_values, true); 142 | foreach ($data['buckets'] as $bucket) { 143 | $bucket = (string)$bucket; 144 | if (!isset($histogram_buckets[$label_values][$bucket])) { 145 | $data['samples'][] = [ 146 | 'name' => $data['name'] . '_bucket', 147 | 'labelNames' => ['le'], 148 | 'labelValues' => array_merge($decoded_values, [$bucket]), 149 | 'value' => $acc, 150 | ]; 151 | } else { 152 | $acc += $histogram_buckets[$label_values][$bucket]; 153 | $data['samples'][] = [ 154 | 'name' => $data['name'] . '_' . 'bucket', 155 | 'labelNames' => ['le'], 156 | 'labelValues' => array_merge($decoded_values, [$bucket]), 157 | 'value' => $acc, 158 | ]; 159 | } 160 | } 161 | 162 | // Add the count 163 | $data['samples'][] = [ 164 | 'name' => $data['name'] . '_count', 165 | 'labelNames' => [], 166 | 'labelValues' => $decoded_values, 167 | 'value' => $acc, 168 | ]; 169 | 170 | // Add the sum 171 | $data['samples'][] = [ 172 | 'name' => $data['name'] . '_sum', 173 | 'labelNames' => [], 174 | 'labelValues' => $decoded_values, 175 | 'value' => $histogram_buckets[$label_values]['sum'], 176 | ]; 177 | } 178 | $result[] = new MetricFamilySamples($data); 179 | } 180 | 181 | return $result; 182 | } 183 | 184 | /** 185 | * @return MetricFamilySamples[] 186 | */ 187 | protected function collectSummaries(): array 188 | { 189 | $math = new Math(); 190 | $result = []; 191 | 192 | $meta_query = $this->getMetaQuery(); 193 | $meta_query->execute([':type' => Summary::TYPE]); 194 | 195 | while ($row = $meta_query->fetch(\PDO::FETCH_ASSOC)) { 196 | $data = json_decode($row['metadata'], true); 197 | $data['samples'] = []; 198 | 199 | switch ($this->database->getAttribute(\PDO::ATTR_DRIVER_NAME)) { 200 | case 'pgsql': 201 | $values_query = $this->database->prepare("SELECT name, labels_hash, labels, value, time FROM \"{$this->prefix}_summaries\" WHERE name = :name"); 202 | break; 203 | default: 204 | $values_query = $this->database->prepare("SELECT name, labels_hash, labels, value, time FROM `{$this->prefix}_summaries` WHERE name = :name"); 205 | } 206 | 207 | $values_query->execute([':name' => $data['name']]); 208 | 209 | $values = []; 210 | while ($value_row = $values_query->fetch(\PDO::FETCH_ASSOC)) { 211 | $values[$value_row['labels_hash']][] = $value_row; 212 | } 213 | 214 | foreach ($values as $_hash => $samples) { 215 | $samples = array_map(function ($sample) { 216 | $sample['value'] = (float) $sample['value']; 217 | return $sample; 218 | }, $samples); 219 | 220 | $decoded_labels = json_decode(reset($samples)['labels'], true); 221 | 222 | // Remove old data 223 | $samples = array_filter($samples, function (array $value) use ($data): bool { 224 | return time() - $value['time'] <= $data['maxAgeSeconds']; 225 | }); 226 | if (count($samples) === 0) { 227 | continue; 228 | } 229 | 230 | // Compute quantiles 231 | usort($samples, function (array $value1, array $value2) { 232 | if ($value1['value'] === $value2['value']) { 233 | return 0; 234 | } 235 | return ($value1['value'] < $value2['value']) ? -1 : 1; 236 | }); 237 | 238 | foreach ($data['quantiles'] as $quantile) { 239 | $data['samples'][] = [ 240 | 'name' => $data['name'], 241 | 'labelNames' => ['quantile'], 242 | 'labelValues' => array_merge($decoded_labels, [$quantile]), 243 | 'value' => $math->quantile(array_column($samples, 'value'), $quantile), 244 | ]; 245 | } 246 | 247 | // Add the count 248 | $data['samples'][] = [ 249 | 'name' => $data['name'] . '_count', 250 | 'labelNames' => [], 251 | 'labelValues' => $decoded_labels, 252 | 'value' => count($samples), 253 | ]; 254 | 255 | // Add the sum 256 | $data['samples'][] = [ 257 | 'name' => $data['name'] . '_sum', 258 | 'labelNames' => [], 259 | 'labelValues' => $decoded_labels, 260 | 'value' => array_sum(array_column($samples, 'value')), 261 | ]; 262 | } 263 | 264 | if (count($data['samples']) > 0) { 265 | $result[] = new MetricFamilySamples($data); 266 | } 267 | } 268 | 269 | return $result; 270 | } 271 | 272 | /** 273 | * @return MetricFamilySamples[] 274 | */ 275 | protected function collectCounters(): array 276 | { 277 | return $this->collectStandard(Counter::TYPE); 278 | } 279 | 280 | /** 281 | * @return MetricFamilySamples[] 282 | */ 283 | protected function collectStandard(string $type): array 284 | { 285 | $result = []; 286 | 287 | $meta_query = $this->getMetaQuery(); 288 | $meta_query->execute([':type' => $type]); 289 | 290 | while ($row = $meta_query->fetch(\PDO::FETCH_ASSOC)) { 291 | $data = json_decode($row['metadata'], true); 292 | $data['samples'] = []; 293 | 294 | switch ($this->database->getAttribute(\PDO::ATTR_DRIVER_NAME)) { 295 | case 'pgsql': 296 | $values_query = $this->database->prepare("SELECT name, labels, value FROM \"{$this->prefix}_values\" WHERE name = :name AND type = :type"); 297 | break; 298 | default: 299 | $values_query = $this->database->prepare("SELECT name, labels, value FROM `{$this->prefix}_values` WHERE name = :name AND type = :type"); 300 | } 301 | 302 | $values_query->execute([ 303 | ':name' => $data['name'], 304 | ':type' => $type, 305 | ]); 306 | while ($value_row = $values_query->fetch(\PDO::FETCH_ASSOC)) { 307 | $data['samples'][] = [ 308 | 'name' => $value_row['name'], 309 | 'labelNames' => [], 310 | 'labelValues' => json_decode($value_row['labels'], true), 311 | 'value' => $value_row['value'], 312 | ]; 313 | } 314 | 315 | usort($data['samples'], function ($a, $b): int { 316 | return strcmp(implode("", $a['labelValues']), implode("", $b['labelValues'])); 317 | }); 318 | 319 | $result[] = new MetricFamilySamples($data); 320 | } 321 | 322 | return $result; 323 | } 324 | 325 | /** 326 | * @return MetricFamilySamples[] 327 | */ 328 | protected function collectGauges(): array 329 | { 330 | return $this->collectStandard(Gauge::TYPE); 331 | } 332 | 333 | /** 334 | * @param mixed[] $data 335 | * @return void 336 | */ 337 | public function updateHistogram(array $data): void 338 | { 339 | $this->updateMetadata($data, Histogram::TYPE); 340 | 341 | switch ($this->database->getAttribute(\PDO::ATTR_DRIVER_NAME)) { 342 | case 'sqlite': 343 | $values_sql = <<prefix}_histograms`(`name`, `labels_hash`, `labels`, `value`, `bucket`) 345 | VALUES(:name,:hash,:labels,:value,:bucket) 346 | ON CONFLICT(name, labels_hash, bucket) DO UPDATE SET 347 | `value` = `value` + excluded.value; 348 | SQL; 349 | break; 350 | 351 | case 'mysql': 352 | $values_sql = <<prefix}_histograms`(`name`, `labels_hash`, `labels`, `value`, `bucket`) 354 | VALUES(:name,:hash,:labels,:value,:bucket) 355 | ON DUPLICATE KEY UPDATE 356 | `value` = `value` + VALUES(`value`); 357 | SQL; 358 | break; 359 | 360 | case 'pgsql': 361 | $values_sql = <<prefix}_histograms"("name", "labels_hash", "labels", "value", "bucket") 363 | VALUES(:name,:hash,:labels,:value,:bucket) 364 | ON CONFLICT("name", "labels_hash", "bucket") DO UPDATE SET 365 | "value" = "{$this->prefix}_histograms"."value" + "excluded"."value"; 366 | SQL; 367 | break; 368 | 369 | default: 370 | throw new \RuntimeException('Unsupported database type'); 371 | } 372 | 373 | 374 | $statement = $this->database->prepare($values_sql); 375 | $label_values = $this->encodeLabelValues($data); 376 | $statement->execute([ 377 | ':name' => $data['name'], 378 | ':hash' => hash('sha256', $label_values), 379 | ':labels' => $label_values, 380 | ':value' => $data['value'], 381 | ':bucket' => 'sum', 382 | ]); 383 | 384 | $bucket_to_increase = '+Inf'; 385 | foreach ($data['buckets'] as $bucket) { 386 | if ($data['value'] <= $bucket) { 387 | $bucket_to_increase = $bucket; 388 | break; 389 | } 390 | } 391 | 392 | $statement->execute([ 393 | ':name' => $data['name'], 394 | ':hash' => hash('sha256', $label_values), 395 | ':labels' => $label_values, 396 | ':value' => 1, 397 | ':bucket' => $bucket_to_increase, 398 | ]); 399 | } 400 | 401 | /** 402 | * @param mixed[] $data 403 | * @return void 404 | */ 405 | public function updateSummary(array $data): void 406 | { 407 | $this->updateMetadata($data, Summary::TYPE); 408 | 409 | switch ($this->database->getAttribute(\PDO::ATTR_DRIVER_NAME)) { 410 | case 'pgsql': 411 | $values_sql = <<prefix}_summaries"("name", "labels_hash", "labels", "value", "time") 413 | VALUES(:name,:hash,:labels,:value,:time) 414 | SQL; 415 | break; 416 | default: 417 | $values_sql = <<prefix}_summaries`(`name`, `labels_hash`, `labels`, `value`, `time`) 419 | VALUES(:name,:hash,:labels,:value,:time) 420 | SQL; 421 | } 422 | 423 | $statement = $this->database->prepare($values_sql); 424 | $label_values = $this->encodeLabelValues($data); 425 | $statement->execute([ 426 | ':name' => $data['name'], 427 | ':hash' => hash('sha256', $label_values), 428 | ':labels' => $label_values, 429 | ':value' => $data['value'], 430 | ':time' => time(), 431 | ]); 432 | } 433 | 434 | /** 435 | * @param mixed[] $data 436 | */ 437 | public function updateGauge(array $data): void 438 | { 439 | $this->updateStandard($data, Gauge::TYPE); 440 | } 441 | 442 | /** 443 | * @param mixed[] $data 444 | */ 445 | public function updateCounter(array $data): void 446 | { 447 | $this->updateStandard($data, Counter::TYPE); 448 | } 449 | 450 | /** 451 | * @param mixed[] $data 452 | */ 453 | protected function updateMetadata(array $data, string $type): void 454 | { 455 | // TODO do we update metadata at all? If metadata changes then the old labels might not be correct any more? 456 | switch ($this->database->getAttribute(\PDO::ATTR_DRIVER_NAME)) { 457 | case 'sqlite': 458 | $metadata_sql = <<prefix}_metadata` 460 | VALUES(:name, :type, :metadata) 461 | ON CONFLICT(name, type) DO UPDATE SET 462 | `metadata` = excluded.metadata; 463 | SQL; 464 | break; 465 | 466 | case 'mysql': 467 | $metadata_sql = <<prefix}_metadata` 469 | VALUES(:name, :type, :metadata) 470 | ON DUPLICATE KEY UPDATE 471 | `metadata` = VALUES(`metadata`); 472 | SQL; 473 | break; 474 | 475 | case 'pgsql': 476 | $metadata_sql = <<prefix}_metadata" 478 | VALUES(:name, :type, :metadata) 479 | ON CONFLICT("name", "type") DO UPDATE SET 480 | "metadata" = "excluded"."metadata"; 481 | SQL; 482 | break; 483 | 484 | default: 485 | throw new \RuntimeException('Unsupported database type'); 486 | } 487 | $statement = $this->database->prepare($metadata_sql); 488 | $statement->execute([ 489 | ':name' => $data['name'], 490 | ':type' => $type, 491 | ':metadata' => $this->encodeMetadata($data), 492 | ]); 493 | } 494 | 495 | /** 496 | * @param mixed[] $data 497 | */ 498 | protected function updateStandard(array $data, string $type): void 499 | { 500 | $this->updateMetadata($data, $type); 501 | 502 | switch ($this->database->getAttribute(\PDO::ATTR_DRIVER_NAME)) { 503 | case 'sqlite': 504 | if ($data['command'] === Adapter::COMMAND_SET) { 505 | $values_sql = <<prefix}_values`(`name`, `type`, `labels_hash`, `labels`, `value`) 507 | VALUES(:name,:type,:hash,:labels,:value) 508 | ON CONFLICT(name, type, labels_hash) DO UPDATE SET 509 | `value` = excluded.value; 510 | SQL; 511 | } else { 512 | $values_sql = <<prefix}_values`(`name`, `type`, `labels_hash`, `labels`, `value`) 514 | VALUES(:name,:type,:hash,:labels,:value) 515 | ON CONFLICT(name, type, labels_hash) DO UPDATE SET 516 | `value` = `value` + excluded.value; 517 | SQL; 518 | } 519 | break; 520 | 521 | case 'mysql': 522 | if ($data['command'] === Adapter::COMMAND_SET) { 523 | $values_sql = <<prefix}_values`(`name`, `type`, `labels_hash`, `labels`, `value`) 525 | VALUES(:name,:type,:hash,:labels,:value) 526 | ON DUPLICATE KEY UPDATE 527 | `value` = VALUES(`value`); 528 | SQL; 529 | } else { 530 | $values_sql = <<prefix}_values`(`name`, `type`, `labels_hash`, `labels`, `value`) 532 | VALUES(:name,:type,:hash,:labels,:value) 533 | ON DUPLICATE KEY UPDATE 534 | `value` = `value` + VALUES(`value`); 535 | SQL; 536 | } 537 | break; 538 | 539 | case 'pgsql': 540 | if ($data['command'] === Adapter::COMMAND_SET) { 541 | $values_sql = <<prefix}_values"("name", "type", "labels_hash", "labels", "value") 543 | VALUES(:name,:type,:hash,:labels,:value) 544 | ON CONFLICT("name", "type", "labels_hash") DO UPDATE SET 545 | "value" = "excluded"."value"; 546 | SQL; 547 | } else { 548 | $values_sql = <<prefix}_values"("name", "type", "labels_hash", "labels", "value") 550 | VALUES(:name,:type,:hash,:labels,:value) 551 | ON CONFLICT("name", "type", "labels_hash") DO UPDATE SET 552 | "value" = "{$this->prefix}_values"."value" + "excluded"."value"; 553 | SQL; 554 | } 555 | break; 556 | 557 | default: 558 | throw new \RuntimeException('Unsupported database type'); 559 | } 560 | 561 | $statement = $this->database->prepare($values_sql); 562 | $label_values = $this->encodeLabelValues($data); 563 | $statement->execute([ 564 | ':name' => $data['name'], 565 | ':type' => $type, 566 | ':hash' => hash('sha256', $label_values), 567 | ':labels' => $label_values, 568 | ':value' => $data['value'], 569 | ]); 570 | } 571 | 572 | protected function createTables(): void 573 | { 574 | $driver = $this->database->getAttribute(\PDO::ATTR_DRIVER_NAME); 575 | 576 | switch ($driver) { 577 | case 'pgsql': 578 | $sql = <<prefix}_metadata" ( 580 | "name" varchar(255) NOT NULL, 581 | "type" varchar(9) NOT NULL, 582 | "metadata" text NOT NULL, 583 | PRIMARY KEY ("name", "type") 584 | ) 585 | SQL; 586 | break; 587 | default: 588 | $sql = <<prefix}_metadata` ( 590 | `name` varchar(255) NOT NULL, 591 | `type` varchar(9) NOT NULL, 592 | `metadata` text NOT NULL, 593 | PRIMARY KEY (`name`, `type`) 594 | ) 595 | SQL; 596 | } 597 | 598 | $this->database->query($sql); 599 | 600 | $hash_size = $driver == 'sqlite' ? 32 : 64; 601 | 602 | switch ($driver) { 603 | case 'pgsql': 604 | $sql = <<prefix}_values" ( 606 | "name" varchar(255) NOT NULL, 607 | "type" varchar(9) NOT NULL, 608 | "labels_hash" varchar({$hash_size}) NOT NULL, 609 | "labels" TEXT NOT NULL, 610 | "value" DOUBLE PRECISION DEFAULT 0.0, 611 | PRIMARY KEY ("name", "type", "labels_hash") 612 | ) 613 | SQL; 614 | break; 615 | default: 616 | $sql = <<prefix}_values` ( 618 | `name` varchar(255) NOT NULL, 619 | `type` varchar(9) NOT NULL, 620 | `labels_hash` varchar({$hash_size}) NOT NULL, 621 | `labels` TEXT NOT NULL, 622 | `value` double DEFAULT 0.0, 623 | PRIMARY KEY (`name`, `type`, `labels_hash`) 624 | ) 625 | SQL; 626 | } 627 | 628 | $this->database->query($sql); 629 | 630 | $timestamp_type = $driver == 'sqlite' ? 'timestamp' : 'int'; 631 | $sqlIndex = null; 632 | 633 | switch ($driver) { 634 | case 'sqlite': 635 | $sql = <<prefix}_summaries` ( 637 | `name` varchar(255) NOT NULL, 638 | `labels_hash` varchar({$hash_size}) NOT NULL, 639 | `labels` TEXT NOT NULL, 640 | `value` double DEFAULT 0.0, 641 | `time` {$timestamp_type} NOT NULL 642 | ); 643 | SQL; 644 | $sqlIndex = "CREATE INDEX IF NOT EXISTS `name` ON `{$this->prefix}_summaries`(`name`);"; 645 | break; 646 | 647 | case 'mysql': 648 | $sql = <<prefix}_summaries` ( 650 | `name` varchar(255) NOT NULL, 651 | `labels_hash` varchar({$hash_size}) NOT NULL, 652 | `labels` TEXT NOT NULL, 653 | `value` double DEFAULT 0.0, 654 | `time` {$timestamp_type} NOT NULL, 655 | KEY `name` (`name`) 656 | ); 657 | SQL; 658 | break; 659 | 660 | case 'pgsql': 661 | $sql = <<prefix}_summaries" ( 663 | "name" varchar(255) NOT NULL, 664 | "labels_hash" varchar({$hash_size}) NOT NULL, 665 | "labels" TEXT NOT NULL, 666 | "value" DOUBLE PRECISION DEFAULT 0.0, 667 | "time" {$timestamp_type} NOT NULL 668 | ); 669 | SQL; 670 | $sqlIndex = "CREATE INDEX IF NOT EXISTS \"name\" ON \"{$this->prefix}_summaries\" (\"name\");"; 671 | break; 672 | } 673 | 674 | $this->database->query($sql); 675 | if ($sqlIndex !== null) { 676 | $this->database->query($sqlIndex); 677 | } 678 | 679 | switch ($driver) { 680 | case 'pgsql': 681 | $sql = <<prefix}_histograms" ( 683 | "name" varchar(255) NOT NULL, 684 | "labels_hash" varchar({$hash_size}) NOT NULL, 685 | "labels" TEXT NOT NULL, 686 | "value" DOUBLE PRECISION DEFAULT 0.0, 687 | "bucket" varchar(255) NOT NULL, 688 | PRIMARY KEY ("name", "labels_hash", "bucket") 689 | ); 690 | SQL; 691 | break; 692 | default: 693 | $sql = <<prefix}_histograms` ( 695 | `name` varchar(255) NOT NULL, 696 | `labels_hash` varchar({$hash_size}) NOT NULL, 697 | `labels` TEXT NOT NULL, 698 | `value` double DEFAULT 0.0, 699 | `bucket` varchar(255) NOT NULL, 700 | PRIMARY KEY (`name`, `labels_hash`, `bucket`) 701 | ); 702 | SQL; 703 | } 704 | 705 | $this->database->query($sql); 706 | } 707 | 708 | /** 709 | * @param mixed[] $data 710 | * @return string 711 | */ 712 | protected function encodeMetadata(array $data): string 713 | { 714 | unset($data['value'], $data['command'], $data['labelValues']); 715 | $json = json_encode($data); 716 | if (false === $json) { 717 | throw new \RuntimeException(json_last_error_msg()); 718 | } 719 | return $json; 720 | } 721 | 722 | /** 723 | * @param mixed[] $data 724 | * @return string 725 | */ 726 | protected function encodeLabelValues(array $data): string 727 | { 728 | $json = json_encode($data['labelValues']); 729 | if (false === $json) { 730 | throw new \RuntimeException(json_last_error_msg()); 731 | } 732 | return $json; 733 | } 734 | 735 | /** 736 | * @return \PDOStatement 737 | */ 738 | private function getMetaQuery() 739 | { 740 | switch ($this->database->getAttribute(\PDO::ATTR_DRIVER_NAME)) { 741 | case 'pgsql': 742 | return $this->database->prepare("SELECT name, metadata FROM \"{$this->prefix}_metadata\" WHERE type = :type"); 743 | default: 744 | return $this->database->prepare("SELECT name, metadata FROM `{$this->prefix}_metadata` WHERE type = :type"); 745 | } 746 | } 747 | } 748 | -------------------------------------------------------------------------------- /src/Prometheus/Storage/APCng.php: -------------------------------------------------------------------------------- 1 | > 40 | */ 41 | private $metaCache = []; 42 | 43 | /** 44 | * APCng constructor. 45 | * 46 | * @param string $prometheusPrefix Prefix for APCu keys (defaults to {@see PROMETHEUS_PREFIX}). 47 | * 48 | * @throws StorageException 49 | */ 50 | public function __construct(string $prometheusPrefix = self::PROMETHEUS_PREFIX, int $decimalPrecision = 3) 51 | { 52 | if (!extension_loaded('apcu')) { 53 | throw new StorageException('APCu extension is not loaded'); 54 | } 55 | if (!apcu_enabled()) { 56 | throw new StorageException('APCu is not enabled'); 57 | } 58 | 59 | $this->prometheusPrefix = $prometheusPrefix; 60 | $this->metainfoCacheKey = implode(':', [ $this->prometheusPrefix, 'metainfocache' ]); 61 | $this->metaInfoCounterKey = implode(':', [ $this->prometheusPrefix, 'metainfocounter' ]); 62 | $this->metaInfoCountedMetricKeyPattern = implode(':', [ $this->prometheusPrefix, 'metainfocountedmetric_#COUNTER#' ]); 63 | 64 | if ($decimalPrecision < 0 || $decimalPrecision > 6) { 65 | throw new UnexpectedValueException( 66 | sprintf('Decimal precision %d is not from interval <0;6>.', $decimalPrecision) 67 | ); 68 | } 69 | 70 | $this->precisionMultiplier = 10 ** $decimalPrecision; 71 | } 72 | 73 | /** 74 | * @return MetricFamilySamples[] 75 | */ 76 | public function collect(bool $sortMetrics = true): array 77 | { 78 | $metrics = $this->collectHistograms(); 79 | $metrics = array_merge($metrics, $this->collectGauges($sortMetrics)); 80 | $metrics = array_merge($metrics, $this->collectCounters($sortMetrics)); 81 | $metrics = array_merge($metrics, $this->collectSummaries()); 82 | return $metrics; 83 | } 84 | 85 | /** 86 | * @param mixed[] $data 87 | * @throws RuntimeException 88 | */ 89 | public function updateHistogram(array $data): void 90 | { 91 | // Initialize or atomically increment the sum 92 | // Taken from https://github.com/prometheus/client_golang/blob/66058aac3a83021948e5fb12f1f408ff556b9037/prometheus/value.go#L91 93 | $sumKey = $this->histogramBucketValueKey($data, 'sum'); 94 | 95 | $old = apcu_fetch($sumKey); 96 | 97 | if ($old === false) { 98 | // If sum does not exist, initialize it, store the metadata for the new histogram 99 | apcu_add($sumKey, 0, 0); 100 | $this->storeMetadata($data); 101 | $this->storeLabelKeys($data); 102 | } 103 | 104 | $this->incrementKeyWithValue($sumKey, $data['value']); 105 | 106 | // Figure out in which bucket the observation belongs 107 | $bucketToIncrease = '+Inf'; 108 | foreach ($data['buckets'] as $bucket) { 109 | if ($data['value'] <= $bucket) { 110 | $bucketToIncrease = $bucket; 111 | break; 112 | } 113 | } 114 | 115 | // Initialize and increment the bucket 116 | $bucketKey = $this->histogramBucketValueKey($data, $bucketToIncrease); 117 | if (!apcu_exists($bucketKey)) { 118 | apcu_add($bucketKey, 0); 119 | } 120 | apcu_inc($bucketKey); 121 | } 122 | 123 | /** 124 | * For each second, store an incrementing counter which points to each individual observation, like this: 125 | * prom:bla..blabla:value:16781560:observations = 199 126 | * Then we know that for the 1-second period at unix timestamp 16781560, 199 observations are stored, and they can 127 | * be retrieved using APC keynames "prom:...:16781560.0" thorough "prom:...:16781560.198" 128 | * We can deterministically calculate the intervening timestamps by subtracting maxAge, to get a range of seconds 129 | * when generating a summary, e.g. 16781560 back to 16780960 for a 600sec maxAge. Then collect observation counts 130 | * for each second, programmatically generate the APC keys for each individual observation, and we're able to avoid 131 | * performing a full APC key scan, which can block for several seconds if APCu contains a few million keys. 132 | * 133 | * @param mixed[] $data 134 | * @throws RuntimeException 135 | */ 136 | public function updateSummary(array $data): void 137 | { 138 | // store value key; store metadata & labels if new 139 | $valueKey = $this->valueKey($data); 140 | $new = apcu_add($valueKey, $this->encodeLabelValues($data['labelValues']), 0); 141 | if ($new) { 142 | $this->storeMetadata($data, false); 143 | $this->storeLabelKeys($data); 144 | } 145 | $sampleKeyPrefix = $valueKey . ':' . time(); 146 | $sampleCountKey = $sampleKeyPrefix . ':observations'; 147 | 148 | // Check if sample counter for this timestamp already exists, so we can deterministically 149 | // store observations+counts, one key per second 150 | // Atomic increment of the observation counter, or initialize if new 151 | $sampleCount = apcu_fetch($sampleCountKey); 152 | 153 | if ($sampleCount === false) { 154 | $sampleCount = 0; 155 | apcu_add($sampleCountKey, $sampleCount, $data['maxAgeSeconds']); 156 | } 157 | 158 | $this->doIncrementKeyWithValue($sampleCountKey, 1); 159 | 160 | // We now have a deterministic keyname for this observation; let's save the observed value 161 | $sampleKey = $sampleKeyPrefix . '.' . $sampleCount; 162 | apcu_add($sampleKey, $data['value'], $data['maxAgeSeconds']); 163 | } 164 | 165 | /** 166 | * @param mixed[] $data 167 | * @throws RuntimeException 168 | */ 169 | public function updateGauge(array $data): void 170 | { 171 | $valueKey = $this->valueKey($data); 172 | $old = apcu_fetch($valueKey); 173 | if ($data['command'] === Adapter::COMMAND_SET) { 174 | $new = $this->convertToIncrementalInteger($data['value']); 175 | if ($old === false) { 176 | apcu_store($valueKey, $new, 0); 177 | $this->storeMetadata($data); 178 | $this->storeLabelKeys($data); 179 | 180 | return; 181 | } 182 | 183 | for ($loops = 0; $loops < self::MAX_LOOPS; $loops++) { 184 | if (apcu_cas($valueKey, $old, $new)) { 185 | break; 186 | } 187 | $old = apcu_fetch($valueKey); 188 | if ($old === false) { 189 | apcu_store($valueKey, $new, 0); 190 | $this->storeMetadata($data); 191 | $this->storeLabelKeys($data); 192 | 193 | return; 194 | } 195 | } 196 | 197 | return; 198 | } 199 | 200 | if ($old === false) { 201 | apcu_add($valueKey, 0, 0); 202 | $this->storeMetadata($data); 203 | $this->storeLabelKeys($data); 204 | } 205 | 206 | if ($data['value'] > 0) { 207 | $this->incrementKeyWithValue($valueKey, $data['value']); 208 | } elseif ($data['value'] < 0) { 209 | $this->decrementKeyWithValue($valueKey, -$data['value']); 210 | } 211 | } 212 | 213 | /** 214 | * @param mixed[] $data 215 | * @throws RuntimeException 216 | */ 217 | public function updateCounter(array $data): void 218 | { 219 | $valueKey = $this->valueKey($data); 220 | $old = apcu_fetch($valueKey); 221 | 222 | if ($old === false) { 223 | apcu_add($valueKey, 0, 0); 224 | $this->storeMetadata($data); 225 | $this->storeLabelKeys($data); 226 | } 227 | 228 | $this->incrementKeyWithValue($valueKey, $data['value']); 229 | } 230 | 231 | /** 232 | * @param array $metaData 233 | * @param string $labels 234 | * @return string 235 | */ 236 | private function assembleLabelKey(array $metaData, string $labels): string 237 | { 238 | return implode(':', [ $this->prometheusPrefix, $metaData['type'], $metaData['name'], $labels, 'label' ]); 239 | } 240 | 241 | /** 242 | * Store ':label' keys for each metric's labelName in APCu. 243 | * 244 | * @param array $data 245 | * @return void 246 | */ 247 | private function storeLabelKeys(array $data): void 248 | { 249 | // Store labelValues in each labelName key 250 | foreach ($data['labelNames'] as $seq => $label) { 251 | $this->addItemToKey(implode(':', [ 252 | $this->prometheusPrefix, 253 | $data['type'], 254 | $data['name'], 255 | $label, 256 | 'label' 257 | ]), isset($data['labelValues']) ? (string)$data['labelValues'][$seq] : ''); // may not need the isset check 258 | } 259 | } 260 | 261 | /** 262 | * Ensures an array serialized into APCu contains exactly one copy of a given string 263 | * 264 | * @return void 265 | * @throws RuntimeException 266 | */ 267 | private function addItemToKey(string $key, string $item): void 268 | { 269 | // Modify serialized array stored in $key 270 | $arr = apcu_fetch($key); 271 | if (false === $arr) { 272 | $arr = []; 273 | } 274 | $_item = $this->encodeLabelKey($item); 275 | if (!array_key_exists($_item, $arr)) { 276 | $arr[$_item] = 1; 277 | apcu_store($key, $arr, 0); 278 | } 279 | } 280 | 281 | /** 282 | * Removes all previously stored data from apcu 283 | * 284 | * NOTE: This is non-atomic: while it's iterating APCu, another thread could write a new Prometheus key that doesn't get erased. 285 | * In case this happens, getMetas() calls scanAndBuildMetainfoCache before reading metainfo back: this will ensure "orphaned" 286 | * metainfo gets enumerated. 287 | * 288 | * @return void 289 | */ 290 | public function wipeStorage(): void 291 | { 292 | // / / | PCRE expresion boundary 293 | // ^ | match from first character only 294 | // %s: | common prefix substitute with colon suffix 295 | // .+ | at least one additional character 296 | $matchAll = sprintf('/^%s:.+/', $this->prometheusPrefix); 297 | 298 | foreach (new APCuIterator($matchAll, APC_ITER_KEY) as $key) { 299 | apcu_delete($key); 300 | } 301 | 302 | apcu_delete($this->metaInfoCounterKey); 303 | apcu_delete($this->metainfoCacheKey); 304 | } 305 | 306 | /** 307 | * Scans the APCu keyspace for all metainfo keys. A new metainfo cache array is built, 308 | * which references all metadata keys in APCu at that moment. This prevents a corner-case 309 | * where an orphaned key, while remaining writable, is rendered permanently invisible when reading 310 | * or enumerating metrics. 311 | * 312 | * Writing the cache to APCu allows it to be shared by other threads and by subsequent calls to getMetas(). This 313 | * reduces contention on APCu from repeated scans, and provides about a 2.5x speed-up when calling $this->collect(). 314 | * The cache TTL is very short (default: 1sec), so if new metrics are tracked after the cache is built, they will 315 | * be readable at most 1 second after being written. 316 | * 317 | * @return array}>> 318 | */ 319 | private function scanAndBuildMetainfoCache(): array 320 | { 321 | $arr = []; 322 | 323 | $counter = (int) apcu_fetch($this->metaInfoCounterKey); 324 | 325 | for ($i = 1; $i <= $counter; $i++) { 326 | $metaCounterKey = $this->metaCounterKey($i); 327 | $metaKey = apcu_fetch($metaCounterKey); 328 | 329 | if (!is_string($metaKey)) { 330 | throw new UnexpectedValueException( 331 | sprintf('Invalid meta counter key: %s', $metaCounterKey) 332 | ); 333 | } 334 | 335 | if (preg_match('/' . $this->prometheusPrefix . ':([^:]+):.*:meta/', $metaKey, $matches) !== 1) { 336 | throw new UnexpectedValueException( 337 | sprintf('Invalid meta key: %s', $metaKey) 338 | ); 339 | } 340 | 341 | $type = $matches[1]; 342 | 343 | if (!isset($arr[$type])) { 344 | $arr[$type] = []; 345 | } 346 | 347 | /** @var array|false $metaInfo */ 348 | $metaInfo = apcu_fetch($metaKey); 349 | 350 | if ($metaInfo === false) { 351 | throw new UnexpectedValueException( 352 | sprintf('Meta info missing for meta key: %s', $metaKey) 353 | ); 354 | } 355 | 356 | $arr[$type][] = ['key' => $metaKey, 'value' => $metaInfo]; 357 | } 358 | 359 | apcu_store($this->metainfoCacheKey, $arr, 0); 360 | 361 | return $arr; 362 | } 363 | 364 | /** 365 | * @param mixed[] $data 366 | * @return string 367 | */ 368 | private function metaKey(array $data): string 369 | { 370 | return implode(':', [$this->prometheusPrefix, $data['type'], $data['name'], 'meta']); 371 | } 372 | 373 | /** 374 | * @param mixed[] $data 375 | * @return string 376 | */ 377 | private function valueKey(array $data): string 378 | { 379 | return implode(':', [ 380 | $this->prometheusPrefix, 381 | $data['type'], 382 | $data['name'], 383 | $this->encodeLabelValues($data['labelValues']), 384 | 'value', 385 | ]); 386 | } 387 | 388 | /** 389 | * @param mixed[] $data 390 | * @param string|int $bucket 391 | * @return string 392 | */ 393 | private function histogramBucketValueKey(array $data, $bucket): string 394 | { 395 | return implode(':', [ 396 | $this->prometheusPrefix, 397 | $data['type'], 398 | $data['name'], 399 | $this->encodeLabelValues($data['labelValues']), 400 | $bucket, 401 | 'value', 402 | ]); 403 | } 404 | 405 | /** 406 | * @param mixed[] $data 407 | * @return mixed[] 408 | */ 409 | private function metaData(array $data): array 410 | { 411 | $metricsMetaData = $data; 412 | unset($metricsMetaData['value'], $metricsMetaData['command'], $metricsMetaData['labelValues']); 413 | return $metricsMetaData; 414 | } 415 | 416 | /** 417 | * When given a ragged 2D array $labelValues of arbitrary size, and a 1D array $labelNames containing one 418 | * string labeling each row of $labelValues, return an array-of-arrays containing all possible permutations 419 | * of labelValues, with the sub-array elements in order of labelName. 420 | * 421 | * Example input: 422 | * $labelNames: ['endpoint', 'method', 'result'] 423 | * $labelValues: [0] => ['/', '/private', '/metrics'], // "endpoint" 424 | * [1] => ['put', 'get', 'post'], // "method" 425 | * [2] => ['success', 'fail'] // "result" 426 | * Returned array: 427 | * [0] => ['/', 'put', 'success'], [1] => ['/', 'put', 'fail'], [2] => ['/', 'get', 'success'], 428 | * [3] => ['/', 'get', 'fail'], [4] => ['/', 'post', 'success'], [5] => ['/', 'post', 'fail'], 429 | * [6] => ['/private', 'put', 'success'], [7] => ['/private', 'put', 'fail'], [8] => ['/private', 'get', 'success'], 430 | * [9] => ['/private', 'get', 'fail'], [10] => ['/private', 'post', 'success'], [11] => ['/private', 'post', 'fail'], 431 | * [12] => ['/metrics', 'put', 'success'], [13] => ['/metrics', 'put', 'fail'], [14] => ['/metrics', 'get', 'success'], 432 | * [15] => ['/metrics', 'get', 'fail'], [16] => ['/metrics', 'post', 'success'], [17] => ['/metrics', 'post', 'fail'] 433 | * @param array $labelValues 434 | * @return \Generator 435 | */ 436 | private function buildPermutationTree(array $labelValues): \Generator /** @phpstan-ignore-line */ 437 | { 438 | if (count($labelValues) > 0) { 439 | $lastIndex = array_key_last($labelValues); 440 | $currentValue = array_pop($labelValues); 441 | if ($currentValue != null) { 442 | foreach ($this->buildPermutationTree($labelValues) as $prefix) { 443 | foreach ($currentValue as $value) { 444 | yield $prefix + [$lastIndex => $value]; 445 | } 446 | } 447 | } 448 | } else { 449 | yield []; 450 | } 451 | } 452 | 453 | /** 454 | * @return MetricFamilySamples[] 455 | */ 456 | private function collectCounters(bool $sortMetrics = true): array 457 | { 458 | $counters = []; 459 | foreach ($this->getMetas('counter') as $counter) { 460 | $metaData = json_decode($counter['value'], true); 461 | $data = [ 462 | 'name' => $metaData['name'], 463 | 'help' => $metaData['help'], 464 | 'type' => $metaData['type'], 465 | 'labelNames' => $metaData['labelNames'], 466 | 'samples' => [], 467 | ]; 468 | foreach ($this->getValues('counter', $metaData) as $value) { 469 | $parts = explode(':', $value['key']); 470 | $labelValues = $parts[3]; 471 | $data['samples'][] = [ 472 | 'name' => $metaData['name'], 473 | 'labelNames' => [], 474 | 'labelValues' => $this->decodeLabelValues($labelValues), 475 | 'value' => $this->convertIncrementalIntegerToFloat($value['value']), 476 | ]; 477 | } 478 | 479 | if ($sortMetrics) { 480 | $this->sortSamples($data['samples']); 481 | } 482 | 483 | $counters[] = new MetricFamilySamples($data); 484 | } 485 | return $counters; 486 | } 487 | 488 | /** 489 | * When given a type ('histogram', 'gauge', or 'counter'), return an iterable array of matching records retrieved from APCu 490 | * 491 | * @param string $type 492 | * @return array 493 | */ 494 | private function getMetas(string $type): array /** @phpstan-ignore-line */ 495 | { 496 | $arr = []; 497 | $counterModified = 0; 498 | $counterModifiedInfo = apcu_key_info($this->metaInfoCounterKey); 499 | 500 | if ($counterModifiedInfo !== null) { 501 | $counterModified = (int) $counterModifiedInfo['mtime']; 502 | } 503 | 504 | $cacheModified = 0; 505 | $cacheModifiedInfo = apcu_key_info($this->metainfoCacheKey); 506 | 507 | if ($cacheModifiedInfo !== null) { 508 | $cacheModified = (int) $cacheModifiedInfo['mtime']; 509 | } 510 | 511 | $cacheNeedsRebuild = $counterModified >= $cacheModified || $cacheModified === 0; 512 | $metaCache = null; 513 | 514 | if (isset($this->metaCache[$type]) && !$cacheNeedsRebuild) { 515 | return $this->metaCache[$type]; 516 | } 517 | 518 | if ($cacheNeedsRebuild) { 519 | $metaCache = $this->scanAndBuildMetainfoCache(); 520 | } 521 | 522 | if ($metaCache === null) { 523 | $metaCache = apcu_fetch($this->metainfoCacheKey); 524 | } 525 | 526 | $this->metaCache = $metaCache; 527 | 528 | return $this->metaCache[$type] ?? []; 529 | } 530 | 531 | /** 532 | * When given a type ('histogram', 'gauge', or 'counter') and metaData array, return an iterable array of matching records retrieved from APCu 533 | * 534 | * @param string $type 535 | * @param array $metaData 536 | * @return array 537 | */ 538 | private function getValues(string $type, array $metaData): array /** @phpstan-ignore-line */ 539 | { 540 | $labels = $arr = []; 541 | foreach (array_values($metaData['labelNames']) as $label) { 542 | $labelKey = $this->assembleLabelKey($metaData, $label); 543 | if (is_array($tmp = apcu_fetch($labelKey))) { 544 | $labels[] = array_map([$this, 'decodeLabelKey'], array_keys($tmp)); 545 | } 546 | } 547 | // Append the histogram bucket-list and the histogram-specific label 'sum' to labels[] then generate the permutations 548 | if (isset($metaData['buckets'])) { 549 | $metaData['buckets'][] = 'sum'; 550 | $labels[] = $metaData['buckets']; 551 | } 552 | 553 | $labelValuesList = $this->buildPermutationTree($labels); 554 | unset($labels); 555 | $histogramBucket = ''; 556 | foreach ($labelValuesList as $labelValues) { 557 | // Extract bucket value from permuted element, if present, then construct the key and retrieve 558 | if (isset($metaData['buckets'])) { 559 | $histogramBucket = ':' . array_pop($labelValues); 560 | } 561 | $key = $this->prometheusPrefix . ":{$type}:{$metaData['name']}:" . $this->encodeLabelValues($labelValues) . $histogramBucket . ':value'; 562 | if (false !== ($value = apcu_fetch($key))) { 563 | $arr[] = [ 'key' => $key, 'value' => $value ]; 564 | } 565 | } 566 | return $arr; 567 | } 568 | 569 | /** 570 | * @return MetricFamilySamples[] 571 | */ 572 | private function collectGauges(bool $sortMetrics = true): array 573 | { 574 | $gauges = []; 575 | foreach ($this->getMetas('gauge') as $gauge) { 576 | $metaData = json_decode($gauge['value'], true); 577 | $data = [ 578 | 'name' => $metaData['name'], 579 | 'help' => $metaData['help'], 580 | 'type' => $metaData['type'], 581 | 'labelNames' => $metaData['labelNames'], 582 | 'samples' => [], 583 | ]; 584 | foreach ($this->getValues('gauge', $metaData) as $value) { 585 | $parts = explode(':', $value['key']); 586 | $labelValues = $parts[3]; 587 | $data['samples'][] = [ 588 | 'name' => $metaData['name'], 589 | 'labelNames' => [], 590 | 'labelValues' => $this->decodeLabelValues($labelValues), 591 | 'value' => $this->convertIncrementalIntegerToFloat($value['value']), 592 | ]; 593 | } 594 | 595 | if ($sortMetrics) { 596 | $this->sortSamples($data['samples']); 597 | } 598 | 599 | $gauges[] = new MetricFamilySamples($data); 600 | } 601 | return $gauges; 602 | } 603 | 604 | /** 605 | * @return MetricFamilySamples[] 606 | */ 607 | private function collectHistograms(): array 608 | { 609 | $histograms = []; 610 | foreach ($this->getMetas('histogram') as $histogram) { 611 | $metaData = json_decode($histogram['value'], true); 612 | 613 | // Add the Inf bucket so we can compute it later on 614 | $metaData['buckets'][] = '+Inf'; 615 | 616 | $data = [ 617 | 'name' => $metaData['name'], 618 | 'help' => $metaData['help'], 619 | 'type' => $metaData['type'], 620 | 'labelNames' => $metaData['labelNames'], 621 | 'buckets' => $metaData['buckets'], 622 | ]; 623 | 624 | $histogramBuckets = []; 625 | foreach ($this->getValues('histogram', $metaData) as $value) { 626 | $parts = explode(':', $value['key']); 627 | $labelValues = $parts[3]; 628 | $bucket = $parts[4]; 629 | // Key by labelValues 630 | $histogramBuckets[$labelValues][$bucket] = $value['value']; 631 | } 632 | 633 | // Compute all buckets 634 | $labels = array_keys($histogramBuckets); 635 | sort($labels); 636 | foreach ($labels as $labelValues) { 637 | $acc = 0; 638 | $decodedLabelValues = $this->decodeLabelValues($labelValues); 639 | foreach ($data['buckets'] as $bucket) { 640 | $bucket = (string)$bucket; 641 | if (!isset($histogramBuckets[$labelValues][$bucket])) { 642 | $data['samples'][] = [ 643 | 'name' => $metaData['name'] . '_bucket', 644 | 'labelNames' => ['le'], 645 | 'labelValues' => array_merge($decodedLabelValues, [$bucket]), 646 | 'value' => $acc, 647 | ]; 648 | } else { 649 | $acc += $histogramBuckets[$labelValues][$bucket]; 650 | $data['samples'][] = [ 651 | 'name' => $metaData['name'] . '_' . 'bucket', 652 | 'labelNames' => ['le'], 653 | 'labelValues' => array_merge($decodedLabelValues, [$bucket]), 654 | 'value' => $acc, 655 | ]; 656 | } 657 | } 658 | 659 | // Add the count 660 | $data['samples'][] = [ 661 | 'name' => $metaData['name'] . '_count', 662 | 'labelNames' => [], 663 | 'labelValues' => $decodedLabelValues, 664 | 'value' => $acc, 665 | ]; 666 | 667 | // Add the sum 668 | $data['samples'][] = [ 669 | 'name' => $metaData['name'] . '_sum', 670 | 'labelNames' => [], 671 | 'labelValues' => $decodedLabelValues, 672 | 'value' => $this->convertIncrementalIntegerToFloat($histogramBuckets[$labelValues]['sum'] ?? 0), 673 | ]; 674 | } 675 | $histograms[] = new MetricFamilySamples($data); 676 | } 677 | return $histograms; 678 | } 679 | 680 | /** 681 | * @return MetricFamilySamples[] 682 | */ 683 | private function collectSummaries(): array 684 | { 685 | $math = new Math(); 686 | $summaries = []; 687 | foreach ($this->getMetas('summary') as $summary) { 688 | $metaData = $summary['value']; 689 | $data = [ 690 | 'name' => $metaData['name'], 691 | 'help' => $metaData['help'], 692 | 'type' => $metaData['type'], 693 | 'labelNames' => $metaData['labelNames'], 694 | 'maxAgeSeconds' => $metaData['maxAgeSeconds'], 695 | 'quantiles' => $metaData['quantiles'], 696 | 'samples' => [], 697 | ]; 698 | 699 | foreach ($this->getValues('summary', $metaData) as $value) { 700 | $encodedLabelValues = (string) $value['value']; 701 | $decodedLabelValues = $this->decodeLabelValues($encodedLabelValues); 702 | $samples = []; 703 | 704 | // Deterministically generate keys for all the sample observations, and retrieve them. Pass arrays to apcu_fetch to reduce calls to APCu. 705 | $end = time(); 706 | $begin = $end - $metaData['maxAgeSeconds']; 707 | $valueKeyPrefix = $this->valueKey(array_merge($metaData, ['labelValues' => $decodedLabelValues])); 708 | 709 | $sampleCountKeysToRetrieve = []; 710 | for ($ts = $begin; $ts <= $end; $ts++) { 711 | $sampleCountKeysToRetrieve[] = $valueKeyPrefix . ':' . $ts . ':observations'; 712 | } 713 | $sampleCounts = apcu_fetch($sampleCountKeysToRetrieve); 714 | unset($sampleCountKeysToRetrieve); 715 | if (is_array($sampleCounts)) { 716 | foreach ($sampleCounts as $k => $sampleCountThisSecond) { 717 | $tstamp = explode(':', $k)[5]; 718 | $sampleKeysToRetrieve = []; 719 | for ($i = 0; $i < $sampleCountThisSecond; $i++) { 720 | $sampleKeysToRetrieve[] = $valueKeyPrefix . ':' . $tstamp . '.' . $i; 721 | } 722 | $newSamples = apcu_fetch($sampleKeysToRetrieve); 723 | unset($sampleKeysToRetrieve); 724 | if (is_array($newSamples)) { 725 | $samples = array_merge($samples, $newSamples); 726 | } 727 | } 728 | } 729 | unset($sampleCounts); 730 | 731 | if (count($samples) === 0) { 732 | apcu_delete($value['key']); 733 | continue; 734 | } 735 | 736 | // Compute quantiles 737 | sort($samples); 738 | foreach ($data['quantiles'] as $quantile) { 739 | $data['samples'][] = [ 740 | 'name' => $metaData['name'], 741 | 'labelNames' => ['quantile'], 742 | 'labelValues' => array_merge($decodedLabelValues, [$quantile]), 743 | 'value' => $math->quantile($samples, $quantile), 744 | ]; 745 | } 746 | 747 | // Add the count 748 | $data['samples'][] = [ 749 | 'name' => $metaData['name'] . '_count', 750 | 'labelNames' => [], 751 | 'labelValues' => $decodedLabelValues, 752 | 'value' => count($samples), 753 | ]; 754 | 755 | // Add the sum 756 | $data['samples'][] = [ 757 | 'name' => $metaData['name'] . '_sum', 758 | 'labelNames' => [], 759 | 'labelValues' => $decodedLabelValues, 760 | 'value' => array_sum($samples), 761 | ]; 762 | } 763 | 764 | if (count($data['samples']) > 0) { 765 | $summaries[] = new MetricFamilySamples($data); 766 | } else { 767 | apcu_delete($summary['key']); 768 | } 769 | } 770 | return $summaries; 771 | } 772 | 773 | /** 774 | * @param int|float $val 775 | */ 776 | private function incrementKeyWithValue(string $key, $val): void 777 | { 778 | $converted = $this->convertToIncrementalInteger($val); 779 | 780 | $this->doIncrementKeyWithValue($key, $converted); 781 | } 782 | 783 | private function doIncrementKeyWithValue(string $key, int $val): void 784 | { 785 | if ($val === 0) { 786 | return; 787 | } 788 | 789 | $loops = 0; 790 | 791 | do { 792 | $loops++; 793 | $success = apcu_inc($key, $val); 794 | } while ($success === false && $loops <= self::MAX_LOOPS); /** @phpstan-ignore-line */ 795 | 796 | if ($success === false) { /** @phpstan-ignore-line */ 797 | throw new RuntimeException('Caught possible infinite loop in ' . __METHOD__ . '()'); 798 | } 799 | } 800 | 801 | /** 802 | * @param int|float $val 803 | */ 804 | private function decrementKeyWithValue(string $key, $val): void 805 | { 806 | if ($val === 0 || $val === 0.0) { 807 | return; 808 | } 809 | 810 | $converted = $this->convertToIncrementalInteger($val); 811 | $loops = 0; 812 | 813 | do { 814 | $loops++; 815 | $success = apcu_dec($key, $converted); 816 | } while ($success === false && $loops <= self::MAX_LOOPS); /** @phpstan-ignore-line */ 817 | 818 | if ($success === false) { /** @phpstan-ignore-line */ 819 | throw new RuntimeException('Caught possible infinite loop in ' . __METHOD__ . '()'); 820 | } 821 | } 822 | 823 | /** 824 | * @param int|float $val 825 | */ 826 | private function convertToIncrementalInteger($val): int 827 | { 828 | return intval($val * $this->precisionMultiplier); 829 | } 830 | 831 | private function convertIncrementalIntegerToFloat(int $val): float 832 | { 833 | return floatval((float) $val / (float) $this->precisionMultiplier); 834 | } 835 | 836 | /** 837 | * @param mixed[] $samples 838 | */ 839 | private function sortSamples(array &$samples): void 840 | { 841 | usort($samples, function ($a, $b): int { 842 | return strcmp(implode("", $a['labelValues']), implode("", $b['labelValues'])); 843 | }); 844 | } 845 | 846 | /** 847 | * @param mixed[] $values 848 | * @return string 849 | * @throws RuntimeException 850 | */ 851 | private function encodeLabelValues(array $values): string 852 | { 853 | $json = json_encode(array_map("strval", $values)); 854 | if (false === $json) { 855 | throw new RuntimeException(json_last_error_msg()); 856 | } 857 | return base64_encode($json); 858 | } 859 | 860 | /** 861 | * @param string $values 862 | * @return mixed[] 863 | * @throws RuntimeException 864 | */ 865 | private function decodeLabelValues(string $values): array 866 | { 867 | $json = base64_decode($values, true); 868 | if (false === $json) { 869 | throw new RuntimeException('Cannot base64 decode label values'); 870 | } 871 | $decodedValues = json_decode($json, true); 872 | if (false === $decodedValues) { 873 | throw new RuntimeException(json_last_error_msg()); 874 | } 875 | return $decodedValues; 876 | } 877 | 878 | /** 879 | * @param string $keyString 880 | * @return string 881 | */ 882 | private function encodeLabelKey(string $keyString): string 883 | { 884 | return base64_encode($keyString); 885 | } 886 | 887 | /** 888 | * @param string $str 889 | * @return string 890 | * @throws RuntimeException 891 | */ 892 | private function decodeLabelKey(string $str): string 893 | { 894 | $decodedKey = base64_decode($str, true); 895 | if (false === $decodedKey) { 896 | throw new RuntimeException('Cannot base64 decode label key'); 897 | } 898 | return $decodedKey; 899 | } 900 | 901 | /** 902 | * @param mixed[] $data 903 | */ 904 | private function storeMetadata(array $data, bool $encoded = true): void 905 | { 906 | $metaKey = $this->metaKey($data); 907 | if (apcu_exists($metaKey)) { 908 | return; 909 | } 910 | 911 | $metaData = $this->metaData($data); 912 | $toStore = $metaData; 913 | 914 | if ($encoded) { 915 | $toStore = json_encode($metaData); 916 | } 917 | 918 | $stored = apcu_add($metaKey, $toStore, 0); 919 | 920 | if (!$stored) { 921 | return; 922 | } 923 | 924 | apcu_add($this->metaInfoCounterKey, 0, 0); 925 | $counter = apcu_inc($this->metaInfoCounterKey); 926 | 927 | $newCountedMetricKey = $this->metaCounterKey($counter); 928 | apcu_store($newCountedMetricKey, $metaKey, 0); 929 | } 930 | 931 | private function metaCounterKey(int $counter): string 932 | { 933 | return str_replace('#COUNTER#', (string) $counter, $this->metaInfoCountedMetricKeyPattern); 934 | } 935 | } 936 | --------------------------------------------------------------------------------