From e1b7d4839ab5b67ed72e282774091935e193d955 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Fri, 24 Oct 2025 15:02:55 -0700 Subject: [PATCH 1/9] feat: add assignment and exposure event tracking options --- src/Remote/FetchOptions.php | 22 ++++++- src/Remote/RemoteEvaluationClient.php | 47 ++++++++++---- tests/Remote/RemoteEvaluationClientTest.php | 70 +++++++++++++++++++++ 3 files changed, 126 insertions(+), 13 deletions(-) diff --git a/src/Remote/FetchOptions.php b/src/Remote/FetchOptions.php index 970ca27..854b74f 100644 --- a/src/Remote/FetchOptions.php +++ b/src/Remote/FetchOptions.php @@ -12,15 +12,33 @@ class FetchOptions * * @var string[] */ - public array $flagKeys; + public array $flagKeys = []; + + /** + * Whether to track the assignment event. + * + * @var ?bool + */ + public ?bool $tracksAssignment = null; + + /** + * Whether to track the exposure event. + * + * @var ?bool + */ + public ?bool $tracksExposure = null; /** * FetchOptions constructor. * * @param string[] $flagKeys Specific flag keys to evaluate and set variants for. + * @param ?bool $tracksAssignment Whether to track the assignment event. + * @param ?bool $tracksExposure Whether to track the exposure event. */ - public function __construct(array $flagKeys) + public function __construct(?array $flagKeys = [], ?bool $tracksAssignment = null, ?bool $tracksExposure = null) { $this->flagKeys = $flagKeys; + $this->tracksAssignment = $tracksAssignment; + $this->tracksExposure = $tracksExposure; } } diff --git a/src/Remote/RemoteEvaluationClient.php b/src/Remote/RemoteEvaluationClient.php index addb385..1b5d4df 100644 --- a/src/Remote/RemoteEvaluationClient.php +++ b/src/Remote/RemoteEvaluationClient.php @@ -40,17 +40,12 @@ public function __construct(string $apiKey, ?RemoteEvaluationConfig $config = nu $this->logger = new InternalLogger($this->config->logger ?? new DefaultLogger(), $this->config->logLevel); } - /** - * Fetch all variants for a user. - * - * This method will automatically retry if configured (default). - * - * @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 = []): array + private function fetchWithOptions(User $user, FetchOptions $options): array { + $flagKeys = $options->flagKeys; + $tracksAssignment = $options->tracksAssignment; + $tracksExposure = $options->tracksExposure; + if ($user->userId == null && $user->deviceId == null) { $this->logger->warning('[Experiment] user id and device id are null; Amplitude may not resolve identity'); } @@ -72,7 +67,7 @@ public function fetch(User $user, array $flagKeys = []): array ->withHeader('Content-Type', 'application/json') ->withHeader('X-Amp-Exp-User', $serializedUser); - if (!empty($flagKeys)) { + if ($flagKeys !== null && !empty($flagKeys)) { $flagKeysJson = json_encode($flagKeys); if ($flagKeysJson === false) { $this->logger->error('[Experiment] Failed to fetch variants: ' . json_last_error_msg()); @@ -81,6 +76,13 @@ public function fetch(User $user, array $flagKeys = []): array $request = $request->withHeader('X-Amp-Exp-Flag-Keys', base64_encode($flagKeysJson)); } + if ($tracksAssignment !== null) { + $request = $request->withHeader('X-Amp-Exp-Track', $tracksAssignment ? 'track' : 'no-track'); + } + if ($tracksExposure !== null) { + $request = $request->withHeader('X-Amp-Exp-Exposure-Track', $tracksExposure ? 'track' : 'no-track'); + } + $httpClient = $this->httpClient->getClient(); try { @@ -101,4 +103,27 @@ public function fetch(User $user, array $flagKeys = []): array return []; } } + + /** + * Fetch all variants for a user. + * + * This method will automatically retry if configured (default). + * + * @param User $user The {@link User} context + * @param array|FetchOptions $args The flags to evaluate for this specific fetch request or a {@link FetchOptions} object. + * If an array is provided, it will be converted to a {@link FetchOptions} object. + * If a {@link FetchOptions} object is provided, it will be used as is. + * If no arguments are provided, a default {@link FetchOptions} object without any options will be used. + * @return array A {@link Variant} array for the user on success, empty array on error. + */ + public function fetch(User $user, array|FetchOptions|null $arg = null): array + { + if ($arg !== null && is_array($arg)) { + return $this->fetchWithOptions($user, new FetchOptions($arg, null, null)); + } + if ($arg !== null && is_object($arg) && $arg instanceof FetchOptions) { + return $this->fetchWithOptions($user, $arg); + } + return $this->fetchWithOptions($user, new FetchOptions()); + } } diff --git a/tests/Remote/RemoteEvaluationClientTest.php b/tests/Remote/RemoteEvaluationClientTest.php index dd3138a..a5660d7 100644 --- a/tests/Remote/RemoteEvaluationClientTest.php +++ b/tests/Remote/RemoteEvaluationClientTest.php @@ -5,6 +5,7 @@ use AmplitudeExperiment\Experiment; use AmplitudeExperiment\Remote\RemoteEvaluationClient; use AmplitudeExperiment\Remote\RemoteEvaluationConfig; +use AmplitudeExperiment\Remote\FetchOptions; use AmplitudeExperiment\Test\Util\MockGuzzleHttpClient; use AmplitudeExperiment\User; use GuzzleHttp\Exception\RequestException; @@ -153,4 +154,73 @@ public function testExperimentInitializeRemote() $client = $experiment->initializeRemote($this->apiKey); $this->assertEquals($client, $experiment->initializeRemote($this->apiKey)); } + + public function testFetchWithFetchOptionsSuccess() + { + // Create an instance of GuzzleFetchClient with the custom handler stack + $mockHandler = new MockHandler([ + function (RequestInterface $request, array $options) { + $headers = $request->getHeaders(); + $this->assertEquals(base64_encode('["sdk-ci-test"]'), $headers['X-Amp-Exp-Flag-Keys'][0]); + $this->assertEquals('track', $headers['X-Amp-Exp-Track'][0]); + $this->assertEquals('track', $headers['X-Amp-Exp-Exposure-Track'][0]); + return new Response(200, [], '{"sdk-ci-test":{"key":"on","payload":"payload"}}'); + }, + function (RequestInterface $request, array $options) { + $headers = $request->getHeaders(); + $this->assertEquals(base64_encode('["sdk-ci-test"]'), $headers['X-Amp-Exp-Flag-Keys'][0]); + $this->assertEquals('no-track', $headers['X-Amp-Exp-Track'][0]); + $this->assertEquals('no-track', $headers['X-Amp-Exp-Exposure-Track'][0]); + return new Response(200, [], '{"sdk-ci-test":{"key":"on","payload":"payload"}}'); + }, + function (RequestInterface $request, array $options) { + $headers = $request->getHeaders(); + $this->assertEquals(base64_encode('["sdk-ci-test"]'), $headers['X-Amp-Exp-Flag-Keys'][0]); + $this->assertArrayNotHasKey('X-Amp-Exp-Track', $headers); + $this->assertArrayNotHasKey('X-Amp-Exp-Exposure-Track', $headers); + return new Response(200, [], '{"sdk-ci-test":{"key":"on","payload":"payload"}}'); + }, + function (RequestInterface $request, array $options) { + $headers = $request->getHeaders(); + $this->assertEquals(base64_encode('["sdk-ci-test"]'), $headers['X-Amp-Exp-Flag-Keys'][0]); + $this->assertArrayNotHasKey('X-Amp-Exp-Track', $headers); + $this->assertArrayNotHasKey('X-Amp-Exp-Exposure-Track', $headers); + return new Response(200, [], '{"sdk-ci-test":{"key":"on","payload":"payload"}}'); + }, + function (RequestInterface $request, array $options) { + $headers = $request->getHeaders(); + $this->assertArrayNotHasKey('X-Amp-Exp-Flag-Keys', $headers); + $this->assertArrayNotHasKey('X-Amp-Exp-Track', $headers); + $this->assertArrayNotHasKey('X-Amp-Exp-Exposure-Track', $headers); + return new Response(200, [], '{"sdk-ci-test":{"key":"on","payload":"payload"}}'); + }, + ]); + $handlerStack = HandlerStack::create($mockHandler); + $httpClient = new MockGuzzleHttpClient([ + 'retries' => 1, + 'timeoutMillis' => 1000, + ], $handlerStack); + + $client = new RemoteEvaluationClient($this->apiKey, RemoteEvaluationConfig::builder()->httpClient($httpClient)->build()); + + $variants = $client->fetch($this->testUser, new FetchOptions(['sdk-ci-test'], true, true)); + $this->assertEquals(1, sizeof($variants)); + $this->assertEquals("on", $variants['sdk-ci-test']->key); + + $variants = $client->fetch($this->testUser, new FetchOptions(['sdk-ci-test'], false, false)); + $this->assertEquals(1, sizeof($variants)); + $this->assertEquals("on", $variants['sdk-ci-test']->key); + + $variants = $client->fetch($this->testUser, new FetchOptions(['sdk-ci-test'])); + $this->assertEquals(1, sizeof($variants)); + $this->assertEquals("on", $variants['sdk-ci-test']->key); + + $variants = $client->fetch($this->testUser, ['sdk-ci-test']); + $this->assertEquals(1, sizeof($variants)); + $this->assertEquals("on", $variants['sdk-ci-test']->key); + + $variants = $client->fetch($this->testUser); + $this->assertEquals(1, sizeof($variants)); + $this->assertEquals("on", $variants['sdk-ci-test']->key); + } } From a22b9b7963c8f6caf22dfcda218a6940ccad70c5 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Mon, 27 Oct 2025 12:07:23 -0700 Subject: [PATCH 2/9] fix: type --- src/Remote/RemoteEvaluationClient.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Remote/RemoteEvaluationClient.php b/src/Remote/RemoteEvaluationClient.php index 1b5d4df..6f6713b 100644 --- a/src/Remote/RemoteEvaluationClient.php +++ b/src/Remote/RemoteEvaluationClient.php @@ -116,7 +116,7 @@ private function fetchWithOptions(User $user, FetchOptions $options): array * If no arguments are provided, a default {@link FetchOptions} object without any options will be used. * @return array A {@link Variant} array for the user on success, empty array on error. */ - public function fetch(User $user, array|FetchOptions|null $arg = null): array + public function fetch(User $user, mixed $arg = null): array { if ($arg !== null && is_array($arg)) { return $this->fetchWithOptions($user, new FetchOptions($arg, null, null)); From ae3643a47f415a0b90512530a29612188bdb68d9 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Mon, 27 Oct 2025 12:10:58 -0700 Subject: [PATCH 3/9] fix: type --- src/Remote/FetchOptions.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Remote/FetchOptions.php b/src/Remote/FetchOptions.php index 854b74f..f4d3e8d 100644 --- a/src/Remote/FetchOptions.php +++ b/src/Remote/FetchOptions.php @@ -35,7 +35,7 @@ class FetchOptions * @param ?bool $tracksAssignment Whether to track the assignment event. * @param ?bool $tracksExposure Whether to track the exposure event. */ - public function __construct(?array $flagKeys = [], ?bool $tracksAssignment = null, ?bool $tracksExposure = null) + public function __construct(array $flagKeys = [], ?bool $tracksAssignment = null, ?bool $tracksExposure = null) { $this->flagKeys = $flagKeys; $this->tracksAssignment = $tracksAssignment; From 4ca9d04ff9811acf2e4e3af8fbbfd61d2feb21a6 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Mon, 27 Oct 2025 12:14:45 -0700 Subject: [PATCH 4/9] fix: doc --- src/Remote/RemoteEvaluationClient.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Remote/RemoteEvaluationClient.php b/src/Remote/RemoteEvaluationClient.php index 6f6713b..ad69edc 100644 --- a/src/Remote/RemoteEvaluationClient.php +++ b/src/Remote/RemoteEvaluationClient.php @@ -110,8 +110,8 @@ private function fetchWithOptions(User $user, FetchOptions $options): array * This method will automatically retry if configured (default). * * @param User $user The {@link User} context - * @param array|FetchOptions $args The flags to evaluate for this specific fetch request or a {@link FetchOptions} object. - * If an array is provided, it will be converted to a {@link FetchOptions} object. + * @param mixed $arg Either flags to evaluate for this specific fetch request or a {@link FetchOptions} object. + * If an array is provided, it is treated as the flag keys and will be converted to a {@link FetchOptions} object. * If a {@link FetchOptions} object is provided, it will be used as is. * If no arguments are provided, a default {@link FetchOptions} object without any options will be used. * @return array A {@link Variant} array for the user on success, empty array on error. From 480523b416e7790c459c8fa4abd05a31d553c293 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Mon, 27 Oct 2025 13:10:24 -0700 Subject: [PATCH 5/9] fix: doc --- src/Remote/RemoteEvaluationClient.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Remote/RemoteEvaluationClient.php b/src/Remote/RemoteEvaluationClient.php index ad69edc..475fb6e 100644 --- a/src/Remote/RemoteEvaluationClient.php +++ b/src/Remote/RemoteEvaluationClient.php @@ -114,7 +114,7 @@ private function fetchWithOptions(User $user, FetchOptions $options): array * If an array is provided, it is treated as the flag keys and will be converted to a {@link FetchOptions} object. * If a {@link FetchOptions} object is provided, it will be used as is. * If no arguments are provided, a default {@link FetchOptions} object without any options will be used. - * @return array A {@link Variant} array for the user on success, empty array on error. + * @return Variant[] A {@link Variant} array for the user on success, empty array on error. */ public function fetch(User $user, mixed $arg = null): array { From 5043fed738ce63dd9b8c401232d0057f7400dd93 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Tue, 28 Oct 2025 09:49:17 -0700 Subject: [PATCH 6/9] fix: docstring --- src/Remote/RemoteEvaluationClient.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/Remote/RemoteEvaluationClient.php b/src/Remote/RemoteEvaluationClient.php index 475fb6e..f2ad92b 100644 --- a/src/Remote/RemoteEvaluationClient.php +++ b/src/Remote/RemoteEvaluationClient.php @@ -40,6 +40,13 @@ public function __construct(string $apiKey, ?RemoteEvaluationConfig $config = nu $this->logger = new InternalLogger($this->config->logger ?? new DefaultLogger(), $this->config->logLevel); } + /** + * Fetch variants for a user with specific options. + * + * @param User $user The {@link User} context + * @param FetchOptions $options The {@link FetchOptions} object + * @return Variant[] A {@link Variant} array for the user on success, empty array on error. + */ private function fetchWithOptions(User $user, FetchOptions $options): array { $flagKeys = $options->flagKeys; From 3d20cf9a4e38a02a9338822b960d011357d38ec9 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Tue, 28 Oct 2025 10:03:31 -0700 Subject: [PATCH 7/9] fix: type hint --- src/Remote/RemoteEvaluationClient.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Remote/RemoteEvaluationClient.php b/src/Remote/RemoteEvaluationClient.php index f2ad92b..11c0132 100644 --- a/src/Remote/RemoteEvaluationClient.php +++ b/src/Remote/RemoteEvaluationClient.php @@ -117,13 +117,13 @@ private function fetchWithOptions(User $user, FetchOptions $options): array * This method will automatically retry if configured (default). * * @param User $user The {@link User} context - * @param mixed $arg Either flags to evaluate for this specific fetch request or a {@link FetchOptions} object. + * @param array|FetchOptions $arg Either flags to evaluate for this specific fetch request or a {@link FetchOptions} object. * If an array is provided, it is treated as the flag keys and will be converted to a {@link FetchOptions} object. * If a {@link FetchOptions} object is provided, it will be used as is. * If no arguments are provided, a default {@link FetchOptions} object without any options will be used. * @return Variant[] A {@link Variant} array for the user on success, empty array on error. */ - public function fetch(User $user, mixed $arg = null): array + public function fetch(User $user, $arg = null): array { if ($arg !== null && is_array($arg)) { return $this->fetchWithOptions($user, new FetchOptions($arg, null, null)); From 5ecd0645dd78afe8af62769a2822697ffee0cb32 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Tue, 28 Oct 2025 10:42:21 -0700 Subject: [PATCH 8/9] fix: type --- src/Remote/RemoteEvaluationClient.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Remote/RemoteEvaluationClient.php b/src/Remote/RemoteEvaluationClient.php index 11c0132..fc42c98 100644 --- a/src/Remote/RemoteEvaluationClient.php +++ b/src/Remote/RemoteEvaluationClient.php @@ -117,7 +117,7 @@ private function fetchWithOptions(User $user, FetchOptions $options): array * This method will automatically retry if configured (default). * * @param User $user The {@link User} context - * @param array|FetchOptions $arg Either flags to evaluate for this specific fetch request or a {@link FetchOptions} object. + * @param array|FetchOptions|null $arg Either flags to evaluate for this specific fetch request or a {@link FetchOptions} object. * If an array is provided, it is treated as the flag keys and will be converted to a {@link FetchOptions} object. * If a {@link FetchOptions} object is provided, it will be used as is. * If no arguments are provided, a default {@link FetchOptions} object without any options will be used. From 971f9821bbc5d915418afe03cf45dfcbe92620b9 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Wed, 19 Nov 2025 15:46:03 -0800 Subject: [PATCH 9/9] docs: add comments --- src/Remote/FetchOptions.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Remote/FetchOptions.php b/src/Remote/FetchOptions.php index f4d3e8d..0fe214b 100644 --- a/src/Remote/FetchOptions.php +++ b/src/Remote/FetchOptions.php @@ -32,8 +32,8 @@ class FetchOptions * FetchOptions constructor. * * @param string[] $flagKeys Specific flag keys to evaluate and set variants for. - * @param ?bool $tracksAssignment Whether to track the assignment event. - * @param ?bool $tracksExposure Whether to track the exposure event. + * @param ?bool $tracksAssignment Whether to track the assignment event. If null, server side default is used (to track assignment event). + * @param ?bool $tracksExposure Whether to track the exposure event. If null, server side default is used (to not track exposure event). */ public function __construct(array $flagKeys = [], ?bool $tracksAssignment = null, ?bool $tracksExposure = null) {