diff --git a/.gitignore b/.gitignore index be09eec..c87b4bf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,9 @@ vendor .idea -cache/* -!cache/.gitkeep +analytics/* +!analytics/.gitkeep backup/* !backup/.gitkeep +cache/* +!cache/.gitkeep .phpunit* \ No newline at end of file diff --git a/analytics/.gitkeep b/analytics/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/analytics/laminascache-c5/laminascache-analytics_bucket.dat b/analytics/laminascache-c5/laminascache-analytics_bucket.dat new file mode 100644 index 0000000..c7ad776 Binary files /dev/null and b/analytics/laminascache-c5/laminascache-analytics_bucket.dat differ diff --git a/src/Endpoints/FeatureFlags.php b/src/Endpoints/FeatureFlags.php index 041f5a3..41fc39c 100644 --- a/src/Endpoints/FeatureFlags.php +++ b/src/Endpoints/FeatureFlags.php @@ -97,9 +97,16 @@ public function isActive(string $featureFlagName): bool // If you ask for an non-existing feature flag, it returns false if (! array_key_exists($featureFlagName, $featureFlags)) { + // TODO: Should we send analytics here? return false; } + if ($this->featurit->isAnalyticsModuleEnabled()) { + $this->featurit->getFeatureAnalyticsService()->registerFeatureFlagRequest( + $featureFlags[$featureFlagName] + ); + } + return $featureFlags[$featureFlagName]->isActive(); } diff --git a/src/Featurit.php b/src/Featurit.php index 4e10c4d..17ab29e 100644 --- a/src/Featurit.php +++ b/src/Featurit.php @@ -4,6 +4,8 @@ use Featurit\Client\Endpoints\FeatureFlags; use Featurit\Client\HttpClient\ClientBuilder; +use Featurit\Client\Modules\Analytics\Services\AnalyticsSender; +use Featurit\Client\Modules\Analytics\Services\FeatureAnalyticsService; use Featurit\Client\Modules\Segmentation\DefaultFeaturitUserContext; use Featurit\Client\Modules\Segmentation\DefaultFeaturitUserContextProvider; use Featurit\Client\Modules\Segmentation\FeaturitUserContext; @@ -21,12 +23,16 @@ class Featurit private string $tenantIdentifier; private string $apiKey; + private bool $isAnalyticsEnabled; + private FeaturitUserContextProvider $featuritUserContextProvider; private ClientBuilder $clientBuilder; private CacheInterface $cache; + private CacheInterface $analyticsCache; private CacheInterface $backupCache; private FeatureSegmentationService $featureSegmentationService; private LocalCacheFactory $localCacheFactory; + private FeatureAnalyticsService $featureAnalyticsService; public function __construct( string $tenantIdentifier, @@ -37,6 +43,8 @@ public function __construct( ClientBuilder $clientBuilder = null, UriFactory $uriFactory = null, FeaturitUserContext $featuritUserContext = null, + bool $enableAnalytics = false, + int $sendAnalyticsIntervalMinutes = FeaturitBuilder::DEFAULT_SEND_ANALYTICS_INTERVAL_MINUTES, ) { $this->tenantIdentifier = $tenantIdentifier; $this->apiKey = $apiKey; @@ -50,10 +58,12 @@ public function __construct( $this->setHttpClientBuilder($clientBuilder, $uriFactory); $this->featureSegmentationService = new FeatureSegmentationService(); + + $this->setupAnalytics($enableAnalytics, $sendAnalyticsIntervalMinutes); } /** - * @throws HttpClient\Exceptions\InvalidApiKeyException + * @throws \Featurit\Client\HttpClient\Exceptions\InvalidApiKeyException */ public function isActive(string $featureName): bool { @@ -61,7 +71,7 @@ public function isActive(string $featureName): bool } /** - * @throws HttpClient\Exceptions\InvalidApiKeyException + * @throws \Featurit\Client\HttpClient\Exceptions\InvalidApiKeyException */ public function version(string $featureName): string { @@ -88,6 +98,11 @@ public function getCache(): CacheInterface return $this->cache; } + public function getAnalyticsCache(): CacheInterface + { + return $this->analyticsCache; + } + public function getBackupCache(): CacheInterface { return $this->backupCache; @@ -103,11 +118,43 @@ public function getFeatureSegmentationService(): FeatureSegmentationService return $this->featureSegmentationService; } + public function getFeatureAnalyticsService(): FeatureAnalyticsService + { + return $this->featureAnalyticsService; + } + + public function isAnalyticsModuleEnabled(): bool + { + return $this->isAnalyticsEnabled; + } + public function setUserContext(FeaturitUserContext $featuritUserContext): void { $this->setFeaturitUserContextProvider($featuritUserContext); } + /** + * @param FeaturitUserContext|null $featuritUserContext + * @param FeaturitUserContextProvider|null $featuritUserContextProvider + * @return void + */ + public function setFeaturitUserContextProvider(?FeaturitUserContext $featuritUserContext = null, ?FeaturitUserContextProvider $featuritUserContextProvider = null): void + { + if (! is_null($featuritUserContext)) { + $this->featuritUserContextProvider = new DefaultFeaturitUserContextProvider($featuritUserContext); + + return; + } + + if (is_null($featuritUserContextProvider)) { + $featuritUserContextProvider = new DefaultFeaturitUserContextProvider( + new DefaultFeaturitUserContext(null, null, null) + ); + } + + $this->featuritUserContextProvider = $featuritUserContextProvider; + } + /** * @param CacheInterface|null $cache * @param int $cacheTtlMinutes @@ -119,9 +166,8 @@ private function setCache(?CacheInterface $cache, int $cacheTtlMinutes): void $cache = $this->localCacheFactory->setLocalCache($cacheTtlMinutes, 'cache' , true); } - /** - * Backup cache will be used when there's some problem with the FeaturIT API. - */ + $this->analyticsCache = $this->localCacheFactory->setLocalCache(0, 'analytics', false); + $this->backupCache = $this->localCacheFactory->setLocalCache(0, 'backup', false); $this->cache = $cache; @@ -155,24 +201,18 @@ private function setHttpClientBuilder(?ClientBuilder $clientBuilder, ?UriFactory } /** - * @param FeaturitUserContext|null $featuritUserContext - * @param FeaturitUserContextProvider|null $featuritUserContextProvider + * @param bool $enableAnalytics + * @param int $sendAnalyticsIntervalMinutes * @return void */ - public function setFeaturitUserContextProvider(?FeaturitUserContext $featuritUserContext = null, ?FeaturitUserContextProvider $featuritUserContextProvider = null): void + private function setupAnalytics(bool $enableAnalytics, int $sendAnalyticsIntervalMinutes): void { - if (! is_null($featuritUserContext)) { - $this->featuritUserContextProvider = new DefaultFeaturitUserContextProvider($featuritUserContext); - - return; - } - - if (is_null($featuritUserContextProvider)) { - $featuritUserContextProvider = new DefaultFeaturitUserContextProvider( - new DefaultFeaturitUserContext(null, null, null) - ); - } + $this->isAnalyticsEnabled = $enableAnalytics; - $this->featuritUserContextProvider = $featuritUserContextProvider; + $this->featureAnalyticsService = new FeatureAnalyticsService( + $this->getAnalyticsCache(), + new AnalyticsSender($this->getHttpClient()), + $sendAnalyticsIntervalMinutes + ); } } \ No newline at end of file diff --git a/src/FeaturitBuilder.php b/src/FeaturitBuilder.php index bc61718..020863a 100644 --- a/src/FeaturitBuilder.php +++ b/src/FeaturitBuilder.php @@ -11,10 +11,13 @@ class FeaturitBuilder { public const DEFAULT_CACHE_TTL_MINUTES = 5; + public const DEFAULT_SEND_ANALYTICS_INTERVAL_MINUTES = 1; private string $tenantIdentifier; private string $apiKey; + private bool $isAnalyticsModuleEnabled = false; private int $cacheTtlMinutes = self::DEFAULT_CACHE_TTL_MINUTES; + private int $sendAnalyticsIntervalMinutes = self::DEFAULT_SEND_ANALYTICS_INTERVAL_MINUTES; private FeaturitUserContextProvider $featuritUserContextProvider; private CacheInterface $cache; private ClientBuilder $httpClientBuilder; @@ -35,6 +38,13 @@ public function setApiKey(string $apiKey): FeaturitBuilder return $this; } + public function setIsAnalyticsModuleEnabled(bool $isAnalyticsModuleEnabled): FeaturitBuilder + { + $this->isAnalyticsModuleEnabled = $isAnalyticsModuleEnabled; + + return $this; + } + public function setCacheTtlMinutes(int $cacheTtlMinutes): FeaturitBuilder { $this->cacheTtlMinutes = $cacheTtlMinutes; @@ -42,6 +52,13 @@ public function setCacheTtlMinutes(int $cacheTtlMinutes): FeaturitBuilder return $this; } + public function setSendAnalyticsIntervalMinutes(int $sendAnalyticsIntervalMinutes): FeaturitBuilder + { + $this->sendAnalyticsIntervalMinutes = $sendAnalyticsIntervalMinutes; + + return $this; + } + public function setFeaturitUserContextProvider(FeaturitUserContextProvider $featuritUserContextProvider): FeaturitBuilder { $this->featuritUserContextProvider = $featuritUserContextProvider; @@ -99,6 +116,8 @@ public function build(): Featurit $this->httpClientBuilder ?? null, $this->uriFactory ?? null, $this->featuritUserContext ?? null, + $this->isAnalyticsModuleEnabled, + $this->sendAnalyticsIntervalMinutes, ); } } \ No newline at end of file diff --git a/src/Modules/Analytics/AnalyticsBucket.php b/src/Modules/Analytics/AnalyticsBucket.php new file mode 100644 index 0000000..4862c9d --- /dev/null +++ b/src/Modules/Analytics/AnalyticsBucket.php @@ -0,0 +1,148 @@ + [ + * "$featureName" => [ + * "$featureVersion" => [ + * "$isActive" => $count, + * ], + * ], + * ], + * ] + * @var array + */ + private array $requests = []; + + public function __construct( + DateTimeInterface $startDateTime, + ?DateTimeInterface $endDateTime = null + ) + { + $this->startDateTime = clone $startDateTime; + + if (! is_null($endDateTime)) { + $this->endDateTime = clone $endDateTime; + } + } + + public function startDateTime(): DateTimeInterface + { + return $this->startDateTime; + } + + public function addFeatureFlagRequest( + FeatureFlag $featureFlag, + DateTime $currentTime = null + ): void + { + if ($this->isClosed()) { + return; + } + + if (is_null($currentTime)) { + $currentTime = new DateTime(); + } + + // Save the Feature Flag Request. + $hourKey = $this->generateHourKey($currentTime); + $flagNameKey = $this->generateFeatureFlagNameKey($featureFlag); + $flagVersionKey = $this->generateFeatureFlagVersionKey($featureFlag); + $flagIsActiveKey = $this->generateFeatureFlagIsActiveKey($featureFlag); + + if (!isset($this->requests["$hourKey"])) { + $this->requests["$hourKey"] = []; + } + + if (!isset($this->requests["$hourKey"]["$flagNameKey"])) { + $this->requests["$hourKey"]["$flagNameKey"] = []; + } + + if (!isset($this->requests["$hourKey"]["$flagNameKey"]["$flagVersionKey"])) { + $this->requests["$hourKey"]["$flagNameKey"]["$flagVersionKey"] = []; + } + + if (!isset($this->requests["$hourKey"]["$flagNameKey"]["$flagVersionKey"]["$flagIsActiveKey"])) { + $this->requests["$hourKey"]["$flagNameKey"]["$flagVersionKey"]["$flagIsActiveKey"] = 1; + } else { + $this->requests["$hourKey"]["$flagNameKey"]["$flagVersionKey"]["$flagIsActiveKey"]++; + } + } + + public function openBucket(): void + { + if (!$this->isClosed()) { + return; + } + + $this->endDateTime = null; + } + + public function closeBucket(?DateTimeInterface $endDateTime = null): void + { + if ($this->isClosed()) { + return; + } + + if (! is_null($endDateTime)) { + $this->endDateTime = $endDateTime; + return; + } + + $this->endDateTime = new DateTime(); + } + + private function isClosed(): bool + { + return ! is_null($this->endDateTime); + } + + /** + * @throws Exception + */ + public function jsonSerialize(): array + { + if (! $this->isClosed()) { + throw new Exception("Can't serialize an open bucket."); + } + + return [ + "start" => $this->startDateTime, + "end" => $this->endDateTime, + "reqs" => $this->requests, + ]; + } + + public function generateHourKey(DateTime $currentTime): string + { + return $currentTime->format("Y-m-d H:00:00"); + } + + public function generateFeatureFlagNameKey(FeatureFlag $featureFlag): string + { + return $featureFlag->name(); + } + + public function generateFeatureFlagVersionKey(FeatureFlag $featureFlag): string + { + return $featureFlag->selectedFeatureFlagVersion()->name(); + } + + public function generateFeatureFlagIsActiveKey(FeatureFlag $featureFlag): string + { + return $featureFlag->isActive() ? "t" : "f"; + } +} \ No newline at end of file diff --git a/src/Modules/Analytics/Exceptions/CantSendAnalyticsToServerException.php b/src/Modules/Analytics/Exceptions/CantSendAnalyticsToServerException.php new file mode 100644 index 0000000..1d98f39 --- /dev/null +++ b/src/Modules/Analytics/Exceptions/CantSendAnalyticsToServerException.php @@ -0,0 +1,10 @@ +httpMethodsClient->post( + '/analytics', + [], + json_encode($analyticsBucket->jsonSerialize()) + ); + + if ($featureFlagsApiResponse->getStatusCode() != 200) { + throw new CantSendAnalyticsToServerException("Error sending Analytics to the API"); + } + } catch (\Http\Client\Exception $exception) { + throw new CantSendAnalyticsToServerException($exception->getMessage(), $exception->getCode(), $exception); + } + } +} \ No newline at end of file diff --git a/src/Modules/Analytics/Services/FeatureAnalyticsService.php b/src/Modules/Analytics/Services/FeatureAnalyticsService.php new file mode 100644 index 0000000..44a8a96 --- /dev/null +++ b/src/Modules/Analytics/Services/FeatureAnalyticsService.php @@ -0,0 +1,58 @@ +analyticsCache->has($analyticsCacheKey)) { + $analyticsBucket = $this->analyticsCache->get($analyticsCacheKey); + } else { + $analyticsBucket = new AnalyticsBucket($now); + } + + $analyticsBucket->addFeatureFlagRequest($featureFlag, $currentTime); + + // TODO: This approach can have problems due to sending big payloads to the server in case of failure or huge traffic. + if ($analyticsBucket->startDateTime()->diff($now)->i >= $this->sendAnalyticsIntervalMinutes) { + try { + $analyticsBucket->closeBucket($now); + $this->analyticsSender->sendAnalyticsBucket($analyticsBucket); + + $this->analyticsCache->delete($analyticsCacheKey); + } catch (CantSendAnalyticsToServerException $exception) { + $analyticsBucket->openBucket(); + $this->analyticsCache->set($analyticsCacheKey, $analyticsBucket); + } + } else { + $this->analyticsCache->set($analyticsCacheKey, $analyticsBucket); + } + } +} \ No newline at end of file diff --git a/src/Modules/Segmentation/Services/FeatureSegmentationService.php b/src/Modules/Segmentation/Services/FeatureSegmentationService.php index 327b35b..161e9c9 100644 --- a/src/Modules/Segmentation/Services/FeatureSegmentationService.php +++ b/src/Modules/Segmentation/Services/FeatureSegmentationService.php @@ -109,8 +109,6 @@ private function evaluateFeatureFlagSegments( } } -// Log::debug("- No segment evaluated to true, so it's false"); - return false; } @@ -138,16 +136,12 @@ private function evaluateFeatureFlagSegment( foreach ($featureFlagSegment->stringSegmentRules() as $stringSegmentRule) { if (! $this->evaluateSegmentRule(AttributeTypes::STRING, $stringSegmentRule, $featuritUserContext)) { -// Log::debug("- Rule {$stringSegmentRule->attribute()->name()} {$stringSegmentRule->operator()} {$stringSegmentRule->value()} is false"); - return false; } } foreach ($featureFlagSegment->numberSegmentRules() as $numberSegmentRule) { if (! $this->evaluateSegmentRule(AttributeTypes::NUMBER, $numberSegmentRule, $featuritUserContext)) { -// Log::debug("- Rule {$numberSegmentRule->attribute()->name()} {$numberSegmentRule->operator()} {$numberSegmentRule->value()} is false"); - return false; } } @@ -174,8 +168,6 @@ private function evaluateSegmentRule( $segmentRuleAttributeValue = $segmentRule->value(); $featuritUserContextAttributeValue = $featuritUserContext->getAttribute($attributeName); -// Log::debug("- Attribute name {$attributeName}, operator {$operator}, ruleValue {$segmentRuleAttributeValue}, userValue {$featuritUserContextAttributeValue}"); - return $this->attributeEvaluators[$attributeType]->evaluate( $featuritUserContextAttributeValue, $operator, diff --git a/tests/FeaturitTest.php b/tests/FeaturitTest.php index b4e1b33..5afa579 100644 --- a/tests/FeaturitTest.php +++ b/tests/FeaturitTest.php @@ -7,6 +7,7 @@ use Featurit\Client\FeaturitBuilder; use Featurit\Client\HttpClient\ClientBuilder; use Featurit\Client\HttpClient\Exceptions\InvalidApiKeyException; +use Featurit\Client\Modules\Analytics\AnalyticsBucket; use Featurit\Client\Modules\Segmentation\ConstantCollections\BaseVersions; use Featurit\Client\Modules\Segmentation\DefaultFeaturitUserContext; use Featurit\Client\Modules\Segmentation\DefaultFeaturitUserContextProvider; @@ -284,6 +285,45 @@ public function test_passing_user_context_in_setter_overrides_user_context_provi $this->assertEquals("1357", $featurit->getUserContext()->getUserId()); } + public function test_that_cache_stores_dates_properly(): void + { + $featurit = $this->getFeaturit(self::VALID_API_KEY); + + $date = new \DateTime("2020-02-11"); + + $featurit->getCache()->set("test_datetime_serdes", $date); + + $cachedDate = $featurit->getCache()->get("test_datetime_serdes"); + + $this->assertEquals($date, $cachedDate); + + $date = new \DateTime('now'); + + $featurit->getCache()->set("test_datetime_serdes", $date); + + $cachedDate = $featurit->getCache()->get("test_datetime_serdes"); + + $this->assertEquals($date, $cachedDate); + + $featurit->getCache()->delete("test_datetime_serdes"); + } + + public function test_that_cache_stores_analytics_bucket_properly(): void + { + $featurit = $this->getFeaturit(self::VALID_API_KEY); + + $date = new \DateTime("2020-02-11"); + $analyticsBucket = new AnalyticsBucket($date); + + $featurit->getBackupCache()->set("test_analytics_bucket_serdes", $analyticsBucket); + + $cachedAnalyticsBucket = $featurit->getBackupCache()->get("test_analytics_bucket_serdes"); + + $this->assertEquals($analyticsBucket, $cachedAnalyticsBucket); + + $featurit->getBackupCache()->delete("test_analytics_bucket_serdes"); + } + /** * @param string $apiKey * @param int $status @@ -312,7 +352,9 @@ private function getFeaturit( $featuritBuilder = (new FeaturitBuilder()) ->setTenantIdentifier(self::TENANT_IDENTIFIER) ->setApiKey($apiKey) + ->setIsAnalyticsModuleEnabled(true) ->setCacheTtlMinutes(5) + ->setSendAnalyticsIntervalMinutes(1) ->setHttpClientBuilder($clientBuilder); if (! is_null($featuritUserContextProvider)) { diff --git a/tests/LocalCacheFactoryTest.php b/tests/LocalCacheFactoryTest.php index 70dbabe..89d877f 100644 --- a/tests/LocalCacheFactoryTest.php +++ b/tests/LocalCacheFactoryTest.php @@ -8,7 +8,7 @@ class LocalCacheFactoryTest extends TestCase { - const TEST_CACHE_DIR = "test"; + const TEST_CACHE_DIR = "cache_test"; private $testCacheDir = ""; protected function setUp(): void diff --git a/tests/Modules/Analytics/AnalyticsBucketTest.php b/tests/Modules/Analytics/AnalyticsBucketTest.php new file mode 100644 index 0000000..9f658c0 --- /dev/null +++ b/tests/Modules/Analytics/AnalyticsBucketTest.php @@ -0,0 +1,312 @@ +closeBucket($endDateTime); + + $result = $bucket->jsonSerialize(); + + $expectedResult = [ + "start" => $startDateTime, + "end" => $endDateTime, + "reqs" => [], + ]; + + $this->assertEquals($expectedResult, $result); + } + + public function test_it_cant_be_serialized_if_not_closed(): void + { + $startDateTime = new DateTime(); + $bucket = new AnalyticsBucket($startDateTime); + + $this->expectException(Exception::class); + + $bucket->jsonSerialize(); + } + + public function test_it_stores_one_flag_properly(): void + { + $startDateTime = new DateTime(); + $bucket = new AnalyticsBucket($startDateTime); + + $featureFlag = new FeatureFlag( + "Test", + true, + BaseAttributes::USER_ID, + [], + [] + ); + + $bucket->addFeatureFlagRequest($featureFlag, $startDateTime); + + $endDateTime = new DateTime(); + $bucket->closeBucket($endDateTime); + + $hour = $bucket->generateHourKey($startDateTime); + $flagNameKey = $bucket->generateFeatureFlagNameKey($featureFlag); + $flagVersionKey = $bucket->generateFeatureFlagVersionKey($featureFlag); + $flagIsActiveKey = $bucket->generateFeatureFlagIsActiveKey($featureFlag); + + $result = $bucket->jsonSerialize(); + $expectedReqs = [ + "$hour" => [ + "$flagNameKey" => [ + "$flagVersionKey" => [ + "$flagIsActiveKey" => 1, + ], + ], + ], + ]; + + $this->assertEquals($expectedReqs, $result["reqs"]); + } + + public function test_it_stores_multiple_different_flags_properly(): void + { + $startDateTime = new DateTime(); + $bucket = new AnalyticsBucket($startDateTime); + + $featureFlag1 = new FeatureFlag( + "Test", + true, + BaseAttributes::USER_ID, + [], + [] + ); + + $bucket->addFeatureFlagRequest($featureFlag1, $startDateTime); + + $featureFlag2 = new FeatureFlag( + "Test2", + false, + BaseAttributes::SESSION_ID, + [], + [], + new FeatureFlagVersion("v1", 100) + ); + + $bucket->addFeatureFlagRequest($featureFlag2, $startDateTime); + + $endDateTime = new DateTime(); + $bucket->closeBucket($endDateTime); + + $hour = $bucket->generateHourKey($startDateTime); + $flagNameKey1 = $bucket->generateFeatureFlagNameKey($featureFlag1); + $flagVersionKey1 = $bucket->generateFeatureFlagVersionKey($featureFlag1); + $flagIsActiveKey1 = $bucket->generateFeatureFlagIsActiveKey($featureFlag1); + + $flagNameKey2 = $bucket->generateFeatureFlagNameKey($featureFlag2); + $flagVersionKey2 = $bucket->generateFeatureFlagVersionKey($featureFlag2); + $flagIsActiveKey2 = $bucket->generateFeatureFlagIsActiveKey($featureFlag2); + + $result = $bucket->jsonSerialize(); + $expectedReqs = [ + "$hour" => [ + "$flagNameKey1" => [ + "$flagVersionKey1" => [ + "$flagIsActiveKey1" => 1, + ], + ], + "$flagNameKey2" => [ + "$flagVersionKey2" => [ + "$flagIsActiveKey2" => 1, + ], + ], + ], + ]; + + $this->assertEquals($expectedReqs, $result["reqs"]); + } + + public function test_it_stores_multiple_equal_flags_properly(): void + { + $startDateTime = new DateTime(); + $bucket = new AnalyticsBucket($startDateTime); + + $featureFlag = new FeatureFlag( + "Test", + true, + BaseAttributes::USER_ID, + [], + [] + ); + + $bucket->addFeatureFlagRequest($featureFlag, $startDateTime); + + $bucket->addFeatureFlagRequest($featureFlag, $startDateTime); + + $endDateTime = new DateTime(); + $bucket->closeBucket($endDateTime); + + $hour = $bucket->generateHourKey($startDateTime); + $flagNameKey = $bucket->generateFeatureFlagNameKey($featureFlag); + $flagVersionKey = $bucket->generateFeatureFlagVersionKey($featureFlag); + $flagIsActiveKey = $bucket->generateFeatureFlagIsActiveKey($featureFlag); + + $result = $bucket->jsonSerialize(); + $expectedReqs = [ + "$hour" => [ + "$flagNameKey" => [ + "$flagVersionKey" => [ + "$flagIsActiveKey" => 2, + ], + ], + ], + ]; + + $this->assertEquals($expectedReqs, $result["reqs"]); + } + + public function test_it_stores_multiple_equal_flags_with_different_active_values_properly(): void + { + $startDateTime = new DateTime(); + $bucket = new AnalyticsBucket($startDateTime); + + $featureFlag = new FeatureFlag( + "Test", + true, + BaseAttributes::USER_ID, + [], + [] + ); + + $bucket->addFeatureFlagRequest($featureFlag, $startDateTime); + + $featureFlag = new FeatureFlag( + "Test", + false, + BaseAttributes::USER_ID, + [], + [] + ); + + $bucket->addFeatureFlagRequest($featureFlag); + + $endDateTime = new DateTime(); + $bucket->closeBucket($endDateTime); + + $hour = $bucket->generateHourKey($startDateTime); + $flagNameKey = $bucket->generateFeatureFlagNameKey($featureFlag); + $flagVersionKey = $bucket->generateFeatureFlagVersionKey($featureFlag); + + $result = $bucket->jsonSerialize(); + $expectedReqs = [ + "$hour" => [ + "$flagNameKey" => [ + "$flagVersionKey" => [ + "t" => 1, + "f" => 1, + ], + ], + ], + ]; + + $this->assertEquals($expectedReqs, $result["reqs"]); + } + + public function test_it_stores_multiple_equal_flags_with_different_insertion_hour_in_different_keys(): void + { + $startDateTime = new DateTime("2023-06-06 11:54:48"); + $bucket = new AnalyticsBucket($startDateTime); + + $featureFlag = new FeatureFlag( + "Test", + true, + BaseAttributes::USER_ID, + [], + [] + ); + + $bucket->addFeatureFlagRequest($featureFlag, $startDateTime); + + $endDateTime = new DateTime("2023-06-06 12:00:01"); + $bucket->addFeatureFlagRequest($featureFlag, $endDateTime); + + $bucket->closeBucket($endDateTime); + + $hour1 = $bucket->generateHourKey($startDateTime); + $hour2 = $bucket->generateHourKey($endDateTime); + $flagNameKey = $bucket->generateFeatureFlagNameKey($featureFlag); + $flagVersionKey = $bucket->generateFeatureFlagVersionKey($featureFlag); + + $result = $bucket->jsonSerialize(); + $expectedReqs = [ + "$hour1" => [ + "$flagNameKey" => [ + "$flagVersionKey" => [ + "t" => 1, + ], + ], + ], + "$hour2" => [ + "$flagNameKey" => [ + "$flagVersionKey" => [ + "t" => 1, + ], + ], + ], + ]; + + $this->assertEquals($expectedReqs, $result["reqs"]); + } + + public function test_new_data_cant_be_added_after_closing(): void + { + $startDateTime = new DateTime(); + $bucket = new AnalyticsBucket($startDateTime); + + $featureFlag = new FeatureFlag( + "Test", + true, + BaseAttributes::USER_ID, + [], + [] + ); + + $bucket->addFeatureFlagRequest($featureFlag, $startDateTime); + + $endDateTime = new DateTime(); + $bucket->closeBucket($endDateTime); + + $bucket->addFeatureFlagRequest($featureFlag, $startDateTime); + + $hour = $bucket->generateHourKey($startDateTime); + $flagNameKey = $bucket->generateFeatureFlagNameKey($featureFlag); + $flagVersionKey = $bucket->generateFeatureFlagVersionKey($featureFlag); + $flagIsActiveKey = $bucket->generateFeatureFlagIsActiveKey($featureFlag); + + $result = $bucket->jsonSerialize(); + $expectedResult = [ + "start" => $startDateTime, + "end" => $endDateTime, + "reqs" => [ + "$hour" => [ + "$flagNameKey" => [ + "$flagVersionKey" => [ + "$flagIsActiveKey" => 1, + ], + ], + ], + ], + ]; + + $this->assertEquals($expectedResult, $result); + } +} \ No newline at end of file diff --git a/tests/Modules/Analytics/Services/AnalyticsSenderTest.php b/tests/Modules/Analytics/Services/AnalyticsSenderTest.php new file mode 100644 index 0000000..502db96 --- /dev/null +++ b/tests/Modules/Analytics/Services/AnalyticsSenderTest.php @@ -0,0 +1,50 @@ +createMock(HttpMethodsClientInterface::class); + $mockHttpMethodsClient->method('post')->willReturn(new Response( + 'php://memory', + 200 + )); + + $now = new DateTime(); + $analyticsBucket = new AnalyticsBucket($now); + $analyticsBucket->closeBucket($now); + + $analyticsSender = new AnalyticsSender($mockHttpMethodsClient); + $analyticsSender->sendAnalyticsBucket($analyticsBucket); + + $this->assertTrue(true); + } + + public function test_it_sends_the_right_exception_on_non_200_http_status_response(): void + { + $mockHttpMethodsClient = $this->createMock(HttpMethodsClientInterface::class); + $mockHttpMethodsClient->method('post')->willReturn(new Response( + 'php://memory', + 500 + )); + + $now = new DateTime(); + $analyticsBucket = new AnalyticsBucket($now); + $analyticsBucket->closeBucket($now); + + $this->expectException(CantSendAnalyticsToServerException::class); + + $analyticsSender = new AnalyticsSender($mockHttpMethodsClient); + $analyticsSender->sendAnalyticsBucket($analyticsBucket); + } +} \ No newline at end of file diff --git a/tests/Modules/Analytics/Services/FeatureAnalyticsServiceTest.php b/tests/Modules/Analytics/Services/FeatureAnalyticsServiceTest.php new file mode 100644 index 0000000..c24437b --- /dev/null +++ b/tests/Modules/Analytics/Services/FeatureAnalyticsServiceTest.php @@ -0,0 +1,178 @@ +testCacheDir = join(DIRECTORY_SEPARATOR, [dirname(__FILE__), '..', self::TEST_CACHE_DIR]); + } + + protected function tearDown(): void + { + $this->deleteDirectory($this->testCacheDir); + } + + private function deleteDirectory($dir): bool + { + if (!file_exists($dir)) { + return true; + } + + if (!is_dir($dir)) { + return unlink($dir); + } + + foreach (scandir($dir) as $item) { + if ($item == '.' || $item == '..') { + continue; + } + + if (!$this->deleteDirectory($dir . DIRECTORY_SEPARATOR . $item)) { + return false; + } + } + + return rmdir($dir); + } + + public function test_it_can_send_a_simple_request_with_negative_analytics_interval(): void + { + $cacheFactory = new LocalCacheFactory(); + + $mockAnalyticsSender = $this->createMock(AnalyticsSender::class); + + $analyticsService = new FeatureAnalyticsService( + $cacheFactory->setLocalCache(0, self::TEST_CACHE_DIR, false), + $mockAnalyticsSender, + -2 + ); + + $featureFlag = new FeatureFlag( + 'TEST', + true, + BaseAttributes::USER_ID, + [], + [], + null + ); + + $currentTime = new DateTime('2021-10-10 00:00:00'); + $analyticsService->registerFeatureFlagRequest( + $featureFlag, + $currentTime + ); + + $this->assertTrue(true); + } + + public function test_it_works_properly_with_60_seconds_time_interval(): void + { + $cacheFactory = new LocalCacheFactory(); + + $mockAnalyticsSender = $this->createMock(AnalyticsSender::class); + + $analyticsService = new FeatureAnalyticsService( + $cacheFactory->setLocalCache(0, self::TEST_CACHE_DIR, false), + $mockAnalyticsSender, + 1 + ); + + $currentTime = new DateTime('2023-06-10 00:00:00'); + + for ($i = 0; $i < 60; $i++) { + $featureFlag = new FeatureFlag( + 'Feat', + rand(0, 1) == 0, + BaseAttributes::USER_ID, + [], + [], + new FeatureFlagVersion( + rand(0, 1) == 0 ? 'v1' : 'v2', + 100 + ) + ); + + $analyticsService->registerFeatureFlagRequest( + $featureFlag, + $currentTime + ); + + $currentTime->add(new DateInterval('PT1S')); + + sleep(1); + } + + $this->assertTrue(true); + } + +// public function test_it_works_properly_indefinitely(): void +// { +// $cacheFactory = new LocalCacheFactory(); +// +// $clientBuilder = new ClientBuilder(); +// $uriFactory = Psr17FactoryDiscovery::findUriFactory(); +// +// $tenantIdentifier = 'test'; +// $apiKey = '5b436559-e1d0-44be-96a3-65c716950c99'; +// +// $clientBuilder->addPlugin( +// new BaseUriPlugin( +//// $uriFactory->createUri("https://{$tenantIdentifier}.featurit.com/api/v1/{$apiKey}") +// $uriFactory->createUri("http://{$tenantIdentifier}.localhost/api/v1/{$apiKey}") +// ) +// ); +// +// $clientBuilder->addPlugin( +// new HeaderDefaultsPlugin( +// [ +// 'User-Agent' => 'FeaturIT', +// 'Content-Type' => 'application/json', +// 'Accept' => 'application/json', +// ] +// ) +// ); +// +// $analyticsService = new FeatureAnalyticsService( +// $cacheFactory->setLocalCache(0, self::TEST_CACHE_DIR, false), +// new AnalyticsSender($clientBuilder->getHttpClient()), +// 1 +// ); +// +// while (true) { +// $featureFlag = new FeatureFlag( +// 'Feat' . rand(0, 100), +// rand(0, 1) == 0, +// BaseAttributes::USER_ID, +// [], +// [], +// new FeatureFlagVersion( +// 'v' . rand(0, 5), +// 100 +// ) +// ); +// +// $analyticsService->registerFeatureFlagRequest( +// $featureFlag +// ); +// +// usleep(10); +// } +// +// $this->assertTrue(true); +// } +} \ No newline at end of file diff --git a/tests/Modules/Segmentation/DefaultFeaturitUserContextTest.php b/tests/Modules/Segmentation/DefaultFeaturitUserContextTest.php index 2ed59b8..b999ae9 100644 --- a/tests/Modules/Segmentation/DefaultFeaturitUserContextTest.php +++ b/tests/Modules/Segmentation/DefaultFeaturitUserContextTest.php @@ -2,6 +2,7 @@ namespace Featurit\Client\Tests\Modules\Segmentation; +use Featurit\Client\Modules\Segmentation\ConstantCollections\BaseAttributes; use Featurit\Client\Modules\Segmentation\DefaultFeaturitUserContext; use PHPUnit\Framework\TestCase; @@ -93,17 +94,17 @@ public function test_it_returns_all_custom_attributes(): void public function test_it_converts_to_array(): void { $expectedArray = [ - "userId" => 146, - "sessionId" => null, - "ipAddress" => "192.168.1.1", + BaseAttributes::USER_ID => 146, + BaseAttributes::SESSION_ID => null, + BaseAttributes::IP_ADDRESS => "192.168.1.1", "gender" => "Female", "description" => "I like trains", ]; $defaultFeaturitUserContext = new DefaultFeaturitUserContext( - $expectedArray["userId"], - $expectedArray["sessionId"], - $expectedArray["ipAddress"], + $expectedArray[BaseAttributes::USER_ID], + $expectedArray[BaseAttributes::SESSION_ID], + $expectedArray[BaseAttributes::IP_ADDRESS], [ "gender" => $expectedArray["gender"], "description" => $expectedArray["description"], @@ -116,18 +117,18 @@ public function test_it_converts_to_array(): void public function test_main_attributes_arent_overwritten_by_custom_attributes_when_it_converts_to_array(): void { $expectedArray = [ - "userId" => "totoro@gmail.com", - "sessionId" => "a124s3243e12321", - "ipAddress" => "192.168.1.1", + BaseAttributes::USER_ID => "totoro@gmail.com", + BaseAttributes::SESSION_ID => "a124s3243e12321", + BaseAttributes::IP_ADDRESS => "192.168.1.1", "birth date" => "20/10/1999", "purchase_amount" => 979.23, "currency" => "EUR", ]; $defaultFeaturitUserContext = new DefaultFeaturitUserContext( - $expectedArray["userId"], - $expectedArray["sessionId"], - $expectedArray["ipAddress"], + $expectedArray[BaseAttributes::USER_ID], + $expectedArray[BaseAttributes::SESSION_ID], + $expectedArray[BaseAttributes::IP_ADDRESS], $expectedArray );