From b3aa95321c557ea3a07650d80ee9dc681c3c675d Mon Sep 17 00:00:00 2001 From: Alexey Solodkiy Date: Mon, 30 Sep 2024 01:12:21 +0300 Subject: [PATCH] Upload code --- .github/workflows/ci.yml | 31 ++++ .gitignore | 4 + LICENSE | 2 +- README.md | 22 +++ composer.json | 32 ++++ phpunit.xml.dist | 17 ++ src/Exception/BadResponseException.php | 24 +++ src/GaugeExporterClient.php | 61 +++++++ src/MetricBag.php | 63 +++++++ src/MetricLine.php | 39 +++++ tests/GaugeExporterClientTest.php | 192 ++++++++++++++++++++ tests/MetricBagTest.php | 234 +++++++++++++++++++++++++ 12 files changed, 720 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 README.md create mode 100755 composer.json create mode 100644 phpunit.xml.dist create mode 100644 src/Exception/BadResponseException.php create mode 100644 src/GaugeExporterClient.php create mode 100644 src/MetricBag.php create mode 100644 src/MetricLine.php create mode 100755 tests/GaugeExporterClientTest.php create mode 100644 tests/MetricBagTest.php 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; + } +}