diff --git a/CHANGES.txt b/CHANGES.txt index ca646cfb..2f86224f 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,3 +1,6 @@ +x.x.x (Unreleased) + - Added support for the new impressions tracking toggle available on feature flags. + 7.4.0 (Apr 15, 2026) - Added support for PHP 8.5 version, removing deprecated warning messages. diff --git a/src/SplitIO/Grammar/Split.php b/src/SplitIO/Grammar/Split.php index 6d6cf385..05459866 100644 --- a/src/SplitIO/Grammar/Split.php +++ b/src/SplitIO/Grammar/Split.php @@ -33,6 +33,8 @@ class Split private $configurations = null; private $sets = null; + private $impressionsDisabled = false; + public function __construct(array $split) { SplitApp::logger()->debug(print_r($split, true)); @@ -52,7 +54,10 @@ public function __construct(array $split) $this->configurations = isset($split['configurations']) && count($split['configurations']) > 0 ? $split['configurations'] : null; $this->sets = isset($split['sets']) ? $split['sets'] : array(); - + // Only an explicit boolean true disables impression tracking. Any other value + // (numbers, strings, null, missing or malformed) leaves impressions tracked. + $this->impressionsDisabled = isset($split['impressionsDisabled']) && $split['impressionsDisabled'] === true; + SplitApp::logger()->info("Constructing Feature Flag: ".$this->name); if (isset($split['conditions']) && is_array($split['conditions'])) { @@ -182,4 +187,12 @@ public function getSets() { return $this->sets; } + + /** + * @return bool + */ + public function impressionsDisabled() + { + return $this->impressionsDisabled; + } } diff --git a/src/SplitIO/Sdk/Client.php b/src/SplitIO/Sdk/Client.php index e21432d4..b9686b4a 100644 --- a/src/SplitIO/Sdk/Client.php +++ b/src/SplitIO/Sdk/Client.php @@ -7,6 +7,7 @@ use SplitIO\Sdk\Events\EventQueueMessage; use SplitIO\Sdk\QueueMetadataMessage; use SplitIO\Sdk\Impressions\Impression; +use SplitIO\Sdk\Impressions\DecoratedImpression; use SplitIO\TreatmentImpression; use SplitIO\Sdk\Impressions\ImpressionLabel; use SplitIO\Grammar\Condition\Partition\TreatmentEnum; @@ -134,7 +135,8 @@ private function doEvaluation($operation, $key, $featureFlagName, $attributes) $bucketingKey ); - $this->registerData($impression, $attributes); + $disabled = isset($result['impressionsDisabled']) && $result['impressionsDisabled']; + $this->registerData(new DecoratedImpression($impression, $disabled), $attributes); return array( 'treatment' => $result['treatment'], 'config' => $result['config'], @@ -155,7 +157,7 @@ private function doEvaluation($operation, $key, $featureFlagName, $attributes) ImpressionLabel::EXCEPTION, $bucketingKey ); - $this->registerData($impression, $attributes); + $this->registerData(new DecoratedImpression($impression, false), $attributes); } catch (\Exception $e) { SplitApp::logger()->critical( "An error occurred when attempting to log impression for " . @@ -237,12 +239,33 @@ private function doInputValidationForTreatments($key, $featureFlagNames, $operat ); } - private function registerData($impressions, $attributes) + private function registerData($decoratedImpressions, $attributes) { try { - TreatmentImpression::log($impressions, $this->queueMetadata); - if (isset($this->impressionListener)) { - $this->impressionListener->sendDataToClient($impressions, $attributes); + // Normalize to array + if (!is_array($decoratedImpressions)) { + $decoratedImpressions = array($decoratedImpressions); + } + + // Partition: all go to listener, only enabled go to log + $forListener = array(); + $forLog = array(); + foreach ($decoratedImpressions as $decorated) { + $impression = $decorated->getImpression(); + $forListener[] = $impression; + if (!$decorated->isDisabled()) { + $forLog[] = $impression; + } + } + + // Log enabled impressions to Redis + if (!empty($forLog)) { + TreatmentImpression::log($forLog, $this->queueMetadata); + } + + // Send all impressions to listener + if (isset($this->impressionListener) && !empty($forListener)) { + $this->impressionListener->sendDataToClient($forListener, $attributes); } } catch (\Exception $e) { SplitApp::logger()->critical( @@ -526,11 +549,11 @@ private function processEvaluations( $evaluations ) { $result = array(); - $impressions = array(); + $decoratedImpressions = array(); foreach ($evaluations as $featureFlagName => $evalResult) { if (InputValidator::isSplitFound($evalResult['impression']['label'], $featureFlagName, $operation)) { // Creates impression - $impressions[] = $this->createImpression( + $impression = $this->createImpression( $matchingKey, $featureFlagName, $evalResult['treatment'], @@ -538,6 +561,8 @@ private function processEvaluations( $evalResult['impression']['label'], $bucketingKey ); + $disabled = isset($evalResult['impressionsDisabled']) && $evalResult['impressionsDisabled']; + $decoratedImpressions[] = new DecoratedImpression($impression, $disabled); $result[$featureFlagName] = array( 'treatment' => $evalResult['treatment'], 'config' => $evalResult['config'], @@ -546,7 +571,7 @@ private function processEvaluations( $result[$featureFlagName] = array('treatment' => TreatmentEnum::CONTROL, 'config' => null); } } - $this->registerData($impressions, $attributes); + $this->registerData($decoratedImpressions, $attributes); return $result; } } diff --git a/src/SplitIO/Sdk/Evaluator.php b/src/SplitIO/Sdk/Evaluator.php index a403278d..45e1e748 100644 --- a/src/SplitIO/Sdk/Evaluator.php +++ b/src/SplitIO/Sdk/Evaluator.php @@ -108,11 +108,13 @@ private function evalTreatment($key, $bucketingKey, $split, ?array $attributes = if (is_null($split)) { $result['impression']['label'] = ImpressionLabel::SPLIT_NOT_FOUND; + $result['impressionsDisabled'] = false; return $result; } try { $configs = $split->getConfigurations(); $result['impression']['changeNumber'] = $split->getChangeNumber(); + $result['impressionsDisabled'] = $split->impressionsDisabled(); if ($split->killed()) { $defaultTreatment = $split->getDefaultTratment(); $result['treatment'] = $defaultTreatment; diff --git a/src/SplitIO/Sdk/Impressions/DecoratedImpression.php b/src/SplitIO/Sdk/Impressions/DecoratedImpression.php new file mode 100644 index 00000000..46502948 --- /dev/null +++ b/src/SplitIO/Sdk/Impressions/DecoratedImpression.php @@ -0,0 +1,47 @@ +impression = $impression; + $this->disabled = (bool) $disabled; + } + + /** + * @return Impression + */ + public function getImpression() + { + return $this->impression; + } + + /** + * @return bool + */ + public function isDisabled() + { + return $this->disabled; + } +} diff --git a/src/SplitIO/Sdk/Manager/LocalhostSplitManager.php b/src/SplitIO/Sdk/Manager/LocalhostSplitManager.php index 3628f952..88b53055 100644 --- a/src/SplitIO/Sdk/Manager/LocalhostSplitManager.php +++ b/src/SplitIO/Sdk/Manager/LocalhostSplitManager.php @@ -66,7 +66,10 @@ public function splits() false, $this->splits[$featureFlagName]["treatments"], 0, - $configs + $configs, + null, + array(), + false ); } } @@ -92,7 +95,8 @@ public function split($featureFlagName) 0, $configs, null, - array() + array(), + false ); } diff --git a/src/SplitIO/Sdk/Manager/SplitManager.php b/src/SplitIO/Sdk/Manager/SplitManager.php index b67bbd69..fd2818a5 100644 --- a/src/SplitIO/Sdk/Manager/SplitManager.php +++ b/src/SplitIO/Sdk/Manager/SplitManager.php @@ -68,7 +68,8 @@ private static function parseSplitView($splitRepresentation) $split->getChangeNumber(), $configs, $split->getDefaultTratment(), - $split->getSets() + $split->getSets(), + $split->impressionsDisabled() ); } } diff --git a/src/SplitIO/Sdk/Manager/SplitView.php b/src/SplitIO/Sdk/Manager/SplitView.php index 558635db..366ca8a6 100644 --- a/src/SplitIO/Sdk/Manager/SplitView.php +++ b/src/SplitIO/Sdk/Manager/SplitView.php @@ -11,6 +11,7 @@ class SplitView private $configs; private $defaultTreatment; private $sets; + private $impressionsDisabled; /** * SplitView constructor. @@ -22,6 +23,7 @@ class SplitView * @param $configurations * @param $defaultTreatment * @param $sets + * @param $impressionsDisabled */ public function __construct( $name, @@ -31,7 +33,8 @@ public function __construct( $changeNumber, $configs, $defaultTreatment, - $sets + $sets, + $impressionsDisabled = false ) { $this->name = $name; $this->trafficType = $trafficType; @@ -41,6 +44,7 @@ public function __construct( $this->configs = $configs; $this->defaultTreatment = $defaultTreatment; $this->sets = $sets; + $this->impressionsDisabled = $impressionsDisabled; } @@ -171,4 +175,20 @@ public function getSets() { return $this->sets; } + + /** + * @return mixed + */ + public function getImpressionsDisabled() + { + return $this->impressionsDisabled; + } + + /** + * @param mixed $impressionsDisabled + */ + public function setImpressionsDisabled($impressionsDisabled) + { + $this->impressionsDisabled = $impressionsDisabled; + } } diff --git a/tests/Suite/Sdk/Helpers/ImpressionsDisabledE2EListener.php b/tests/Suite/Sdk/Helpers/ImpressionsDisabledE2EListener.php new file mode 100644 index 00000000..cfef6a60 --- /dev/null +++ b/tests/Suite/Sdk/Helpers/ImpressionsDisabledE2EListener.php @@ -0,0 +1,30 @@ +receivedImpressions[] = array( + 'feature' => $impression->getFeature(), + 'key' => $impression->getId(), + 'treatment' => $impression->getTreatment(), + 'label' => $impression->getLabel(), + 'changeNumber' => $impression->getChangeNumber() + ); + } + + public function close() + { + } + + public function reset() + { + $this->receivedImpressions = array(); + } +} diff --git a/tests/Suite/Sdk/Helpers/ImpressionsDisabledListener.php b/tests/Suite/Sdk/Helpers/ImpressionsDisabledListener.php new file mode 100644 index 00000000..ccf532df --- /dev/null +++ b/tests/Suite/Sdk/Helpers/ImpressionsDisabledListener.php @@ -0,0 +1,24 @@ +receivedImpressions[] = $impression; + } + + public function close() + { + } + + public function reset() + { + $this->receivedImpressions = array(); + $this->receivedAttributes = array(); + } +} diff --git a/tests/Suite/Sdk/ImpressionsDisabledE2ETest.php b/tests/Suite/Sdk/ImpressionsDisabledE2ETest.php new file mode 100644 index 00000000..6b6a9c16 --- /dev/null +++ b/tests/Suite/Sdk/ImpressionsDisabledE2ETest.php @@ -0,0 +1,429 @@ + 'redis', + 'host' => REDIS_HOST, + 'port' => REDIS_PORT, + 'timeout' => 881, + ); + $options = array(); + $options['cache'] = array( + 'adapter' => 'predis', + 'parameters' => $parameters, + 'options' => array('prefix' => TEST_PREFIX) + ); + if ($listener !== null) { + $options['impressionListener'] = $listener; + } + return \SplitIO\Sdk::factory('test-api-key', $options); + } + + private function getRedisClient() + { + return new \Predis\Client( + array( + 'host' => REDIS_HOST, + 'port' => REDIS_PORT, + ), + array('prefix' => TEST_PREFIX) + ); + } + + private function seedE2EFixtures() + { + $redis = $this->getRedisClient(); + + // Fixture 1: disabled, in set_e2e + $split1 = array( + 'name' => 'flag_e2e_disabled', + 'trafficTypeName' => 'user', + 'seed' => 123456789, + 'status' => 'ACTIVE', + 'killed' => false, + 'defaultTreatment' => 'off', + 'changeNumber' => 1750000000000, + 'algo' => 2, + 'sets' => array('set_e2e'), + 'impressionsDisabled' => true, + 'conditions' => array( + array( + 'matcherGroup' => array( + 'combiner' => 'AND', + 'matchers' => array(array('matcherType' => 'ALL_KEYS', 'negate' => false)) + ), + 'partitions' => array( + array('treatment' => 'on', 'size' => 100), + array('treatment' => 'off', 'size' => 0) + ) + ) + ) + ); + + // Fixture 2: enabled, in set_e2e + $split2 = array( + 'name' => 'flag_e2e_enabled', + 'trafficTypeName' => 'user', + 'seed' => 123456789, + 'status' => 'ACTIVE', + 'killed' => false, + 'defaultTreatment' => 'off', + 'changeNumber' => 1750000000000, + 'algo' => 2, + 'sets' => array('set_e2e'), + 'impressionsDisabled' => false, + 'conditions' => array( + array( + 'matcherGroup' => array( + 'combiner' => 'AND', + 'matchers' => array(array('matcherType' => 'ALL_KEYS', 'negate' => false)) + ), + 'partitions' => array( + array('treatment' => 'on', 'size' => 100), + array('treatment' => 'off', 'size' => 0) + ) + ) + ) + ); + + // Fixture 3: legacy (no property) + $split3 = array( + 'name' => 'flag_e2e_legacy', + 'trafficTypeName' => 'user', + 'seed' => 123456789, + 'status' => 'ACTIVE', + 'killed' => false, + 'defaultTreatment' => 'off', + 'changeNumber' => 1750000000000, + 'algo' => 2, + 'sets' => array(), + 'conditions' => array( + array( + 'matcherGroup' => array( + 'combiner' => 'AND', + 'matchers' => array(array('matcherType' => 'ALL_KEYS', 'negate' => false)) + ), + 'partitions' => array( + array('treatment' => 'on', 'size' => 100), + array('treatment' => 'off', 'size' => 0) + ) + ) + ) + ); + + // Fixture 4: disabled + killed + $split4 = array( + 'name' => 'flag_e2e_disabled_killed', + 'trafficTypeName' => 'user', + 'seed' => 123456789, + 'status' => 'ACTIVE', + 'killed' => true, + 'defaultTreatment' => 'off', + 'changeNumber' => 1750000000000, + 'algo' => 2, + 'sets' => array(), + 'impressionsDisabled' => true, + 'conditions' => array( + array( + 'matcherGroup' => array( + 'combiner' => 'AND', + 'matchers' => array(array('matcherType' => 'ALL_KEYS', 'negate' => false)) + ), + 'partitions' => array( + array('treatment' => 'on', 'size' => 100), + array('treatment' => 'off', 'size' => 0) + ) + ) + ) + ); + + // Fixture 5: impressionsDisabled is numeric 1 (not boolean true) -> tracked + $split5 = array( + 'name' => 'flag_e2e_truthy', + 'trafficTypeName' => 'user', + 'seed' => 123456789, + 'status' => 'ACTIVE', + 'killed' => false, + 'defaultTreatment' => 'off', + 'changeNumber' => 1750000000000, + 'algo' => 2, + 'sets' => array(), + 'impressionsDisabled' => 1, + 'conditions' => array( + array( + 'matcherGroup' => array( + 'combiner' => 'AND', + 'matchers' => array(array('matcherType' => 'ALL_KEYS', 'negate' => false)) + ), + 'partitions' => array( + array('treatment' => 'on', 'size' => 100), + array('treatment' => 'off', 'size' => 0) + ) + ) + ) + ); + + // Seed all splits + $redis->set('SPLITIO.split.flag_e2e_disabled', json_encode($split1)); + $redis->set('SPLITIO.split.flag_e2e_enabled', json_encode($split2)); + $redis->set('SPLITIO.split.flag_e2e_legacy', json_encode($split3)); + $redis->set('SPLITIO.split.flag_e2e_disabled_killed', json_encode($split4)); + $redis->set('SPLITIO.split.flag_e2e_truthy', json_encode($split5)); + $redis->set('SPLITIO.splits.till', 1750000000000); + + // Seed flag-set index (set_e2e contains fixtures 1 and 2) + $redis->sadd('SPLITIO.flagSet.set_e2e', 'flag_e2e_disabled'); + $redis->sadd('SPLITIO.flagSet.set_e2e', 'flag_e2e_enabled'); + } + + public function setUp(): void + { + Utils\Utils::cleanCache(); + Di::set(Di::KEY_FACTORY_TRACKER, false); + $this->seedE2EFixtures(); + } + + public function tearDown(): void + { + Utils\Utils::cleanCache(); + } + + public function testE2EAllFixtures() + { + $listener = new ImpressionsDisabledE2EListener(); + $factory = $this->createFactory($listener); + $client = $factory->client(); + $redis = $this->getRedisClient(); + + // Exercise all fixtures via getTreatments + $treatments = $client->getTreatments('e2e_user_1', array( + 'flag_e2e_disabled', + 'flag_e2e_enabled', + 'flag_e2e_legacy', + 'flag_e2e_disabled_killed', + 'flag_e2e_truthy' + )); + + // All treatments correct + $this->assertEquals('on', $treatments['flag_e2e_disabled']); + $this->assertEquals('on', $treatments['flag_e2e_enabled']); + $this->assertEquals('on', $treatments['flag_e2e_legacy']); + $this->assertEquals('off', $treatments['flag_e2e_disabled_killed']); // killed + $this->assertEquals('on', $treatments['flag_e2e_truthy']); + + // Redis: only flags that are NOT explicitly disabled are queued. + // flag_e2e_truthy has impressionsDisabled=1 (not boolean true), so it IS tracked. + $queuedFeatures = array(); + while ($raw = $redis->rpop(ImpressionCache::IMPRESSIONS_QUEUE_KEY)) { + $parsed = json_decode($raw, true); + $queuedFeatures[] = $parsed['i']['f']; + } + $this->assertCount(3, $queuedFeatures); + $this->assertContains('flag_e2e_enabled', $queuedFeatures); + $this->assertContains('flag_e2e_legacy', $queuedFeatures); + $this->assertContains('flag_e2e_truthy', $queuedFeatures); + $this->assertNotContains('flag_e2e_disabled', $queuedFeatures); + $this->assertNotContains('flag_e2e_disabled_killed', $queuedFeatures); + + // Listener: all 5 should be sent + $this->assertCount(5, $listener->receivedImpressions); + $listenerFeatures = array_map(function ($imp) { + return $imp['feature']; + }, $listener->receivedImpressions); + $this->assertContains('flag_e2e_disabled', $listenerFeatures); + $this->assertContains('flag_e2e_enabled', $listenerFeatures); + $this->assertContains('flag_e2e_legacy', $listenerFeatures); + $this->assertContains('flag_e2e_disabled_killed', $listenerFeatures); + $this->assertContains('flag_e2e_truthy', $listenerFeatures); + } + + public function testE2EGetTreatmentWithConfigDisabled() + { + $listener = new ImpressionsDisabledE2EListener(); + $factory = $this->createFactory($listener); + $client = $factory->client(); + $redis = $this->getRedisClient(); + + $result = $client->getTreatmentWithConfig('e2e_user_1', 'flag_e2e_disabled'); + $this->assertEquals('on', $result['treatment']); + + // Not in Redis + $raw = $redis->rpop(ImpressionCache::IMPRESSIONS_QUEUE_KEY); + $this->assertNull($raw); + + // In listener + $this->assertCount(1, $listener->receivedImpressions); + $this->assertEquals('flag_e2e_disabled', $listener->receivedImpressions[0]['feature']); + } + + public function testE2EGetTreatmentsWithConfigMixed() + { + $listener = new ImpressionsDisabledE2EListener(); + $factory = $this->createFactory($listener); + $client = $factory->client(); + $redis = $this->getRedisClient(); + + $results = $client->getTreatmentsWithConfig('e2e_user_1', array('flag_e2e_disabled', 'flag_e2e_enabled')); + $this->assertEquals('on', $results['flag_e2e_disabled']['treatment']); + $this->assertEquals('on', $results['flag_e2e_enabled']['treatment']); + + // Only enabled queued + $queuedFeatures = array(); + while ($raw = $redis->rpop(ImpressionCache::IMPRESSIONS_QUEUE_KEY)) { + $parsed = json_decode($raw, true); + $queuedFeatures[] = $parsed['i']['f']; + } + $this->assertCount(1, $queuedFeatures); + $this->assertContains('flag_e2e_enabled', $queuedFeatures); + + // Both in listener + $this->assertCount(2, $listener->receivedImpressions); + $listenerFeatures = array_map(function ($imp) { + return $imp['feature']; + }, $listener->receivedImpressions); + $this->assertContains('flag_e2e_disabled', $listenerFeatures); + $this->assertContains('flag_e2e_enabled', $listenerFeatures); + } + + public function testE2EGetTreatmentsByFlagSet() + { + $listener = new ImpressionsDisabledE2EListener(); + $factory = $this->createFactory($listener); + $client = $factory->client(); + $redis = $this->getRedisClient(); + + // set_e2e contains flag_e2e_disabled (disabled) + flag_e2e_enabled (enabled) + $treatments = $client->getTreatmentsByFlagSet('e2e_user_1', 'set_e2e'); + + // Both treatments correct + $this->assertEquals('on', $treatments['flag_e2e_disabled']); + $this->assertEquals('on', $treatments['flag_e2e_enabled']); + + // Only enabled queued + $queuedFeatures = array(); + while ($raw = $redis->rpop(ImpressionCache::IMPRESSIONS_QUEUE_KEY)) { + $parsed = json_decode($raw, true); + $queuedFeatures[] = $parsed['i']['f']; + } + $this->assertCount(1, $queuedFeatures); + $this->assertContains('flag_e2e_enabled', $queuedFeatures); + + // Both in listener + $this->assertCount(2, $listener->receivedImpressions); + $listenerFeatures = array_map(function ($imp) { + return $imp['feature']; + }, $listener->receivedImpressions); + $this->assertContains('flag_e2e_disabled', $listenerFeatures); + $this->assertContains('flag_e2e_enabled', $listenerFeatures); + } + + public function testE2EGetTreatmentsByFlagSets() + { + $listener = new ImpressionsDisabledE2EListener(); + $factory = $this->createFactory($listener); + $client = $factory->client(); + $redis = $this->getRedisClient(); + + // set_e2e contains flag_e2e_disabled (disabled) + flag_e2e_enabled (enabled) + $treatments = $client->getTreatmentsByFlagSets('e2e_user_1', array('set_e2e')); + + // Both treatments correct + $this->assertEquals('on', $treatments['flag_e2e_disabled']); + $this->assertEquals('on', $treatments['flag_e2e_enabled']); + + // Only enabled queued + $queuedFeatures = array(); + while ($raw = $redis->rpop(ImpressionCache::IMPRESSIONS_QUEUE_KEY)) { + $parsed = json_decode($raw, true); + $queuedFeatures[] = $parsed['i']['f']; + } + $this->assertCount(1, $queuedFeatures); + $this->assertContains('flag_e2e_enabled', $queuedFeatures); + + // Both in listener + $this->assertCount(2, $listener->receivedImpressions); + $listenerFeatures = array_map(function ($imp) { + return $imp['feature']; + }, $listener->receivedImpressions); + $this->assertContains('flag_e2e_disabled', $listenerFeatures); + $this->assertContains('flag_e2e_enabled', $listenerFeatures); + } + + public function testE2EGetTreatmentsWithConfigByFlagSets() + { + $listener = new ImpressionsDisabledE2EListener(); + $factory = $this->createFactory($listener); + $client = $factory->client(); + $redis = $this->getRedisClient(); + + // set_e2e contains flag_e2e_disabled (disabled) + flag_e2e_enabled (enabled) + $results = $client->getTreatmentsWithConfigByFlagSets('e2e_user_1', array('set_e2e')); + + // Both treatments correct + $this->assertEquals('on', $results['flag_e2e_disabled']['treatment']); + $this->assertEquals('on', $results['flag_e2e_enabled']['treatment']); + + // Only enabled queued + $queuedFeatures = array(); + while ($raw = $redis->rpop(ImpressionCache::IMPRESSIONS_QUEUE_KEY)) { + $parsed = json_decode($raw, true); + $queuedFeatures[] = $parsed['i']['f']; + } + $this->assertCount(1, $queuedFeatures); + $this->assertContains('flag_e2e_enabled', $queuedFeatures); + + // Both in listener + $this->assertCount(2, $listener->receivedImpressions); + $listenerFeatures = array_map(function ($imp) { + return $imp['feature']; + }, $listener->receivedImpressions); + $this->assertContains('flag_e2e_disabled', $listenerFeatures); + $this->assertContains('flag_e2e_enabled', $listenerFeatures); + } + + public function testE2EEnabledImpressionPayloadFidelity() + { + // Assert every field in the serialized impression payload for an enabled + // flag, plus that the queue TTL is unchanged. + $factory = $this->createFactory(); + $client = $factory->client(); + $redis = $this->getRedisClient(); + + // Check TTL BEFORE rpop (rpop drains the list, leaving no TTL on empty list) + $treatment = $client->getTreatment('e2e_user_1', 'flag_e2e_enabled'); + $this->assertEquals('on', $treatment); + + // Assert TTL is within bounds (> 0 and <= 3600) + $ttl = $redis->ttl(ImpressionCache::IMPRESSIONS_QUEUE_KEY); + $this->assertGreaterThan(0, $ttl); + $this->assertLessThanOrEqual(3600, $ttl); + + // Now rpop and assert full payload + $raw = $redis->rpop(ImpressionCache::IMPRESSIONS_QUEUE_KEY); + $this->assertNotNull($raw); + $parsed = json_decode($raw, true); + + // Assert every impression field + $this->assertEquals('e2e_user_1', $parsed['i']['k']); // key + $this->assertNull($parsed['i']['b']); // bucketing key (null when simple string key used) + $this->assertEquals('flag_e2e_enabled', $parsed['i']['f']); // feature + $this->assertEquals('on', $parsed['i']['t']); // treatment + $this->assertArrayHasKey('r', $parsed['i']); // label field exists (may be null if labels disabled) + $this->assertEquals(1750000000000, $parsed['i']['c']); // changeNumber + $this->assertIsInt($parsed['i']['m']); // timestamp + $this->assertGreaterThan(0, $parsed['i']['m']); + + // Assert metadata exists + $this->assertArrayHasKey('m', $parsed); + $this->assertIsArray($parsed['m']); + } +} diff --git a/tests/Suite/Sdk/ImpressionsDisabledTest.php b/tests/Suite/Sdk/ImpressionsDisabledTest.php new file mode 100644 index 00000000..d622ce69 --- /dev/null +++ b/tests/Suite/Sdk/ImpressionsDisabledTest.php @@ -0,0 +1,522 @@ + 'redis', + 'host' => REDIS_HOST, + 'port' => REDIS_PORT, + 'timeout' => 881, + ); + $options = array(); + $options['cache'] = array( + 'adapter' => 'predis', + 'parameters' => $parameters, + 'options' => array('prefix' => TEST_PREFIX) + ); + if ($impressionListener !== null) { + $options['impressionListener'] = $impressionListener; + } + return \SplitIO\Sdk::factory('test-api-key', $options); + } + + private function getRedisClient() + { + return new \Predis\Client( + array( + 'host' => REDIS_HOST, + 'port' => REDIS_PORT, + ), + array('prefix' => TEST_PREFIX) + ); + } + + private function seedSplitWithImpressionsDisabled($name, $impressionsDisabled, $killed = false, $sets = array()) + { + $split = array( + 'name' => $name, + 'trafficTypeName' => 'user', + 'seed' => 123456789, + 'status' => 'ACTIVE', + 'killed' => $killed, + 'defaultTreatment' => 'off', + 'changeNumber' => 1750000000000, + 'algo' => 2, + 'sets' => $sets, + 'conditions' => array( + array( + 'matcherGroup' => array( + 'combiner' => 'AND', + 'matchers' => array( + array( + 'matcherType' => 'ALL_KEYS', + 'negate' => false, + ) + ) + ), + 'partitions' => array( + array('treatment' => 'on', 'size' => 100), + array('treatment' => 'off', 'size' => 0) + ) + ) + ) + ); + + if ($impressionsDisabled !== null) { + $split['impressionsDisabled'] = $impressionsDisabled; + } + + $splitChanges = json_encode(array('splits' => array($split), 'till' => 1750000000000)); + Utils\Utils::addSplitsInCache($splitChanges); + } + + public function setUp(): void + { + Utils\Utils::cleanCache(); + Di::set(Di::KEY_FACTORY_TRACKER, false); + } + + public function tearDown(): void + { + Utils\Utils::cleanCache(); + } + + public function testSingleEvalImpressionDisabled() + { + $this->seedSplitWithImpressionsDisabled('flag_disabled', true); + $factory = $this->createFactory(); + $client = $factory->client(); + $redis = $this->getRedisClient(); + + $treatment = $client->getTreatment('e2e_user_1', 'flag_disabled'); + $this->assertEquals('on', $treatment); + + // Should NOT be in Redis queue + $raw = $redis->rpop(ImpressionCache::IMPRESSIONS_QUEUE_KEY); + $this->assertNull($raw); + } + + public function testSingleEvalImpressionEnabled() + { + $this->seedSplitWithImpressionsDisabled('flag_enabled', false); + $factory = $this->createFactory(); + $client = $factory->client(); + $redis = $this->getRedisClient(); + + $treatment = $client->getTreatment('e2e_user_1', 'flag_enabled'); + $this->assertEquals('on', $treatment); + + // Should be in Redis queue + $raw = $redis->rpop(ImpressionCache::IMPRESSIONS_QUEUE_KEY); + $this->assertNotNull($raw); + $parsed = json_decode($raw, true); + $this->assertEquals('flag_enabled', $parsed['i']['f']); + $this->assertEquals('e2e_user_1', $parsed['i']['k']); + $this->assertEquals('on', $parsed['i']['t']); + } + + public function testPropertyAbsentDefaultsToEnabled() + { + $this->seedSplitWithImpressionsDisabled('flag_legacy', null); + $factory = $this->createFactory(); + $client = $factory->client(); + $redis = $this->getRedisClient(); + + $treatment = $client->getTreatment('e2e_user_1', 'flag_legacy'); + $this->assertEquals('on', $treatment); + + // Should be in Redis queue (default is tracked) + $raw = $redis->rpop(ImpressionCache::IMPRESSIONS_QUEUE_KEY); + $this->assertNotNull($raw); + $parsed = json_decode($raw, true); + $this->assertEquals('flag_legacy', $parsed['i']['f']); + } + + public function testNonBooleanJsonValueNumericOneIsTracked() + { + // Only an explicit boolean true disables impressions. A numeric 1 is not + // boolean true, so the impression must still be tracked. + $redis = $this->getRedisClient(); + $split = array( + 'name' => 'flag_numeric_one', + 'trafficTypeName' => 'user', + 'seed' => 123456789, + 'status' => 'ACTIVE', + 'killed' => false, + 'defaultTreatment' => 'off', + 'changeNumber' => 1750000000000, + 'algo' => 2, + 'sets' => array(), + 'impressionsDisabled' => 1, + 'conditions' => array( + array( + 'matcherGroup' => array( + 'combiner' => 'AND', + 'matchers' => array( + array('matcherType' => 'ALL_KEYS', 'negate' => false) + ) + ), + 'partitions' => array( + array('treatment' => 'on', 'size' => 100), + array('treatment' => 'off', 'size' => 0) + ) + ) + ) + ); + $redis->set('SPLITIO.split.flag_numeric_one', json_encode($split)); + $redis->set('SPLITIO.splits.till', 1750000000000); + + $factory = $this->createFactory(); + $client = $factory->client(); + + $treatment = $client->getTreatment('e2e_user_1', 'flag_numeric_one'); + $this->assertEquals('on', $treatment); + + // 1 is not boolean true, so the impression IS queued + $raw = $redis->rpop(ImpressionCache::IMPRESSIONS_QUEUE_KEY); + $this->assertNotNull($raw); + $parsed = json_decode($raw, true); + $this->assertEquals('flag_numeric_one', $parsed['i']['f']); + } + + public function testMixedBatchGetTreatments() + { + $this->seedSplitWithImpressionsDisabled('flag_batch_disabled', true); + $this->seedSplitWithImpressionsDisabled('flag_batch_enabled', false); + $factory = $this->createFactory(); + $client = $factory->client(); + $redis = $this->getRedisClient(); + + $treatments = $client->getTreatments('e2e_user_1', array('flag_batch_disabled', 'flag_batch_enabled')); + $this->assertEquals('on', $treatments['flag_batch_disabled']); + $this->assertEquals('on', $treatments['flag_batch_enabled']); + + // Only the enabled one should be queued + $raw = $redis->rpop(ImpressionCache::IMPRESSIONS_QUEUE_KEY); + $this->assertNotNull($raw); + $parsed = json_decode($raw, true); + $this->assertEquals('flag_batch_enabled', $parsed['i']['f']); + + // No second impression + $raw2 = $redis->rpop(ImpressionCache::IMPRESSIONS_QUEUE_KEY); + $this->assertNull($raw2); + } + + public function testMixedBatchGetTreatmentsWithConfig() + { + $this->seedSplitWithImpressionsDisabled('flag_config_disabled', true); + $this->seedSplitWithImpressionsDisabled('flag_config_enabled', false); + $factory = $this->createFactory(); + $client = $factory->client(); + $redis = $this->getRedisClient(); + + $results = $client->getTreatmentsWithConfig('e2e_user_1', array('flag_config_disabled', 'flag_config_enabled')); + $this->assertEquals('on', $results['flag_config_disabled']['treatment']); + $this->assertEquals('on', $results['flag_config_enabled']['treatment']); + + // Only the enabled one should be queued + $raw = $redis->rpop(ImpressionCache::IMPRESSIONS_QUEUE_KEY); + $this->assertNotNull($raw); + $parsed = json_decode($raw, true); + $this->assertEquals('flag_config_enabled', $parsed['i']['f']); + + $raw2 = $redis->rpop(ImpressionCache::IMPRESSIONS_QUEUE_KEY); + $this->assertNull($raw2); + } + + public function testDisabledAndKilled() + { + $this->seedSplitWithImpressionsDisabled('flag_disabled_killed', true, true); + $factory = $this->createFactory(); + $client = $factory->client(); + $redis = $this->getRedisClient(); + + $treatment = $client->getTreatment('e2e_user_1', 'flag_disabled_killed'); + // Killed returns defaultTreatment + $this->assertEquals('off', $treatment); + + // Even though killed, impressionsDisabled=true means NOT queued + $raw = $redis->rpop(ImpressionCache::IMPRESSIONS_QUEUE_KEY); + $this->assertNull($raw); + } + + public function testManagerSplitDisabled() + { + $this->seedSplitWithImpressionsDisabled('flag_manager_disabled', true); + $factory = $this->createFactory(); + $manager = $factory->manager(); + + $splitView = $manager->split('flag_manager_disabled'); + $this->assertNotNull($splitView); + $this->assertEquals('flag_manager_disabled', $splitView->getName()); + $this->assertTrue($splitView->getImpressionsDisabled()); + } + + public function testManagerSplitLegacy() + { + $this->seedSplitWithImpressionsDisabled('flag_manager_legacy', null); + $factory = $this->createFactory(); + $manager = $factory->manager(); + + $splitView = $manager->split('flag_manager_legacy'); + $this->assertNotNull($splitView); + $this->assertEquals('flag_manager_legacy', $splitView->getName()); + $this->assertFalse($splitView->getImpressionsDisabled()); + } + + public function testMixedBatchGetTreatmentsByFlagSet() + { + $this->seedSplitWithImpressionsDisabled('flag_flagset_disabled', true, false, array('set_unit')); + $this->seedSplitWithImpressionsDisabled('flag_flagset_enabled', false, false, array('set_unit')); + $factory = $this->createFactory(); + $client = $factory->client(); + $redis = $this->getRedisClient(); + + $treatments = $client->getTreatmentsByFlagSet('e2e_user_1', 'set_unit'); + $this->assertEquals('on', $treatments['flag_flagset_disabled']); + $this->assertEquals('on', $treatments['flag_flagset_enabled']); + + // Only the enabled one should be queued + $raw = $redis->rpop(ImpressionCache::IMPRESSIONS_QUEUE_KEY); + $this->assertNotNull($raw); + $parsed = json_decode($raw, true); + $this->assertEquals('flag_flagset_enabled', $parsed['i']['f']); + + // No second impression + $raw2 = $redis->rpop(ImpressionCache::IMPRESSIONS_QUEUE_KEY); + $this->assertNull($raw2); + } + + public function testMixedBatchGetTreatmentsByFlagSets() + { + $this->seedSplitWithImpressionsDisabled('flag_flagsets_disabled', true, false, array('set_unit2')); + $this->seedSplitWithImpressionsDisabled('flag_flagsets_enabled', false, false, array('set_unit2')); + $factory = $this->createFactory(); + $client = $factory->client(); + $redis = $this->getRedisClient(); + + $treatments = $client->getTreatmentsByFlagSets('e2e_user_1', array('set_unit2')); + $this->assertEquals('on', $treatments['flag_flagsets_disabled']); + $this->assertEquals('on', $treatments['flag_flagsets_enabled']); + + // Only the enabled one should be queued + $raw = $redis->rpop(ImpressionCache::IMPRESSIONS_QUEUE_KEY); + $this->assertNotNull($raw); + $parsed = json_decode($raw, true); + $this->assertEquals('flag_flagsets_enabled', $parsed['i']['f']); + + // No second impression + $raw2 = $redis->rpop(ImpressionCache::IMPRESSIONS_QUEUE_KEY); + $this->assertNull($raw2); + } + + public function testExceptionPathStillQueues() + { + // Induce evaluation exception by storing malformed JSON that causes json_decode + // or Split constructor to fail. When Evaluator catches the exception, + // Client::doEvaluation's outer catch builds a control impression with label EXCEPTION + // and changeNumber -1. This impression MUST still be queued (wrapped as not-disabled). + // This is a regression guard: exception queuing behavior is unchanged by impressionsDisabled. + $redis = $this->getRedisClient(); + + // Store invalid JSON (missing required fields) that will cause Split construction to fail + $redis->set('SPLITIO.split.flag_exception', '{"name":"flag_exception"}'); + $redis->set('SPLITIO.splits.till', 1750000000000); + + $factory = $this->createFactory(); + $client = $factory->client(); + + $treatment = $client->getTreatment('e2e_user_1', 'flag_exception'); + $this->assertEquals('control', $treatment); + + // The EXCEPTION impression MUST still be queued + $raw = $redis->rpop(ImpressionCache::IMPRESSIONS_QUEUE_KEY); + $this->assertNotNull($raw); + $parsed = json_decode($raw, true); + $this->assertEquals('flag_exception', $parsed['i']['f']); + $this->assertEquals('control', $parsed['i']['t']); + $this->assertEquals('exception', $parsed['i']['r']); + } + + public function testNonBooleanJsonValueNull() + { + // Only an explicit boolean true disables impressions. An explicit null is + // not true, so the impression IS tracked (back-compat critical). + // The existing helper omits null, so seed manually with explicit null. + $redis = $this->getRedisClient(); + $split = array( + 'name' => 'flag_null', + 'trafficTypeName' => 'user', + 'seed' => 123456789, + 'status' => 'ACTIVE', + 'killed' => false, + 'defaultTreatment' => 'off', + 'changeNumber' => 1750000000000, + 'algo' => 2, + 'sets' => array(), + 'impressionsDisabled' => null, + 'conditions' => array( + array( + 'matcherGroup' => array( + 'combiner' => 'AND', + 'matchers' => array( + array('matcherType' => 'ALL_KEYS', 'negate' => false) + ) + ), + 'partitions' => array( + array('treatment' => 'on', 'size' => 100), + array('treatment' => 'off', 'size' => 0) + ) + ) + ) + ); + $redis->set('SPLITIO.split.flag_null', json_encode($split)); + $redis->set('SPLITIO.splits.till', 1750000000000); + + $factory = $this->createFactory(); + $client = $factory->client(); + + $treatment = $client->getTreatment('e2e_user_1', 'flag_null'); + $this->assertEquals('on', $treatment); + + // Should be queued: (bool)null === false → tracked + $raw = $redis->rpop(ImpressionCache::IMPRESSIONS_QUEUE_KEY); + $this->assertNotNull($raw); + $parsed = json_decode($raw, true); + $this->assertEquals('flag_null', $parsed['i']['f']); + } + + public function testNonBooleanJsonValueStringTrueIsTracked() + { + // The string "true" is not the boolean true, so the impression IS tracked. + $redis = $this->getRedisClient(); + $split = array( + 'name' => 'flag_string_true', + 'trafficTypeName' => 'user', + 'seed' => 123456789, + 'status' => 'ACTIVE', + 'killed' => false, + 'defaultTreatment' => 'off', + 'changeNumber' => 1750000000000, + 'algo' => 2, + 'sets' => array(), + 'impressionsDisabled' => 'true', + 'conditions' => array( + array( + 'matcherGroup' => array( + 'combiner' => 'AND', + 'matchers' => array( + array('matcherType' => 'ALL_KEYS', 'negate' => false) + ) + ), + 'partitions' => array( + array('treatment' => 'on', 'size' => 100), + array('treatment' => 'off', 'size' => 0) + ) + ) + ) + ); + $redis->set('SPLITIO.split.flag_string_true', json_encode($split)); + $redis->set('SPLITIO.splits.till', 1750000000000); + + $factory = $this->createFactory(); + $client = $factory->client(); + + $treatment = $client->getTreatment('e2e_user_1', 'flag_string_true'); + $this->assertEquals('on', $treatment); + + // The string "true" is not boolean true, so the impression IS queued + $raw = $redis->rpop(ImpressionCache::IMPRESSIONS_QUEUE_KEY); + $this->assertNotNull($raw); + $parsed = json_decode($raw, true); + $this->assertEquals('flag_string_true', $parsed['i']['f']); + } + + public function testListenerDivergenceUnit() + { + // Listener divergence guard: disabled + listener → listener fires with full payload; + // enabled + listener → listener fires AND queue receives. This tests both directions. + $listener = new ImpressionsDisabledListener(); + $this->seedSplitWithImpressionsDisabled('flag_listener_disabled', true); + $this->seedSplitWithImpressionsDisabled('flag_listener_enabled', false); + $factory = $this->createFactory($listener); + $client = $factory->client(); + $redis = $this->getRedisClient(); + + // Call getTreatment on each + $treatment1 = $client->getTreatment('e2e_user_1', 'flag_listener_disabled'); + $treatment2 = $client->getTreatment('e2e_user_1', 'flag_listener_enabled'); + $this->assertEquals('on', $treatment1); + $this->assertEquals('on', $treatment2); + + // Listener should receive BOTH (disabled fires listener) + $this->assertCount(2, $listener->receivedImpressions); + + // Redis queue should contain ONLY the enabled one + $raw = $redis->rpop(ImpressionCache::IMPRESSIONS_QUEUE_KEY); + $this->assertNotNull($raw); + $parsed = json_decode($raw, true); + $this->assertEquals('flag_listener_enabled', $parsed['i']['f']); + + // No second impression in queue + $raw2 = $redis->rpop(ImpressionCache::IMPRESSIONS_QUEUE_KEY); + $this->assertNull($raw2); + } + + public function testCrossSdkParityCanonicalFixture() + { + // Two flags: "tracked" (impressionsDisabled false) and "not_tracked" + // (impressionsDisabled true). An ALL_KEYS matcher (on:100) is used so the + // treatment is deterministic without seeding segments. + $this->seedSplitWithImpressionsDisabled('tracked', false); + $this->seedSplitWithImpressionsDisabled('not_tracked', true); + + $listener = new ImpressionsDisabledListener(); + $factory = $this->createFactory($listener); + $client = $factory->client(); + $redis = $this->getRedisClient(); + + $trackedTreatment = $client->getTreatment('CUSTOMER_ID', 'tracked'); + $notTrackedTreatment = $client->getTreatment('CUSTOMER_ID', 'not_tracked'); + + // Both treatments are "on" (ALL_KEYS matcher with on:100 partition) + $this->assertEquals('on', $trackedTreatment); + $this->assertEquals('on', $notTrackedTreatment); + + // The listener receives both impressions (disabled flags still fire it) + $this->assertCount(2, $listener->receivedImpressions); + + // The Redis queue contains only the "tracked" impression + $raw = $redis->rpop(ImpressionCache::IMPRESSIONS_QUEUE_KEY); + $this->assertNotNull($raw); + $parsed = json_decode($raw, true); + $this->assertEquals('tracked', $parsed['i']['f']); + $this->assertEquals('CUSTOMER_ID', $parsed['i']['k']); + $this->assertEquals('on', $parsed['i']['t']); + + // "not_tracked" is dropped, so nothing else is queued + $raw2 = $redis->rpop(ImpressionCache::IMPRESSIONS_QUEUE_KEY); + $this->assertNull($raw2); + + // The manager's SplitView exposes impressionsDisabled + $manager = $factory->manager(); + + $trackedSplit = $manager->split('tracked'); + $this->assertNotNull($trackedSplit); + $this->assertEquals('tracked', $trackedSplit->getName()); + $this->assertFalse($trackedSplit->getImpressionsDisabled()); + + $notTrackedSplit = $manager->split('not_tracked'); + $this->assertNotNull($notTrackedSplit); + $this->assertEquals('not_tracked', $notTrackedSplit->getName()); + $this->assertTrue($notTrackedSplit->getImpressionsDisabled()); + } +}