From 8a12742f44049c33cd3a7f232cccdb0b43543819 Mon Sep 17 00:00:00 2001 From: Ben Poulson Date: Sat, 25 Jan 2025 17:21:43 +0000 Subject: [PATCH] Cleanup and better compression --- .github/workflows/ci.yml | 5 +- composer.json | 5 + .../PerfbaseApiKeyMissingException.php | 11 -- src/Exception/PerfbaseEncodingException.php | 8 ++ src/Exception/PerfbaseException.php | 14 +- .../PerfbaseExtensionMissingException.php | 11 -- .../PerfbaseInvalidConfigException.php | 8 ++ .../PerfbaseNonScalarMetaDataException.php | 11 -- src/Exception/PerfbaseStateException.php | 5 +- .../PerfbaseTranslationNotFoundException.php | 11 -- src/Http/ApiClient.php | 6 +- src/Perfbase.php | 4 +- src/Tracing/Compression/Compressor.php | 130 ++++++++++++++++++ src/Tracing/TraceInstance.php | 39 ++---- src/Tracing/TraceState.php | 14 +- src/Utils/TranslationUtil.php | 74 ---------- tests/ApiClientTest.php | 8 +- tests/TraceInstanceTest.php | 39 ++++-- tests/TranslationUtilsTest.php | 86 ------------ 19 files changed, 216 insertions(+), 273 deletions(-) delete mode 100644 src/Exception/PerfbaseApiKeyMissingException.php create mode 100644 src/Exception/PerfbaseEncodingException.php delete mode 100644 src/Exception/PerfbaseExtensionMissingException.php create mode 100644 src/Exception/PerfbaseInvalidConfigException.php delete mode 100644 src/Exception/PerfbaseNonScalarMetaDataException.php delete mode 100644 src/Exception/PerfbaseTranslationNotFoundException.php create mode 100644 src/Tracing/Compression/Compressor.php delete mode 100644 src/Utils/TranslationUtil.php delete mode 100644 tests/TranslationUtilsTest.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b7322cf..823f73e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: strategy: fail-fast: false matrix: - php-versions: [7.4, 8.0, 8.1, 8.2, 8.3, 8.4] + php-versions: [ 7.4, 8.0, 8.1, 8.2, 8.3, 8.4 ] steps: - name: Checkout code @@ -26,6 +26,9 @@ jobs: php-version: ${{ matrix.php-versions }} tools: composer + - name: Validate composer.json and composer.lock + run: composer validate --no-check-all --no-check-publish + - name: Install dependencies run: composer install --prefer-dist --no-progress diff --git a/composer.json b/composer.json index 7bca1bf..9b62f3c 100644 --- a/composer.json +++ b/composer.json @@ -19,6 +19,11 @@ "email": "ben.poulson@perfbase.com" } ], + "scripts": { + "lint": "composer run-script phpstan && composer run-script test", + "test": "phpunit", + "phpstan": "phpstan analyse --memory-limit=2G" + }, "require": { "php": ">=7.4 <8.5", "ext-curl": "*", diff --git a/src/Exception/PerfbaseApiKeyMissingException.php b/src/Exception/PerfbaseApiKeyMissingException.php deleted file mode 100644 index f431b11..0000000 --- a/src/Exception/PerfbaseApiKeyMissingException.php +++ /dev/null @@ -1,11 +0,0 @@ - $values - * @param int $code - * @param Throwable|null $previous - * @throws PerfbaseTranslationNotFoundException - */ - public function __construct(string $translation, array $values = [], int $code = 0, ?Throwable $previous = null) + public function __construct(string $message = "") { - $message = TranslationUtil::get($translation, $values); - parent::__construct($message, $code, $previous); + parent::__construct($message); } } \ No newline at end of file diff --git a/src/Exception/PerfbaseExtensionMissingException.php b/src/Exception/PerfbaseExtensionMissingException.php deleted file mode 100644 index 8f54f18..0000000 --- a/src/Exception/PerfbaseExtensionMissingException.php +++ /dev/null @@ -1,11 +0,0 @@ -api_key)) { - throw new PerfbaseApiKeyMissingException(); + throw new PerfbaseInvalidConfigException(); } $this->config = $config; diff --git a/src/Perfbase.php b/src/Perfbase.php index fbebd06..0bfa3a2 100644 --- a/src/Perfbase.php +++ b/src/Perfbase.php @@ -2,7 +2,7 @@ namespace Perfbase\SDK; -use Perfbase\SDK\Exception\PerfbaseApiKeyMissingException; +use Perfbase\SDK\Exception\PerfbaseInvalidConfigException; use Perfbase\SDK\Exception\PerfbaseStateException; use Perfbase\SDK\Tracing\TraceInstance; use Perfbase\SDK\Utils\ExtensionUtils; @@ -53,7 +53,7 @@ public static function hasInstance(): bool * @param Config $config * @return TraceInstance * @throws PerfbaseStateException - * @throws PerfbaseApiKeyMissingException + * @throws PerfbaseInvalidConfigException */ public static function createInstance(Config $config): TraceInstance { diff --git a/src/Tracing/Compression/Compressor.php b/src/Tracing/Compression/Compressor.php new file mode 100644 index 0000000..50d2ad9 --- /dev/null +++ b/src/Tracing/Compression/Compressor.php @@ -0,0 +1,130 @@ + + */ + public array $glossary = []; + + /** + * Glossary map. + * @var array + */ + public array $glossaryMap = []; + + /** + * The next glossary ID to use. + * @var int + */ + public int $nextGlossaryId = 0; + + /** + * The root node. It has an array of children (keyed "c"), each child has: + * 'k' => int[] (array of glossary IDs for this segment), + * 'c' => array of children, + * 'v' => value if it's a leaf + * @var array + */ + public array $map = ['c' => []]; + + /** + * Return the glossary ID for a given string token. + * @param string $token + * @return int + */ + private function getGlossaryId(string $token): int + { + if (isset($this->glossaryMap[$token])) { + return $this->glossaryMap[$token]; + } + $id = $this->nextGlossaryId++; + $this->glossary[$id] = $token; + $this->glossaryMap[$token] = $id; + return $id; + } + + /** + * Insert a single key => value into the trie. + * This method has a recursive function call, so phpstan gets really confused. + * @param array $node Current trie node + * @param array> $segmentsOfSegments Each element = array of integer IDs for that segment + * @param array $value + */ + private function trieInsert(array &$node, array $segmentsOfSegments, array $value): void + { + // If we’ve consumed all segments, store the value here + if (!$segmentsOfSegments) { + $node['v'] = $value; + return; + } + + // Grab the next segment (array of integer token IDs) + $currentSeg = array_shift($segmentsOfSegments); + + // If no children yet, add an empty array + if (!isset($node['c'])) { + $node['c'] = []; + } + // Child array. We'll see if there's a child with the same `k` + // @phpstan-ignore-next-line + foreach ($node['c'] as &$child) { + // @phpstan-ignore-next-line + if (isset($child['k']) && $child['k'] === $currentSeg) { + // Found a match; recurse + // @phpstan-ignore-next-line + $this->trieInsert($child, $segmentsOfSegments, $value); + return; + } + } + // No child with these tokens => create a new child + $newChild = [ + 'k' => $currentSeg, // array of integers + 'c' => [] + ]; + + // @phpstan-ignore-next-line + $node['c'][] = $newChild; + + // Insert value deeper + // @phpstan-ignore-next-line + $this->trieInsert($node['c'][count($node['c']) - 1], $segmentsOfSegments, $value); + } + + /** + * Compress the data. + * @param array> $data + * @return array + */ + public function execute(array $data): array + { + /** Build the trie from all data */ + foreach ($data as $longKey => $val) { + // 1) Split on '~' => segments + $segments = explode('~', $longKey); + + // 2) For each segment, split on '::', map each token to a glossary ID + $segmentsOfSegments = []; + foreach ($segments as $segment) { + $subParts = explode('::', $segment); + $subPartIds = array_map([$this, 'getGlossaryId'], $subParts); + $segmentsOfSegments[] = $subPartIds; + } + + // 3) Insert into trie + // @phpstan-ignore-next-line + $this->trieInsert($this->map, $segmentsOfSegments, $val); + } + + return [ + 'compressor' => 'trie', + 'data' => [ + 'glossary' => $this->glossary, + 'map' => $this->map + ] + ]; + } +} \ No newline at end of file diff --git a/src/Tracing/TraceInstance.php b/src/Tracing/TraceInstance.php index 47516ab..e7de7d1 100644 --- a/src/Tracing/TraceInstance.php +++ b/src/Tracing/TraceInstance.php @@ -2,12 +2,13 @@ namespace Perfbase\SDK\Tracing; -use http\Exception\RuntimeException; use JsonException; use Perfbase\SDK\Config; -use Perfbase\SDK\Exception\PerfbaseApiKeyMissingException; +use Perfbase\SDK\Exception\PerfbaseEncodingException; +use Perfbase\SDK\Exception\PerfbaseInvalidConfigException; use Perfbase\SDK\Exception\PerfbaseStateException; use Perfbase\SDK\Http\ApiClient; +use Perfbase\SDK\Tracing\Compression\Compressor; class TraceInstance { @@ -49,7 +50,7 @@ class TraceInstance /** * @param Config $config - * @throws PerfbaseApiKeyMissingException + * @throws PerfbaseInvalidConfigException */ public function __construct(Config $config) { @@ -72,7 +73,7 @@ public function startProfiling(): void { // Check if the state is new, otherwise we cannot start a new profiling session if ($this->state->isNew() === false) { - throw new PerfbaseStateException('instance_already_active'); + throw new PerfbaseStateException('A profiling instance is already active and cannot be started again.'); } // Enable the Perfbase profiler @@ -109,7 +110,7 @@ public function stopProfiling(bool $andSend = true): void { // State should be active, otherwise we cannot stop the profiling if ($this->state->isActive() === false) { - throw new PerfbaseStateException('instance_not_active'); + throw new PerfbaseStateException('No profiling instance is active.'); } // Set the state to complete @@ -145,6 +146,7 @@ public function sendProfilingData(): void /** * Transforms the collected data into a format that can be sent to the API * @return array + * @throws PerfbaseEncodingException */ public function transformData(): array { @@ -175,17 +177,18 @@ public function transformData(): array /** * @param array $data * @return string + * @throws PerfbaseEncodingException */ private function encodeAndCompressData(array $data): string { $json = json_encode($data); if ($json === false) { - throw new RuntimeException('Failed to encode data'); + throw new PerfbaseEncodingException('Failed to encode data'); } $compressed = gzencode($json, 6, FORCE_GZIP); if ($compressed === false) { - throw new RuntimeException('Failed to compress data'); + throw new PerfbaseEncodingException('Failed to compress data'); } return base64_encode($compressed); } @@ -200,25 +203,7 @@ private function encodeAndCompressData(array $data): string */ private function shrinkProfilingData(array $data): array { - $map = []; - $mapIndex = []; // Reverse lookup for faster array_search equivalent - $output = []; - - foreach ($data as $k => $v) { - $keys = []; - $exploded = explode('~', $k); - - foreach ($exploded as $part) { - if (!isset($mapIndex[$part])) { - $map[] = $part; - $mapIndex[$part] = count($map) - 1; - } - $keys[] = $mapIndex[$part]; - } - - $output[] = [$keys, $v]; - } - - return [$map, $output]; + // @phpstan-ignore-next-line + return (new Compressor())->execute($data); } } \ No newline at end of file diff --git a/src/Tracing/TraceState.php b/src/Tracing/TraceState.php index af580cc..f28bacd 100644 --- a/src/Tracing/TraceState.php +++ b/src/Tracing/TraceState.php @@ -49,14 +49,12 @@ public function setStateActive(): void private function setState(string $requested, array $required): void { if (!in_array($this->state, $required, true)) { - throw new PerfbaseStateException( - 'bad_state_transition', - [ - $this->state, - $requested, - implode(', ', $required) - ] - ); + throw new PerfbaseStateException(sprintf( + 'Invalid state transition from "%s" to "%s". Required states: %s.', + $this->state, + $requested, + implode(', ', $required) + )); } $this->state = $requested; } diff --git a/src/Utils/TranslationUtil.php b/src/Utils/TranslationUtil.php deleted file mode 100644 index 601fa4a..0000000 --- a/src/Utils/TranslationUtil.php +++ /dev/null @@ -1,74 +0,0 @@ -> - */ - private static array $translations = [ - 'en' => [ - 'api_key_missing' => 'No Perfbase API key provided.', - 'state_exception' => 'Perfbase is in an invalid state to perform this operation.', - 'extension_missing' => 'The required `perfbase` PHP extension is not installed or active', - 'instance_already_active' => 'A profiling instance is already active and cannot be started again.', - 'instance_not_active' => 'No profiling instance is active.', - 'translation_missing' => 'Translation entry "%s" not found in language "%s".', - 'bad_state_transition' => 'Invalid state transition from "%s" to "%s". Required states: %s.', - 'non_scalar_meta' => 'Meta data can only contain scalar values.' - ] - ]; - - /** - * Set the language to use for translations - * @param string $language The language to use, as a 2 character ISO code. - * @throws PerfbaseException - */ - public static function setLanguage(string $language): void - { - if (!in_array($language, array_keys(self::$translations))) { - throw new PerfbaseException(sprintf('The requested language "%s" is not available', $language)); - } - self::$language = $language; - } - - /** - * Get a translation for the current language - * @param string $path The path to the translation, separated by dots. - * @param array $values The values to replace in the translation string. - * @throws PerfbaseTranslationNotFoundException - */ - public static function get(string $path, array $values = []): string - { - $result = self::$translations[self::$language]; - $keys = explode('.', $path); - foreach ($keys as $key) { - if (!isset($result[$key])) { - throw new PerfbaseTranslationNotFoundException(self::$language, $key); - } - $result = $result[$key]; - } - return sprintf($result, ...$values); - } - -} diff --git a/tests/ApiClientTest.php b/tests/ApiClientTest.php index 3d70cf8..2e44c95 100644 --- a/tests/ApiClientTest.php +++ b/tests/ApiClientTest.php @@ -9,7 +9,7 @@ use GuzzleHttp\Psr7\Response; use JsonException; use Perfbase\SDK\Config; -use Perfbase\SDK\Exception\PerfbaseApiKeyMissingException; +use Perfbase\SDK\Exception\PerfbaseInvalidConfigException; use Perfbase\SDK\Http\ApiClient; use ReflectionClass; @@ -21,21 +21,21 @@ class ApiClientTest extends BaseTest /** * @return void - * @throws PerfbaseApiKeyMissingException + * @throws PerfbaseInvalidConfigException * @covers ::__construct */ public function testThrowsExceptionIfApiKeyIsMissing(): void { $config = new Config(); // api_key is null by default - $this->expectException(PerfbaseApiKeyMissingException::class); + $this->expectException(PerfbaseInvalidConfigException::class); new ApiClient($config); } /** * @return void - * @throws PerfbaseApiKeyMissingException + * @throws PerfbaseInvalidConfigException * @covers ::__construct */ public function testInitializesWithValidApiKey(): void diff --git a/tests/TraceInstanceTest.php b/tests/TraceInstanceTest.php index b060a00..e07ab1c 100644 --- a/tests/TraceInstanceTest.php +++ b/tests/TraceInstanceTest.php @@ -3,7 +3,7 @@ namespace Perfbase\SDK\Tests; use Perfbase\SDK\Config; -use Perfbase\SDK\Exception\PerfbaseApiKeyMissingException; +use Perfbase\SDK\Exception\PerfbaseInvalidConfigException; use Perfbase\SDK\Http\ApiClient; use Perfbase\SDK\Tracing\Attributes; use Perfbase\SDK\Tracing\TraceInstance; @@ -22,14 +22,14 @@ class TraceInstanceTest extends BaseTest public function testThrowsExceptionIfApiKeyIsMissing(): void { $config = new Config(); - $this->expectException(PerfbaseApiKeyMissingException::class); + $this->expectException(PerfbaseInvalidConfigException::class); new TraceInstance($config); } /** * @covers ::__construct * @return void - * @throws PerfbaseApiKeyMissingException + * @throws PerfbaseInvalidConfigException */ public function testInitializesWithValidApiKey(): void { @@ -42,7 +42,7 @@ public function testInitializesWithValidApiKey(): void /** * @covers ::__construct * @return void - * @throws PerfbaseApiKeyMissingException + * @throws PerfbaseInvalidConfigException * @throws ReflectionException */ public function testHasValidAttributes(): void @@ -63,7 +63,7 @@ public function testHasValidAttributes(): void /** * @covers ::__construct * @return void - * @throws PerfbaseApiKeyMissingException + * @throws PerfbaseInvalidConfigException * @throws ReflectionException */ public function testTransformsDataCorrectly(): void @@ -88,8 +88,31 @@ public function testTransformsDataCorrectly(): void // Expected output $expectedPerfOutput = [ - ['php', 'example', 'function'], - [[[0, 1, 2], [null, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14]]] + "compressor" => "trie", + "data" => [ + "glossary" => [ + "php", "example", "function" + ], + "map" => [ + "c" => [ + [ + "k" => [0], + "c" => [ + [ + "k" => [1], + "c" => [ + [ + "k" => [2], + "c" => [], + "v" => [null, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14] + ] + ] + ] + ] + ] + ] + ] + ] ]; // Assert that the transformed data is the same as the original data @@ -105,7 +128,7 @@ public function testTransformsDataCorrectly(): void /** * @covers ::__construct * @return void - * @throws PerfbaseApiKeyMissingException + * @throws PerfbaseInvalidConfigException * @throws ReflectionException */ public function testEncodesAndCompressesDataCorrectly(): void diff --git a/tests/TranslationUtilsTest.php b/tests/TranslationUtilsTest.php deleted file mode 100644 index 2904a15..0000000 --- a/tests/TranslationUtilsTest.php +++ /dev/null @@ -1,86 +0,0 @@ -assertTrue(true, 'No exception was thrown, as expected.'); // Optional - } catch (\Throwable $e) { - $this->fail('An exception was not expected, but one was thrown: ' . $e->getMessage()); - } - } - - /** - * @covers ::setLanguage - * @return void - * @throws PerfbaseException - */ - public function testThrowsExceptionWhenSettingUnavailableLanguage(): void - { - $this->expectException(PerfbaseException::class); - $this->expectExceptionMessage('The requested language "fr" is not available'); - - TranslationUtil::setLanguage('fr'); - } - - /** - * @covers ::get - * @return void - * @throws PerfbaseException - * @throws PerfbaseTranslationNotFoundException - */ - public function testRetrievesTranslationSuccessfully(): void - { - TranslationUtil::setLanguage('en'); - - $translation = TranslationUtil::get('api_key_missing'); - - $this->assertSame('No Perfbase API key provided.', $translation); - } - - /** - * @covers ::get - * @return void - * @throws PerfbaseException - * @throws PerfbaseTranslationNotFoundException - */ - public function testRetrievesTranslationWithPlaceholdersSuccessfully(): void - { - TranslationUtil::setLanguage('en'); - - $translation = TranslationUtil::get('translation_missing', ['key_name', 'en']); - - $this->assertSame('Translation entry "key_name" not found in language "en".', $translation); - } - - /** - * @covers ::get - * @return void - * @throws PerfbaseException - * @throws PerfbaseTranslationNotFoundException - */ - public function testThrowsExceptionWhenTranslationPathDoesNotExist(): void - { - TranslationUtil::setLanguage('en'); - - $this->expectException(PerfbaseTranslationNotFoundException::class); - - TranslationUtil::get('nonexistent_translation'); - } -}