diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..26c0390
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,31 @@
+name: PHPUnit Test
+
+on:
+ push:
+ branches:
+ - '**'
+
+jobs:
+ phpunit:
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ php-version: ['8.0', '8.1', '8.2', '8.3']
+ steps:
+ # Checkout the code
+ - name: Checkout code
+ uses: actions/checkout@v3
+
+ # Set up PHP with matrix version
+ - name: Set up PHP ${{ matrix.php-version }}
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php-version }}
+
+ # Install dependencies via Composer
+ - name: Install dependencies
+ uses: php-actions/composer@v6
+
+ # Run PHPUnit tests
+ - name: Run PHPUnit
+ run: ./vendor/bin/phpunit
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..2976ea6
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+/vendor/
+/composer.lock
+/.phpunit.result.cache
+/.idea
diff --git a/LICENSE b/LICENSE
index 1349250..fd46c23 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2024 belka-car
+Copyright (c) 2024 BelkaCar
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..89870ec
--- /dev/null
+++ b/README.md
@@ -0,0 +1,22 @@
+# Gauge Exporter PHP Client
+
+PHP Client for [Gague Exporter](https://github.com/belka-car/gague-exporter).
+
+
+## Usage example
+```php
+increment(['a' => 'b'], 100);
+$bag->increment(['a' => 'b', 'c' => 'd'], 500);
+
+$client = new GaugeExporterClient(new Client(), 'https://127.0.0.1:8181', ['env' => 'prod']);
+$client->send($bag, 150);
+```
diff --git a/composer.json b/composer.json
new file mode 100755
index 0000000..3b24166
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,32 @@
+{
+ "name": "belkacar/gauge-exporter-client",
+ "description": "Gauge Exporter PHP client",
+ "type": "library",
+ "license": "MIT",
+ "require": {
+ "php": "^8.0",
+ "ext-json": "*",
+ "guzzlehttp/psr7": "^2.6",
+ "psr/http-client": "^1.0",
+ "webmozart/assert": "^1.11"
+ },
+ "autoload": {
+ "psr-4": {
+ "Belkacar\\GaugeExporterClient\\": "src/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Belkacar\\GaugeExporterClient\\Tests\\": "tests/"
+ }
+ },
+ "config": {
+ "allow-plugins": {
+ "php-http/discovery": true
+ }
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.6",
+ "php-http/mock-client": "^1.6"
+ }
+}
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
new file mode 100644
index 0000000..7d24a86
--- /dev/null
+++ b/phpunit.xml.dist
@@ -0,0 +1,17 @@
+
+
+
+
+ tests
+
+
+
diff --git a/src/Exception/BadResponseException.php b/src/Exception/BadResponseException.php
new file mode 100644
index 0000000..35aa595
--- /dev/null
+++ b/src/Exception/BadResponseException.php
@@ -0,0 +1,24 @@
+response = $response;
+ parent::__construct('');
+ }
+
+ public function getResponse(): ResponseInterface
+ {
+ return $this->response;
+ }
+}
diff --git a/src/GaugeExporterClient.php b/src/GaugeExporterClient.php
new file mode 100644
index 0000000..24437a9
--- /dev/null
+++ b/src/GaugeExporterClient.php
@@ -0,0 +1,61 @@
+client = $client;
+ $this->apiDomain = trim($apiDomain, " \n\r\t\v\0/");
+ $this->defaultLabels = MetricLine::normalizeLabels($defaultLabels);
+ }
+
+ /**
+ * @throws BadResponseException
+ * @throws ClientExceptionInterface
+ */
+ public function send(MetricBag $metricBag, int $ttlSec): void
+ {
+ $body = [
+ 'ttl' => $ttlSec,
+ 'data' => $this->generateData($metricBag),
+ ];
+ $request = new Request(
+ 'PUT',
+ sprintf('%s/gauge/%s', $this->apiDomain, $metricBag->getMetricName()),
+ ['Content-Type' => 'application/json'],
+ json_encode($body),
+ );
+
+ $response = $this->client->sendRequest($request);
+ if ($response->getStatusCode() !== 200) {
+ throw new BadResponseException($response);
+ }
+ }
+
+ private function generateData(MetricBag $metricBag): array
+ {
+ $result = [];
+ foreach ($metricBag->getLogLines() as $logLine) {
+ $labels = array_merge($this->defaultLabels, $logLine->getLabels());
+ $result[] = [
+ 'labels' => !empty($labels) ? $labels : new stdClass(),
+ 'value' => $logLine->getValue()
+ ];
+ }
+ return $result;
+ }
+}
diff --git a/src/MetricBag.php b/src/MetricBag.php
new file mode 100644
index 0000000..6633bc8
--- /dev/null
+++ b/src/MetricBag.php
@@ -0,0 +1,63 @@
+
+ */
+ private array $labelsToValuePair;
+
+ public function __construct(string $metricName)
+ {
+ if (!preg_match(self::METRIC_NAME_PATTERN, $metricName)) {
+ throw new InvalidArgumentException('Metric name "' . $metricName . '" does not match ' . self::METRIC_NAME_PATTERN);
+ }
+ $this->metric = $metricName;
+ $this->labelsToValuePair = [];
+ }
+
+ public function set(array $labels, float $value): void
+ {
+ $labels = MetricLine::normalizeLabels($labels);
+
+ $this->labelsToValuePair[json_encode($labels)] = $value;
+ }
+
+ public function increment(array $labels, float $value = 1): void
+ {
+ $labels = MetricLine::normalizeLabels($labels);
+
+ $key = json_encode($labels);
+ if (!array_key_exists($key, $this->labelsToValuePair)) {
+ $this->labelsToValuePair[$key] = 0;
+ }
+ $this->labelsToValuePair[$key] += $value;
+ }
+
+ public function getMetricName(): string
+ {
+ return $this->metric;
+ }
+
+ /**
+ * @return list
+ */
+ public function getLogLines(): array
+ {
+ $result = [];
+ foreach ($this->labelsToValuePair as $labels => $value) {
+ $result[] = new MetricLine(json_decode($labels, true), $value);
+ }
+ return $result;
+ }
+}
diff --git a/src/MetricLine.php b/src/MetricLine.php
new file mode 100644
index 0000000..e4a2024
--- /dev/null
+++ b/src/MetricLine.php
@@ -0,0 +1,39 @@
+labels = self::normalizeLabels($labels);
+ $this->value = $value;
+ }
+
+ public function getValue(): float
+ {
+ return $this->value;
+ }
+
+ public function getLabels(): array
+ {
+ return $this->labels;
+ }
+
+ public static function normalizeLabels(array $labels): array
+ {
+ if (count(array_filter(array_keys($labels), 'is_int')) > 0) {
+ throw new InvalidArgumentException('Labels must be specified as associative array');
+ }
+ asort($labels);
+
+ return $labels;
+ }
+}
diff --git a/tests/GaugeExporterClientTest.php b/tests/GaugeExporterClientTest.php
new file mode 100755
index 0000000..03bb76a
--- /dev/null
+++ b/tests/GaugeExporterClientTest.php
@@ -0,0 +1,192 @@
+send(new MetricBag('metric.name'), 100);
+
+ // Assert
+ $expected = 'https://example.com/gauge/metric.name';
+ $actual = (string)$psrClientMock->getLastRequest()->getUri();
+ $this->assertSame($expected, $actual);
+ }
+
+ public function testWholeRequest(): void
+ {
+ // Arrange
+ $psrClientMock = new MockClient();
+ $exporterClient = new GaugeExporterClient($psrClientMock, 'https://example.com');
+
+ // Act
+ $metricBag = new MetricBag('metric.name');
+ $metricBag->set(['key1' => 'b', 'key2' => 'd'], 10);
+ $metricBag->set(['key1' => 'b', 'key2' => 'e'], 10);
+ $exporterClient->send($metricBag, 100);
+
+ // Assert
+ $this->assertSame(
+ [
+ 'method' => 'PUT',
+ 'url' => 'https://example.com/gauge/metric.name',
+ 'data' => '{"ttl":100,"data":[{"labels":{"key1":"b","key2":"d"},"value":10},{"labels":{"key1":"b","key2":"e"},"value":10}]}',
+ ],
+ $this->simplifyRequest($psrClientMock->getLastRequest()),
+ );
+ }
+
+ public function testWholeRequestWithEmptyLabelsMetric(): void
+ {
+ // Arrange
+ $psrClientMock = new MockClient();
+ $exporterClient = new GaugeExporterClient($psrClientMock, 'https://example.com');
+
+ // Act
+ $metricBag = new MetricBag('metric.name');
+ $metricBag->set([], 10);
+ $exporterClient->send($metricBag, 100);
+
+ // Assert
+ $this->assertSame(
+ [
+ 'method' => 'PUT',
+ 'url' => 'https://example.com/gauge/metric.name',
+ 'data' => '{"ttl":100,"data":[{"labels":{},"value":10}]}',
+ ],
+ $this->simplifyRequest($psrClientMock->getLastRequest()),
+ );
+ }
+
+ public function testWholeRequestWithEmptyBag(): void
+ {
+ // Arrange
+ $psrClientMock = new MockClient();
+ $exporterClient = new GaugeExporterClient($psrClientMock, 'https://example.com');
+
+ // Act
+ $metricBag = new MetricBag('metric.name');
+ $exporterClient->send($metricBag, 100);
+
+ // Assert
+ $this->assertSame(
+ [
+ 'method' => 'PUT',
+ 'url' => 'https://example.com/gauge/metric.name',
+ 'data' => '{"ttl":100,"data":[]}',
+ ],
+ $this->simplifyRequest($psrClientMock->getLastRequest()),
+ );
+ }
+
+ public function testWholeRequestWithDefaultLabels(): void
+ {
+ // Arrange
+ $psrClientMock = new MockClient();
+ $exporterClient = new GaugeExporterClient(
+ $psrClientMock,
+ 'https://example.com',
+ [
+ 'key2' => 'default_key2',
+ 'key3' => 'default_key3',
+ ]
+ );
+
+ // Act
+ $metricBag = new MetricBag('metric.name');
+ $metricBag->set(['key1' => 'b', 'key2' => 'd'], 10);
+ $metricBag->set(['key1' => 'b'], 12);
+ $exporterClient->send($metricBag, 100);
+
+ // Assert
+ $expectedData = [
+ ['labels' => ['key2' => 'd', 'key3' => 'default_key3', 'key1' => 'b'], 'value' => 10],
+ ['labels' => ['key2' => 'default_key2', 'key3' => 'default_key3', 'key1' => 'b'], 'value' => 12],
+ ];
+ $this->assertSame(
+ [
+ 'method' => 'PUT',
+ 'url' => 'https://example.com/gauge/metric.name',
+ 'data' => '{"ttl":100,"data":' . json_encode($expectedData) . '}',
+ ],
+ $this->simplifyRequest($psrClientMock->getLastRequest()),
+ );
+ }
+
+ public function testWholeRequestWithEmptyBagAndDefaultLabels(): void
+ {
+ // Arrange
+ $psrClientMock = new MockClient();
+ $exporterClient = new GaugeExporterClient(
+ $psrClientMock,
+ 'https://example.com',
+ [
+ 'key2' => 'default_key2',
+ 'key3' => 'default_key3',
+ ]
+ );
+
+ // Act
+ $metricBag = new MetricBag('metric.name');
+ $exporterClient->send($metricBag, 100);
+
+ // Assert
+ $this->assertSame(
+ [
+ 'method' => 'PUT',
+ 'url' => 'https://example.com/gauge/metric.name',
+ 'data' => '{"ttl":100,"data":[]}',
+ ],
+ $this->simplifyRequest($psrClientMock->getLastRequest()),
+ );
+ }
+
+ public function testGaugeExporterClientThrowsExceptionWhenResponseCodeOtherThan200(): void
+ {
+ // Arrange
+ $psrClientMock = new MockClient();
+ $psrClientMock->on(
+ new RequestMatcher('gauge/metric.name', 'example.com', ['PUT'], ['https']),
+ new Response(500, ['Content-Type' => 'application/json'], '{"error": "Unexpected error"}')
+ );
+ $exporterClient = new GaugeExporterClient($psrClientMock, 'https://example.com');
+
+ // Act
+ $exception = null;
+ try {
+ $exporterClient->send(new MetricBag('metric.name'), 100);
+ } catch (BadResponseException $e) {
+ $exception = $e;
+ }
+
+ // Assert
+ $this->assertSame(BadResponseException::class, get_class($exception));
+ $this->assertSame($exception->getResponse()->getBody()->getContents(), '{"error": "Unexpected error"}');
+ }
+
+ private function simplifyRequest(RequestInterface $request): array
+ {
+ return [
+ 'method' => $request->getMethod(),
+ 'url' => (string)$request->getUri(),
+ 'data' => $request->getBody()->getContents(),
+ ];
+ }
+}
diff --git a/tests/MetricBagTest.php b/tests/MetricBagTest.php
new file mode 100644
index 0000000..dd7057d
--- /dev/null
+++ b/tests/MetricBagTest.php
@@ -0,0 +1,234 @@
+assertSame(InvalidArgumentException::class, $exceptionClass);
+ }
+
+ /**
+ * @dataProvider invalidMetricsNamesProvider
+ */
+ public function testCannotCreateMetricWithWrongName(string $metricName): void
+ {
+ // Arrange, Act
+ $exceptionClass = null;
+ try {
+ new MetricBag($metricName);
+ } catch (Exception $e) {
+ $exceptionClass = get_class($e);
+ }
+
+ // Assert
+ $this->assertSame(InvalidArgumentException::class, $exceptionClass);
+ }
+
+ public function invalidMetricsNamesProvider(): array
+ {
+ return [
+ ['123abc'],
+ ['/abc/'],
+ ['-abc'],
+ ['abc!'],
+ ];
+ }
+
+ public function testEmptyMetricWithNoValuesIsValid(): void
+ {
+ // Arrange, Act
+ $metricBag = new MetricBag('metric.name');
+
+ // Assert
+ $actual = $this->convertMetricBagToArray($metricBag);
+ $this->assertSame([], $actual);
+ }
+
+ public function testSetCorrectlyAddsNewMetricValues(): void
+ {
+ // Arrange
+ $metricBag = new MetricBag('metric.name');
+
+ // Act
+ $metricBag->set(['label1' => 'value1', 'label2' => 'value2'], 123);
+ $metricBag->set(['label1' => 'value3', 'label2' => 'value4'], 123.4);
+
+ // Assert
+ $expected = [
+ ['labels' => ['label1' => 'value1', 'label2' => 'value2'], 'value' => 123.0],
+ ['labels' => ['label1' => 'value3', 'label2' => 'value4'], 'value' => 123.4],
+ ];
+ $actual = $this->convertMetricBagToArray($metricBag);
+ $this->assertSame($expected, $actual);
+ }
+
+ public function testSetCorrectlyOverwritesExistingMetricValue(): void
+ {
+ // Arrange
+ $metricBag = new MetricBag('metric.name');
+ $metricBag->set(['label1' => 'value1', 'label2' => 'value2'], 123);
+ $metricBag->set(['label1' => 'value3', 'label2' => 'value4'], 123.4);
+
+ // Act
+ $metricBag->set(['label1' => 'value3', 'label2' => 'value4'], 789);
+
+ // Assert
+ $expected = [
+ ['labels' => ['label1' => 'value1', 'label2' => 'value2'], 'value' => 123.0],
+ ['labels' => ['label1' => 'value3', 'label2' => 'value4'], 'value' => 789.0],
+ ];
+ $actual = $this->convertMetricBagToArray($metricBag);
+ $this->assertSame($expected, $actual);
+ }
+
+ public function testIncrementCorrectlyChangesMetricValue(): void
+ {
+ // Arrange
+ $metricBag = new MetricBag('metric.name');
+ $metricBag->set(['label1' => 'value1', 'label2' => 'value2'], 123);
+ $metricBag->set(['label1' => 'value3', 'label2' => 'value4'], 123.4);
+ $metricBag->set(['label1' => 'value5', 'label2' => 'value6'], 123);
+
+ // Act
+ $metricBag->increment(['label1' => 'value3', 'label2' => 'value4'], 123.4);
+
+ // Assert
+ $expected = [
+ ['labels' => ['label1' => 'value1', 'label2' => 'value2'], 'value' => 123.0],
+ ['labels' => ['label1' => 'value3', 'label2' => 'value4'], 'value' => 246.8],
+ ['labels' => ['label1' => 'value5', 'label2' => 'value6'], 'value' => 123.0],
+ ];
+ $actual = $this->convertMetricBagToArray($metricBag);
+ $this->assertSame($expected, $actual);
+ }
+
+ public function testIncrementCorrectlyChangesMissingMetricValue(): void
+ {
+ // Arrange
+ $metricBag = new MetricBag('metric.name');
+
+ // Act
+ $metricBag->increment(['label1' => 'value3', 'label2' => 'value4'], 123.4);
+
+ // Assert
+ $expected = [
+ ['labels' => ['label1' => 'value3', 'label2' => 'value4'], 'value' => 123.4],
+ ];
+ $actual = $this->convertMetricBagToArray($metricBag);
+ $this->assertSame($expected, $actual);
+ }
+
+ public function testOnlyAssocLabelsAreAllowedWithinSet(): void
+ {
+ // Arrange
+ $metricBag = new MetricBag('metric.name');
+ $exceptionClassForSet = null;
+ $exceptionClassForInc = null;
+
+ // Act
+ try {
+ $metricBag->set(['label1:value1', 'label2:value2'], 123.4);
+ } catch (Exception $e) {
+ $exceptionClassForSet = get_class($e);
+ }
+ try {
+ $metricBag->increment(['label1:value1', 'label2:value2'], 123.4);
+ } catch (Exception $e) {
+ $exceptionClassForInc = get_class($e);
+ }
+
+ // Assert
+ $this->assertSame(InvalidArgumentException::class, $exceptionClassForSet);
+ $this->assertSame(InvalidArgumentException::class, $exceptionClassForInc);
+ }
+
+ public function testDifferentSortForLabelsAreTreatedTheSame(): void
+ {
+ // Arrange
+ $metricBag = new MetricBag('metric.name');
+
+ // Act
+ $metricBag->increment(['label1' => 'value3', 'label2' => 'value4'], 123.4);
+ $metricBag->increment(['label2' => 'value4', 'label1' => 'value3'], 123.4);
+
+ $metricBag->set(['label3' => 'value3', 'label4' => 'value4'], 123.4);
+ $metricBag->set(['label4' => 'value4', 'label3' => 'value3'], 200);
+
+ // Assert
+ $expected = [
+ ['labels' => ['label1' => 'value3', 'label2' => 'value4'], 'value' => 246.8],
+ ['labels' => ['label3' => 'value3', 'label4' => 'value4'], 'value' => 200.0],
+ ];
+ $actual = $this->convertMetricBagToArray($metricBag);
+ $this->assertSame($expected, $actual);
+ }
+
+ public function testEmptyLabelsAreAllowed(): void
+ {
+ // Arrange
+ $metricBag = new MetricBag('metric.name');
+ $exceptionClassForSet = null;
+ $exceptionClassForInc = null;
+
+ // Act
+ $metricBag->set([], 123.4);
+ $metricBag->increment([], 123.4);
+
+ // Assert
+ $expected = [
+ ['labels' => [], 'value' => 246.8],
+ ];
+ $actual = $this->convertMetricBagToArray($metricBag);
+ $this->assertSame($expected, $actual);
+ }
+
+ public function testDifferentLabelsCardinalityWithinSameMetricBagIsAllowed(): void
+ {
+ // Arrange
+ $metricBag = new MetricBag('metric.name');
+
+ // Act
+ $metricBag->increment(['label1' => 'value3', 'label2' => 'value4'], 123.4);
+ $metricBag->increment(['label1' => 'value3', 'label2' => 'value4', 'label3' => 'value5'], 123.4);
+
+ $metricBag->set(['label3' => 'value3', 'label4' => 'value4'], 123.4);
+ $metricBag->set(['label3' => 'value3', 'label4' => 'value4', 'label5' => 'value5'], 123.4);
+
+ // Assert
+ $expected = [
+ ['labels' => ['label1' => 'value3', 'label2' => 'value4'], 'value' => 123.4],
+ ['labels' => ['label1' => 'value3', 'label2' => 'value4', 'label3' => 'value5'], 'value' => 123.4],
+ ['labels' => ['label3' => 'value3', 'label4' => 'value4'], 'value' => 123.4],
+ ['labels' => ['label3' => 'value3', 'label4' => 'value4', 'label5' => 'value5'], 'value' => 123.4],
+ ];
+ $actual = $this->convertMetricBagToArray($metricBag);
+ $this->assertSame($expected, $actual);
+ }
+
+ private function convertMetricBagToArray(MetricBag $metricBag): array
+ {
+ $result = [];
+ foreach ($metricBag->getLogLines() as $logLine) {
+ $result[] = ['labels' => $logLine->getLabels(), 'value' => $logLine->getValue()];
+ }
+ return $result;
+ }
+}