diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 04f743c..a0e3dd6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,38 +3,43 @@ name: Test on: pull_request: push: - branches: [ "main" ] - -permissions: - contents: read + branches: + - main jobs: - build: - - runs-on: ubuntu-latest + test: + strategy: + fail-fast: false + matrix: + php-version: ['7.4', '8.0', '8.1', '8.2', '8.3'] + os: [ubuntu-latest] + runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v3 - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: 7.4 + - name: Checkout + uses: actions/checkout@v2 - name: Validate composer.json and composer.lock run: composer validate --strict - name: Cache Composer packages - id: composer-cache - uses: actions/cache@v3 + uses: actions/cache@v2 with: path: vendor - key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} + key: ${{ runner.os }}-php-${{ matrix.php-version }}-${{ hashFiles('**/composer.lock') }} restore-keys: | ${{ runner.os }}-php- + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + - name: Install dependencies run: composer install --prefer-dist --no-progress - name: Run test suite run: composer run-script test + + - name: Run PHPStan + run: vendor/bin/phpstan analyse --ansi diff --git a/README.md b/README.md index 27c3e66..49d9e7f 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ $user = \AmplitudeExperiment\User::builder() ->userProperties(['premium' => True]) ->build(); -$variants = $client->fetch($user)->wait(); +$variants = $client->fetch($user); // (3) Access a flag's variant $variant = $variants['FLAG_KEY'] ?? null; @@ -48,8 +48,8 @@ if ($variant) { $experiment = new \AmplitudeExperiment\Experiment(); $client = $experiment->initializeLocal(''); -// (2) Start the local evaluation client. -$client->start()->wait(); +// (2) Fetch latest flag configurations for the local evaluation client. +$client->refreshFlagConfigs(); // (3) Evaluate a user. $user = \AmplitudeExperiment\User::builder() diff --git a/composer.json b/composer.json index 416df10..89a762c 100644 --- a/composer.json +++ b/composer.json @@ -20,13 +20,16 @@ "prefer-stable": true, "require": { "php": "^7.4 || ^8", - "lastguest/murmurhash": "^1", "ext-json": "*", "guzzlehttp/guzzle": "^7", - "monolog/monolog": "^2" + "psr/log": "^1 || ^2 || ^3" }, "require-dev": { - "phpunit/phpunit": "9.*" + "phpunit/phpunit": "9.*", + "phpstan/phpstan": "^1" + }, + "suggest": { + "monolog/monolog": "Allows more advanced logging of the application flow" }, "autoload": { "psr-4": { diff --git a/composer.lock b/composer.lock index 1f30aba..4572640 100644 --- a/composer.lock +++ b/composer.lock @@ -4,20 +4,20 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "5b2a0888b709340519126b0c9db130b3", + "content-hash": "c42bcc95b8a3bba4f70533ad1f9b0be1", "packages": [ { "name": "guzzlehttp/guzzle", - "version": "7.8.0", + "version": "7.8.1", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "1110f66a6530a40fe7aea0378fe608ee2b2248f9" + "reference": "41042bc7ab002487b876a0683fc8dce04ddce104" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/1110f66a6530a40fe7aea0378fe608ee2b2248f9", - "reference": "1110f66a6530a40fe7aea0378fe608ee2b2248f9", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/41042bc7ab002487b876a0683fc8dce04ddce104", + "reference": "41042bc7ab002487b876a0683fc8dce04ddce104", "shasum": "" }, "require": { @@ -32,11 +32,11 @@ "psr/http-client-implementation": "1.0" }, "require-dev": { - "bamarni/composer-bin-plugin": "^1.8.1", + "bamarni/composer-bin-plugin": "^1.8.2", "ext-curl": "*", "php-http/client-integration-tests": "dev-master#2c025848417c1135031fdf9c728ee53d0a7ceaee as 3.0.999", "php-http/message-factory": "^1.1", - "phpunit/phpunit": "^8.5.29 || ^9.5.23", + "phpunit/phpunit": "^8.5.36 || ^9.6.15", "psr/log": "^1.1 || ^2.0 || ^3.0" }, "suggest": { @@ -114,7 +114,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.8.0" + "source": "https://github.com/guzzle/guzzle/tree/7.8.1" }, "funding": [ { @@ -130,28 +130,28 @@ "type": "tidelift" } ], - "time": "2023-08-27T10:20:53+00:00" + "time": "2023-12-03T20:35:24+00:00" }, { "name": "guzzlehttp/promises", - "version": "2.0.1", + "version": "2.0.2", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "111166291a0f8130081195ac4556a5587d7f1b5d" + "reference": "bbff78d96034045e58e13dedd6ad91b5d1253223" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/111166291a0f8130081195ac4556a5587d7f1b5d", - "reference": "111166291a0f8130081195ac4556a5587d7f1b5d", + "url": "https://api.github.com/repos/guzzle/promises/zipball/bbff78d96034045e58e13dedd6ad91b5d1253223", + "reference": "bbff78d96034045e58e13dedd6ad91b5d1253223", "shasum": "" }, "require": { "php": "^7.2.5 || ^8.0" }, "require-dev": { - "bamarni/composer-bin-plugin": "^1.8.1", - "phpunit/phpunit": "^8.5.29 || ^9.5.23" + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.36 || ^9.6.15" }, "type": "library", "extra": { @@ -197,7 +197,7 @@ ], "support": { "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/2.0.1" + "source": "https://github.com/guzzle/promises/tree/2.0.2" }, "funding": [ { @@ -213,20 +213,20 @@ "type": "tidelift" } ], - "time": "2023-08-03T15:11:55+00:00" + "time": "2023-12-03T20:19:20+00:00" }, { "name": "guzzlehttp/psr7", - "version": "2.6.1", + "version": "2.6.2", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "be45764272e8873c72dbe3d2edcfdfcc3bc9f727" + "reference": "45b30f99ac27b5ca93cb4831afe16285f57b8221" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/be45764272e8873c72dbe3d2edcfdfcc3bc9f727", - "reference": "be45764272e8873c72dbe3d2edcfdfcc3bc9f727", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/45b30f99ac27b5ca93cb4831afe16285f57b8221", + "reference": "45b30f99ac27b5ca93cb4831afe16285f57b8221", "shasum": "" }, "require": { @@ -240,9 +240,9 @@ "psr/http-message-implementation": "1.0" }, "require-dev": { - "bamarni/composer-bin-plugin": "^1.8.1", + "bamarni/composer-bin-plugin": "^1.8.2", "http-interop/http-factory-tests": "^0.9", - "phpunit/phpunit": "^8.5.29 || ^9.5.23" + "phpunit/phpunit": "^8.5.36 || ^9.6.15" }, "suggest": { "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" @@ -313,7 +313,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.6.1" + "source": "https://github.com/guzzle/psr7/tree/2.6.2" }, "funding": [ { @@ -329,156 +329,7 @@ "type": "tidelift" } ], - "time": "2023-08-27T10:13:57+00:00" - }, - { - "name": "lastguest/murmurhash", - "version": "1.3.0", - "source": { - "type": "git", - "url": "https://github.com/lastguest/murmurhash-php.git", - "reference": "8eb06483456bc98f5adb7707d981a8ef6a065fa2" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/lastguest/murmurhash-php/zipball/8eb06483456bc98f5adb7707d981a8ef6a065fa2", - "reference": "8eb06483456bc98f5adb7707d981a8ef6a065fa2", - "shasum": "" - }, - "require": { - "php": ">=4.3" - }, - "type": "library", - "autoload": { - "files": [ - "murmurhash3.php" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Stefano Azzolini", - "email": "lastguest@gmail.com", - "homepage": "https://github.com/lastguest/murmurhash-php" - } - ], - "description": "MurmurHash3 Hash", - "homepage": "https://github.com/lastguest/murmurhash-php", - "keywords": [ - "hash", - "hashing", - "murmur" - ], - "support": { - "issues": "https://github.com/lastguest/murmurhash-php/issues", - "source": "https://github.com/lastguest/murmurhash-php/tree/master" - }, - "time": "2016-05-16T23:22:58+00:00" - }, - { - "name": "monolog/monolog", - "version": "2.9.2", - "source": { - "type": "git", - "url": "https://github.com/Seldaek/monolog.git", - "reference": "437cb3628f4cf6042cc10ae97fc2b8472e48ca1f" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/Seldaek/monolog/zipball/437cb3628f4cf6042cc10ae97fc2b8472e48ca1f", - "reference": "437cb3628f4cf6042cc10ae97fc2b8472e48ca1f", - "shasum": "" - }, - "require": { - "php": ">=7.2", - "psr/log": "^1.0.1 || ^2.0 || ^3.0" - }, - "provide": { - "psr/log-implementation": "1.0.0 || 2.0.0 || 3.0.0" - }, - "require-dev": { - "aws/aws-sdk-php": "^2.4.9 || ^3.0", - "doctrine/couchdb": "~1.0@dev", - "elasticsearch/elasticsearch": "^7 || ^8", - "ext-json": "*", - "graylog2/gelf-php": "^1.4.2 || ^2@dev", - "guzzlehttp/guzzle": "^7.4", - "guzzlehttp/psr7": "^2.2", - "mongodb/mongodb": "^1.8", - "php-amqplib/php-amqplib": "~2.4 || ^3", - "phpspec/prophecy": "^1.15", - "phpstan/phpstan": "^0.12.91", - "phpunit/phpunit": "^8.5.14", - "predis/predis": "^1.1 || ^2.0", - "rollbar/rollbar": "^1.3 || ^2 || ^3", - "ruflin/elastica": "^7", - "swiftmailer/swiftmailer": "^5.3|^6.0", - "symfony/mailer": "^5.4 || ^6", - "symfony/mime": "^5.4 || ^6" - }, - "suggest": { - "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB", - "doctrine/couchdb": "Allow sending log messages to a CouchDB server", - "elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client", - "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)", - "ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler", - "ext-mbstring": "Allow to work properly with unicode symbols", - "ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)", - "ext-openssl": "Required to send log messages using SSL", - "ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)", - "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server", - "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)", - "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib", - "rollbar/rollbar": "Allow sending log messages to Rollbar", - "ruflin/elastica": "Allow sending log messages to an Elastic Search server" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "2.x-dev" - } - }, - "autoload": { - "psr-4": { - "Monolog\\": "src/Monolog" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Jordi Boggiano", - "email": "j.boggiano@seld.be", - "homepage": "https://seld.be" - } - ], - "description": "Sends your logs to files, sockets, inboxes, databases and various web services", - "homepage": "https://github.com/Seldaek/monolog", - "keywords": [ - "log", - "logging", - "psr-3" - ], - "support": { - "issues": "https://github.com/Seldaek/monolog/issues", - "source": "https://github.com/Seldaek/monolog/tree/2.9.2" - }, - "funding": [ - { - "url": "https://github.com/Seldaek", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/monolog/monolog", - "type": "tidelift" - } - ], - "time": "2023-10-27T15:25:26+00:00" + "time": "2023-12-03T20:05:35+00:00" }, { "name": "psr/http-client", @@ -934,25 +785,27 @@ }, { "name": "nikic/php-parser", - "version": "v4.17.1", + "version": "v5.0.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "a6303e50c90c355c7eeee2c4a8b27fe8dc8fef1d" + "reference": "4a21235f7e56e713259a6f76bf4b5ea08502b9dc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/a6303e50c90c355c7eeee2c4a8b27fe8dc8fef1d", - "reference": "a6303e50c90c355c7eeee2c4a8b27fe8dc8fef1d", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/4a21235f7e56e713259a6f76bf4b5ea08502b9dc", + "reference": "4a21235f7e56e713259a6f76bf4b5ea08502b9dc", "shasum": "" }, "require": { + "ext-ctype": "*", + "ext-json": "*", "ext-tokenizer": "*", - "php": ">=7.0" + "php": ">=7.4" }, "require-dev": { "ircmaxell/php-yacc": "^0.0.7", - "phpunit/phpunit": "^6.5 || ^7.0 || ^8.0 || ^9.0" + "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0" }, "bin": [ "bin/php-parse" @@ -960,7 +813,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "4.9-dev" + "dev-master": "5.0-dev" } }, "autoload": { @@ -984,9 +837,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v4.17.1" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.0.0" }, - "time": "2023-08-13T19:53:39+00:00" + "time": "2024-01-07T17:17:35+00:00" }, { "name": "phar-io/manifest", @@ -1099,25 +952,87 @@ }, "time": "2022-02-21T01:04:05+00:00" }, + { + "name": "phpstan/phpstan", + "version": "1.10.56", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan.git", + "reference": "27816a01aea996191ee14d010f325434c0ee76fa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/27816a01aea996191ee14d010f325434c0ee76fa", + "reference": "27816a01aea996191ee14d010f325434c0ee76fa", + "shasum": "" + }, + "require": { + "php": "^7.2|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpstan/phpstan", + "type": "tidelift" + } + ], + "time": "2024-01-15T10:43:00+00:00" + }, { "name": "phpunit/php-code-coverage", - "version": "9.2.29", + "version": "9.2.30", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "6a3a87ac2bbe33b25042753df8195ba4aa534c76" + "reference": "ca2bd87d2f9215904682a9cb9bb37dda98e76089" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/6a3a87ac2bbe33b25042753df8195ba4aa534c76", - "reference": "6a3a87ac2bbe33b25042753df8195ba4aa534c76", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/ca2bd87d2f9215904682a9cb9bb37dda98e76089", + "reference": "ca2bd87d2f9215904682a9cb9bb37dda98e76089", "shasum": "" }, "require": { "ext-dom": "*", "ext-libxml": "*", "ext-xmlwriter": "*", - "nikic/php-parser": "^4.15", + "nikic/php-parser": "^4.18 || ^5.0", "php": ">=7.3", "phpunit/php-file-iterator": "^3.0.3", "phpunit/php-text-template": "^2.0.2", @@ -1167,7 +1082,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.29" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.30" }, "funding": [ { @@ -1175,7 +1090,7 @@ "type": "github" } ], - "time": "2023-09-19T04:57:46+00:00" + "time": "2023-12-22T06:47:57+00:00" }, { "name": "phpunit/php-file-iterator", @@ -1420,16 +1335,16 @@ }, { "name": "phpunit/phpunit", - "version": "9.6.13", + "version": "9.6.16", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "f3d767f7f9e191eab4189abe41ab37797e30b1be" + "reference": "3767b2c56ce02d01e3491046f33466a1ae60a37f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/f3d767f7f9e191eab4189abe41ab37797e30b1be", - "reference": "f3d767f7f9e191eab4189abe41ab37797e30b1be", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/3767b2c56ce02d01e3491046f33466a1ae60a37f", + "reference": "3767b2c56ce02d01e3491046f33466a1ae60a37f", "shasum": "" }, "require": { @@ -1503,7 +1418,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.13" + "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.16" }, "funding": [ { @@ -1519,7 +1434,7 @@ "type": "tidelift" } ], - "time": "2023-09-19T05:39:22+00:00" + "time": "2024-01-19T07:03:14+00:00" }, { "name": "sebastian/cli-parser", @@ -1764,20 +1679,20 @@ }, { "name": "sebastian/complexity", - "version": "2.0.2", + "version": "2.0.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/complexity.git", - "reference": "739b35e53379900cc9ac327b2147867b8b6efd88" + "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/739b35e53379900cc9ac327b2147867b8b6efd88", - "reference": "739b35e53379900cc9ac327b2147867b8b6efd88", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/25f207c40d62b8b7aa32f5ab026c53561964053a", + "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a", "shasum": "" }, "require": { - "nikic/php-parser": "^4.7", + "nikic/php-parser": "^4.18 || ^5.0", "php": ">=7.3" }, "require-dev": { @@ -1809,7 +1724,7 @@ "homepage": "https://github.com/sebastianbergmann/complexity", "support": { "issues": "https://github.com/sebastianbergmann/complexity/issues", - "source": "https://github.com/sebastianbergmann/complexity/tree/2.0.2" + "source": "https://github.com/sebastianbergmann/complexity/tree/2.0.3" }, "funding": [ { @@ -1817,7 +1732,7 @@ "type": "github" } ], - "time": "2020-10-26T15:52:27+00:00" + "time": "2023-12-22T06:19:30+00:00" }, { "name": "sebastian/diff", @@ -2091,20 +2006,20 @@ }, { "name": "sebastian/lines-of-code", - "version": "1.0.3", + "version": "1.0.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/lines-of-code.git", - "reference": "c1c2e997aa3146983ed888ad08b15470a2e22ecc" + "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/c1c2e997aa3146983ed888ad08b15470a2e22ecc", - "reference": "c1c2e997aa3146983ed888ad08b15470a2e22ecc", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/e1e4a170560925c26d424b6a03aed157e7dcc5c5", + "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5", "shasum": "" }, "require": { - "nikic/php-parser": "^4.6", + "nikic/php-parser": "^4.18 || ^5.0", "php": ">=7.3" }, "require-dev": { @@ -2136,7 +2051,7 @@ "homepage": "https://github.com/sebastianbergmann/lines-of-code", "support": { "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", - "source": "https://github.com/sebastianbergmann/lines-of-code/tree/1.0.3" + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/1.0.4" }, "funding": [ { @@ -2144,7 +2059,7 @@ "type": "github" } ], - "time": "2020-11-28T06:42:11+00:00" + "time": "2023-12-22T06:20:34+00:00" }, { "name": "sebastian/object-enumerator", @@ -2487,16 +2402,16 @@ }, { "name": "theseer/tokenizer", - "version": "1.2.1", + "version": "1.2.2", "source": { "type": "git", "url": "https://github.com/theseer/tokenizer.git", - "reference": "34a41e998c2183e22995f158c581e7b5e755ab9e" + "reference": "b2ad5003ca10d4ee50a12da31de12a5774ba6b96" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theseer/tokenizer/zipball/34a41e998c2183e22995f158c581e7b5e755ab9e", - "reference": "34a41e998c2183e22995f158c581e7b5e755ab9e", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b2ad5003ca10d4ee50a12da31de12a5774ba6b96", + "reference": "b2ad5003ca10d4ee50a12da31de12a5774ba6b96", "shasum": "" }, "require": { @@ -2525,7 +2440,7 @@ "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", "support": { "issues": "https://github.com/theseer/tokenizer/issues", - "source": "https://github.com/theseer/tokenizer/tree/1.2.1" + "source": "https://github.com/theseer/tokenizer/tree/1.2.2" }, "funding": [ { @@ -2533,7 +2448,7 @@ "type": "github" } ], - "time": "2021-07-28T10:34:58+00:00" + "time": "2023-11-20T00:12:19+00:00" } ], "aliases": [], diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..14be0ec --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,10 @@ +parameters: + level: 8 + paths: + - src + + excludePaths: + - src/EvaluationCore + + scanFiles: + - src/EvaluationCore/Util.php diff --git a/src/Amplitude/Amplitude.php b/src/Amplitude/Amplitude.php index faa0035..a9a2674 100644 --- a/src/Amplitude/Amplitude.php +++ b/src/Amplitude/Amplitude.php @@ -2,13 +2,10 @@ namespace AmplitudeExperiment\Amplitude; -use AmplitudeExperiment\Backoff; -use GuzzleHttp\Client; -use GuzzleHttp\Promise\PromiseInterface; -use Monolog\Logger; -use function AmplitudeExperiment\initializeLogger; - -require_once __DIR__ . '/../Util.php'; +use AmplitudeExperiment\Http\HttpClientInterface; +use AmplitudeExperiment\Http\GuzzleHttpClient; +use Psr\Http\Client\ClientExceptionInterface; +use Psr\Log\LoggerInterface; /** * Amplitude client for sending events to Amplitude. @@ -16,41 +13,33 @@ class Amplitude { private string $apiKey; + /** + * @var array> + */ protected array $queue = []; - protected Client $httpClient; - private Logger $logger; - private ?AmplitudeConfig $config; + protected HttpClientInterface $httpClient; + private LoggerInterface $logger; + private AmplitudeConfig $config; - public function __construct(string $apiKey, bool $debug, AmplitudeConfig $config = null) + public function __construct(string $apiKey, LoggerInterface $logger, AmplitudeConfig $config = null) { $this->apiKey = $apiKey; - $this->httpClient = new Client(); - $this->logger = initializeLogger($debug); + $this->logger = $logger; $this->config = $config ?? AmplitudeConfig::builder()->build(); + $this->httpClient = $this->config->httpClient ?? $this->config->httpClient ?? new GuzzleHttpClient($this->config->guzzleClientConfig); } - public function flush(): PromiseInterface + public function flush(): void { $payload = ["api_key" => $this->apiKey, "events" => $this->queue, "options" => ["min_id_length" => $this->config->minIdLength]]; - - // Fetch initial flag configs and await the result. - return Backoff::doWithBackoff( - function () use ($payload) { - return $this->post($this->config->serverUrl, $payload)->then( - function () { - $this->queue = []; - } - ); - }, - new Backoff($this->config->flushMaxRetries, 1, 1, 1) - ); + $this->post($this->config->serverUrl, $payload); } - public function logEvent(Event $event) + public function logEvent(Event $event): void { $this->queue[] = $event->toArray(); if (count($this->queue) >= $this->config->flushQueueSize) { - $this->flush()->wait(); + $this->flush(); } } @@ -60,27 +49,35 @@ public function logEvent(Event $event) public function __destruct() { if (count($this->queue) > 0) { - $this->flush()->wait(); + $this->flush(); } } - private function post(string $url, array $payload): PromiseInterface + /** + * @param array $payload + */ + private function post(string $url, array $payload): void { - // Using sendAsync to make an asynchronous request - $promise = $this->httpClient->postAsync($url, [ - 'json' => $payload, - ]); - - return $promise->then( - function ($response) use ($payload) { - // Process the successful response if needed - $this->logger->debug("[Amplitude] Event sent successfully: " . json_encode($payload)); - }, - function (\Exception $exception) use ($payload) { - // Handle the exception for async request - $this->logger->error('[Amplitude] Failed to send event: ' . json_encode($payload) . ', ' . $exception->getMessage()); - throw $exception; + $httpClient = $this->httpClient->getClient(); + $payloadJson = json_encode($payload); + if ($payloadJson === false) { + $this->logger->error('[Amplitude] Failed to encode payload: ' . json_last_error()); + return; + } + $request = $this->httpClient + ->createRequest('POST', $url, $payloadJson) + ->withHeader('Content-Type', 'application/json'); + try { + $response = $httpClient->sendRequest($request); + if ($response->getStatusCode() != 200) { + $this->logger->error('[Amplitude] Failed to send event: ' . $payloadJson . ', ' . $response->getStatusCode() . ' ' . $response->getReasonPhrase()); + return; } - ); + $this->logger->debug("[Amplitude] Event sent successfully: " . $payloadJson); + $this->queue = []; + + } catch (ClientExceptionInterface $e) { + $this->logger->error('[Amplitude] Failed to send event: ' . $payloadJson . ', ' . $e->getMessage()); + } } } diff --git a/src/Amplitude/AmplitudeConfig.php b/src/Amplitude/AmplitudeConfig.php index 045b935..4e9c9ba 100644 --- a/src/Amplitude/AmplitudeConfig.php +++ b/src/Amplitude/AmplitudeConfig.php @@ -2,11 +2,13 @@ namespace AmplitudeExperiment\Amplitude; +use AmplitudeExperiment\Assignment\AssignmentConfig; +use AmplitudeExperiment\Assignment\AssignmentConfigBuilder; +use AmplitudeExperiment\Http\HttpClientInterface; + /** - * Configuration options for Amplitude. This is an object that can be created using - * a {@link AmplitudeConfigBuilder}. Example usage: - * - * AmplitudeConfigBuilder::builder()->serverZone("EU")->build(); + * Configuration options for Amplitude. The Amplitude object is created when you create an {@link AssignmentConfig}. + * Options should be set using {@link AssignmentConfigBuilder}. */ class AmplitudeConfig { @@ -18,10 +20,6 @@ class AmplitudeConfig /** * The maximum retry attempts for an event when receiving error response. */ - public int $flushMaxRetries; - /** - * The minimum length of user_id and device_id for events. Default to 5. - */ public int $minIdLength; /** * The server zone of project. Default to 'US'. Support 'EU'. @@ -34,7 +32,16 @@ class AmplitudeConfig /** * True to use batch API endpoint, False to use HTTP V2 API endpoint. */ - public string $useBatch; + public bool $useBatch; + /** + * The underlying HTTP client to use for requests, if this is not set, the default {@link GuzzleHttpClient} will be used. + */ + public ?HttpClientInterface $httpClient; + /** + * @var array + * The configuration for the underlying default {@link GuzzleHttpClient} client (if used). See {@link GUZZLE_DEFAULTS} for defaults. + */ + public array $guzzleClientConfig; const DEFAULTS = [ 'serverZone' => 'US', @@ -52,23 +59,30 @@ class AmplitudeConfig 'minIdLength' => 5, 'flushQueueSize' => 200, 'flushMaxRetries' => 12, + 'httpClient' => null, + 'guzzleClientConfig' => [] ]; + /** + * @param array $guzzleClientConfig + */ public function __construct( - int $flushQueueSize, - int $flushMaxRetries, - int $minIdLength, - string $serverZone, - string $serverUrl, - bool $useBatch + int $flushQueueSize, + int $minIdLength, + string $serverZone, + string $serverUrl, + bool $useBatch, + ?HttpClientInterface $httpClient, + array $guzzleClientConfig ) { $this->flushQueueSize = $flushQueueSize; - $this->flushMaxRetries = $flushMaxRetries; $this->minIdLength = $minIdLength; $this->serverZone = $serverZone; $this->serverUrl = $serverUrl; $this->useBatch = $useBatch; + $this->httpClient = $httpClient; + $this->guzzleClientConfig = $guzzleClientConfig; } public static function builder(): AmplitudeConfigBuilder diff --git a/src/Amplitude/AmplitudeConfigBuilder.php b/src/Amplitude/AmplitudeConfigBuilder.php index 59b7659..27222d4 100644 --- a/src/Amplitude/AmplitudeConfigBuilder.php +++ b/src/Amplitude/AmplitudeConfigBuilder.php @@ -2,14 +2,20 @@ namespace AmplitudeExperiment\Amplitude; +use AmplitudeExperiment\Http\HttpClientInterface; + class AmplitudeConfigBuilder { protected int $flushQueueSize = AmplitudeConfig::DEFAULTS['flushQueueSize']; - protected int $flushMaxRetries = AmplitudeConfig::DEFAULTS['flushMaxRetries']; protected int $minIdLength = AmplitudeConfig::DEFAULTS['minIdLength']; protected string $serverZone = AmplitudeConfig::DEFAULTS['serverZone']; protected ?string $serverUrl = null; protected bool $useBatch = AmplitudeConfig::DEFAULTS['useBatch']; + protected ?HttpClientInterface $httpClient = AmplitudeConfig::DEFAULTS['httpClient']; + /** + * @var array + */ + protected array $guzzleClientConfig = AmplitudeConfig::DEFAULTS['guzzleClientConfig']; public function __construct() { @@ -21,12 +27,6 @@ public function flushQueueSize(int $flushQueueSize): AmplitudeConfigBuilder return $this; } - public function flushMaxRetries(int $flushMaxRetries): AmplitudeConfigBuilder - { - $this->flushMaxRetries = $flushMaxRetries; - return $this; - } - public function minIdLength(int $minIdLength): AmplitudeConfigBuilder { $this->minIdLength = $minIdLength; @@ -51,6 +51,24 @@ public function useBatch(bool $useBatch): AmplitudeConfigBuilder return $this; } + public function httpClient(HttpClientInterface $httpClient): AmplitudeConfigBuilder + { + $this->httpClient = $httpClient; + return $this; + } + + /** + * @param array $guzzleClientConfig + */ + public function guzzleClientConfig(array $guzzleClientConfig): AmplitudeConfigBuilder + { + $this->guzzleClientConfig = $guzzleClientConfig; + return $this; + } + + /** + * @phpstan-ignore-next-line + */ public function build() { if (!$this->serverUrl) { @@ -62,11 +80,12 @@ public function build() } return new AmplitudeConfig( $this->flushQueueSize, - $this->flushMaxRetries, $this->minIdLength, $this->serverZone, $this->serverUrl, - $this->useBatch + $this->useBatch, + $this->httpClient, + $this->guzzleClientConfig ); } } diff --git a/src/Amplitude/Event.php b/src/Amplitude/Event.php index d2615a6..a2865ac 100644 --- a/src/Amplitude/Event.php +++ b/src/Amplitude/Event.php @@ -5,7 +5,13 @@ class Event { public ?string $eventType = null; + /** + * @var ?array + */ public ?array $eventProperties = null; + /** + * @var ?array + */ public ?array $userProperties = null; public ?string $userId = null; public ?string $deviceId = null; @@ -16,6 +22,9 @@ public function __construct(string $eventType) $this->eventType = $eventType; } + /** + * @return array + */ public function toArray(): array { return array_filter([ diff --git a/src/AmplitudeCookie.php b/src/AmplitudeCookie.php index 76d2965..b8ed0d4 100644 --- a/src/AmplitudeCookie.php +++ b/src/AmplitudeCookie.php @@ -2,6 +2,9 @@ namespace AmplitudeExperiment; +use AmplitudeExperiment\Logger\DefaultLogger; +use AmplitudeExperiment\Logger\InternalLogger; +use AmplitudeExperiment\Logger\LogLevel; use Exception; require_once __DIR__ . '/Util.php'; @@ -34,7 +37,7 @@ public static function cookieName(string $amplitudeApiKey, bool $newFormat = fal /** * @param string $amplitudeCookie A string from the amplitude cookie * @param bool $newFormat True if the cookie is in the Browser SDK 2.0 format - * @return array An array containing device_id and user_id (if available) + * @return array An array containing device_id and user_id (if available) */ public static function parse(string $amplitudeCookie, bool $newFormat = false): array { @@ -42,17 +45,16 @@ public static function parse(string $amplitudeCookie, bool $newFormat = false): $decoding = base64_decode($amplitudeCookie); $decoded = urldecode($decoding); - try { - $userSession = json_decode($decoded, true); - return [ - 'deviceId' => $userSession['deviceId'], - 'userId' => $userSession['userId'] ?? null, - ]; - } catch (\Exception $e) { - $logger = initializeLogger(false); - $logger->error("Error parsing the Amplitude cookie: '{$amplitudeCookie}'. " . $e->getMessage()); + $userSession = json_decode($decoded, true); + if ($userSession === null) { + $logger = new InternalLogger(new DefaultLogger(), LogLevel::INFO); + $logger->error("Error parsing the Amplitude cookie: '{$amplitudeCookie}'."); return []; } + return [ + 'deviceId' => $userSession['deviceId'], + 'userId' => $userSession['userId'] ?? null, + ]; } $values = explode('.', $amplitudeCookie); @@ -89,6 +91,9 @@ public static function generate(string $deviceId, bool $newFormat = false): stri ]; $json_data = json_encode($userSessionHash); + if ($json_data === false) { + return ''; + } $encoded_json = urlencode($json_data); return base64_encode($encoded_json); } diff --git a/src/Assignment/Assignment.php b/src/Assignment/Assignment.php index 42b4844..69baed3 100644 --- a/src/Assignment/Assignment.php +++ b/src/Assignment/Assignment.php @@ -3,18 +3,25 @@ namespace AmplitudeExperiment\Assignment; use AmplitudeExperiment\User; +use AmplitudeExperiment\Variant; class Assignment { public User $user; + /** + * @var array + */ public array $variants; public int $timestamp; + /** + * @param array $variants + */ public function __construct(User $user, array $variants) { $this->user = $user; $this->variants = $variants; - $this->timestamp = floor(microtime(true) * 1000); + $this->timestamp = (int) floor(microtime(true) * 1000); } public function canonicalize(): string diff --git a/src/Assignment/AssignmentConfig.php b/src/Assignment/AssignmentConfig.php index b02ff60..fc8931e 100644 --- a/src/Assignment/AssignmentConfig.php +++ b/src/Assignment/AssignmentConfig.php @@ -6,15 +6,27 @@ /** * Configuration options for assignment tracking. This is an object that can be created using - * a {@link AssignmentConfigBuilder}. Example usage: + * a {@link AssignmentConfigBuilder}, which also sets options for {@link AmplitudeConfig}. Example usage: * - * AssignmentConfigBuilder::builder('api-key')->build() + * ``` + * AssignmentConfigBuilder::builder('api-key')->minIdLength(10)->build(); + * ``` */ class AssignmentConfig { + /** + * The Amplitude Analytics API key. + */ public string $apiKey; + /** + * The maximum number of assignments stored in the assignment cache + */ public int $cacheCapacity; + /** + * Configuration options for the underlying {@link Amplitude} client. This is created when + * calling {@link AssignmentConfigBuilder::build()} and does not need to be explicitly set. + */ public AmplitudeConfig $amplitudeConfig; const DEFAULTS = [ diff --git a/src/Assignment/AssignmentConfigBuilder.php b/src/Assignment/AssignmentConfigBuilder.php index fe353c6..1882639 100644 --- a/src/Assignment/AssignmentConfigBuilder.php +++ b/src/Assignment/AssignmentConfigBuilder.php @@ -2,16 +2,17 @@ namespace AmplitudeExperiment\Assignment; +use AmplitudeExperiment\Amplitude\AmplitudeConfig; use AmplitudeExperiment\Amplitude\AmplitudeConfigBuilder; /** * Extends AmplitudeConfigBuilder to allow configuration {@link AmplitudeConfig} of underlying {@link Amplitude} client. */ - class AssignmentConfigBuilder extends AmplitudeConfigBuilder { protected string $apiKey; protected int $cacheCapacity = AssignmentConfig::DEFAULTS['cacheCapacity']; + public function __construct(string $apiKey) { parent::__construct(); @@ -24,7 +25,10 @@ public function cacheCapacity(int $cacheCapacity): AssignmentConfigBuilder return $this; } - public function build(): AssignmentConfig + /** + * @phpstan-ignore-next-line + */ + public function build() { return new AssignmentConfig( $this->apiKey, diff --git a/src/Assignment/LRUCache.php b/src/Assignment/LRUCache.php index 87e7e3a..7a6d2e9 100644 --- a/src/Assignment/LRUCache.php +++ b/src/Assignment/LRUCache.php @@ -2,38 +2,59 @@ namespace AmplitudeExperiment\Assignment; -class ListNode { - public $prev; - public $next; +class ListNode +{ + public ?ListNode $prev = null; + public ?ListNode $next = null; + /** + * @var mixed + */ public $data; - public function __construct($data) { + /** + * @param mixed $data + */ + public function __construct($data) + { $this->prev = null; $this->next = null; $this->data = $data; } } -class CacheItem { - public $key; +class CacheItem +{ + public string $key; + /** + * @var mixed + */ public $value; - public $createdAt; + public int $createdAt; - public function __construct($key, $value) { + /** + * @param mixed $value + */ + public function __construct(string $key, $value) + { $this->key = $key; $this->value = $value; - $this->createdAt = floor(microtime(true) * 1000); + $this->createdAt = (int) floor(microtime(true) * 1000); } } -class LRUCache { - private $capacity; - private $ttlMillis; - private $cache; - private $head; - private $tail; - - public function __construct($capacity, $ttlMillis) { +class LRUCache +{ + private int $capacity; + private int $ttlMillis; + /** + * @var array + */ + private array $cache; + private ?ListNode $head = null; + private ?ListNode $tail = null; + + public function __construct(int $capacity, int $ttlMillis) + { $this->capacity = $capacity; $this->ttlMillis = $ttlMillis; $this->cache = []; @@ -41,7 +62,11 @@ public function __construct($capacity, $ttlMillis) { $this->tail = null; } - public function put($key, $value): void { + /** + * @param mixed $value + */ + public function put(string $key, $value): void + { if (isset($this->cache[$key])) { $this->removeFromList($key); } elseif (count($this->cache) >= $this->capacity) { @@ -54,7 +79,11 @@ public function put($key, $value): void { $this->insertToList($node); } - public function get($key) { + /** + * @return mixed + */ + public function get(string $key) + { if (isset($this->cache[$key])) { $node = $this->cache[$key]; $timeElapsed = floor(microtime(true) * 1000) - $node->data->createdAt; @@ -72,24 +101,28 @@ public function get($key) { return null; } - public function remove($key): void { + public function remove(string $key): void + { $this->removeFromList($key); unset($this->cache[$key]); } - public function clear(): void { + public function clear(): void + { $this->cache = []; $this->head = null; $this->tail = null; } - private function evictLRU(): void { + private function evictLRU(): void + { if ($this->head) { $this->remove($this->head->data->key); } } - private function removeFromList($key): void { + private function removeFromList(string $key): void + { $node = $this->cache[$key]; if ($node->prev) { @@ -105,7 +138,8 @@ private function removeFromList($key): void { } } - private function insertToList($node): void { + private function insertToList(ListNode $node): void + { if ($this->tail) { $this->tail->next = $node; $node->prev = $this->tail; @@ -117,4 +151,3 @@ private function insertToList($node): void { } } } - diff --git a/src/EvaluationCore/EvaluationEngine.php b/src/EvaluationCore/EvaluationEngine.php index 582c496..8590b8a 100644 --- a/src/EvaluationCore/EvaluationEngine.php +++ b/src/EvaluationCore/EvaluationEngine.php @@ -112,7 +112,7 @@ private function matchCondition(array $target, array $condition): bool private function getHash(string $key): int { - return murmurhash3_int($key); + return Murmur3::hash3_int($key); } private function bucket(array $target, array $segment): ?string diff --git a/src/EvaluationCore/Murmur3.php b/src/EvaluationCore/Murmur3.php new file mode 100644 index 0000000..6ec33c0 --- /dev/null +++ b/src/EvaluationCore/Murmur3.php @@ -0,0 +1,49 @@ += 0 ? $k1 >> 16 : (($k1 & 0x7fffffff) >> 16) | 0x8000)) * 0xcc9e2d51) & 0xffff) << 16))) & 0xffffffff; + $k1 = $k1 << 15 | ($k1 >= 0 ? $k1 >> 17 : (($k1 & 0x7fffffff) >> 17) | 0x4000); + $k1 = (((($k1 & 0xffff) * 0x1b873593) + ((((($k1 >= 0 ? $k1 >> 16 : (($k1 & 0x7fffffff) >> 16) | 0x8000)) * 0x1b873593) & 0xffff) << 16))) & 0xffffffff; + $h1 ^= $k1; + $h1 = $h1 << 13 | ($h1 >= 0 ? $h1 >> 19 : (($h1 & 0x7fffffff) >> 19) | 0x1000); + $h1b = (((($h1 & 0xffff) * 5) + ((((($h1 >= 0 ? $h1 >> 16 : (($h1 & 0x7fffffff) >> 16) | 0x8000)) * 5) & 0xffff) << 16))) & 0xffffffff; + $h1 = ((($h1b & 0xffff) + 0x6b64) + ((((($h1b >= 0 ? $h1b >> 16 : (($h1b & 0x7fffffff) >> 16) | 0x8000)) + 0xe654) & 0xffff) << 16)); + } + $k1 = 0; + switch ($remainder) { + case 3: $k1 ^= $key[$i + 2] << 16; + case 2: $k1 ^= $key[$i + 1] << 8; + case 1: $k1 ^= $key[$i]; + $k1 = ((($k1 & 0xffff) * 0xcc9e2d51) + ((((($k1 >= 0 ? $k1 >> 16 : (($k1 & 0x7fffffff) >> 16) | 0x8000)) * 0xcc9e2d51) & 0xffff) << 16)) & 0xffffffff; + $k1 = $k1 << 15 | ($k1 >= 0 ? $k1 >> 17 : (($k1 & 0x7fffffff) >> 17) | 0x4000); + $k1 = ((($k1 & 0xffff) * 0x1b873593) + ((((($k1 >= 0 ? $k1 >> 16 : (($k1 & 0x7fffffff) >> 16) | 0x8000)) * 0x1b873593) & 0xffff) << 16)) & 0xffffffff; + $h1 ^= $k1; + } + $h1 ^= $klen; + $h1 ^= ($h1 >= 0 ? $h1 >> 16 : (($h1 & 0x7fffffff) >> 16) | 0x8000); + $h1 = ((($h1 & 0xffff) * 0x85ebca6b) + ((((($h1 >= 0 ? $h1 >> 16 : (($h1 & 0x7fffffff) >> 16) | 0x8000)) * 0x85ebca6b) & 0xffff) << 16)) & 0xffffffff; + $h1 ^= ($h1 >= 0 ? $h1 >> 13 : (($h1 & 0x7fffffff) >> 13) | 0x40000); + $h1 = (((($h1 & 0xffff) * 0xc2b2ae35) + ((((($h1 >= 0 ? $h1 >> 16 : (($h1 & 0x7fffffff) >> 16) | 0x8000)) * 0xc2b2ae35) & 0xffff) << 16))) & 0xffffffff; + $h1 ^= ($h1 >= 0 ? $h1 >> 16 : (($h1 & 0x7fffffff) >> 16) | 0x8000); + return $h1; + } +} diff --git a/src/Experiment.php b/src/Experiment.php index e9f4347..2fc5424 100644 --- a/src/Experiment.php +++ b/src/Experiment.php @@ -9,7 +9,13 @@ class Experiment { + /** + * @var array + */ private array $remoteInstances = []; + /** + * @var array + */ private array $localInstances = []; /** diff --git a/src/Flag/FlagConfigFetcher.php b/src/Flag/FlagConfigFetcher.php index 2085680..aa0232d 100644 --- a/src/Flag/FlagConfigFetcher.php +++ b/src/Flag/FlagConfigFetcher.php @@ -2,74 +2,60 @@ namespace AmplitudeExperiment\Flag; +use AmplitudeExperiment\Http\HttpClientInterface; use AmplitudeExperiment\Local\LocalEvaluationConfig; -use AmplitudeExperiment\Util; -use Exception; -use GuzzleHttp\Client; -use GuzzleHttp\Promise\PromiseInterface; -use Monolog\Logger; -use Psr\Http\Message\ResponseInterface; -use RuntimeException; -use function AmplitudeExperiment\initializeLogger; +use Psr\Http\Client\ClientExceptionInterface; +use Psr\Log\LoggerInterface; require_once __DIR__ . '/../Version.php'; -require_once __DIR__ . '/../Util.php'; - -const FLAG_CONFIG_TIMEOUT = 5000; class FlagConfigFetcher { - private Logger $logger; + private LoggerInterface $logger; private string $apiKey; private string $serverUrl; - private Client $httpClient; + private HttpClientInterface $httpClient; - public function __construct(string $apiKey, bool $debug, string $serverUrl = LocalEvaluationConfig::DEFAULTS["serverUrl"]) + public function __construct(string $apiKey, LoggerInterface $logger, HttpClientInterface $httpClient, string $serverUrl = LocalEvaluationConfig::DEFAULTS["serverUrl"]) { $this->apiKey = $apiKey; $this->serverUrl = $serverUrl; - $this->httpClient = new Client(); - $this->logger = initializeLogger($debug); + $this->logger = $logger; + $this->httpClient = $httpClient; } /** * Fetch local evaluation mode flag configs from the Experiment API server. * These flag configs can be used to perform local evaluation. * - * @return PromiseInterface + * @return array> The flag configs + * @throws ClientExceptionInterface */ - public function fetch(): PromiseInterface + public function fetch(): array { $endpoint = $this->serverUrl . '/sdk/v2/flags?v=0'; - $headers = [ - 'Authorization' => 'Api-Key ' . $this->apiKey, - 'Accept' => 'application/json', - 'Content-Type' => 'application/json;charset=utf-8', - 'X-Amp-Exp-Library' => 'experiment-php-server/' . VERSION, - ]; + $request = $this->httpClient->createRequest('GET', $endpoint) + ->withHeader('Authorization', 'Api-Key ' . $this->apiKey) + ->withHeader('Content-Type', 'application/json') + ->withHeader('X-Amp-Exp-Library', 'experiment-php-server/' . VERSION); $this->logger->debug('[Experiment] Fetch flag configs'); - $promise = $this->httpClient->requestAsync('GET', $endpoint, [ - 'headers' => $headers, - 'timeout' => FLAG_CONFIG_TIMEOUT / 1000, - ]); - return $promise->then( - function (ResponseInterface $response) { - // Check if the HTTP status code is not 200 - if ($response->getStatusCode() !== 200) { - $errorMessage = '[Experiment] Fetch flag configs - received error response: ' . $response->getStatusCode() . ': ' . $response->getBody(); - throw new RuntimeException($errorMessage); - } - $this->logger->debug('[Experiment] Got flag configs: ' . $response->getBody()); - return $this->parse(json_decode($response->getBody(), true)); - }, - function (Exception $reason) { - $this->logger->error('[Experiment] Fetch flag configs - received error response: ' . $reason->getMessage()); - throw $reason; - } - ); + $httpClient = $this->httpClient->getClient(); + + $response = $httpClient->sendRequest($request); + if ($response->getStatusCode() !== 200) { + $this->logger->error('[Experiment] Fetch flag configs - received error response: ' . $response->getStatusCode() . ': ' . $response->getBody()); + return []; + } + $this->logger->debug('[Experiment] Got flag configs: ' . $response->getBody()); + return $this->parse(json_decode($response->getBody(), true)); + } + /** + * @param array> $flagConfigs + * @return array> + */ private function parse(array $flagConfigs): array { $flagConfigsRecord = []; diff --git a/src/Flag/FlagConfigService.php b/src/Flag/FlagConfigService.php index 7ac3574..30b00de 100644 --- a/src/Flag/FlagConfigService.php +++ b/src/Flag/FlagConfigService.php @@ -2,55 +2,43 @@ namespace AmplitudeExperiment\Flag; -use AmplitudeExperiment\Backoff; -use AmplitudeExperiment\Util; -use GuzzleHttp\Promise\PromiseInterface; -use Monolog\Logger; -use function AmplitudeExperiment\initializeLogger; - -require_once __DIR__ . '/../Util.php'; +use Psr\Http\Client\ClientExceptionInterface; +use Psr\Log\LoggerInterface; class FlagConfigService { - private Logger $logger; + private LoggerInterface $logger; public FlagConfigFetcher $fetcher; + + /** + * @var array + */ public array $cache; - public function __construct(FlagConfigFetcher $fetcher, bool $debug, array $bootstrap) + /** + * @param array $bootstrap + */ + public function __construct(FlagConfigFetcher $fetcher, LoggerInterface $logger, array $bootstrap) { $this->fetcher = $fetcher; - $this->logger = initializeLogger($debug); + $this->logger = $logger; $this->cache = $bootstrap; } - public function start(): PromiseInterface - { - $this->logger->debug('[Experiment] Flag service - start'); - - // Fetch initial flag configs and await the result. - return Backoff::doWithBackoff( - function () { - return $this->refresh(); - }, - new Backoff(5, 1, 1, 1) - ); - } - - private function refresh(): PromiseInterface + public function refresh(): void { $this->logger->debug('[Experiment] Flag config update'); - return $this->fetcher->fetch()->then( - function (array $flagConfigs) { - $this->cache = $flagConfigs; - $this->logger->debug('[Experiment] Flag config update success'); - }, - function (\Exception $error) { - $this->logger->debug('[Experiment] Flag config update failed: ' . $error); - throw $error; - } - ); + try { + $flagConfigs = $this->fetcher->fetch(); + $this->cache = $flagConfigs; + } catch (ClientExceptionInterface $error) { + $this->logger->error('[Experiment] Failed to fetch flag configs: ' . $error->getMessage()); + } } + /** + * @return array + */ public function getFlagConfigs(): array { return $this->cache; diff --git a/src/Http/GuzzleHttpClient.php b/src/Http/GuzzleHttpClient.php new file mode 100644 index 0000000..d9046e1 --- /dev/null +++ b/src/Http/GuzzleHttpClient.php @@ -0,0 +1,100 @@ + 10000, + /** + * The number of retries to attempt before failing + */ + 'retries' => 8, + /** + * Retry backoff minimum (starting backoff delay) in milliseconds. The minimum backoff is scaled by + * `retryBackoffScalar` after each retry failure. + */ + 'retryBackoffMinMillis' => 500, + /** + * Retry backoff maximum in milliseconds. If the scaled backoff is greater than the max, the max is + * used for all subsequent retries. + */ + 'retryBackoffMaxMillis' => 10000, + /** + * Scales the minimum backoff exponentially. + */ + 'retryBackoffScalar' => 1.5, + /** + * The request timeout for retrying fetch requests. + */ + 'retryTimeoutMillis' => 10000 +]; + +/** + * A default {@link HttpClientInterface} implementation that uses Guzzle. + */ +class GuzzleHttpClient implements HttpClientInterface +{ + private Client $client; + /** + * @var array + */ + private array $config; + + /** + * @param array $config + */ + public function __construct(array $config) + { + $handlerStack = HandlerStack::create(); + $this->config = array_merge(GUZZLE_DEFAULTS, $config); + + // Add middleware for retries + $handlerStack->push(Middleware::retry( + function ($retries, Request $request, $response = null, $exception = null) { + // Retry if the maximum number of retries is not reached and an exception occurred + return $retries < $this->config['retries'] && $exception instanceof Exception; + }, + function ($retries) { + // Calculate delay + return $this->calculateDelayMillis($retries); + } + )); + + // Create a Guzzle client with the custom handler stack + $this->client = new Client(['handler' => $handlerStack, RequestOptions::TIMEOUT => $this->config['timeoutMillis'] / 1000]); + } + + public function getClient(): ClientInterface + { + return $this->client; + } + + public function createRequest(string $method, string $uri, ?string $body = null): Request + { + return new Request($method, $uri, [], $body); + } + + + protected function calculateDelayMillis(int $iteration): int + { + $delayMillis = $this->config['retryBackoffMinMillis']; + + for ($i = 1; $i < $iteration; $i++) { + $delayMillis = min( + $delayMillis * $this->config['retryBackoffScalar'], + $this->config['retryBackoffMaxMillis'] + ); + } + return $delayMillis; + } +} diff --git a/src/Http/HttpClientInterface.php b/src/Http/HttpClientInterface.php new file mode 100644 index 0000000..f378dad --- /dev/null +++ b/src/Http/HttpClientInterface.php @@ -0,0 +1,24 @@ +apiKey = $apiKey; $this->config = $config ?? LocalEvaluationConfig::builder()->build(); - $fetcher = new FlagConfigFetcher($apiKey, $this->config->debug, $this->config->serverUrl); - $this->flagConfigService = new FlagConfigService($fetcher, $this->config->debug, $this->config->bootstrap); - $this->logger = initializeLogger($this->config->debug); - $this->initializeAssignmentService($config->assignmentConfig); + $this->logger = new InternalLogger($this->config->logger ?? new DefaultLogger(), $this->config->logLevel); + $httpClient = $config->httpClient ?? $this->config->httpClient ?? new GuzzleHttpClient($this->config->guzzleClientConfig); + $fetcher = new FlagConfigFetcher($apiKey, $this->logger, $httpClient, $this->config->serverUrl); + $this->flagConfigService = new FlagConfigService($fetcher, $this->logger, $this->config->bootstrap); + $this->initializeAssignmentService($this->config->assignmentConfig); $this->evaluation = new EvaluationEngine(); } /** - * Fetch initial flag configurations. - * - * The promise returned by this function is resolved when the initial call - * to fetch the flag configuration completes. - * + * Fetch latest flag configurations. */ - public function start(): PromiseInterface + public function refreshFlagConfigs(): void { - return $this->flagConfigService->start(); + $this->flagConfigService->refresh(); } /** @@ -63,10 +58,10 @@ public function start(): PromiseInterface * flagKeys argument. If flagKeys is missing or empty, all flags in the * {@link FlagConfigService} will be evaluated. * - * @param $user User The user to evaluate - * @param $flagKeys array The flags to evaluate with the user. If empty, all flags + * @param User $user The user to evaluate + * @param array $flagKeys The flags to evaluate with the user. If empty, all flags * from the flag cache are evaluated. - * @returns array evaluated variants + * @return array evaluated variants */ public function evaluate(User $user, array $flagKeys = []): array { @@ -77,20 +72,29 @@ public function evaluate(User $user, array $flagKeys = []): array $this->logger->error('[Experiment] Evaluate - error sorting flags: ' . $e->getMessage()); } $this->logger->debug('[Experiment] Evaluate - user: ' . json_encode($user->toArray()) . ' with flags: ' . json_encode($flags)); - $results = array_map('AmplitudeExperiment\Variant::convertEvaluationVariantToVariant',$this->evaluation->evaluate($user->toEvaluationContext(), $flags)); - $this->logger->debug('[Experiment] Evaluate - variants:', $results); + $results = array_map('AmplitudeExperiment\Variant::convertEvaluationVariantToVariant', $this->evaluation->evaluate($user->toEvaluationContext(), $flags)); + $this->logger->debug('[Experiment] Evaluate - variants:' . json_encode($results)); if ($this->assignmentService) { $this->assignmentService->track(new Assignment($user, $results)); } return $results; } + + /** + * @return array flag configurations. + */ + public function getFlagConfigs(): array + { + return $this->flagConfigService->getFlagConfigs(); + } + private function initializeAssignmentService(?AssignmentConfig $config): void { if ($config) { $this->assignmentService = new AssignmentService( new Amplitude($config->apiKey, - $this->config->debug, + $this->logger, $config->amplitudeConfig), new AssignmentFilter($config->cacheCapacity)); } diff --git a/src/Local/LocalEvaluationConfig.php b/src/Local/LocalEvaluationConfig.php index 367cbfe..c36dc86 100644 --- a/src/Local/LocalEvaluationConfig.php +++ b/src/Local/LocalEvaluationConfig.php @@ -3,38 +3,72 @@ namespace AmplitudeExperiment\Local; use AmplitudeExperiment\Assignment\AssignmentConfig; +use AmplitudeExperiment\Http\HttpClientInterface; +use AmplitudeExperiment\Logger\LogLevel; +use Psr\Log\LoggerInterface; +/** + * Configuration options. This is an object that can be created using + * a {@link LocalEvaluationConfigBuilder}. Example usage: + * + *``` + * LocalEvaluationConfig::builder()->serverUrl("https://api.lab.amplitude.com/")->build(); + * ``` + */ class LocalEvaluationConfig { /** - * Set to true to log some extra information to the console. + * Set to use custom logger. If not set, a {@link DefaultLogger} is used. */ - public bool $debug; + public ?LoggerInterface $logger; + /** + * The {@link LogLevel} to use for the logger. + */ + public int $logLevel; /** * The server endpoint from which to request variants. */ public string $serverUrl; /** + * @var array * Bootstrap the client with a pre-fetched flag configurations. - * * Useful if you are managing the flag configurations separately. */ public array $bootstrap; public ?AssignmentConfig $assignmentConfig; + /** + * The underlying HTTP client to use for requests, if this is not set, the default {@link GuzzleHttpClient} will be used. + */ + public ?HttpClientInterface $httpClient; + /** + * @var array + * The configuration for the underlying default {@link GuzzleHttpClient} client (if used). See {@link GUZZLE_DEFAULTS} for defaults. + */ + public array $guzzleClientConfig; const DEFAULTS = [ - 'debug' => false, + 'logger' => null, + 'logLevel' => LogLevel::ERROR, 'serverUrl' => 'https://api.lab.amplitude.com', 'bootstrap' => [], - 'assignmentConfig' => null + 'assignmentConfig' => null, + 'httpClient' => null, + 'guzzleClientConfig' => [] ]; - public function __construct(bool $debug, string $serverUrl, array $bootstrap, ?AssignmentConfig $assignmentConfig) + /** + * @param array $guzzleClientConfig + * @param array $bootstrap + */ + public function __construct(?LoggerInterface $logger, int $logLevel, string $serverUrl, array $bootstrap, ?AssignmentConfig $assignmentConfig, ?HttpClientInterface $httpClient, array $guzzleClientConfig) { - $this->debug = $debug; + $this->logger = $logger; + $this->logLevel = $logLevel; $this->serverUrl = $serverUrl; $this->bootstrap = $bootstrap; $this->assignmentConfig = $assignmentConfig; + $this->httpClient = $httpClient; + $this->guzzleClientConfig = $guzzleClientConfig; } public static function builder(): LocalEvaluationConfigBuilder diff --git a/src/Local/LocalEvaluationConfigBuilder.php b/src/Local/LocalEvaluationConfigBuilder.php index 496eb46..d2e4937 100644 --- a/src/Local/LocalEvaluationConfigBuilder.php +++ b/src/Local/LocalEvaluationConfigBuilder.php @@ -3,21 +3,38 @@ namespace AmplitudeExperiment\Local; use AmplitudeExperiment\Assignment\AssignmentConfig; +use AmplitudeExperiment\Http\HttpClientInterface; +use Psr\Log\LoggerInterface; class LocalEvaluationConfigBuilder { - protected bool $debug = LocalEvaluationConfig::DEFAULTS['debug']; + protected ?LoggerInterface $logger = LocalEvaluationConfig::DEFAULTS['logger']; + protected int $logLevel = LocalEvaluationConfig::DEFAULTS['logLevel']; protected string $serverUrl = LocalEvaluationConfig::DEFAULTS['serverUrl']; + /** + * @var array + */ protected array $bootstrap = LocalEvaluationConfig::DEFAULTS['bootstrap']; protected ?AssignmentConfig $assignmentConfig = LocalEvaluationConfig::DEFAULTS['assignmentConfig']; + protected ?HttpClientInterface $httpClient = LocalEvaluationConfig::DEFAULTS['httpClient']; + /** + * @var array + */ + protected array $guzzleClientConfig = LocalEvaluationConfig::DEFAULTS['guzzleClientConfig']; public function __construct() { } - public function debug(bool $debug): LocalEvaluationConfigBuilder + public function logger(LoggerInterface $logger): LocalEvaluationConfigBuilder { - $this->debug = $debug; + $this->logger = $logger; + return $this; + } + + public function logLevel(int $logLevel): LocalEvaluationConfigBuilder + { + $this->logLevel = $logLevel; return $this; } @@ -27,6 +44,9 @@ public function serverUrl(string $serverUrl): LocalEvaluationConfigBuilder return $this; } + /** + * @param array $bootstrap + */ public function bootstrap(array $bootstrap): LocalEvaluationConfigBuilder { $this->bootstrap = $bootstrap; @@ -39,13 +59,31 @@ public function assignmentConfig(AssignmentConfig $assignmentConfig): LocalEvalu return $this; } + public function httpClient(HttpClientInterface $httpClient): LocalEvaluationConfigBuilder + { + $this->httpClient = $httpClient; + return $this; + } + + /** + * @param array $guzzleClientConfig + */ + public function guzzleClientConfig(array $guzzleClientConfig): LocalEvaluationConfigBuilder + { + $this->guzzleClientConfig = $guzzleClientConfig; + return $this; + } + public function build(): LocalEvaluationConfig { return new LocalEvaluationConfig( - $this->debug, + $this->logger, + $this->logLevel, $this->serverUrl, $this->bootstrap, - $this->assignmentConfig + $this->assignmentConfig, + $this->httpClient, + $this->guzzleClientConfig ); } } diff --git a/src/Logger/DefaultLogger.php b/src/Logger/DefaultLogger.php new file mode 100644 index 0000000..ea04885 --- /dev/null +++ b/src/Logger/DefaultLogger.php @@ -0,0 +1,71 @@ + $context + */ + private static function logMessage(int $level, string $message, array $context = []): void + { + $date = new DateTimeImmutable(); + $timestamp = $date->format('Y-m-d\\TH:i:sP'); + $level = LogLevel::toString($level); + $message = "[$timestamp] AmplitudeExperiment.$level: $message"; + error_log($message); + } +} diff --git a/src/Logger/InternalLogger.php b/src/Logger/InternalLogger.php new file mode 100644 index 0000000..cd4ef3e --- /dev/null +++ b/src/Logger/InternalLogger.php @@ -0,0 +1,83 @@ +logger = $logger; + $this->logLevel = $logLevel; + } + + public function emergency($message, array $context = []): void + { + if ($this->shouldLog(LogLevel::EMERGENCY)) { + $this->logger->emergency($message, $context); + } + } + + public function alert($message, array $context = []): void + { + if ($this->shouldLog(LogLevel::ALERT)) { + $this->logger->alert($message, $context); + } + } + + public function critical($message, array $context = []): void + { + if ($this->shouldLog(LogLevel::CRITICAL)) { + $this->logger->critical($message, $context); + } + } + + public function error($message, array $context = []): void + { + if ($this->shouldLog(LogLevel::ERROR)) { + $this->logger->error($message, $context); + } + } + + public function warning($message, array $context = []): void + { + if ($this->shouldLog(LogLevel::WARNING)) { + $this->logger->warning($message, $context); + } + } + + public function notice($message, array $context = []): void + { + if ($this->shouldLog(LogLevel::NOTICE)) { + $this->logger->notice($message, $context); + } + } + + public function info($message, array $context = []): void + { + if ($this->shouldLog(LogLevel::INFO)) { + $this->logger->info($message, $context); + } + } + + public function debug($message, array $context = []): void + { + if ($this->shouldLog(LogLevel::DEBUG)) { + $this->logger->debug($message, $context); + } + } + + public function log($level, $message, array $context = []): void + { + // Do nothing + } + + private function shouldLog(int $level): bool + { + return $level <= $this->logLevel; + } +} diff --git a/src/Logger/LogLevel.php b/src/Logger/LogLevel.php new file mode 100644 index 0000000..d014db9 --- /dev/null +++ b/src/Logger/LogLevel.php @@ -0,0 +1,40 @@ +apiKey = $apiKey; $this->config = $config ?? RemoteEvaluationConfig::builder()->build(); - $this->httpClient = new Client(); - $this->logger = initializeLogger($this->config->debug); + $this->httpClient = $config->httpClient ?? $this->config->httpClient ?? new GuzzleHttpClient($this->config->guzzleClientConfig); + $this->logger = new InternalLogger($this->config->logger ?? new DefaultLogger(), $this->config->logLevel); } /** @@ -46,123 +44,61 @@ public function __construct(string $apiKey, ?RemoteEvaluationConfig $config = nu * * This method will automatically retry if configured (default). * - * @param $user User The {@link User} context - * @param $flagKeys array The flags to evaluate for this specific fetch request. - * @return PromiseInterface A {@link Variant} array for the user on success, empty array on error. - * @throws Exception + * @param User $user The {@link User} context + * @param array $flagKeys The flags to evaluate for this specific fetch request. + * @return array A {@link Variant} array for the user on success, empty array on error. */ - public function fetch(User $user, array $flagKeys = []): PromiseInterface + public function fetch(User $user, array $flagKeys = []): array { if ($user->userId == null && $user->deviceId == null) { $this->logger->warning('[Experiment] user id and device id are null; Amplitude may not resolve identity'); } $this->logger->debug('[Experiment] Fetching variants for user: ' . json_encode($user->toArray())); - return $this->doFetch($user, $this->config->fetchTimeoutMillis, $flagKeys) - ->otherwise(function (Throwable $e) use ($user, $flagKeys) { - // Handle the exception - $this->logger->error('[Experiment] Fetch variant failed: ' . $e->getMessage()); - - // Retry the fetch - return $this->retryFetch($user, $flagKeys) - ->then(function ($result) { - // Process the result if retry is successful - return $result; - }) - ->otherwise(function (Throwable $retryException) use ($e) { - // Handle the exception for the retry attempt - $this->logger->error('[Experiment] Fetch variant retry failed: ' . $retryException->getMessage()); - - // Re-throw the original exception if needed - throw $e; - }); - }); - } - - public function doFetch(User $user, int $timeoutMillis, array $flagKeys = []): PromiseInterface - { // Define the request data $libraryUser = $user->copyToBuilder()->library('experiment-php-server/' . VERSION)->build(); - $serializedUser = base64_encode(json_encode($libraryUser->toArray())); + $userJson = json_encode($libraryUser->toArray()); + if ($userJson === false) { + $this->logger->error('[Experiment] Failed to fetch variants: ' . json_last_error_msg()); + return []; + } + $serializedUser = base64_encode($userJson); // Define the request URL $endpoint = $this->config->serverUrl . '/sdk/v2/vardata?v=0'; - - // Define the request headers - $headers = [ - 'Authorization' => 'Api-Key ' . $this->apiKey, - 'Content-Type' => 'application/json', - 'X-Amp-Exp-User' => $serializedUser, - ]; + $request = $this->httpClient->createRequest('GET', $endpoint) + ->withHeader('Authorization', 'Api-Key ' . $this->apiKey) + ->withHeader('Content-Type', 'application/json') + ->withHeader('X-Amp-Exp-User', $serializedUser); if (!empty($flagKeys)) { - $headers['X-Amp-Exp-Flag-Keys'] = base64_encode(json_encode($flagKeys)); - } - - $promise = $this->httpClient->requestAsync('GET', $endpoint, [ - 'headers' => $headers, - 'timeout' => $timeoutMillis / 1000, - ]); - - return $promise->then( - function (ResponseInterface $response) { - $results = json_decode($response->getBody(), true); - $variants = []; - foreach ($results as $flagKey => $flagResult) { - $variants[$flagKey] = Variant::convertEvaluationVariantToVariant($flagResult); - } - $this->logger->debug('[Experiment] Fetched variants: ' . $response->getBody()); - return $variants; - }, - function (Exception $reason) { - $this->logger->error('[Experiment] Failed to fetch variants: ' . $reason->getMessage()); - throw $reason; + $flagKeysJson = json_encode($flagKeys); + if ($flagKeysJson === false) { + $this->logger->error('[Experiment] Failed to fetch variants: ' . json_last_error_msg()); + return []; } - ); - } - - /** - * @throws Exception - */ - private function retryFetch(User $user, array $flagKeys = []): PromiseInterface - { - if ($this->config->fetchRetries == 0) { - return Create::promiseFor([]); + $request = $request->withHeader('X-Amp-Exp-Flag-Keys', base64_encode($flagKeysJson)); } - $this->logger->debug('[Experiment] Retrying fetch variant'); + $httpClient = $this->httpClient->getClient(); - $err = null; - $delayMillis = $this->config->fetchRetryBackoffMinMillis; - - for ($i = 0; $i < $this->config->fetchRetries; $i++) { - usleep($delayMillis * 1000); // Convert to microseconds - - try { - return $this->doFetch( - $user, - $this->config->fetchRetryTimeoutMillis, - $flagKeys - )->then( - function ($result) { - return $result; - }, - function ($e) use (&$err) { - $this->logger->error('[Experiment] Fetch variant retry failed: ' . $e->getMessage()); - $err = $e; - } - ); - } catch (Exception $e) { - $this->logger->error('[Experiment] Fetch variant retry failed: ' . $e->getMessage()); - $err = $e; + try { + $response = $httpClient->sendRequest($request); + if ($response->getStatusCode() != 200) { + $this->logger->error('[Experiment] Failed to fetch variants: ' . $response->getBody()); + return []; } - $delayMillis = min( - $delayMillis * $this->config->fetchRetryBackoffScalar, - $this->config->fetchRetryBackoffMaxMillis - ); + $results = json_decode($response->getBody(), true); + $variants = []; + foreach ($results as $flagKey => $flagResult) { + $variants[$flagKey] = Variant::convertEvaluationVariantToVariant($flagResult); + } + $this->logger->debug('[Experiment] Fetched variants: ' . $response->getBody()); + return $variants; + } catch (ClientExceptionInterface $e) { + $this->logger->error('[Experiment] Failed to fetch variants: ' . $e->getMessage()); + return []; } - - throw $err; } } diff --git a/src/Remote/RemoteEvaluationConfig.php b/src/Remote/RemoteEvaluationConfig.php index 184ad12..91ee0fb 100644 --- a/src/Remote/RemoteEvaluationConfig.php +++ b/src/Remote/RemoteEvaluationConfig.php @@ -2,79 +2,69 @@ namespace AmplitudeExperiment\Remote; +use AmplitudeExperiment\Http\HttpClientInterface; +use AmplitudeExperiment\Logger\DefaultLogger; +use AmplitudeExperiment\Logger\LogLevel; +use Psr\Log\LoggerInterface; + /** * Configuration options. This is an object that can be created using * a {@link RemoteEvaluationConfigBuilder}. Example usage: * - *`RemoteEvaluationConfig::builder()->serverUrl("https://api.lab.amplitude.com/")->build()` + *``` + * RemoteEvaluationConfig::builder()->serverUrl("https://api.lab.amplitude.com/")->build(); + * ``` */ class RemoteEvaluationConfig { /** - * Set to true to log some extra information to the console. - */ - public bool $debug; - /** - * The server endpoint from which to request variants. - */ - public string $serverUrl; - /** - * The request socket timeout, in milliseconds. + * Set to use custom logger. If not set, a {@link DefaultLogger} is used. */ - public int $fetchTimeoutMillis; + public ?LoggerInterface $logger; /** - * The number of retries to attempt before failing + * The {@link LogLevel} to use for the logger. */ - public int $fetchRetries; + public int $logLevel; /** - * Retry backoff minimum (starting backoff delay) in milliseconds. The minimum backoff is scaled by - * `fetchRetryBackoffScalar` after each retry failure. - */ - public int $fetchRetryBackoffMinMillis; - /** - * Retry backoff maximum in milliseconds. If the scaled backoff is greater than the max, the max is - * used for all subsequent retries. + * The server endpoint from which to request variants. */ - public int $fetchRetryBackoffMaxMillis; + public string $serverUrl; /** - * Scales the minimum backoff exponentially. + * The underlying HTTP client to use for requests, if this is not set, the default {@link GuzzleHttpClient} will be used. */ - public float $fetchRetryBackoffScalar; + public ?HttpClientInterface $httpClient; /** - * The request timeout for retrying fetch requests. + * @var array + * The configuration for the underlying default {@link GuzzleHttpClient} (if used). See {@link GUZZLE_DEFAULTS} for defaults. */ - public int $fetchRetryTimeoutMillis; + public array $guzzleClientConfig; const DEFAULTS = [ + 'logger' => null, + 'logLevel' => LogLevel::ERROR, 'debug' => false, 'serverUrl' => 'https://api.lab.amplitude.com', - 'fetchTimeoutMillis' => 10000, - 'fetchRetries' => 8, - 'fetchRetryBackoffMinMillis' => 500, - 'fetchRetryBackoffMaxMillis' => 10000, - 'fetchRetryBackoffScalar' => 1.5, - 'fetchRetryTimeoutMillis' => 10000 + 'httpClient' => null, + 'guzzleClientConfig' => [] ]; + + /** + * @param array $guzzleClientConfig + */ public function __construct( - bool $debug, - string $serverUrl, - int $fetchTimeoutMillis, - int $fetchRetries, - int $fetchRetryBackoffMinMillis, - int $fetchRetryBackoffMaxMillis, - float $fetchRetryBackoffScalar, - int $fetchRetryTimeoutMillis + ?LoggerInterface $logger, + int $logLevel, + string $serverUrl, + ?HttpClientInterface $httpClient, + array $guzzleClientConfig ) { - $this->debug = $debug; + $this->logger = $logger; + $this->logLevel = $logLevel; $this->serverUrl = $serverUrl; - $this->fetchTimeoutMillis = $fetchTimeoutMillis; - $this->fetchRetries = $fetchRetries; - $this->fetchRetryBackoffMinMillis = $fetchRetryBackoffMinMillis; - $this->fetchRetryBackoffMaxMillis = $fetchRetryBackoffMaxMillis; - $this->fetchRetryBackoffScalar = $fetchRetryBackoffScalar; - $this->fetchRetryTimeoutMillis = $fetchRetryTimeoutMillis; + $this->httpClient = $httpClient; + $this->guzzleClientConfig = $guzzleClientConfig; } public static function builder(): RemoteEvaluationConfigBuilder diff --git a/src/Remote/RemoteEvaluationConfigBuilder.php b/src/Remote/RemoteEvaluationConfigBuilder.php index b91a537..d79df61 100644 --- a/src/Remote/RemoteEvaluationConfigBuilder.php +++ b/src/Remote/RemoteEvaluationConfigBuilder.php @@ -2,80 +2,67 @@ namespace AmplitudeExperiment\Remote; +use AmplitudeExperiment\Http\HttpClientInterface; +use Psr\Log\LoggerInterface; + class RemoteEvaluationConfigBuilder { + protected ?LoggerInterface $logger = RemoteEvaluationConfig::DEFAULTS['logger']; + protected int $logLevel = RemoteEvaluationConfig::DEFAULTS['logLevel']; protected bool $debug = RemoteEvaluationConfig::DEFAULTS['debug']; protected string $serverUrl = RemoteEvaluationConfig::DEFAULTS['serverUrl']; - protected int $fetchTimeoutMillis = RemoteEvaluationConfig::DEFAULTS['fetchTimeoutMillis']; - protected int $fetchRetries = RemoteEvaluationConfig::DEFAULTS['fetchRetries']; - protected int $fetchRetryBackoffMinMillis = RemoteEvaluationConfig::DEFAULTS['fetchRetryBackoffMinMillis']; - protected int $fetchRetryBackoffMaxMillis = RemoteEvaluationConfig::DEFAULTS['fetchRetryBackoffMaxMillis']; - protected float $fetchRetryBackoffScalar = RemoteEvaluationConfig::DEFAULTS['fetchRetryBackoffScalar']; - protected int $fetchRetryTimeoutMillis = RemoteEvaluationConfig::DEFAULTS['fetchRetryTimeoutMillis']; + protected ?HttpClientInterface $httpClient = RemoteEvaluationConfig::DEFAULTS['httpClient']; + /** + * @var array + */ + protected array $guzzleClientConfig = RemoteEvaluationConfig::DEFAULTS['guzzleClientConfig']; public function __construct() { } - public function debug(bool $debug): RemoteEvaluationConfigBuilder - { - $this->debug = $debug; - return $this; - } - - public function serverUrl(string $serverUrl): RemoteEvaluationConfigBuilder - { - $this->serverUrl = $serverUrl; - return $this; - } - - public function fetchTimeoutMillis(int $fetchTimeoutMillis): RemoteEvaluationConfigBuilder + public function logger(LoggerInterface $logger): RemoteEvaluationConfigBuilder { - $this->fetchTimeoutMillis = $fetchTimeoutMillis; + $this->logger = $logger; return $this; } - public function fetchRetries(int $fetchRetries): RemoteEvaluationConfigBuilder + public function logLevel(int $logLevel): RemoteEvaluationConfigBuilder { - $this->fetchRetries = $fetchRetries; + $this->logLevel = $logLevel; return $this; } - public function fetchRetryBackoffMinMillis(int $fetchRetryBackoffMinMillis): RemoteEvaluationConfigBuilder + public function serverUrl(string $serverUrl): RemoteEvaluationConfigBuilder { - $this->fetchRetryBackoffMinMillis = $fetchRetryBackoffMinMillis; + $this->serverUrl = $serverUrl; return $this; } - public function fetchRetryBackoffMaxMillis(int $fetchRetryBackoffMaxMillis): RemoteEvaluationConfigBuilder + public function httpClient(HttpClientInterface $httpClient): RemoteEvaluationConfigBuilder { - $this->fetchRetryBackoffMaxMillis = $fetchRetryBackoffMaxMillis; + $this->httpClient = $httpClient; return $this; } - public function fetchRetryBackoffScalar(float $fetchRetryBackoffScalar): RemoteEvaluationConfigBuilder - { - $this->fetchRetryBackoffScalar = $fetchRetryBackoffScalar; - return $this; - } - public function fetchRetryTimeoutMillis(int $fetchRetryTimeoutMillis): RemoteEvaluationConfigBuilder + /** + * @param array $guzzleClientConfig + */ + public function guzzleClientConfig(array $guzzleClientConfig): RemoteEvaluationConfigBuilder { - $this->fetchRetryTimeoutMillis = $fetchRetryTimeoutMillis; + $this->guzzleClientConfig = $guzzleClientConfig; return $this; } public function build(): RemoteEvaluationConfig { return new RemoteEvaluationConfig( - $this->debug, + $this->logger, + $this->logLevel, $this->serverUrl, - $this->fetchTimeoutMillis, - $this->fetchRetries, - $this->fetchRetryBackoffMinMillis, - $this->fetchRetryBackoffMaxMillis, - $this->fetchRetryBackoffScalar, - $this->fetchRetryTimeoutMillis, + $this->httpClient, + $this->guzzleClientConfig ); } } diff --git a/src/User.php b/src/User.php index 8266e58..2e67d96 100644 --- a/src/User.php +++ b/src/User.php @@ -38,10 +38,24 @@ class User public ?string $deviceModel; public ?string $carrier; public ?string $library; + /** + * @var ?array + */ public ?array $userProperties; + /** + * @var ?array + */ public ?array $groups; + /** + * @var ?array + */ public ?array $groupProperties; + /** + * @param ?array $userProperties + * @param ?array $groups + * @param ?array $groupProperties + */ public function __construct( ?string $deviceId, ?string $userId, @@ -109,6 +123,9 @@ public function copyToBuilder(): UserBuilder ->groupProperties($this->groupProperties); } + /** + * @return array + */ public function toArray(): array { return array_filter(["device_id" => $this->deviceId, "user_id" => $this->userId, @@ -130,6 +147,9 @@ public function toArray(): array { "group_properties" => $this->groupProperties]); } + /** + * @return array + */ public function toEvaluationContext(): array { diff --git a/src/UserBuilder.php b/src/UserBuilder.php index 3cfd7cc..9f79ee5 100644 --- a/src/UserBuilder.php +++ b/src/UserBuilder.php @@ -19,8 +19,17 @@ class UserBuilder protected ?string $deviceModel = null; protected ?string $carrier = null; protected ?string $library = null; + /** + * @var ?array + */ protected ?array $userProperties = null; + /** + * @var ?array + */ protected ?array $groups = null; + /** + * @var ?array + */ protected ?array $groupProperties = null; public function __construct() @@ -117,18 +126,27 @@ public function library(?string $library): UserBuilder return $this; } + /** + * @param ?array $userProperties + */ public function userProperties(?array $userProperties): UserBuilder { $this->userProperties = $userProperties; return $this; } + /** + * @param ?array $groups + */ public function groups(?array $groups): UserBuilder { $this->groups = $groups; return $this; } + /** + * @param ?array $groupProperties + */ public function groupProperties(?array $groupProperties): UserBuilder { $this->groupProperties = $groupProperties; diff --git a/src/Util.php b/src/Util.php index 4ca0ae5..b4220e9 100644 --- a/src/Util.php +++ b/src/Util.php @@ -2,20 +2,6 @@ namespace AmplitudeExperiment; -use Monolog\Formatter\LineFormatter; -use Monolog\Handler\StreamHandler; -use Monolog\Logger; - -function initializeLogger(bool $debug): Logger -{ - $logger = new Logger('AmplitudeExperiment'); - $handler = new StreamHandler('php://stdout', $debug ? Logger::DEBUG : Logger::INFO); - $formatter = new LineFormatter(null, null, false, true); - $handler->setFormatter($formatter); - $logger->pushHandler($handler); - return $logger; -} - function hashCode(string $s): int { $hash = 0; @@ -24,8 +10,7 @@ function hashCode(string $s): int } for ($i = 0; $i < strlen($s); $i++) { $chr = ord($s[$i]); - $hash = ($hash << 5) - $hash + $chr; - $hash |= 0; + $hash = (int) (($hash << 5) - $hash + $chr); } return $hash; } diff --git a/src/Variant.php b/src/Variant.php index de86ab3..8486ffd 100644 --- a/src/Variant.php +++ b/src/Variant.php @@ -13,6 +13,7 @@ class Variant */ public ?string $value; /** + * @var mixed * The attached payload, if any */ public $payload; @@ -21,11 +22,16 @@ class Variant */ public ?string $expKey; /** + * @var ?array * Flag, segment, and variant metadata produced as a result of * evaluation for the user. Used for system purposes. */ public ?array $metadata; + /** + * @param mixed $payload + * @param ?array $metadata + */ public function __construct( ?string $key = null, ?string $value = null, @@ -41,6 +47,9 @@ public function __construct( $this->metadata = $metadata; } + /** + * @param array $evaluationVariant + */ public static function convertEvaluationVariantToVariant(array $evaluationVariant): Variant { diff --git a/tests/Amplitude/AmplitudeTest.php b/tests/Amplitude/AmplitudeTest.php index 50ea91c..5152d4a 100644 --- a/tests/Amplitude/AmplitudeTest.php +++ b/tests/Amplitude/AmplitudeTest.php @@ -4,23 +4,26 @@ use AmplitudeExperiment\Amplitude\AmplitudeConfig; use AmplitudeExperiment\Amplitude\Event; -use GuzzleHttp\Client; +use AmplitudeExperiment\Logger\DefaultLogger; +use AmplitudeExperiment\Logger\InternalLogger; +use AmplitudeExperiment\Logger\LogLevel; +use AmplitudeExperiment\Test\Util\MockGuzzleHttpClient; use GuzzleHttp\Exception\RequestException; use GuzzleHttp\Handler\MockHandler; use GuzzleHttp\HandlerStack; -use GuzzleHttp\Psr7\Request; use GuzzleHttp\Psr7\Response; -use GuzzleHttp\TransferStats; -use Monolog\Test\TestCase; +use PHPUnit\Framework\TestCase; +use Psr\Http\Message\RequestInterface; +use Psr\Log\LoggerInterface; class AmplitudeTest extends TestCase { - private array $postContainer; + private LoggerInterface $logger; const API_KEY = 'test'; public function setUp(): void { - $this->postContainer = []; + $this->logger = new InternalLogger(new DefaultLogger(), LogLevel::DEBUG); } public function testAmplitudeConfigServerUrl() @@ -50,13 +53,12 @@ public function testAmplitudeConfigServerUrl() public function testEmptyQueueAfterFlushSuccess() { - $client = new MockAmplitude(self::API_KEY, true); + $client = new MockAmplitude(self::API_KEY, $this->logger); $mock = new MockHandler([ new Response(200, ['X-Foo' => 'Bar']), ]); - $handlerStack = HandlerStack::create($mock); - $httpClient = new Client(['handler' => $handlerStack]); + $httpClient = new MockGuzzleHttpClient([], $handlerStack); $client->setHttpClient($httpClient); $event1 = new Event('test1'); $event2 = new Event('test2'); @@ -65,24 +67,32 @@ public function testEmptyQueueAfterFlushSuccess() $client->logEvent($event2); $client->logEvent($event3); $this->assertEquals(3, $client->getQueueSize()); - $client->flush()->wait(); + $client->flush(); $this->assertEquals(0, $client->getQueueSize()); } public function testFlushAfterMaxQueue() { + // Initialize the request counter + $requestCounter = 0; + $config = AmplitudeConfig::builder() ->flushQueueSize(3) ->build(); - $client = new MockAmplitude(self::API_KEY, true, $config); - $mock = new MockHandler([ - new Response(200, ['X-Foo' => 'Bar']), + $client = new MockAmplitude(self::API_KEY, $this->logger, $config); + $mockHandler = new MockHandler([ + function (RequestInterface $request, array $options) use (&$requestCounter) { + $requestCounter++; + + return new Response(200, ['X-Foo' => 'Bar']); + }, ]); - $handlerStack = HandlerStack::create($mock); - $httpClient = new Client(['handler' => $handlerStack, - 'on_stats' => function (TransferStats $stats) { - $this->postContainer[] = $stats; - }]); + + // Create a handler stack with the mock handler + $handlerStack = HandlerStack::create($mockHandler); + + // Create an instance of GuzzleFetchClient with the custom handler stack + $httpClient = new MockGuzzleHttpClient([], $handlerStack); $client->setHttpClient($httpClient); $event1 = new Event('test1'); $event2 = new Event('test2'); @@ -91,68 +101,65 @@ public function testFlushAfterMaxQueue() $client->logEvent($event2); $this->assertEquals(2, $client->getQueueSize()); $client->logEvent($event3); - $this->assertEquals(1, $this->countPostRequests()); + $this->assertEquals(1, $requestCounter); $this->assertEquals(0, $client->getQueueSize()); } public function testBackoffRetriesToFailure() { - $config = AmplitudeConfig::builder() - ->flushMaxRetries(5) - ->build(); - $client = new MockAmplitude(self::API_KEY, true, $config); - $mock = new MockHandler([ - new RequestException('Error Communicating with Server', new Request('POST', 'test')), - new RequestException('Error Communicating with Server', new Request('POST', 'test')), - new RequestException('Error Communicating with Server', new Request('POST', 'test')), - new RequestException('Error Communicating with Server', new Request('POST', 'test')), - new RequestException('Error Communicating with Server', new Request('POST', 'test')), - ]); + // Initialize the request counter + $requestCounter = 0; + $config = AmplitudeConfig::builder()->build(); + $client = new MockAmplitude(self::API_KEY, $this->logger, $config); - $handlerStack = HandlerStack::create($mock); - $httpClient = new Client(['handler' => $handlerStack, - 'on_stats' => function (TransferStats $stats) { - $this->postContainer[] = $stats; - }]); + // Set up the mock handler with request counter incrementation logic + $mockHandler = new MockHandler(array_fill(1, 5, function (RequestInterface $request, array $options) use (&$requestCounter) { + $requestCounter++; + return new RequestException('Error Communicating with Server', $request); + })); + + $handlerStack = HandlerStack::create($mockHandler); + $httpClient = new MockGuzzleHttpClient(['retries' => 4], $handlerStack); $client->setHttpClient($httpClient); + $event1 = new Event('test'); $event1->userId = 'user_id'; $client->logEvent($event1); - $client->flush()->wait(); - $this->assertEquals(5, $this->countPostRequests()); + $client->flush(); + + // Assert the number of requests sent (including retries) + $this->assertEquals(5, $requestCounter); $this->assertEquals(1, $client->getQueueSize()); } + public function testBackoffRetriesThenSuccess() { - $config = AmplitudeConfig::builder() - ->flushMaxRetries(5) - ->build(); - $client = new MockAmplitude(self::API_KEY, true, $config); - $mock = new MockHandler([ - new RequestException('Error Communicating with Server', new Request('POST', 'test')), - new RequestException('Error Communicating with Server', new Request('POST', 'test')), - new Response(200, ['X-Foo' => 'Bar']), - ]); + // Initialize the request counter + $requestCounter = 0; + $config = AmplitudeConfig::builder()->build(); + $client = new MockAmplitude(self::API_KEY, $this->logger, $config); - $handlerStack = HandlerStack::create($mock); - $httpClient = new Client(['handler' => $handlerStack, - 'on_stats' => function (TransferStats $stats) { - $this->postContainer[] = $stats; - }]); + // Set up the mock handler with request counter incrementation logic + $mockHandler = new MockHandler(array_fill(1, 2, function (RequestInterface $request, array $options) use (&$requestCounter) { + $requestCounter++; + return new RequestException('Error Communicating with Server', $request); + }) + [ + function (RequestInterface $request, array $options) use (&$requestCounter) { + $requestCounter++; + + return new Response(200, ['X-Foo' => 'Bar']); + }, + ]); + + $handlerStack = HandlerStack::create($mockHandler); + $httpClient = new MockGuzzleHttpClient(['retries' => 4], $handlerStack); $client->setHttpClient($httpClient); $event1 = new Event('test'); $event1->userId = 'user_id'; $client->logEvent($event1); - $client->flush()->wait(); - $this->assertEquals(3, $this->countPostRequests()); + $client->flush(); + $this->assertEquals(3, $requestCounter); $this->assertEquals(0, $client->getQueueSize()); } - - private function countPostRequests(): int - { - return count(array_filter($this->postContainer, function (TransferStats $stats) { - return $stats->getRequest()->getMethod() === 'POST'; - })); - } } diff --git a/tests/Amplitude/MockAmplitude.php b/tests/Amplitude/MockAmplitude.php index cc21a08..dfa8e07 100644 --- a/tests/Amplitude/MockAmplitude.php +++ b/tests/Amplitude/MockAmplitude.php @@ -4,17 +4,21 @@ use AmplitudeExperiment\Amplitude\Amplitude; use AmplitudeExperiment\Amplitude\AmplitudeConfig; -use GuzzleHttp\Client; +use AmplitudeExperiment\Http\HttpClientInterface; +use Psr\Log\LoggerInterface; class MockAmplitude extends Amplitude { - public function __construct(string $apiKey, bool $debug, AmplitudeConfig $config = null) + public function __construct(string $apiKey, LoggerInterface $logger, AmplitudeConfig $config = null) { - parent::__construct($apiKey, $debug, $config); + parent::__construct($apiKey, $logger, $config); } - public function setHttpClient(Client $httpClient) { + public function setHttpClient(HttpClientInterface $httpClient) { $this->httpClient = $httpClient; } + public function __destruct() { + // Do nothing + } public function getQueueSize() : int { return count($this->queue); } diff --git a/tests/Assignment/AssignmentFilterTest.php b/tests/Assignment/AssignmentFilterTest.php index 140edbf..b2fa16d 100644 --- a/tests/Assignment/AssignmentFilterTest.php +++ b/tests/Assignment/AssignmentFilterTest.php @@ -7,7 +7,6 @@ use AmplitudeExperiment\User; use AmplitudeExperiment\Variant; use PHPUnit\Framework\TestCase; -use function AmplitudeExperiment\sleep; require_once __DIR__ . '/../../src/Util.php'; @@ -149,7 +148,7 @@ public function testTtlBasedEviction() $assignment1 = new Assignment($user1, $results); $assignment2 = new Assignment($user2, $results); $this->assertTrue($filter->shouldTrack($assignment1)); - \sleep(1.05); + sleep(1); $this->assertTrue($filter->shouldTrack($assignment2)); } } diff --git a/tests/Assignment/AssignmentServiceTest.php b/tests/Assignment/AssignmentServiceTest.php index 7595446..7a33c4b 100644 --- a/tests/Assignment/AssignmentServiceTest.php +++ b/tests/Assignment/AssignmentServiceTest.php @@ -6,6 +6,9 @@ use AmplitudeExperiment\Assignment\Assignment; use AmplitudeExperiment\Assignment\AssignmentFilter; use AmplitudeExperiment\Assignment\AssignmentService; +use AmplitudeExperiment\Logger\DefaultLogger; +use AmplitudeExperiment\Logger\InternalLogger; +use AmplitudeExperiment\Logger\LogLevel; use AmplitudeExperiment\User; use AmplitudeExperiment\Variant; use PHPUnit\Framework\TestCase; @@ -82,10 +85,11 @@ public function testAssignmentToEventAsExpected() $this->assertEquals($expected, $event->insertId); } - public function testlogEventCalledInAmplitude() { + public function testlogEventCalledInAmplitude() + { $assignmentFilter = new AssignmentFilter(1); $mockAmp = $this->getMockBuilder(Amplitude::class) - ->setConstructorArgs(['', false]) + ->setConstructorArgs(['', new InternalLogger(new DefaultLogger(), LogLevel::INFO)]) ->onlyMethods(['logEvent']) ->getMock(); $results = [ diff --git a/tests/EvaluationCore/Murmur3Test.php b/tests/EvaluationCore/Murmur3Test.php index c494a03..4d3774c 100644 --- a/tests/EvaluationCore/Murmur3Test.php +++ b/tests/EvaluationCore/Murmur3Test.php @@ -2,6 +2,7 @@ namespace AmplitudeExperiment\Test\EvaluationCore; +use AmplitudeExperiment\EvaluationCore\Murmur3; use PHPUnit\Framework\TestCase; class Murmur3Test extends TestCase @@ -11,7 +12,7 @@ class Murmur3Test extends TestCase public function testMurmur3HashSimple() { $input = 'brian'; - $result = murmurhash3_int($input, self::MURMUR_SEED); + $result = Murmur3::hash3_int($input, self::MURMUR_SEED); $this->assertEquals(3948467465, $result); } @@ -23,16 +24,16 @@ public function testMurmur3HashEnglishWords() for ($i = 0; $i < count($inputs); $i++) { $input = $inputs[$i]; $output = (int)$outputs[$i]; - $result = murmurhash3_int($input, self::MURMUR_SEED); + $result = Murmur3::hash3_int($input, self::MURMUR_SEED); $this->assertEquals($output, $result); } } public function testUnicodeStrings() { - $this->assertEquals(2953494853, murmurhash3_int('My hovercraft is full of eels.')); - $this->assertEquals(1818098979, murmurhash3_int('My 🚀 is full of 🦎.')); - $this->assertEquals(3435142074, murmurhash3_int('吉 星 高 照')); + $this->assertEquals(2953494853, Murmur3::hash3_int('My hovercraft is full of eels.')); + $this->assertEquals(1818098979, Murmur3::hash3_int('My 🚀 is full of 🦎.')); + $this->assertEquals(3435142074, Murmur3::hash3_int('吉 星 高 照')); } const MURMUR3_X86_32 = ' diff --git a/tests/Local/LocalEvaluationClientTest.php b/tests/Local/LocalEvaluationClientTest.php index 5837e2d..4505766 100644 --- a/tests/Local/LocalEvaluationClientTest.php +++ b/tests/Local/LocalEvaluationClientTest.php @@ -5,6 +5,7 @@ use AmplitudeExperiment\Experiment; use AmplitudeExperiment\Local\LocalEvaluationClient; use AmplitudeExperiment\Local\LocalEvaluationConfig; +use AmplitudeExperiment\Logger\LogLevel; use AmplitudeExperiment\User; use PHPUnit\Framework\TestCase; @@ -22,13 +23,13 @@ public function __construct() ->deviceId('test_device') ->build(); $experiment = new Experiment(); - $config = LocalEvaluationConfig::builder()->debug(true)->build(); + $config = LocalEvaluationConfig::builder()->logLevel(LogLevel::DEBUG)->build(); $this->client = $experiment->initializeLocal($this->apiKey, $config); } public function setUp(): void { - $this->client->start()->wait(); + $this->client->refreshFlagConfigs(); } public function testEvaluateAllFlags() @@ -69,4 +70,14 @@ public function testEvaluateWithDependenciesVariantHeldOut() $this->assertEquals(null, $variant->payload); $this->assertTrue($variant->metadata["default"]); } + + public function testGetFlagConfigs() + { + $flagConfigs = $this->client->getFlagConfigs(); + $bootstrapClient = new LocalEvaluationClient('', LocalEvaluationConfig::builder()->bootstrap($flagConfigs)->build()); + $variants = $bootstrapClient->evaluate($this->testUser); + $variant = $variants['sdk-local-evaluation-ci-test']; + $this->assertEquals("on", $variant->key); + $this->assertEquals("payload", $variant->payload); + } } diff --git a/tests/Remote/RemoteEvaluationClientTest.php b/tests/Remote/RemoteEvaluationClientTest.php index 0537fce..dd3138a 100644 --- a/tests/Remote/RemoteEvaluationClientTest.php +++ b/tests/Remote/RemoteEvaluationClientTest.php @@ -5,9 +5,14 @@ use AmplitudeExperiment\Experiment; use AmplitudeExperiment\Remote\RemoteEvaluationClient; use AmplitudeExperiment\Remote\RemoteEvaluationConfig; +use AmplitudeExperiment\Test\Util\MockGuzzleHttpClient; use AmplitudeExperiment\User; -use Exception; +use GuzzleHttp\Exception\RequestException; +use GuzzleHttp\Handler\MockHandler; +use GuzzleHttp\HandlerStack; +use GuzzleHttp\Psr7\Response; use PHPUnit\Framework\TestCase; +use Psr\Http\Message\RequestInterface; class RemoteEvaluationClientTest extends TestCase { @@ -22,73 +27,120 @@ public function __construct() ->build(); } - /** - * @throws Exception - */ public function testFetchSuccess() { $client = new RemoteEvaluationClient($this->apiKey); - $variants = $client->fetch($this->testUser)->wait(); + $variants = $client->fetch($this->testUser); $variant = $variants['sdk-ci-test']; $this->assertEquals("on", $variant->key); $this->assertEquals("payload", $variant->payload); } - /** - * @throws Exception - */ public function testFetchWithNoRetriesTimeoutFailure() { + $guzzleConfig = ['retries' => 0, 'timeoutMillis' => 1]; $config = RemoteEvaluationConfig::builder() - ->fetchRetries(0) - ->fetchTimeoutMillis(1) + ->guzzleClientConfig($guzzleConfig) ->build(); $client = new RemoteEvaluationClient($this->apiKey, $config); - $variants = $client->fetch($this->testUser)->wait(); + $variants = $client->fetch($this->testUser); $this->assertEquals([], $variants); } - /** - * @throws Exception - */ public function testFetchNoRetriesTimeoutFailureRetrySuccess() { - $config = RemoteEvaluationConfig::builder() - ->fetchRetries(1) - ->fetchTimeoutMillis(1) - ->build(); - $client = new RemoteEvaluationClient($this->apiKey, $config); - $variants = $client->fetch($this->testUser)->wait(); + // Initialize the request counter + $requestCounter = 0; + + // Set up the mock handler + $mockHandler = new MockHandler([ + // Simulate a failure (e.g., timeout) for the first request + function (RequestInterface $request, array $options) use (&$requestCounter) { + $requestCounter++; + + return new RequestException('Error Communicating with Server', $request); + }, + // Simulate a successful response for the retried request + function (RequestInterface $request, array $options) use (&$requestCounter) { + $requestCounter++; + + return new Response(200, [], '{"sdk-ci-test":{"key":"on","payload":"payload"}}'); + }, + ]); + + // Create a handler stack with the mock handler + $handlerStack = HandlerStack::create($mockHandler); + + // Create an instance of GuzzleFetchClient with the custom handler stack + $httpClient = new MockGuzzleHttpClient([ + 'retries' => 1, + 'timeoutMillis' => 10000, + 'retryBackoffMinMillis' => 100, + 'retryBackoffScalar' => 2, + 'retryBackoffMaxMillis' => 500, + ], $handlerStack); + + $client = new RemoteEvaluationClient($this->apiKey, RemoteEvaluationConfig::builder()->httpClient($httpClient)->build()); + + // Expect a successful response after auto-retry + $variants = $client->fetch($this->testUser); $variant = $variants['sdk-ci-test']; $this->assertEquals("on", $variant->key); $this->assertEquals("payload", $variant->payload); + + // Assert the number of requests sent (including retries) + $this->assertEquals(2, $requestCounter); } - /** - * @throws Exception - */ - public function testFetchRetryOnceTimeoutFirstThenSucceedWithZeroBackoff() + public function testretryOnceTimeoutFirstThenSucceedWithZeroBackoff() { - $config = RemoteEvaluationConfig::builder() - ->fetchRetries(1) - ->fetchTimeoutMillis(1) - ->fetchRetryBackoffMinMillis(0) - ->fetchRetryTimeoutMillis(10000) - ->build(); - $client = new RemoteEvaluationClient($this->apiKey, $config); - $variants = $client->fetch($this->testUser)->wait(); + // Initialize the request counter + $requestCounter = 0; + + // Set up the mock handler + $mockHandler = new MockHandler([ + // Simulate a failure (e.g., timeout) for the first request + function (RequestInterface $request, array $options) use (&$requestCounter) { + $requestCounter++; + + return new RequestException('Error Communicating with Server', $request); + }, + // Simulate a successful response for the retried request + function (RequestInterface $request, array $options) use (&$requestCounter) { + $requestCounter++; + + return new Response(200, [], '{"sdk-ci-test":{"key":"on","payload":"payload"}}'); + }, + ]); + + // Create a handler stack with the mock handler + $handlerStack = HandlerStack::create($mockHandler); + + // Create an instance of GuzzleFetchClient with the custom handler stack + $httpClient = new MockGuzzleHttpClient([ + 'retries' => 1, + 'timeoutMillis' => 10000, + 'retryBackoffMinMillis' => 0, + 'retryBackoffScalar' => 2, + 'retryBackoffMaxMillis' => 0, + ], $handlerStack); + + $client = new RemoteEvaluationClient($this->apiKey, RemoteEvaluationConfig::builder()->httpClient($httpClient)->build()); + + // Expect a successful response after auto-retry + $variants = $client->fetch($this->testUser); $variant = $variants['sdk-ci-test']; $this->assertEquals("on", $variant->key); $this->assertEquals("payload", $variant->payload); + + // Assert the number of requests sent (including retries) + $this->assertEquals(2, $requestCounter); } - /** - * @throws Exception - */ public function testFetchWithFlagKeysOptionsSuccess() { $client = new RemoteEvaluationClient($this->apiKey); - $variants = $client->fetch($this->testUser, ['sdk-ci-test'])->wait(); + $variants = $client->fetch($this->testUser, ['sdk-ci-test']); $variant = $variants['sdk-ci-test']; $this->assertEquals(1, sizeof($variants)); $this->assertEquals("on", $variant->key); diff --git a/tests/Util/MockGuzzleHttpClient.php b/tests/Util/MockGuzzleHttpClient.php new file mode 100644 index 0000000..d046f0d --- /dev/null +++ b/tests/Util/MockGuzzleHttpClient.php @@ -0,0 +1,62 @@ +config = array_merge(GUZZLE_DEFAULTS, $config); + + // Add middleware for retries + $handlerStack->push(Middleware::retry( + function ($retries, Request $request, $response = null, $exception = null) { + // Retry if the maximum number of retries is not reached and an exception occurred + return $retries < $this->config['retries'] && $exception instanceof Exception; + }, + function ($retries) { + // Calculate delay + return $this->calculateDelayMillis($retries); + } + )); + + // Create a Guzzle client with the custom handler stack + $this->client = new Client(['handler' => $handlerStack, RequestOptions::TIMEOUT => $this->config['timeoutMillis'] / 1000]); + } + + public function getClient(): ClientInterface + { + return $this->client; + } + + public function createRequest(string $method, string $uri, ?string $body = null): Request + { + return new Request($method, $uri); + } + + protected function calculateDelayMillis($iteration): int + { + $delayMillis = $this->config['retryBackoffMinMillis']; + + for ($i = 0; $i < $iteration; $i++) { + $delayMillis = min( + $delayMillis * $this->config['retryBackoffScalar'], + $this->config['retryBackoffMaxMillis'] + ); + } + return $delayMillis; + } +}