diff --git a/.circleci/config.yml b/.circleci/config.yml index b376278b..cf4caf75 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -9,7 +9,7 @@ workflows: - test-on-linux: matrix: parameters: - php-version: ["8.0", "8.1"] + php-version: ["8.1", "8.2"] composer-dependencies: ["lowest", "highest"] - test-on-windows diff --git a/.ldrelease/config.yml b/.ldrelease/config.yml index 7604660e..7e78fcd4 100644 --- a/.ldrelease/config.yml +++ b/.ldrelease/config.yml @@ -10,12 +10,13 @@ publications: branches: - name: main - description: 5.x + description: 6.x + - name: 5.x - name: 4.x jobs: - docker: - image: ldcircleci/php-sdk-release:4 # Releaser's default for PHP is still php-sdk-release:3, which is PHP 7.x + image: ldcircleci/php-sdk-release:5 # Releaser's default for PHP is still php-sdk-release:3, which is PHP 7.x template: name: php diff --git a/README.md b/README.md index 4376af0d..0f1c3947 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ ## Supported PHP versions -This version of the LaunchDarkly SDK is compatible with PHP 8.0 and higher. +This version of the LaunchDarkly SDK is compatible with PHP 8.1 and higher. ## Getting started diff --git a/baseline.xml b/baseline.xml deleted file mode 100644 index b5f0a5f9..00000000 --- a/baseline.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - LDContext|LDUser - LDContext|LDUser - LDContext|LDUser - LDContext|LDUser - LDContext|LDUser - LDContext|LDUser - LDContext|LDUser - - - - - LDUser - - - - - LDUser - LDUser - - - diff --git a/composer.json b/composer.json index 6f50d356..19dbc8b4 100644 --- a/composer.json +++ b/composer.json @@ -14,17 +14,17 @@ } ], "require": { - "php": ">=8.0", + "php": ">=8.1", "monolog/monolog": "^2.0|^3.0", "psr/log": "^1.0|^2.0|^3.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.12.0", + "friendsofphp/php-cs-fixer": "^3.15.0", "guzzlehttp/guzzle": "^7", "kevinrob/guzzle-cache-middleware": "^4.0", "phpunit/php-code-coverage": "^9", "phpunit/phpunit": "^9", - "vimeo/psalm": "^4.9" + "vimeo/psalm": "^5.15" }, "suggest": { "guzzlehttp/guzzle": "(^6.3 | ^7) Required when using GuzzleEventPublisher or the default FeatureRequester", diff --git a/phpunit.xml b/phpunit.xml index fc621927..2394fcf0 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,5 +1,5 @@ - + diff --git a/psalm.xml b/psalm.xml index 8e1e03f6..4e9226bd 100644 --- a/psalm.xml +++ b/psalm.xml @@ -5,7 +5,6 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="https://getpsalm.org/schema/config" xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd" - errorBaseline="baseline.xml" > diff --git a/src/LaunchDarkly/EvaluationReason.php b/src/LaunchDarkly/EvaluationReason.php index a20ab080..7846f593 100644 --- a/src/LaunchDarkly/EvaluationReason.php +++ b/src/LaunchDarkly/EvaluationReason.php @@ -83,6 +83,13 @@ class EvaluationReason implements \JsonSerializable */ const EXCEPTION_ERROR = 'EXCEPTION'; + /** + * A possible value for getErrorKind(): indicates the value of the + * evaluation did not match the PHP type expected. + */ + + const WRONG_TYPE_ERROR = 'WRONG_TYPE'; + private string $_kind; private ?string $_errorKind; private ?int $_ruleIndex; diff --git a/src/LaunchDarkly/Impl/Events/EventFactory.php b/src/LaunchDarkly/Impl/Events/EventFactory.php index ab9a421d..1a422c47 100644 --- a/src/LaunchDarkly/Impl/Events/EventFactory.php +++ b/src/LaunchDarkly/Impl/Events/EventFactory.php @@ -50,7 +50,14 @@ public function newEvalEvent( 'default' => $default, 'version' => $flag->getVersion() ]; + // the following properties are handled separately so we don't waste bandwidth on unused keys + if ($flag->getExcludeFromSummaries()) { + $e['excludeFromSummaries'] = true; + } + if ($flag->getSamplingRatio() !== 1) { + $e['samplingRatio'] = $flag->getSamplingRatio(); + } if ($forceReasonTracking || $flag->isTrackEvents()) { $e['trackEvents'] = true; } @@ -66,33 +73,6 @@ public function newEvalEvent( return $e; } - /** - * @return mixed[] - */ - public function newDefaultEvent(FeatureFlag $flag, LDContext $context, EvaluationDetail $detail): array - { - $e = [ - 'kind' => 'feature', - 'creationDate' => Util::currentTimeUnixMillis(), - 'key' => $flag->getKey(), - 'context' => $context, - 'value' => $detail->getValue(), - 'default' => $detail->getValue(), - 'version' => $flag->getVersion() - ]; - // the following properties are handled separately so we don't waste bandwidth on unused keys - if ($flag->isTrackEvents()) { - $e['trackEvents'] = true; - } - if ($flag->getDebugEventsUntilDate()) { - $e['debugEventsUntilDate'] = $flag->getDebugEventsUntilDate(); - } - if ($this->_withReasons) { - $e['reason'] = $detail->getReason()->jsonSerialize(); - } - return $e; - } - /** * @return mixed[] */ @@ -124,7 +104,7 @@ public function newIdentifyEvent(LDContext $context): array 'context' => $context ]; } - + /** * @return mixed[] */ diff --git a/src/LaunchDarkly/Impl/Events/EventProcessor.php b/src/LaunchDarkly/Impl/Events/EventProcessor.php index ceae128a..17c06d6b 100644 --- a/src/LaunchDarkly/Impl/Events/EventProcessor.php +++ b/src/LaunchDarkly/Impl/Events/EventProcessor.php @@ -4,6 +4,7 @@ namespace LaunchDarkly\Impl\Events; +use LaunchDarkly\Impl\Util; use LaunchDarkly\Integrations\Curl; use LaunchDarkly\Subsystems\EventPublisher; @@ -21,7 +22,7 @@ class EventProcessor private int $_capacity; /** - * @psalm-param array{capacity: int} $options + * @param array $options */ public function __construct(string $sdkKey, array $options) { @@ -50,6 +51,13 @@ public function enqueue(array $event): bool return false; } + if (isset($event['samplingRatio'])) { + $samplingRatio = $event['samplingRatio']; + if (is_int($samplingRatio) && !Util::sample($samplingRatio)) { + return false; + } + } + $this->_queue[] = $event; return true; diff --git a/src/LaunchDarkly/Impl/Migrations/Executor.php b/src/LaunchDarkly/Impl/Migrations/Executor.php new file mode 100644 index 00000000..9e853be5 --- /dev/null +++ b/src/LaunchDarkly/Impl/Migrations/Executor.php @@ -0,0 +1,56 @@ +fn)($this->payload); + } catch (Exception $e) { + $result = Result::error($e->getMessage(), $e); + } + + if ($this->trackLatency) { + $this->tracker->latency($this->origin, Util::currentTimeUnixMillis() - $start); + } + + if ($this->trackErrors && !$result->isSuccessful()) { + $this->tracker->error($this->origin); + } + + $this->tracker->invoked($this->origin); + + return new OperationResult($this->origin, $result); + } +} diff --git a/src/LaunchDarkly/Impl/Model/FeatureFlag.php b/src/LaunchDarkly/Impl/Model/FeatureFlag.php index 05c16168..00c60b6a 100644 --- a/src/LaunchDarkly/Impl/Model/FeatureFlag.php +++ b/src/LaunchDarkly/Impl/Model/FeatureFlag.php @@ -34,6 +34,9 @@ class FeatureFlag protected bool $_trackEventsFallthrough = false; protected ?int $_debugEventsUntilDate = null; protected bool $_clientSide = false; + protected ?int $_samplingRatio = null; + protected bool $_excludeFromSummaries = false; + protected ?MigrationSettings $_migrationSettings = null; // Note, trackEvents and debugEventsUntilDate are not used in EventProcessor, because // the PHP client doesn't do summary events. However, we need to capture them in case @@ -55,7 +58,10 @@ public function __construct( bool $trackEvents, bool $trackEventsFallthrough, ?int $debugEventsUntilDate, - bool $clientSide + bool $clientSide, + ?int $samplingRatio, + bool $excludeFromSummaries, + ?MigrationSettings $migrationSettings, ) { $this->_key = $key; $this->_version = $version; @@ -73,6 +79,9 @@ public function __construct( $this->_trackEventsFallthrough = $trackEventsFallthrough; $this->_debugEventsUntilDate = $debugEventsUntilDate; $this->_clientSide = $clientSide; + $this->_samplingRatio = $samplingRatio; + $this->_excludeFromSummaries = $excludeFromSummaries; + $this->_migrationSettings = $migrationSettings; } /** @@ -82,8 +91,14 @@ public function __construct( */ public static function getDecoder(): \Closure { - return fn ($v) => - new FeatureFlag( + return function ($v) { + $migrationSettings = null; + + if (is_array($v['migration'] ?? null)) { + $migrationSettings = call_user_func(MigrationSettings::getDecoder(), $v['migration']); + } + + return new FeatureFlag( $v['key'], $v['version'], $v['on'], @@ -99,8 +114,12 @@ public static function getDecoder(): \Closure !!($v['trackEvents'] ?? false), !!($v['trackEventsFallthrough'] ?? false), $v['debugEventsUntilDate'] ?? null, - !!($v['clientSide'] ?? false) + !!($v['clientSide'] ?? false), + $v['samplingRatio'] ?? null, + !!($v['excludeFromSummaries'] ?? false), + $migrationSettings, ); + }; } public static function decode(array $v): self @@ -192,4 +211,19 @@ public function getVersion(): int { return $this->_version; } + + public function getSamplingRatio(): int + { + return $this->_samplingRatio ?? 1; + } + + public function getExcludeFromSummaries(): bool + { + return $this->_excludeFromSummaries; + } + + public function getMigrationSettings(): ?MigrationSettings + { + return $this->_migrationSettings; + } } diff --git a/src/LaunchDarkly/Impl/Model/MigrationSettings.php b/src/LaunchDarkly/Impl/Model/MigrationSettings.php new file mode 100644 index 00000000..bb8b6a58 --- /dev/null +++ b/src/LaunchDarkly/Impl/Model/MigrationSettings.php @@ -0,0 +1,30 @@ +checkRatio ?? 1; + } + + public static function getDecoder(): \Closure + { + return fn (array $v) => new MigrationSettings($v['checkRatio'] ?? null); + } +} diff --git a/src/LaunchDarkly/Impl/SemanticVersion.php b/src/LaunchDarkly/Impl/SemanticVersion.php index 365a1d38..226ff9fc 100644 --- a/src/LaunchDarkly/Impl/SemanticVersion.php +++ b/src/LaunchDarkly/Impl/SemanticVersion.php @@ -17,7 +17,7 @@ */ class SemanticVersion { - private static string $REGEX = '/^(?0|[1-9]\d*)(\.(?0|[1-9]\d*))?(\.(?0|[1-9]\d*))?(\-(?[0-9A-Za-z\-\.]+))?(\+(?[0-9A-Za-z\-\.]+))?$/'; + private const REGEX = '/^(?0|[1-9]\d*)(\.(?0|[1-9]\d*))?(\.(?0|[1-9]\d*))?(\-(?[0-9A-Za-z\-\.]+))?(\+(?[0-9A-Za-z\-\.]+))?$/'; public int $major; public int $minor; @@ -47,7 +47,7 @@ public function __construct( */ public static function parse(string $input, bool $loose = false): SemanticVersion { - if (!preg_match(self::$REGEX, $input, $matches)) { + if (!preg_match(self::REGEX, $input, $matches)) { throw new \InvalidArgumentException("not a valid semantic version"); } $major = intval($matches['major']); diff --git a/src/LaunchDarkly/Impl/Util.php b/src/LaunchDarkly/Impl/Util.php index 8f9f9ebc..f90f9876 100644 --- a/src/LaunchDarkly/Impl/Util.php +++ b/src/LaunchDarkly/Impl/Util.php @@ -21,6 +21,21 @@ */ class Util { + public static function sample(int $ratio): bool + { + if ($ratio === 0) { + return false; + } + + if ($ratio === 1) { + return true; + } + + $rand = mt_rand() / mt_getrandmax(); + + return $rand < (1 / $ratio); + } + public static function adjustBaseUri(string $uri): string { if (substr($uri, strlen($uri) - 1, 1) == '/') { @@ -31,7 +46,7 @@ public static function adjustBaseUri(string $uri): string public static function dateTimeToUnixMillis(DateTime $dateTime): int { - $timeStampSeconds = (int)$dateTime->getTimestamp(); + $timeStampSeconds = $dateTime->getTimestamp(); $timestampMicros = (int)$dateTime->format('u'); return $timeStampSeconds * 1000 + (int)($timestampMicros / 1000); } diff --git a/src/LaunchDarkly/Integrations/TestData/FlagBuilder.php b/src/LaunchDarkly/Integrations/TestData/FlagBuilder.php index 0a332bd0..3893aaf5 100644 --- a/src/LaunchDarkly/Integrations/TestData/FlagBuilder.php +++ b/src/LaunchDarkly/Integrations/TestData/FlagBuilder.php @@ -23,6 +23,9 @@ class FlagBuilder protected array $_variations; protected ?int $_offVariation; protected ?int $_fallthroughVariation; + protected ?MigrationSettingsBuilder $_migrationSettingsBuilder; + protected ?int $_samplingRatio; + protected bool $_excludeFromSummaries; // In _targets, each key is a context kind, and the value is another associative array where the key is a // variation index and the value is an array of context keys. @@ -39,6 +42,9 @@ public function __construct(string $key) $this->_fallthroughVariation = null; $this->_targets = []; $this->_rules = []; + $this->_samplingRatio = null; + $this->_excludeFromSummaries = false; + $this->_migrationSettingsBuilder = null; } /** @@ -71,6 +77,9 @@ public function copy(): FlagBuilder $to->_targets[$k] = $v; } $to->_rules = $this->_rules; + $to->_samplingRatio = $this->_samplingRatio; + $to->_excludeFromSummaries = $this->_excludeFromSummaries; + $to->_migrationSettingsBuilder = $this->_migrationSettingsBuilder; return $to; } @@ -138,6 +147,30 @@ public function fallthroughVariation(bool|int $variation): FlagBuilder return $this; } + public function migrationSettings(MigrationSettingsBuilder $builder): FlagBuilder + { + $this->_migrationSettingsBuilder = $builder; + return $this; + } + + /** + * Control the rate at which events from this flag will be sampled. + */ + public function samplingRatio(int $samplingRatio): FlagBuilder + { + $this->_samplingRatio = $samplingRatio; + return $this; + } + + /** + * Control whether or not this flag should should be included in flag summary counts. + */ + public function excludeFromSummaries(bool $excludeFromSummaries): FlagBuilder + { + $this->_excludeFromSummaries = $excludeFromSummaries; + return $this; + } + /** * Specifies the off variation for a boolean flag or index of variation. * This is the variation that is returned whenever targeting is off. @@ -155,19 +188,6 @@ public function offVariation(bool|int $variation): FlagBuilder return $this; } - /** - * Deprecated name for variationForAll. - * - * @param bool|int $variation `true` or `false` or the desired variation index to return: - * `0` for the first, `1` for the second, etc. - * @return FlagBuilder the flag builder - * @deprecated Use {@see \LaunchDarkly\Integrations\TestData\FlagBuilder::variationForAll()}. - */ - public function variationForAllUsers(bool|int $variation): FlagBuilder - { - return $this->variationForAll($variation); - } - /** * Sets the flag to always return the specified variation for all users. * @@ -190,18 +210,6 @@ public function variationForAll(bool|int $variation): FlagBuilder return $this->on(true)->clearRules()->clearTargets()->fallthroughVariation($variation); } - /** - * Deprecated name for valueForAll. - * - * @param mixed $value the desired value to be returned for all users - * @return FlagBuilder the flag builder - * @deprecated Use {@see \LaunchDarkly\Integrations\TestData\FlagBuilder::valueForAll()}. - */ - public function valueForAllUsers(mixed $value): FlagBuilder - { - return $this->valueForAll($value); - } - /** * Sets the flag to always return the specified variation value for all users. * @@ -425,17 +433,6 @@ public function clearRules(): FlagBuilder return $this; } - /** - * Deprecated name for clearTargets. - * - * @return FlagBuilder the same builder - * @deprecated Use {@see \LaunchDarkly\Integrations\TestData\FlagBuilder::clearTargets()}. - */ - public function clearUserTargets(): FlagBuilder - { - return $this->clearTargets(); - } - /** * Removes any existing targets for individual user/context keys from the flag. This undoes the effect of * the `variationForUser` and `variationForKey` methods. @@ -520,6 +517,11 @@ public function build(int $version): array $baseFlagObject['rules'][] = $rule->build($idx); } + $migrationSettings = $this->_migrationSettingsBuilder?->build() ?? []; + if (!empty($migrationSettings)) { + $baseFlagObject['migration'] = $migrationSettings; + } + $baseFlagObject['deleted'] = false; return $baseFlagObject; diff --git a/src/LaunchDarkly/Integrations/TestData/MigrationSettingsBuilder.php b/src/LaunchDarkly/Integrations/TestData/MigrationSettingsBuilder.php new file mode 100644 index 00000000..f8a7329c --- /dev/null +++ b/src/LaunchDarkly/Integrations/TestData/MigrationSettingsBuilder.php @@ -0,0 +1,28 @@ +checkRatio = $checkRatio; + return $this; + } + + /** + * Creates an associative array representation of the migration settings + * + * @return array the array representation of the migration settings + */ + public function build(): array + { + return [ + "checkRatio" => $this->checkRatio, + ]; + } +} diff --git a/src/LaunchDarkly/LDClient.php b/src/LaunchDarkly/LDClient.php index b5b03740..59455562 100644 --- a/src/LaunchDarkly/LDClient.php +++ b/src/LaunchDarkly/LDClient.php @@ -15,6 +15,8 @@ use LaunchDarkly\Impl\UnrecoverableHTTPStatusException; use LaunchDarkly\Impl\Util; use LaunchDarkly\Integrations\Guzzle; +use LaunchDarkly\Migrations\OpTracker; +use LaunchDarkly\Migrations\Stage; use LaunchDarkly\Subsystems\FeatureRequester; use LaunchDarkly\Types\ApplicationInfo; use Monolog\Handler\ErrorLogHandler; @@ -52,8 +54,6 @@ class LDClient /** * Creates a new client instance that connects to LaunchDarkly. * - * @psalm-param array{capacity?: int, defaults?: array} $options - * * @param string $sdkKey The SDK key for your account * @param array $options Client configuration settings * - `base_uri`: Base URI of the LaunchDarkly service. Change this if you are connecting to a Relay Proxy instance instead of @@ -153,6 +153,11 @@ public function __construct(string $sdkKey, array $options = []) $this->_evaluator = new Evaluator($this->_featureRequester, $this->_logger); } + public function getLogger(): LoggerInterface + { + return $this->_logger; + } + /** * @param string $sdkKey * @param mixed[] $options @@ -189,14 +194,14 @@ private function getFeatureRequester(string $sdkKey, array $options): FeatureReq * does not match any existing flag), `$defaultValue` is returned. * * @param string $key the unique key for the feature flag - * @param LDContext|LDUser $context the evaluation context or user + * @param LDContext $context the evaluation context * @param mixed $defaultValue the default value of the flag * @return mixed The variation for the given context, or `$defaultValue` if the flag cannot be evaluated * @see \LaunchDarkly\LDClient::variationDetail() */ - public function variation(string $key, LDContext|LDUser $context, mixed $defaultValue = false): mixed + public function variation(string $key, LDContext $context, mixed $defaultValue = false): mixed { - $detail = $this->variationDetailInternal($key, $context, $defaultValue, $this->_eventFactoryDefault); + $detail = $this->variationDetailInternal($key, $context, $defaultValue, $this->_eventFactoryDefault)['detail']; return $detail->getValue(); } @@ -208,28 +213,82 @@ public function variation(string $key, LDContext|LDUser $context, mixed $default * detailed event data for this flag. * * @param string $key the unique key for the feature flag - * @param LDContext|LDUser $context the evaluation context or user + * @param LDContext $context the evaluation context * @param mixed $defaultValue the default value of the flag * * @return EvaluationDetail An EvaluationDetail object that includes the feature flag value * and evaluation reason */ - public function variationDetail(string $key, LDContext|LDUser $context, mixed $defaultValue = false): EvaluationDetail + public function variationDetail(string $key, LDContext $context, mixed $defaultValue = false): EvaluationDetail { - return $this->variationDetailInternal($key, $context, $defaultValue, $this->_eventFactoryWithReasons); + return $this->variationDetailInternal($key, $context, $defaultValue, $this->_eventFactoryWithReasons)['detail']; + } + + /** + * This method returns the migration stage of the migration feature flag + * for the given evaluation context. + * + * This method returns the default stage if there is an error or the flag + * does not exist. If the default stage is not a valid stage, then a + * default stage of {@see Stage::OFF} will be used + * instead. + * + * @psalm-return array{'stage': Stage, 'tracker': OpTracker} + */ + public function migrationVariation(string $key, LDContext $context, Stage $defaultStage): array + { + $result = $this->variationDetailInternal($key, $context, $defaultStage->value, $this->_eventFactoryDefault); + /** @var EvaluationDetail $detail */ + $detail = $result['detail']; + /** @var ?FeatureFlag $flag */ + $flag = $result['flag']; + + $value = $detail->getValue(); + $valueAsString = null; + if (is_string($value)) { + $valueAsString = Stage::tryFrom($detail->getValue()); + } + + if ($valueAsString !== null) { + $tracker = new OpTracker( + $this->_logger, + $key, + $flag, + $context, + $detail, + $defaultStage + ); + + return ['stage' => $valueAsString, 'tracker' => $tracker]; + } + + $detail = new EvaluationDetail( + $defaultStage->value, + null, + EvaluationReason::error(EvaluationReason::WRONG_TYPE_ERROR) + ); + $tracker = new OpTracker( + $this->_logger, + $key, + $flag, + $context, + $detail, + $defaultStage + ); + + return ['stage' => $defaultStage, 'tracker' => $tracker]; } /** * @param string $key - * @param LDContext|LDUser $contextOrUser + * @param LDContext $context * @param mixed $default * @param EventFactory $eventFactory * - * @return EvaluationDetail + * @psalm-return array{'detail': EvaluationDetail, 'flag': ?FeatureFlag} */ - private function variationDetailInternal(string $key, LDContext|LDUser $contextOrUser, mixed $default, EventFactory $eventFactory): EvaluationDetail + private function variationDetailInternal(string $key, LDContext $context, mixed $default, EventFactory $eventFactory): array { - $context = $contextOrUser instanceof LDUser ? LDContext::fromUser($contextOrUser) : $contextOrUser; $default = $this->_get_default($key, $default); $errorDetail = fn (string $errorKind): EvaluationDetail => @@ -255,25 +314,26 @@ private function variationDetailInternal(string $key, LDContext|LDUser $contextO ] ); - return $result; + return ['detail' => $result, 'flag' => null]; } if ($this->_offline) { - return $errorDetail(EvaluationReason::CLIENT_NOT_READY_ERROR); + return ['detail' => $errorDetail(EvaluationReason::CLIENT_NOT_READY_ERROR), 'flag' => null]; } + $flag = null; try { try { $flag = $this->_featureRequester->getFeature($key); } catch (UnrecoverableHTTPStatusException $e) { $this->handleUnrecoverableError(); - return $errorDetail(EvaluationReason::EXCEPTION_ERROR); + return ['detail' => $errorDetail(EvaluationReason::EXCEPTION_ERROR), 'flag' => null]; } if (is_null($flag)) { $result = $errorDetail(EvaluationReason::FLAG_NOT_FOUND_ERROR); $sendEvent(new EvalResult($result, false), null); - return $result; + return ['detail' => $result, 'flag' => null]; } $evalResult = $this->_evaluator->evaluate( $flag, @@ -295,12 +355,12 @@ function (PrerequisiteEvaluationRecord $pe) use ($context, $eventFactory) { $evalResult = new EvalResult($detail, $evalResult->isForceReasonTracking()); } $sendEvent($evalResult, $flag); - return $detail; + return ['detail' => $detail, 'flag' => $flag]; } catch (\Exception $e) { Util::logExceptionAtErrorLevel($this->_logger, $e, "Unexpected error evaluating flag $key"); $result = $errorDetail(EvaluationReason::EXCEPTION_ERROR); $sendEvent(new EvalResult($result, false), null); - return $result; + return ['detail' => $result, 'flag' => $flag]; } } @@ -323,14 +383,13 @@ public function isOffline(): bool * see {@see \LaunchDarkly\LDClient::flush()}. * * @param string $eventName The name of the event - * @param LDContext|LDUser $context The evaluation context or user associated with the event + * @param LDContext $context The evaluation context or user associated with the event * @param mixed $data Optional additional information to associate with the event * @param int|float|null $metricValue A numeric value used by the LaunchDarkly experimentation feature in * numeric custom metrics; can be omitted if this event is used by only non-numeric metrics */ - public function track(string $eventName, LDContext|LDUser $context, mixed $data = null, int|float|null $metricValue = null): void + public function track(string $eventName, LDContext $context, mixed $data = null, int|float|null $metricValue = null): void { - $context = $context instanceof LDUser ? LDContext::fromUser($context) : $context; if (!$context->isValid()) { $this->_logger->warning("Track called with null/empty user key!"); return; @@ -339,7 +398,31 @@ public function track(string $eventName, LDContext|LDUser $context, mixed $data } /** - * Reports details about an evaluation context or user. + * Tracks the results of a migrations operation. This event includes + * measurements which can be used to enhance the observability of a + * migration within the LaunchDarkly UI. + * + * Customers making use of the {@see + * LaunchDarkly\Migrations\MigrationBuilder} should not need to call this + * method manually. + * + * Customers not using the builder should provide this method with the + * tracker returned from calling {@ LDClient::migrationVariation}. + */ + public function trackMigrationOperation(OpTracker $tracker): void + { + $event = $tracker->build(); + + if (is_string($event)) { + $this->_logger->error("error generating migration op event {$event}; no event will be emitted"); + return; + } + + $this->_eventProcessor->enqueue($event); + } + + /** + * Reports details about an evaluation context. * * This method simply creates an analytics event containing the context properties, to * that LaunchDarkly will know about that context if it does not already. @@ -349,12 +432,11 @@ public function track(string $eventName, LDContext|LDUser $context, mixed $data * the context information to LaunchDarkly (if events are enabled), so you only need to use * identify() if you want to identify the context without evaluating a flag. * - * @param LDContext|LDUser $context The context or user to register + * @param LDContext $context The context to register * @return void */ - public function identify(LDContext|LDUser $context): void + public function identify(LDContext $context): void { - $context = $context instanceof LDUser ? LDContext::fromUser($context) : $context; if (!$context->isValid()) { $this->_logger->warning("Identify called with null/empty user key!"); return; @@ -371,7 +453,7 @@ public function identify(LDContext|LDUser $context): void * * This method does not send analytics events back to LaunchDarkly. * - * @param LDContext|LDUser $context the evalation context or user + * @param LDContext $context the evalation context * @param array $options Optional properties affecting how the state is computed: * - `clientSideOnly`: Set this to true to specify that only flags marked for client-side use * should be included; by default, all flags are included @@ -383,9 +465,8 @@ public function identify(LDContext|LDUser $context): void * * @return FeatureFlagsState a FeatureFlagsState object (will never be null) */ - public function allFlagsState(LDContext|LDUser $context, array $options = []): FeatureFlagsState + public function allFlagsState(LDContext $context, array $options = []): FeatureFlagsState { - $context = $context instanceof LDUser ? LDContext::fromUser($context) : $context; if (!$context->isValid()) { $error = $context->getError(); $this->_logger->warning("Invalid context for allFlagsState ($error); returning empty state"); @@ -428,12 +509,11 @@ public function allFlagsState(LDContext|LDUser $context, array $options = []): F * * See: [Secure mode](https://docs.launchdarkly.com/sdk/features/secure-mode) * - * @param LDContext|LDUser $context The evaluation context or user + * @param LDContext $context The evaluation context * @return string The hash value */ - public function secureModeHash(LDContext|LDUser $context): string + public function secureModeHash(LDContext $context): string { - $context = $context instanceof LDUser ? LDContext::fromUser($context) : $context; if (!$context->isValid()) { return ""; } diff --git a/src/LaunchDarkly/LDContext.php b/src/LaunchDarkly/LDContext.php index 4477f6f4..b7203a0b 100644 --- a/src/LaunchDarkly/LDContext.php +++ b/src/LaunchDarkly/LDContext.php @@ -6,33 +6,10 @@ use LaunchDarkly\Types\AttributeReference; -function isAllowableUserCustomAttr(string $name): bool -{ - switch ($name) { - case 'anonymous': - case 'avatar': - case 'country': - case 'email': - case 'firstName': - case 'ip': - case 'key': - case 'kind': - case 'lastName': - case 'name': - return false; - default: - return true; - } -} - /** * A collection of attributes that can be referenced in flag evaluations and analytics events. * This entity is also called an "evaluation context." * - * LDContext is the newer replacement for the previous, less flexible {@see \LaunchDarkly\LDUser} type. - * The current SDK still supports LDUser, but LDContext is now the preferred model and may entirely - * replace User in the future. - * * To create an LDContext of a single kind, such as a user, you may use * {@see \LaunchDarkly\LDContext::create()} when only the key and the kind are relevant; or, to * specify other attributes, use {@see \LaunchDarkly\LDContext::builder()}. @@ -220,60 +197,6 @@ public static function createMulti(LDContext ...$contexts): LDContext return $b->build(); } - /** - * @param LDUser $user - * @return LDContext - */ - public static function fromUser(LDUser $user): LDContext - { - $attrs = null; - self::maybeAddAttr($attrs, "avatar", $user->getAvatar()); - self::maybeAddAttr($attrs, "country", $user->getCountry()); - self::maybeAddAttr($attrs, "email", $user->getEmail()); - self::maybeAddAttr($attrs, "firstName", $user->getFirstName()); - self::maybeAddAttr($attrs, "ip", $user->getIP()); - self::maybeAddAttr($attrs, "lastName", $user->getLastName()); - $userCustom = $user->getCustom(); - if ($userCustom !== null && count($userCustom) !== 0) { - if ($attrs === null) { - $attrs = []; - } - foreach ($userCustom as $k => $v) { - if (isAllowableUserCustomAttr($k)) { - $attrs[$k] = $v; - } - } - } - $privateAttrs = null; - $userPrivate = $user->getPrivateAttributeNames(); - if ($userPrivate !== null && count($userPrivate) !== 0) { - $privateAttrs = []; - foreach ($userPrivate as $pa) { - $privateAttrs[] = AttributeReference::fromLiteral($pa); - } - } - return new LDContext( - self::DEFAULT_KIND, - $user->getKey(), - $user->getName(), - $user->getAnonymous() ?? false, - $attrs, - $privateAttrs, - null, - null - ); - } - - private static function maybeAddAttr(?array &$attrsOut, string $name, ?string $value): void - { - if ($value !== null) { - if ($attrsOut === null) { - $attrsOut = []; - } - $attrsOut[$name] = $value; - } - } - /** * Creates a builder for building an LDContext. * @@ -323,10 +246,7 @@ public static function multiBuilder(): LDContextMultiBuilder /** * Creates an LDContext from a parsed JSON representation. * - * The JSON must be in one of the standard formats used by LaunchDarkly. This can either be a - * context representation similar to what would be produced by {@see \LaunchDarkly\LDContext::jsonSerialize()}, - * or a user representation in the format used by older LaunchDarkly SDKs. A user representation - * does not have a `kind` property and will be converted to a context with the kind "user". + * The JSON must be in one of the standard formats used by LaunchDarkly. * * ```php * $json = '{"kind": "user", "key": "aaa"}'; @@ -359,9 +279,6 @@ public static function fromJson($jsonObject): LDContext $a = (array)$o; $kind = $a['kind'] ?? null; - if ($kind === null) { - return self::decodeJsonOldUser($a, is_array($o)); - } if ($kind === self::MULTI_KIND) { $b = self::multiBuilder(); foreach ($a as $k => $v) { @@ -453,6 +370,30 @@ public function getKind(): string return $this->_kind; } + /** + * Returns an associate array mapping each context kind to its key. + * + * If the context is invalid, this will return an empty array. A single + * kind context will return an array with a single mapping. + */ + public function getKeys(): array + { + if (!$this->isValid()) { + return []; + } + + if ($this->_multiContexts !== null) { + $result = []; + foreach ($this->_multiContexts as $context) { + $result[$context->getKind()] = $context->getKey(); + } + + return $result; + } + + return [$this->getKind() => $this->getKey()]; + } + /** * Returns the context's `key` attribute. * @@ -829,76 +770,6 @@ private static function decodeJsonSingleKind(array $o, ?string $kind): LDContext return $b->build(); } - private static function decodeJsonOldUser(array $o, bool $wasParsedAsArray): LDContext - { - $b = self::builder(''); - $key = null; - foreach ($o as $k => $v) { - switch ($k) { - case 'custom': - if ($v !== null) { - if (!is_object($v) && !($wasParsedAsArray && is_array($v))) { - // The reason for the awkward test expression above is that if the JSON was parsed - // as an associative array, there is no way for us to distinguish {} from [] so we - // can't safely say that [] is invalid. - throw self::parsingBadTypeError($k); - } - foreach ((array)$v as $k1 => $v1) { - if (isAllowableUserCustomAttr($k1)) { - $b->set($k1, $v1); - } - } - } - break; - case 'privateAttributeNames': - if ($v !== null) { - if (!is_array($v)) { - throw self::parsingBadTypeError($k); - } - foreach ($v as $p) { - $b->private($p); - } - } - break; - case 'avatar': - case 'country': - case 'email': - case 'firstName': - case 'ip': - case 'lastName': - // These used to be built-in attributes with a string type constraint, so even though - // the new context model has no such constraint, we enforce it when parsing user JSON - if ($v !== null && !is_string($v)) { - throw self::parsingBadTypeError($k); - } - $b->set($k, $v); - break; - case 'anonymous': - // Special case where the old user model allowed anonymous to be null; we don't now, - // so treat null the same as false - if ($v !== null && !is_bool($v)) { - throw self::parsingBadTypeError($k); - } - $b->set($k, !!$v); - break; - default: - if (!$b->trySet($k, $v)) { - throw self::parsingBadTypeError($k); - } - if ($k === 'key') { - $key = $v; - } - } - } - if ($key === '') { - // The context builder won't allow an empty key, but it is allowed in the old user model. - $c = $b->key('x')->build(); - $c->_key = ''; - return $c; - } - return $b->build(); - } - private static function validateKind(string $kind): ?string { switch ($kind) { diff --git a/src/LaunchDarkly/LDContextMultiBuilder.php b/src/LaunchDarkly/LDContextMultiBuilder.php index c31b0e82..51ceb090 100644 --- a/src/LaunchDarkly/LDContextMultiBuilder.php +++ b/src/LaunchDarkly/LDContextMultiBuilder.php @@ -7,7 +7,7 @@ /** * A mutable object that uses the builder pattern to specify properties for a multi-context. * - * Use this builder if you need to construct an {@see \LaunchDarkly\LDContext) that contains + * Use this builder if you need to construct an {@see \LaunchDarkly\LDContext} that contains * multiple contexts, each for a different context kind. To define a regular context for a * single kind, use {@see \LaunchDarkly\LDContext::create()} or * {@see \LaunchDarkly\LDContext::builder()}. diff --git a/src/LaunchDarkly/LDUser.php b/src/LaunchDarkly/LDUser.php deleted file mode 100644 index d49b8dd1..00000000 --- a/src/LaunchDarkly/LDUser.php +++ /dev/null @@ -1,181 +0,0 @@ -_key = $key; - $this->_ip = $ip; - $this->_country = $country; - $this->_email = $email; - $this->_name = $name; - $this->_avatar = $avatar; - $this->_firstName = $firstName; - $this->_lastName = $lastName; - $this->_anonymous = $anonymous; - $this->_custom = $custom; - $this->_privateAttributeNames = $privateAttributeNames; - } - - /** - * Used internally in flag evaluation. - * @ignore - * @return mixed - */ - public function getValueForEvaluation(?string $attr): mixed - { - if (is_null($attr)) { - return null; - } - switch ($attr) { - case "key": - return $this->_key; - case "ip": - return $this->_ip; - case "country": - return $this->_country; - case "email": - return $this->_email; - case "name": - return $this->_name; - case "avatar": - return $this->_avatar; - case "firstName": - return $this->_firstName; - case "lastName": - return $this->_lastName; - case "anonymous": - return $this->_anonymous; - default: - if ($this->_custom === null) { - return null; - } - return $this->_custom[$attr] ?? null; - } - } - - public function getCountry(): ?string - { - return $this->_country; - } - - public function getCustom(): ?array - { - return $this->_custom; - } - - public function getIP(): ?string - { - return $this->_ip; - } - - public function getKey(): string - { - return $this->_key; - } - - public function getEmail(): ?string - { - return $this->_email; - } - - public function getName(): ?string - { - return $this->_name; - } - - public function getAvatar(): ?string - { - return $this->_avatar; - } - - public function getFirstName(): ?string - { - return $this->_firstName; - } - - public function getLastName(): ?string - { - return $this->_lastName; - } - - public function getAnonymous(): ?bool - { - return $this->_anonymous; - } - - public function getPrivateAttributeNames(): ?array - { - return $this->_privateAttributeNames; - } - - public function isKeyBlank(): bool - { - return empty($this->_key); - } -} diff --git a/src/LaunchDarkly/LDUserBuilder.php b/src/LaunchDarkly/LDUserBuilder.php deleted file mode 100644 index 20fa00e8..00000000 --- a/src/LaunchDarkly/LDUserBuilder.php +++ /dev/null @@ -1,268 +0,0 @@ -_key = $key; - } - - /** - * Sets the user's IP address attribute. - * @param string|null $ip The IP address - * @return LDUserBuilder the same builder - */ - public function ip(?string $ip): LDUserBuilder - { - $this->_ip = $ip; - return $this; - } - - /** - * Sets the user's IP address attribute, and marks it as private. - * @param string|null $ip The IP address - * @return LDUserBuilder the same builder - */ - public function privateIp(?string $ip): LDUserBuilder - { - $this->_privateAttributeNames[] = 'ip'; - return $this->ip($ip); - } - - /** - * Sets the user's country attribute. - * - * This may be an ISO 3166-1 country code, or any other value you wish; it is not validated. - * @param string|null $country The country - * @return LDUserBuilder the same builder - */ - public function country(?string $country): LDUserBuilder - { - $this->_country = $country; - return $this; - } - - /** - * Sets the user's country attribute, and marks it as private. - * - * This may be an ISO 3166-1 country code, or any other value you wish; it is not validated. - * @param string|null $country The country - * @return LDUserBuilder the same builder - */ - public function privateCountry(?string $country): LDUserBuilder - { - $this->_privateAttributeNames[] = 'country'; - return $this->country($country); - } - - /** - * Sets the user's email address attribute. - * @param string|null $email The email address - * @return LDUserBuilder the same builder - */ - public function email(?string $email): LDUserBuilder - { - $this->_email = $email; - return $this; - } - - /** - * Sets the user's email address attribute, and marks it as private. - * @param string|null $email The email address - * @return LDUserBuilder the same builder - */ - public function privateEmail(?string $email): LDUserBuilder - { - $this->_privateAttributeNames[] = 'email'; - return $this->email($email); - } - - /** - * Sets the user's full name attribute. - * @param string|null $name The full name - * @return LDUserBuilder the same builder - */ - public function name(?string $name): LDUserBuilder - { - $this->_name = $name; - return $this; - } - - /** - * Sets the user's full name attribute, and marks it as private. - * @param string|null $name The full name - * @return LDUserBuilder the same builder - */ - public function privateName(?string $name): LDUserBuilder - { - $this->_privateAttributeNames[] = 'name'; - return $this->name($name); - } - - /** - * Sets the user's avatar URL attribute. - * @param string|null $avatar The avatar URL - * @return LDUserBuilder the same builder - */ - public function avatar(?string $avatar) - { - $this->_avatar = $avatar; - return $this; - } - - /** - * Sets the user's avatar URL attribute, and marks it as private. - * @param string|null $avatar The avatar URL - * @return LDUserBuilder the same builder - */ - public function privateAvatar(?string $avatar): LDUserBuilder - { - $this->_privateAttributeNames[] = 'avatar'; - return $this->avatar($avatar); - } - - /** - * Sets the user's first name attribute. - * @param string|null $firstName The first name - * @return LDUserBuilder the same builder - */ - public function firstName(?string $firstName): LDUserBuilder - { - $this->_firstName = $firstName; - return $this; - } - - /** - * Sets the user's first name attribute, and marks it as private. - * @param string|null $firstName The first name - * @return LDUserBuilder the same builder - */ - public function privateFirstName(?string $firstName): LDUserBuilder - { - $this->_privateAttributeNames[] = 'firstName'; - return $this->firstName($firstName); - } - - /** - * Sets the user's last name attribute. - * @param string|null $lastName The last name - * @return LDUserBuilder the same builder - */ - public function lastName(?string $lastName): LDUserBuilder - { - $this->_lastName = $lastName; - return $this; - } - - /** - * Sets the user's last name attribute, and marks it as private. - * @param string|null $lastName The last name - * @return LDUserBuilder the same builder - */ - public function privateLastName(?string $lastName): LDUserBuilder - { - $this->_privateAttributeNames[] = 'lastName'; - return $this->lastName($lastName); - } - - /** - * Sets whether this user is anonymous. - * - * The default is false. - * @param bool|null $anonymous True if the user should not appear on the LaunchDarkly dashboard - * @return LDUserBuilder the same builder - */ - public function anonymous(?bool $anonymous): LDUserBuilder - { - $this->_anonymous = $anonymous; - return $this; - } - - /** - * Sets any number of custom attributes for the user. - * - * @param array $custom An associative array of custom attribute names and values. - * @return LDUserBuilder the same builder - */ - public function custom(array $custom): LDUserBuilder - { - $this->_custom = $custom; - return $this; - } - - /** - * Sets a single custom attribute for the user. - * - * @param string $customKey The attribute name - * @param mixed $customValue The attribute value - * @return LDUserBuilder the same builder - */ - public function customAttribute(string $customKey, mixed $customValue): LDUserBuilder - { - $this->_custom[$customKey] = $customValue; - return $this; - } - - /** - * Sets a single custom attribute for the user, and marks it as private. - * - * @param string $customKey The attribute name - * @param mixed $customValue The attribute value - * @return LDUserBuilder the same builder - */ - public function privateCustomAttribute(string $customKey, mixed $customValue): LDUserBuilder - { - $this->_privateAttributeNames[] = $customKey; - return $this->customAttribute($customKey, $customValue); - } - - /** - * Creates the LDUser instance based on the builder's current properties. - * @return LDUser the user - */ - public function build(): LDUser - { - return new LDUser( - $this->_key, - null, - $this->_ip, - $this->_country, - $this->_email, - $this->_name, - $this->_avatar, - $this->_firstName, - $this->_lastName, - $this->_anonymous, - $this->_custom, - $this->_privateAttributeNames - ); - } -} diff --git a/src/LaunchDarkly/Migrations/ExecutionOrder.php b/src/LaunchDarkly/Migrations/ExecutionOrder.php new file mode 100644 index 00000000..ec7e661d --- /dev/null +++ b/src/LaunchDarkly/Migrations/ExecutionOrder.php @@ -0,0 +1,26 @@ +client->migrationVariation($key, $context, $defaultStage); + /** @var Stage */ + $stage = $variationResult['stage']; + /** @var OpTracker */ + $tracker = $variationResult['tracker']; + $tracker->operation(Operation::READ); + + $old = new Executor(Origin::OLD, $this->readConfig->old, $tracker, $this->trackLatency, $this->trackErrors, $payload); + $new = new Executor(Origin::NEW, $this->readConfig->new, $tracker, $this->trackLatency, $this->trackErrors, $payload); + + $result = match ($stage) { + Stage::OFF => $old->run(), + Stage::DUALWRITE => $old->run(), + Stage::SHADOW => $this->readBoth($old, $new, $tracker), + Stage::LIVE => $this->readBoth($new, $old, $tracker), + Stage::RAMPDOWN => $new->run(), + Stage::COMPLETE => $new->run(), + }; + + $this->client->trackMigrationOperation($tracker); + + return $result; + } + + /** + * Uses the provided flag key and context to execute a migration-backed write operation. + */ + public function write( + string $key, + LDContext $context, + Stage $defaultStage, + mixed $payload = null + ): WriteResult { + $variationResult = $this->client->migrationVariation($key, $context, $defaultStage); + /** @var Stage */ + $stage = $variationResult['stage']; + /** @var OpTracker */ + $tracker = $variationResult['tracker']; + $tracker->operation(Operation::WRITE); + + $old = new Executor(Origin::OLD, $this->writeConfig->old, $tracker, $this->trackLatency, $this->trackErrors, $payload); + $new = new Executor(Origin::NEW, $this->writeConfig->new, $tracker, $this->trackLatency, $this->trackErrors, $payload); + + $writeResult = match ($stage) { + Stage::OFF => new WriteResult($old->run()), + Stage::DUALWRITE => $this->writeBoth($old, $new, $tracker), + Stage::SHADOW => $this->writeBoth($old, $new, $tracker), + Stage::LIVE => $this->writeBoth($new, $old, $tracker), + Stage::RAMPDOWN => $this->writeBoth($new, $old, $tracker), + Stage::COMPLETE => new WriteResult($new->run()), + }; + + $this->client->trackMigrationOperation($tracker); + + return $writeResult; + } + + private function readBoth(Executor $authoritative, Executor $nonauthoritative, OpTracker $tracker): OperationResult + { + if ($this->executionOrder == ExecutionOrder::RANDOM && Util::sample(2)) { + $nonauthoritativeResult = $nonauthoritative->run(); + $authoritativeResult = $authoritative->run(); + } else { + $authoritativeResult = $authoritative->run(); + $nonauthoritativeResult = $nonauthoritative->run(); + } + + if ($this->readConfig->comparison === null) { + return $authoritativeResult; + } + + if ($authoritativeResult->isSuccessful() && $nonauthoritativeResult->isSuccessful()) { + $tracker->consistent(fn (): bool => ($this->readConfig->comparison)($authoritativeResult->value, $nonauthoritativeResult->value)); + } + + return $authoritativeResult; + } + + private function writeBoth(Executor $authoritative, Executor $nonauthoritative, OpTracker $tracker): WriteResult + { + $authoritativeResult = $authoritative->run(); + $tracker->invoked($authoritative->origin); + + if (!$authoritativeResult->isSuccessful()) { + return new WriteResult($authoritativeResult); + } + + $nonauthoritativeResult = $nonauthoritative->run(); + $tracker->invoked($nonauthoritative->origin); + + return new WriteResult($authoritativeResult, $nonauthoritativeResult); + } +} diff --git a/src/LaunchDarkly/Migrations/MigratorBuilder.php b/src/LaunchDarkly/Migrations/MigratorBuilder.php new file mode 100644 index 00000000..887899ae --- /dev/null +++ b/src/LaunchDarkly/Migrations/MigratorBuilder.php @@ -0,0 +1,143 @@ +readExecutionOrder = $order; + return $this; + } + + /** + * Enable or disable latency tracking for migration operations. This + * latency information can be sent upstream to LaunchDarkly to enhance + * migration visibility. + */ + public function trackLatency(bool $track): MigratorBuilder + { + $this->trackLatency = $track; + return $this; + } + + /** + * Enable or disable error tracking for migration operations. This error + * information can be sent upstream to LaunchDarkly to enhance migration + * visibility. + */ + public function trackErrors(bool $track): MigratorBuilder + { + $this->trackErrors = $track; + return $this; + } + + /** + * Read can be used to configure the migration-read behavior of the + * resulting migrator instance. + * + * Users are required to provide two different read methods -- one to read + * from the old migration origin, and one to read from the new origin. + * Additionally, customers can opt-in to consistency tracking by providing + * a comparison function. + * + * Depending on the migration stage, one or both of these read methods may + * be called. + * + * The read methods should accept a single nullable parameter. This + * parameter is a payload passed through the {@see Migrator.read()} method. + * This method should return a {@see Result} instance. + * + * The consistency method should accept 2 parameters of any type. These + * parameters are the results of executing the read operation against the + * old and new origins. If both operations were successful, the + * consistency method will be invoked. This method should return true if + * the two parameters are equal, or false otherwise. + * + * @param Closure(mixed): Result $old + * @param Closure(mixed): Result $new + * @param Closure(mixed,mixed): bool $comparison + */ + public function read(Closure $old, Closure $new, ?Closure $comparison = null): MigratorBuilder + { + $this->readConfig = new MigrationConfig($old, $new, $comparison); + return $this; + } + + /** + * Write can be used to configure the migration-write behavior of the + * resulting :class:`Migrator` instance. + * + * Users are required to provide two different write methods -- one to + * write to the old migration origin, and one to write to the new origin. + * + * Depending on the migration stage, one or both of these write methods may + * be called. + * + * The write methods should accept a single nullable parameter. This + * parameter is a payload passed through the {@see Migrator.write()} method. + * This method should return a {@see Result} instance. + * + * @param Closure(mixed): Result $old + * @param Closure(mixed): Result $new + */ + public function write(Closure $old, Closure $new): MigratorBuilder + { + $this->writeConfig = new MigrationConfig($old, $new); + return $this; + } + + /** + * Build constructs a Migrator instance to support migration-based + * reads and writes. + */ + public function build(): Result + { + if ($this->readConfig === null) { + return Result::error('read configuration not provided'); + } + + if ($this->writeConfig === null) { + return Result::error('write configuration not provided'); + } + + return Result::success( + new Migrator( + $this->client, + $this->readExecutionOrder, + $this->readConfig, + $this->writeConfig, + $this->trackLatency, + $this->trackErrors, + ) + ); + } +} diff --git a/src/LaunchDarkly/Migrations/OpTracker.php b/src/LaunchDarkly/Migrations/OpTracker.php new file mode 100644 index 00000000..d8e57883 --- /dev/null +++ b/src/LaunchDarkly/Migrations/OpTracker.php @@ -0,0 +1,225 @@ +consistentRatio = $flag?->getMigrationSettings()?->getCheckRatio() ?? 1; + } + + + + /** + * Sets the migration related Operation associated with these tracking measurements. + */ + public function operation(Operation $operation): OpTracker + { + $this->operation = $operation; + return $this; + } + + /** + * Allows recording which {@see Origin}s were called during a migration. + */ + public function invoked(Origin $origin): OpTracker + { + $this->invoked[$origin->value] = true; + return $this; + } + + + /** + * Allows recording the results of a consistency check. + * + * This method accepts a callable which should take no parameters and return + * a single boolean to represent the consistency check results for a read + * operation. + * + * A callable is provided in case sampling rules do not require consistency + * checking to run. In this case, we can avoid the overhead of a function by + * not using the callable. + * + * @param callable $isConsistent Callable that accepts 0 parameters and must return a boolean + */ + public function consistent(callable $isConsistent): OpTracker + { + if (!Util::sample($this->consistentRatio)) { + return $this; + } + + try { + $this->consistent = boolval($isConsistent()); + } catch (Exception $e) { + $msg = $e->getMessage(); + $this->logger->error("exception raised during consistency check $msg; failed to record measurement"); + } + + return $this; + } + + /** + * Allows recording whether an error occurred during the operation. + */ + public function error(Origin $origin): OpTracker + { + $this->errors[$origin->value] = true; + return $this; + } + + + /** + * Allows tracking the recorded latency for an individual operation. + */ + public function latency(Origin $origin, float $elapsedMs): OpTracker + { + $this->latencies[$origin->value] = $elapsedMs; + return $this; + } + + + /** + * Returns an array representing a migration operation event. + * + * This event data can be provided to {@see + * \LaunchDarkly\LDClient::trackMigrationOp()} to relay this metric + * information upstream to LaunchDarkly services. + * + * @return array|string + */ + public function build(): array|string + { + if (!$this->operation) { + return "operation not provided"; + } elseif (strlen($this->key) === 0) { + return "migration operation cannot contain an empty key"; + } elseif (count($this->invoked) === 0) { + return "no origins were invoked"; + } elseif (!$this->context->isValid()) { + return "provided context was invalid"; + } + + $error = $this->checkInvokedConsistency(); + if ($error !== null) { + return $error; + } + + $event = [ + 'kind' => 'migration_op', + 'creationDate' => Util::currentTimeUnixMillis(), + 'contextKeys' => $this->context->getKeys(), + 'operation' => $this->operation->value, + 'evaluation' => [ + 'key' => $this->key, + 'value' => $this->detail->getValue(), + 'default' => $this->default_stage->value, + 'reason' => $this->detail->getReason()->jsonSerialize(), + ], + + 'measurements' => [ + [ + 'key' => 'invoked', + 'values' => $this->invoked, + ] + ], + ]; + + if ($this->flag) { + $event['evaluation']['version'] = $this->flag->getVersion(); + + if ($this->flag->getSamplingRatio() !== 1) { + $event['samplingRatio'] = $this->flag->getSamplingRatio(); + } + } + + if ($this->detail->getVariationIndex() !== null) { + $event['evaluation']['variation'] = $this->detail->getVariationIndex(); + } + + if ($this->consistent !== null) { + $measurement = [ + 'key' => 'consistent', + 'value' => $this->consistent, + ]; + + if ($this->consistentRatio !== 1) { + $measurement['samplingRatio'] = $this->consistentRatio; + } + + $event['measurements'][] = $measurement; + } + + if (count($this->errors)) { + $event['measurements'][] = [ + 'key' => 'error', + 'values' => $this->errors, + ]; + } + + if (count($this->latencies)) { + $event['measurements'][] = [ + 'key' => 'latency_ms', + 'values' => $this->latencies, + ]; + } + + return $event; + } + + private function checkInvokedConsistency(): ?string + { + foreach (Origin::cases() as $origin) { + $originValue = $origin->value; + if (isset($this->invoked[$originValue])) { + continue; + } + + if (isset($this->latencies[$originValue])) { + return "provided latency for origin {$originValue} without recording invocation"; + } + + if (isset($this->errors[$originValue])) { + return "provided error for origin {$originValue} without recording invocation"; + } + } + + if ($this->consistent !== null && count($this->invoked) !== 2) { + return "provided consistency without recording both invocations"; + } + + return null; + } +} diff --git a/src/LaunchDarkly/Migrations/Operation.php b/src/LaunchDarkly/Migrations/Operation.php new file mode 100644 index 00000000..518f4a28 --- /dev/null +++ b/src/LaunchDarkly/Migrations/Operation.php @@ -0,0 +1,28 @@ +value = $result->value; + $this->error = $result->error; + $this->exception = $result->exception; + } + + /** + * Determine whether this result represents success or failure. + */ + public function isSuccessful(): bool + { + return $this->result->isSuccessful(); + } +} diff --git a/src/LaunchDarkly/Migrations/Origin.php b/src/LaunchDarkly/Migrations/Origin.php new file mode 100644 index 00000000..781475f9 --- /dev/null +++ b/src/LaunchDarkly/Migrations/Origin.php @@ -0,0 +1,25 @@ + DUALWRITE -> SHADOW -> LIVE -> RAMPDOWN -> COMPLETE + */ +enum Stage: string +{ + /** + * The migration hasn't started. 'old' is authoritative for reads and writes + */ + case OFF = 'off'; + + /** + * Write to both 'old' and 'new', 'old' is authoritative for reads + */ + case DUALWRITE = 'dualwrite'; + + /** + * Both 'new' and 'old' versions run with a preference for 'old' + */ + case SHADOW = 'shadow'; + + /** + * Both 'new' and 'old' versions run with a preference for 'new' + */ + case LIVE = 'live'; + + /** + * Only read from 'new', write to 'old' and 'new' + */ + case RAMPDOWN = 'rampdown'; + + /** + * The migration is finished. 'new' is authoritative for reads and writes + */ + case COMPLETE = 'complete'; +} diff --git a/src/LaunchDarkly/Migrations/WriteResult.php b/src/LaunchDarkly/Migrations/WriteResult.php new file mode 100644 index 00000000..12283731 --- /dev/null +++ b/src/LaunchDarkly/Migrations/WriteResult.php @@ -0,0 +1,17 @@ +error === null; + } +} diff --git a/test-service/SdkClientEntity.php b/test-service/SdkClientEntity.php index e29dee67..937b0181 100644 --- a/test-service/SdkClientEntity.php +++ b/test-service/SdkClientEntity.php @@ -4,9 +4,16 @@ namespace Tests; +use Closure; +use GuzzleHttp\Client; use LaunchDarkly\Integrations\Guzzle; use LaunchDarkly\LDClient; use LaunchDarkly\LDContext; +use LaunchDarkly\Migrations\ExecutionOrder; +use LaunchDarkly\Migrations\MigratorBuilder; +use LaunchDarkly\Migrations\Operation; +use LaunchDarkly\Migrations\Stage; +use LaunchDarkly\Types\Result; use Monolog\Formatter\LineFormatter; use Monolog\Handler\StreamHandler; use Monolog\Logger; @@ -87,10 +94,16 @@ public function doCommand(mixed $reqParams): mixed case 'contextBuild': return $this->doContextBuild($commandParams); - + case 'contextConvert': return $this->doContextConvert($commandParams); - + + case 'migrationVariation': + return $this->doMigrationVariation($commandParams); + + case 'migrationOperation': + return $this->doMigrationOperation($commandParams); + default: return false; // means invalid command } @@ -208,6 +221,86 @@ private function doContextConvert(array $params): array } } + private function doMigrationVariation(array $params): array + { + try { + $results = $this->_client->migrationVariation( + $params['key'], + $this->makeContext($params['context']), + Stage::from($params['defaultStage']) + ); + + return ['result' => $results['stage']->value]; + } catch (\Throwable $e) { + return ['error' => "$e"]; + } + } + + private function doMigrationOperation(array $params): array + { + $builder = new MigratorBuilder($this->_client); + + // PHP doesn't support concurrent, so we just do the best we can. + if ($params['readExecutionOrder'] == 'concurrent') { + $params['readExecutionOrder'] = 'serial'; + } + + $builder->readExecutionOrder(ExecutionOrder::from($params['readExecutionOrder'])); + $builder->trackLatency($params['trackLatency']); + $builder->trackErrors($params['trackErrors']); + + $callback = function (string $endpoint): Closure { + return function ($payload) use ($endpoint): Result { + $client = new Client(); + $response = $client->request('POST', $endpoint, ['body' => $payload]); + + $statusCode = $response->getStatusCode(); + if ($statusCode == 200) { + return Result::success($response->getBody()->getContents()); + }; + + return Result::error("Request failed with status code {$statusCode}"); + }; + }; + + $consistency = null; + if ($params['trackConsistency'] ?? false) { + $consistency = fn ($lhs, $rhs) => $lhs == $rhs; + } + + $builder->read(($callback)($params['oldEndpoint']), ($callback)($params['newEndpoint']), $consistency); + $builder->write(($callback)($params['oldEndpoint']), ($callback)($params['newEndpoint'])); + + $results = $builder->build(); + + if (!$results->isSuccessful()) { + return ['result' => $results->error]; + } + + /** @var \LaunchDarkly\Migrations\Migrator */ + $migrator = $results->value; + + if ($params['operation'] == Operation::READ->value) { + $result = $migrator->read( + $params['key'], + $this->makeContext($params['context']), + Stage::from($params['defaultStage']), + $params['payload'], + ); + + return ['result' => $result->isSuccessful() ? $result->value : $result->error]; + } + + $result = $migrator->write( + $params['key'], + $this->makeContext($params['context']), + Stage::from($params['defaultStage']), + $params['payload'], + ); + + return ['result' => $result->authoritative->isSuccessful() ? $result->authoritative->value : $result->authoritative->error]; + } + private function makeContext(array $data): LDContext { return LDContext::fromJson($data); diff --git a/test-service/TestService.php b/test-service/TestService.php index a4232187..c09a8151 100644 --- a/test-service/TestService.php +++ b/test-service/TestService.php @@ -73,7 +73,9 @@ public function getStatus(): array 'all-flags-details-only-for-tracked-flags', 'all-flags-with-reasons', 'context-type', - 'secure-mode-hash' + 'secure-mode-hash', + 'migrations', + 'event-sampling' ], 'clientVersion' => \LaunchDarkly\LDClient::VERSION ]; diff --git a/test-service/composer.json b/test-service/composer.json index d7e3778f..dec94a0a 100644 --- a/test-service/composer.json +++ b/test-service/composer.json @@ -12,7 +12,7 @@ "launchdarkly/server-sdk": "*", "mikecao/flight": "^2", "monolog/monolog": "^2", - "php": ">=8.0", + "php": ">=8.1", "psr/log": "1.*" }, "autoload": { diff --git a/tests/FlagBuilder.php b/tests/FlagBuilder.php index d1cf70cd..e25234f9 100644 --- a/tests/FlagBuilder.php +++ b/tests/FlagBuilder.php @@ -3,6 +3,7 @@ namespace LaunchDarkly\Tests; use LaunchDarkly\Impl\Model\FeatureFlag; +use LaunchDarkly\Impl\Model\MigrationSettings; use LaunchDarkly\Impl\Model\Prerequisite; use LaunchDarkly\Impl\Model\Rollout; use LaunchDarkly\Impl\Model\Rule; @@ -31,6 +32,9 @@ class FlagBuilder private bool $_trackEventsFallthrough = false; private ?int $_debugEventsUntilDate = null; private bool $_clientSide = false; + private ?MigrationSettings $_migrationSettings = null; + private ?int $_samplingRatio = null; + private bool $_excludeFromSummaries = false; public function __construct(string $key) { @@ -56,7 +60,10 @@ public function build(): FeatureFlag $this->_trackEvents, $this->_trackEventsFallthrough, $this->_debugEventsUntilDate, - $this->_clientSide + $this->_clientSide, + $this->_samplingRatio, + $this->_excludeFromSummaries, + $this->_migrationSettings, ); } @@ -155,4 +162,22 @@ public function version(int $version): FlagBuilder $this->_version = $version; return $this; } + + public function migrationSettings(MigrationSettingsBuilder $builder): FlagBuilder + { + $this->_migrationSettingsBuilder = $builder; + return $this; + } + + public function samplingRatio(int $samplingRatio): FlagBuilder + { + $this->_samplingRatio = $samplingRatio; + return $this; + } + + public function excludeFromSummaries(bool $excludeFromSummaries): FlagBuilder + { + $this->_excludeFromSummaries = $excludeFromSummaries; + return $this; + } } diff --git a/tests/Impl/Events/EventFactoryTest.php b/tests/Impl/Events/EventFactoryTest.php index 37f88828..7f46e3ab 100644 --- a/tests/Impl/Events/EventFactoryTest.php +++ b/tests/Impl/Events/EventFactoryTest.php @@ -8,6 +8,7 @@ use LaunchDarkly\Impl\Events\EventFactory; use LaunchDarkly\Impl\Model\FeatureFlag; use LaunchDarkly\LDContext; +use LaunchDarkly\Tests\ModelBuilders; use PHPUnit\Framework\TestCase; class EventFactoryTest extends TestCase @@ -76,6 +77,57 @@ public function testTrackEventTrue() $this->assertTrue($result['trackEvents']); } + public function testEvalEventHandlesSamplingRatio() + { + $builder = ModelBuilders::flagBuilder('flag') + ->variations('fall', 'off', 'on') + ->on(true) + ->offVariation(1) + ->fallthroughVariation(0); + + $ef = new EventFactory(false); + $context = LDContext::create('userkey'); + $detail = new EvaluationDetail('off', 1, EvaluationReason::fallthrough()); + + $flag = $builder->build(); + $result = $ef->newEvalEvent($flag, $context, new EvalResult($detail, false), null); + $this->assertArrayNotHasKey('samplingRatio', $result); + + $flag = $builder->samplingRatio(0)->build(); + $result = $ef->newEvalEvent($flag, $context, new EvalResult($detail, false), null); + $this->assertEquals(0, $result['samplingRatio']); + + $flag = $builder->samplingRatio(1)->build(); + $result = $ef->newEvalEvent($flag, $context, new EvalResult($detail, false), null); + $this->assertArrayNotHasKey('samplingRatio', $result); + + $flag = $builder->samplingRatio(2)->build(); + $result = $ef->newEvalEvent($flag, $context, new EvalResult($detail, false), null); + $this->assertEquals(2, $result['samplingRatio']); + } + + public function testEvalEventHandlesExcludeFromSummaries() + { + $builder = ModelBuilders::flagBuilder('flag') + ->variations('fall', 'off', 'on') + ->on(true) + ->offVariation(1) + ->fallthroughVariation(0); + + $ef = new EventFactory(false); + $context = LDContext::create('userkey'); + + $detail = new EvaluationDetail('off', 1, EvaluationReason::fallthrough()); + + $flag = $builder->excludeFromSummaries(true)->build(); + $result = $ef->newEvalEvent($flag, $context, new EvalResult($detail, false), null); + $this->assertTrue($result['excludeFromSummaries']); + + $flag = $builder->excludeFromSummaries(false)->build(); + $result = $ef->newEvalEvent($flag, $context, new EvalResult($detail, false), null); + $this->assertArrayNotHasKey('excludeFromSummaries', $result); + } + public function testTrackEventTrueWhenTrackEventsFalseButExperimentFallthroughReason() { $ef = new EventFactory(false); diff --git a/tests/Impl/UtilTest.php b/tests/Impl/UtilTest.php new file mode 100644 index 00000000..dd671246 --- /dev/null +++ b/tests/Impl/UtilTest.php @@ -0,0 +1,29 @@ +assertTrue(Util::sample(1)); + $this->assertFalse(Util::sample(0)); + } + + public function testNonTrivialSamplingRatio(): void + { + // Seed to control randomness. + mt_srand(0); + + $counts = array_reduce( + range(1, 1_000), + fn (int $carry, int $mixed): int => Util::sample(2) ? ++$carry : $carry, + 0 + ); + + $this->assertEquals(504, $counts); + } +} diff --git a/tests/Integrations/TestDataTest.php b/tests/Integrations/TestDataTest.php index 069f49a9..3d9653ce 100644 --- a/tests/Integrations/TestDataTest.php +++ b/tests/Integrations/TestDataTest.php @@ -102,19 +102,6 @@ public function flagConfigParameterizedTestParams() ], fn ($f) => $f->variationForAll(true) ], - 'set false boolean variation for all users' => [ - [ - 'fallthrough' => ['variation' => 1], - ], - fn ($f) => $f->variationForAllUsers(false) - ], - 'set true boolean variation for all users' => [ - [ - 'variations' => [true, false], - 'fallthrough' => ['variation' => 0], - ], - fn ($f) => $f->variationForAllUsers(true) - ], 'set variation index for all' => [ [ 'fallthrough' => ['variation' => 2], @@ -122,13 +109,6 @@ public function flagConfigParameterizedTestParams() ], fn ($f) => $f->variations('a', 'b', 'c')->variationForAll(2) ], - 'set variation index for all users' => [ - [ - 'fallthrough' => ['variation' => 2], - 'variations' => ['a', 'b', 'c'] - ], - fn ($f) => $f->variations('a', 'b', 'c')->variationForAllUsers(2) - ], 'set fallthrough variation boolean' => [ [ 'fallthrough' => ['variation' => 1] @@ -232,11 +212,6 @@ public function flagConfigParameterizedTestParams() fn ($f) => $f->variationForKey('kind1', 'key1', 0) ->clearTargets() ], - 'clearUserTargets is synonym for clearTargets' => [ - [], - fn ($f) => $f->variationForKey('kind1', 'key1', 0) - ->clearUserTargets() - ], 'ifMatchContext' => [ [ 'rules' => [ @@ -426,7 +401,7 @@ public function testCanSetAndResetFeatureFlag() $td = new TestData(); $flag = $td->flag($key); $td->update($flag); - + $updatedFlag = $flag->variations('red', 'amber', 'green')->fallthroughVariation(2); $td->update($updatedFlag); diff --git a/tests/LDClientTest.php b/tests/LDClientTest.php index 36a236bb..3f129c07 100644 --- a/tests/LDClientTest.php +++ b/tests/LDClientTest.php @@ -3,12 +3,16 @@ namespace LaunchDarkly\Tests; use InvalidArgumentException; +use LaunchDarkly\EvaluationDetail; use LaunchDarkly\EvaluationReason; use LaunchDarkly\Impl\Model\FeatureFlag; use LaunchDarkly\LDClient; use LaunchDarkly\LDContext; -use LaunchDarkly\LDUser; -use LaunchDarkly\LDUserBuilder; +use LaunchDarkly\Migrations\Operation; +use LaunchDarkly\Migrations\OpTracker; +use LaunchDarkly\Migrations\Origin; +use LaunchDarkly\Migrations\Stage; +use LaunchDarkly\Tests\Impl\Evaluation\EvaluatorTestUtil; use Psr\Log\LoggerInterface; class LDClientTest extends \PHPUnit\Framework\TestCase @@ -25,7 +29,7 @@ public function testDefaultCtor() $this->assertInstanceOf(LDClient::class, new LDClient("BOGUS_SDK_KEY")); } - private function makeOffFlagWithValue($key, $value) + private function makeOffFlagWithValue($key, $value, $samplingRatio = 1, $excludeFromSummaries = false) { return ModelBuilders::flagBuilder($key) ->version(100) @@ -33,6 +37,8 @@ private function makeOffFlagWithValue($key, $value) ->variations('FALLTHROUGH', $value) ->fallthroughVariation(0) ->offVariation(1) + ->samplingRatio($samplingRatio) + ->excludeFromSummaries($excludeFromSummaries) ->build(); } @@ -140,16 +146,6 @@ public function testVariationPassesContextToEvaluator() $this->assertTrue($client->variation($flag->getKey(), $context, false)); } - public function testVariationPassesUserToEvaluator() - { - $flag = ModelBuilders::booleanFlagWithClauses(ModelBuilders::clause('user', 'attr1', 'in', 'value1')); - $this->mockRequester->addFlag($flag); - $client = $this->makeClient(); - - $user = (new LDUserBuilder('key'))->customAttribute('attr1', 'value1')->build(); - $this->assertTrue($client->variation($flag->getKey(), $user, false)); - } - public function testVariationSendsEvent() { $flag = $this->makeOffFlagWithValue('flagkey', 'flagvalue'); @@ -175,7 +171,7 @@ public function testVariationSendsEvent() public function testVariationDetailSendsEvent() { - $flag = $this->makeOffFlagWithValue('flagkey', 'flagvalue'); + $flag = $this->makeOffFlagWithValue('flagkey', 'flagvalue', 1); $this->mockRequester->addFlag($flag); $ep = new MockEventProcessor(); $client = $this->makeClient(['event_processor' => $ep]); @@ -196,6 +192,121 @@ public function testVariationDetailSendsEvent() $this->assertEquals(['kind' => 'OFF'], $event['reason']); } + public function testZeroSamplingRatioSuppressesFeatureEvent() + { + $flag = $this->makeOffFlagWithValue('flagkey', 'flagvalue', 0); + $this->mockRequester->addFlag($flag); + + $mockPublisher = new MockEventPublisher("", []); + $options = [ + 'feature_requester' => $this->mockRequester, + 'event_publisher' => $mockPublisher, + ]; + $client = new LDClient("someKey", $options); + + $context = LDContext::create('userkey'); + $client->variationDetail('flagkey', $context, 'default'); + // We don't flush the event processor until __destruct is called. Let's + // force that by unsetting this variable. + unset($client); + $this->assertCount(0, $mockPublisher->payloads); + } + + public function testFeatureEventContainsExcludeFlagSummaryValue(): void + { + $flag = $this->makeOffFlagWithValue('flagkey', 'flagvalue', 1, true); + $this->mockRequester->addFlag($flag); + + $mockPublisher = new MockEventPublisher("", []); + $options = [ + 'feature_requester' => $this->mockRequester, + 'event_publisher' => $mockPublisher, + ]; + $client = new LDClient("someKey", $options); + + $context = LDContext::create('userkey'); + $client->variationDetail('flagkey', $context, 'default'); + // We don't flush the event processor until __destruct is called. Let's + // force that by unsetting this variable. + unset($client); + + $event = json_decode($mockPublisher->payloads[0], true)[0]; + $this->assertEquals('feature', $event['kind']); + $this->assertTrue($event['excludeFromSummaries']); + } + + public function testMigrationVariationSendsEvent(): void + { + $flag = $this->makeOffFlagWithValue('flag', 'off', 1); + $this->mockRequester->addFlag($flag); + + $detail = new EvaluationDetail('off', 0, EvaluationReason::fallthrough()); + $tracker = new OpTracker( + EvaluatorTestUtil::testLogger(), + 'flag', + $flag, + LDContext::create('user-key'), + $detail, + Stage::LIVE, + ); + $tracker->operation(Operation::READ) + ->invoked(Origin::OLD) + ->invoked(Origin::NEW); + + $mockPublisher = new MockEventPublisher("", []); + $options = [ + 'feature_requester' => $this->mockRequester, + 'event_publisher' => $mockPublisher, + ]; + $client = new LDClient("someKey", $options); + + $client->trackMigrationOperation($tracker); + // We don't flush the event processor until __destruct is called. Let's + // force that by unsetting this variable. + unset($client); + + $events = json_decode($mockPublisher->payloads[0], true); + $this->assertCount(1, $events); + + $event = $events[0]; + $this->assertEquals('migration_op', $event['kind']); + $this->assertEquals('flag', $event['evaluation']['key']); + $this->assertArrayNotHasKey('samplingRatio', $event); + } + + public function testMigrationVariationDoesNotSendEventWith0SamplingRatio() + { + $flag = $this->makeOffFlagWithValue('flag', 'off', 0); + $this->mockRequester->addFlag($flag); + + $detail = new EvaluationDetail('off', 0, EvaluationReason::fallthrough()); + $tracker = new OpTracker( + EvaluatorTestUtil::testLogger(), + 'flag', + $flag, + LDContext::create('user-key'), + $detail, + Stage::LIVE, + ); + $tracker->operation(Operation::READ) + ->invoked(Origin::OLD) + ->invoked(Origin::NEW); + + $mockPublisher = new MockEventPublisher("", []); + $options = [ + 'feature_requester' => $this->mockRequester, + 'event_publisher' => $mockPublisher, + ]; + $client = new LDClient("someKey", $options); + + $client->trackMigrationOperation($tracker); + // We don't flush the event processor until __destruct is called. Let's + // force that by unsetting this variable. + unset($client); + + $this->assertCount(0, $mockPublisher->payloads); + } + public function testVariationForcesTrackingWhenMatchedRuleHasTrackEventsSet() { $flag = ModelBuilders::flagBuilder('flagkey') @@ -339,10 +450,6 @@ public function testAllFlagsStateReturnsState() '$valid' => true ]; $this->assertEquals($expectedState, $state->jsonSerialize()); - - $user = new LDUser('userkey'); - $state2 = $client->allFlagsState($user); - $this->assertEquals($expectedState, $state2->jsonSerialize()); } public function testAllFlagsStateHandlesExperimentationReasons() @@ -516,20 +623,6 @@ public function testIdentifySendsEvent() $this->assertEquals($context, $event['context']); } - public function testIdentifyAcceptsUser() - { - $ep = new MockEventProcessor(); - $client = $this->makeClient(['event_processor' => $ep]); - - $user = new LDUser('userkey'); - $client->identify($user); - $queue = $ep->getEvents(); - $this->assertEquals(1, sizeof($queue)); - $event = $queue[0]; - $this->assertEquals('identify', $event['kind']); - $this->assertEquals(LDContext::create('userkey'), $event['context']); - } - public function testTrackSendsEvent() { $ep = new MockEventProcessor(); @@ -584,23 +677,6 @@ public function testTrackSendsEventWithDataAndMetricValue() $this->assertEquals($metricValue, $event['metricValue']); } - public function testTrackAcceptsUser() - { - $ep = new MockEventProcessor(); - $client = $this->makeClient(['event_processor' => $ep]); - - $user = new LDUser('userkey'); - $client->track('eventkey', $user); - $queue = $ep->getEvents(); - $this->assertEquals(1, sizeof($queue)); - $event = $queue[0]; - $this->assertEquals('custom', $event['kind']); - $this->assertEquals('eventkey', $event['key']); - $this->assertEquals(LDContext::create('userkey'), $event['context']); - $this->assertFalse(isset($event['data'])); - $this->assertFalse(isset($event['metricValue'])); - } - public function testEventsAreNotPublishedIfSendEventsIsFalse() { // In order to do this test, we cannot provide a mock object for Event_Processor_, @@ -632,10 +708,8 @@ public function testSecureModeHash() { $client = new LDClient("secret", ['offline' => true]); $context = LDContext::create("Message"); - $user = new LDUser($context->getKey()); $expected = "aa747c502a898200f9e4fa21bac68136f886a0e27aec70ba06daf2e2a5cb5597"; $this->assertEquals($expected, $client->secureModeHash($context)); - $this->assertEquals($expected, $client->secureModeHash($user)); } public function testLoggerInterfaceWarn() @@ -654,4 +728,52 @@ public function testLoggerInterfaceWarn() $client->variation('MyFeature', $invalidContext); } + + public function testUsesDefaultIfFlagIsNotFound(): void + { + $client = $this->makeClient(); + $result = $client->migrationVariation('unknown-flag-key', LDContext::create('userkey'), Stage::LIVE); + + $this->assertEquals(Stage::LIVE, $result['stage']); + $this->assertInstanceOf(OpTracker::class, $result['tracker']); + } + + public function testUsesDefaultIfFlagReturnsInvalidStage(): void + { + $flag = $this->makeOffFlagWithValue('feature', 'invalid stage value'); + $this->mockRequester->addFlag($flag); + $client = $this->makeClient(); + + $result = $client->migrationVariation('feature', LDContext::create('userkey'), Stage::LIVE); + + $this->assertEquals(Stage::LIVE, $result['stage']); + $this->assertInstanceOf(OpTracker::class, $result['tracker']); + } + + public function stageProvider(): array + { + return [ + [Stage::OFF], + [Stage::DUALWRITE], + [Stage::SHADOW], + [Stage::LIVE], + [Stage::RAMPDOWN], + [Stage::COMPLETE], + ]; + } + + /** + * @dataProvider stageProvider + */ + public function testCanDetermineCorrectStage(Stage $stage): void + { + $flag = $this->makeOffFlagWithValue('feature', $stage->value); + $this->mockRequester->addFlag($flag); + $client = $this->makeClient(); + + $result = $client->migrationVariation('feature', LDContext::create('userkey'), Stage::OFF); + + $this->assertEquals($stage, $result['stage']); + $this->assertInstanceOf(OpTracker::class, $result['tracker']); + } } diff --git a/tests/LDContextTest.php b/tests/LDContextTest.php index 405ac4ad..40186459 100644 --- a/tests/LDContextTest.php +++ b/tests/LDContextTest.php @@ -3,7 +3,6 @@ namespace LaunchDarkly\Tests; use LaunchDarkly\LDContext; -use LaunchDarkly\LDUserBuilder; use LaunchDarkly\Types\AttributeReference; class LDContextTest extends \PHPUnit\Framework\TestCase @@ -19,12 +18,13 @@ public function testCreateWithDefaultKind() self::assertNull($c->getName()); self::assertFalse($c->isAnonymous()); self::assertEquals([], $c->getCustomAttributeNames()); + self::assertSame(['user' => 'a'], $c->getKeys()); } public function testCreateWithNonDefaultKind() { $c = LDContext::create('a', 'b'); - + self::assertContextValid($c); self::assertFalse($c->isMultiple()); self::assertEquals('a', $c->getKey()); @@ -32,6 +32,7 @@ public function testCreateWithNonDefaultKind() self::assertNull($c->getName()); self::assertFalse($c->isAnonymous()); self::assertEquals([], $c->getCustomAttributeNames()); + self::assertSame(['b' => 'a'], $c->getKeys()); } public function testBuilderWithDefaultKind() @@ -45,6 +46,7 @@ public function testBuilderWithDefaultKind() self::assertNull($c->getName()); self::assertFalse($c->isAnonymous()); self::assertEquals([], $c->getCustomAttributeNames()); + self::assertSame(['user' => 'a'], $c->getKeys()); } public function testBuilderWithNonDefaultKind() @@ -58,6 +60,7 @@ public function testBuilderWithNonDefaultKind() self::assertNull($c->getName()); self::assertFalse($c->isAnonymous()); self::assertEquals([], $c->getCustomAttributeNames()); + self::assertSame(['b' => 'a'], $c->getKeys()); } public function testBuilderName() @@ -84,7 +87,7 @@ public function testBuilderSetCustomAttributes() ->set('b', true) ->set('c', 'd') ->build(); - + self::assertContextValid($c); self::assertEquals('a', $c->getKey()); self::assertEquals(true, $c->get('b')); @@ -100,7 +103,7 @@ public function testBuilderSetBuiltInAttributeByName() ->set('name', 'c') ->set('anonymous', true) ->build(); - + self::assertContextValid($c); self::assertEquals('a', $c->getKey()); self::assertEquals('b', $c->getKind()); @@ -174,7 +177,7 @@ public function testCreateMulti() $c1 = LDContext::create('a', 'kind1'); $c2 = LDContext::create('b', 'kind2'); $mc = LDContext::createMulti($c1, $c2); - + self::assertContextValid($mc); self::assertTrue($mc->isMultiple()); self::assertEquals(2, $mc->getIndividualContextCount()); @@ -187,6 +190,7 @@ public function testCreateMulti() self::assertSame($c1, $mc->getIndividualContext('kind1')); self::assertSame($c2, $mc->getIndividualContext('kind2')); self::assertNull($mc->getIndividualContext('kind3')); + self::assertSame(['kind1' => 'a', 'kind2' => 'b'], $mc->getKeys()); } public function testMultiBuilder() @@ -248,7 +252,7 @@ public function testEquals() LDContext::builder('a')->set('c', 3)->set('b', true)->build() ); self::assertContextsFromFactoryEqual(fn () => LDContext::create('invalid', 'kind')); - + self::assertContextsUnequal(LDContext::create('a', 'kind1'), LDContext::create('a', 'kind2')); self::assertContextsUnequal(LDContext::create('b', 'kind1'), LDContext::create('a', 'kind1')); self::assertContextsUnequal( @@ -275,7 +279,7 @@ public function testEquals() LDContext::createMulti(LDContext::create('a', 'kind1'), LDContext::create('b', 'kind2')), LDContext::createMulti(LDContext::create('b', 'kind2'), LDContext::create('a', 'kind1')) ); - + self::assertContextsUnequal( LDContext::createMulti(LDContext::create('a', 'kind1'), LDContext::create('b', 'kind2')), LDContext::createMulti(LDContext::create('a', 'kind1'), LDContext::create('c', 'kind2')) @@ -340,57 +344,6 @@ public function testJsonDecoding() ); } - public function testContextFromUser() - { - $u1 = (new LDUserBuilder("key")) - ->ip("127.0.0.1") - ->firstName("Bob") - ->lastName("Loblaw") - ->email("bob@example.com") - ->privateName("Bob Loblaw") - ->avatar("image") - ->country("US") - ->anonymous(true) - ->build(); - $c1 = LDContext::fromUser($u1); - $c1Expected = LDContext::builder($u1->getKey()) - ->set("ip", $u1->getIP()) - ->set("firstName", $u1->getFirstName()) - ->set("lastName", $u1->getLastName()) - ->set("email", $u1->getEmail()) - ->set("name", $u1->getName()) - ->set("avatar", $u1->getAvatar()) - ->set("country", $u1->getCountry()) - ->private("name") - ->anonymous(true) - ->build(); - self::assertContextsEqual($c1Expected, $c1); - - // test case where there were no built-in optional attrs, only custom - $u2 = (new LDUserBuilder("key")) - ->customAttribute("c1", "v1") - ->privateCustomAttribute("c2", "v2") - ->build(); - $c2 = LDContext::fromUser($u2); - $c2Expected = LDContext::builder($u2->getKey()) - ->set("c1", "v1") - ->set("c2", "v2") - ->private("c2") - ->build(); - self::assertContextsEqual($c2Expected, $c2); - - // make sure custom attrs can't override built-in ones - $u3 = (new LDUserBuilder("key")) - ->email("good") - ->custom(["email" => "bad"]) - ->build(); - $c3 = LDContext::fromUser($u3); - $c3Expected = LDContext::builder($u3->getKey()) - ->set("email", "good") - ->build(); - self::assertContextsEqual($c3Expected, $c3); - } - private static function assertContextValid($c) { self::assertNull($c->getError()); diff --git a/tests/LDUserTest.php b/tests/LDUserTest.php deleted file mode 100644 index 5d0e2e47..00000000 --- a/tests/LDUserTest.php +++ /dev/null @@ -1,177 +0,0 @@ -build(); - $this->assertEquals("foo@bar.com", $user->getKey()); - } - - public function testCoerceLDUserKey() - { - $builder = new LDUserBuilder(3); - $user = $builder->build(); - $this->assertEquals("string", gettype($user->getKey())); - } - - public function testEmptyCustom() - { - $builder = new LDUserBuilder("foo@bar.com"); - - $user = $builder->build(); - - $this->assertInstanceOf(LDUser::class, $user); - } - - public function testLDUserIP() - { - $builder = new LDUserBuilder("foo@bar.com"); - $user = $builder->ip("127.0.0.1")->build(); - $this->assertEquals("127.0.0.1", $user->getIP()); - } - - public function testLDUserPrivateIP() - { - $builder = new LDUserBuilder("foo@bar.com"); - $user = $builder->privateIp("127.0.0.1")->build(); - $this->assertEquals("127.0.0.1", $user->getIP()); - $this->assertEquals(["ip"], $user->getPrivateAttributeNames()); - } - - public function testLDUserCountry() - { - $builder = new LDUserBuilder("foo@bar.com"); - $user = $builder->country("US")->build(); - $this->assertEquals("US", $user->getCountry()); - } - - public function testLDUserPrivateCountry() - { - $builder = new LDUserBuilder("foo@bar.com"); - $user = $builder->privateCountry("US")->build(); - $this->assertEquals("US", $user->getCountry()); - $this->assertEquals(["country"], $user->getPrivateAttributeNames()); - } - - public function testLDUserEmail() - { - $builder = new LDUserBuilder("foo@bar.com"); - $user = $builder->email("foo+test@bar.com")->build(); - $this->assertEquals("foo+test@bar.com", $user->getEmail()); - } - - public function testLDUserPrivateEmail() - { - $builder = new LDUserBuilder("foo@bar.com"); - $user = $builder->privateEmail("foo+test@bar.com")->build(); - $this->assertEquals("foo+test@bar.com", $user->getEmail()); - $this->assertEquals(["email"], $user->getPrivateAttributeNames()); - } - - public function testLDUserName() - { - $builder = new LDUserBuilder("foo@bar.com"); - $user = $builder->name("Foo Bar")->build(); - $this->assertEquals("Foo Bar", $user->getName()); - } - - public function testLDUserPrivateName() - { - $builder = new LDUserBuilder("foo@bar.com"); - $user = $builder->privateName("Foo Bar")->build(); - $this->assertEquals("Foo Bar", $user->getName()); - $this->assertEquals(["name"], $user->getPrivateAttributeNames()); - } - - public function testLDUserAvatar() - { - $builder = new LDUserBuilder("foo@bar.com"); - $user = $builder->avatar("http://www.gravatar.com/avatar/1")->build(); - $this->assertEquals("http://www.gravatar.com/avatar/1", $user->getAvatar()); - } - - public function testLDUserPrivateAvatar() - { - $builder = new LDUserBuilder("foo@bar.com"); - $user = $builder->privateAvatar("http://www.gravatar.com/avatar/1")->build(); - $this->assertEquals("http://www.gravatar.com/avatar/1", $user->getAvatar()); - $this->assertEquals(["avatar"], $user->getPrivateAttributeNames()); - } - - public function testLDUserFirstName() - { - $builder = new LDUserBuilder("foo@bar.com"); - $user = $builder->firstName("Foo")->build(); - $this->assertEquals("Foo", $user->getFirstName()); - } - - public function testLDUserPrivateFirstName() - { - $builder = new LDUserBuilder("foo@bar.com"); - $user = $builder->privateFirstName("Foo")->build(); - $this->assertEquals("Foo", $user->getFirstName()); - $this->assertEquals(["firstName"], $user->getPrivateAttributeNames()); - } - - public function testLDUserLastName() - { - $builder = new LDUserBuilder("foo@bar.com"); - $user = $builder->lastName("Bar")->build(); - $this->assertEquals("Bar", $user->getLastName()); - } - - public function testLDUserPrivateLastName() - { - $builder = new LDUserBuilder("foo@bar.com"); - $user = $builder->privateLastName("Bar")->build(); - $this->assertEquals("Bar", $user->getLastName()); - $this->assertEquals(["lastName"], $user->getPrivateAttributeNames()); - } - - public function testLDUserCustom() - { - $builder = new LDUserBuilder("foo@bar.com"); - $user = $builder->customAttribute("foo", "bar")->customAttribute("baz", "boo")->build(); - $this->assertEquals(["foo" => "bar", "baz" => "boo"], $user->getCustom()); - } - - public function testLDUserPrivateCustom() - { - $builder = new LDUserBuilder("foo@bar.com"); - $user = $builder->privateCustomAttribute("foo", "bar")->privateCustomAttribute("baz", "boo")->build(); - $this->assertEquals(["foo" => "bar", "baz" => "boo"], $user->getCustom()); - $this->assertEquals(["foo", "baz"], $user->getPrivateAttributeNames()); - } - - public function testLDUserAnonymous() - { - $builder = new LDUserBuilder("foo@bar.com"); - $user = $builder->anonymous(true)->build(); - $this->assertEquals(true, $user->getAnonymous()); - } - - public function testLDUserBlankKey() - { - $builder = new LDUserBuilder(""); - $user = $builder->build(); - $this->assertTrue($user->isKeyBlank()); - $this->assertFalse(is_null($user->getKey())); - - $builder = new LDUserBuilder("key"); - $user = $builder->build(); - $this->assertFalse($user->isKeyBlank()); - } - - public function testLDUserNullKey() - { - $this->expectException(\TypeError::class); - $builder = new LDUserBuilder(null); - } -} diff --git a/tests/Migrations/MigratorBuilderTest.php b/tests/Migrations/MigratorBuilderTest.php new file mode 100644 index 00000000..ff6c4516 --- /dev/null +++ b/tests/Migrations/MigratorBuilderTest.php @@ -0,0 +1,80 @@ +builder = new MigratorBuilder($client); + $this->noop = fn () => Result::success(null); + } + + public function testCanBuildSuccessfully(): void + { + $this->builder->read( + fn () => Result::success('old origin'), + fn () => Result::success('new origin'), + ); + $this->builder->write( + fn () => Result::success('old origin'), + fn () => Result::success('new origin'), + ); + $result = $this->builder->build(); + + $this->assertTrue($result->isSuccessful()); + $this->assertInstanceOf(Migrator::class, $result->value); + } + + public function orderProvider(): array + { + return [ + [ExecutionOrder::SERIAL], + [ExecutionOrder::RANDOM], + ]; + } + + /** + * @dataProvider orderProvider + */ + public function testCanModifyExecutionOrder(ExecutionOrder $order): void + { + $this->builder->read($this->noop, $this->noop); + $this->builder->write($this->noop, $this->noop); + $this->builder->readExecutionOrder($order); + + $result = $this->builder->build(); + + $this->assertTrue($result->isSuccessful()); + $this->assertInstanceOf(Migrator::class, $result->value); + } + + public function testFailsWithoutRead(): void + { + $this->builder->write($this->noop, $this->noop); + $result = $this->builder->build(); + + $this->assertFalse($result->isSuccessful()); + $this->assertEquals('read configuration not provided', $result->error); + } + + public function testFailsWithoutWrite(): void + { + $this->builder->read($this->noop, $this->noop); + $result = $this->builder->build(); + + $this->assertFalse($result->isSuccessful()); + $this->assertEquals('write configuration not provided', $result->error); + } +} diff --git a/tests/Migrations/MigratorTest.php b/tests/Migrations/MigratorTest.php new file mode 100644 index 00000000..8d781279 --- /dev/null +++ b/tests/Migrations/MigratorTest.php @@ -0,0 +1,510 @@ +addFlag($this->makeOffFlagWithValue($stage->value, $stage->value)); + } + + $zeroSamplingRatio = ModelBuilders::flagBuilder("zero-sampling-ratio") + ->version(100) + ->on(false) + ->variations('FALLTHROUGH', Stage::OFF->value) + ->fallthroughVariation(0) + ->offVariation(1) + ->build(); + + $this->eventProcessor = new MockEventProcessor(); + $options = [ + 'feature_requester' => $requester, + 'event_processor' => $this->eventProcessor, + ]; + + $client = new LDClient("someKey", $options); + $successFn = fn () => Result::success(null); + + $this->builder = new MigratorBuilder($client); + $this->builder->trackLatency(false) + ->trackErrors(false) + ->read($successFn, $successFn) + ->write($successFn, $successFn); + } + + private function makeOffFlagWithValue(string $key, string $value): FeatureFlag + { + return ModelBuilders::flagBuilder($key) + ->version(100) + ->on(false) + ->variations('FALLTHROUGH', $value) + ->fallthroughVariation(0) + ->offVariation(1) + ->build(); + } + + public function payloadReadPassthroughProvider(): array + { + return [ + [Stage::OFF, 1], + [Stage::DUALWRITE, 1], + [Stage::SHADOW, 2], + [Stage::LIVE, 2], + [Stage::RAMPDOWN, 1], + [Stage::COMPLETE, 1], + ]; + } + + /** + * @dataProvider payloadReadPassthroughProvider + */ + public function testPayloadPassesThroughRead(Stage $stage, int $expectedCount): void + { + $payloads = []; + $capturePayloads = function (mixed $payload) use (&$payloads): Result { + $payloads[] = $payload; + return Result::success(null); + }; + + $this->builder->read($capturePayloads, $capturePayloads); + /** @var Migrator */ + $migrator = $this->builder->build()->value; + + $result = $migrator->read($stage->value, LDContext::create('user-key'), Stage::LIVE, "payload"); + + $this->assertTrue($result->isSuccessful()); + $this->assertCount($expectedCount, $payloads); + $this->assertEquals('payload', array_unique($payloads)[0]); + } + + public function payloadWritePassthroughProvider(): array + { + return [ + [Stage::OFF, 1], + [Stage::DUALWRITE, 2], + [Stage::SHADOW, 2], + [Stage::LIVE, 2], + [Stage::RAMPDOWN, 2], + [Stage::COMPLETE, 1], + ]; + } + + /** + * @dataProvider payloadWritePassthroughProvider + */ + public function testPayloadPassesThroughWrite(Stage $stage, int $expectedCount): void + { + $payloads = []; + $capturePayloads = function (mixed $payload) use (&$payloads): Result { + $payloads[] = $payload; + return Result::success(null); + }; + + $this->builder->write($capturePayloads, $capturePayloads); + /** @var Migrator */ + $migrator = $this->builder->build()->value; + + $writeResult = $migrator->write($stage->value, LDContext::create('user-key'), Stage::LIVE, "payload"); + + $this->assertTrue($writeResult->authoritative->isSuccessful()); + $this->assertCount($expectedCount, $payloads); + $this->assertEquals('payload', array_unique($payloads)[0]); + } + + public function readStageOriginProvider(): array + { + return [ + [Stage::OFF, [Origin::OLD]], + [Stage::DUALWRITE, [Origin::OLD]], + [Stage::SHADOW, [Origin::OLD, Origin::NEW]], + [Stage::LIVE, [Origin::OLD, Origin::NEW]], + [Stage::RAMPDOWN, [Origin::NEW]], + [Stage::COMPLETE, [Origin::NEW]], + ]; + } + + /** + * @dataProvider readStageOriginProvider + */ + public function testTrackingInvokedForReads(Stage $stage, array $origins): void + { + /** @var Migrator */ + $migrator = $this->builder->build()->value; + $migrator->read($stage->value, LDContext::create('user-key'), Stage::LIVE); + + $events = $this->eventProcessor->getEvents(); + + $this->assertCount(2, $events); + + $event = $events[1]; // First event is evaluation result + $invoked = $event['measurements'][0]; + + $this->assertEquals('invoked', $invoked['key']); + + array_map(fn ($origin) => $this->assertTrue($invoked['values'][$origin->value]), $origins); + } + + /** + * @dataProvider readStageOriginProvider + */ + public function testTrackingLatencyForReads(Stage $stage, array $origins): void + { + $delayed = function (mixed $payload): Result { + return Result::success(null); + }; + + $this->builder->read($delayed, $delayed); + $this->builder->trackLatency(true); + + /** @var Migrator */ + $migrator = $this->builder->build()->value; + $migrator->read($stage->value, LDContext::create('user-key'), Stage::LIVE); + + $events = $this->eventProcessor->getEvents(); + + $this->assertCount(2, $events); + + $event = $events[1]; // First event is evaluation result + $latencies = $event['measurements'][1]; // First measurement is invoked + + $this->assertEquals('latency_ms', $latencies['key']); + foreach ($origins as $origin) { + $this->assertGreaterThanOrEqual($latencies['values'][$origin->value], 100); + } + } + + /** + * @dataProvider readStageOriginProvider + */ + public function testTrackingErrorsForReads(Stage $stage, array $origins): void + { + $this->builder->read( + fn () => throw new Exception("old write"), + fn () => throw new Exception("new write"), + ); + $this->builder->trackErrors(true); + + /** @var Migrator */ + $migrator = $this->builder->build()->value; + $migrator->read($stage->value, LDContext::create('user-key'), Stage::LIVE); + + $events = $this->eventProcessor->getEvents(); + + $this->assertCount(2, $events); + + $event = $events[1]; // First event is evaluation result + $errors = $event['measurements'][1]; // First measurement is invoked + + $this->assertEquals('error', $errors['key']); + foreach ($origins as $origin) { + $this->assertTrue($errors['values'][$origin->value]); + } + } + + public function writeStageOriginProvider(): array + { + return [ + [Stage::OFF, [Origin::OLD]], + [Stage::DUALWRITE, [Origin::OLD, Origin::NEW]], + [Stage::SHADOW, [Origin::OLD, Origin::NEW]], + [Stage::LIVE, [Origin::OLD, Origin::NEW]], + [Stage::RAMPDOWN, [Origin::OLD, Origin::NEW]], + [Stage::COMPLETE, [Origin::NEW]], + ]; + } + + /** + * @dataProvider writeStageOriginProvider + */ + public function testTrackingInvokedForWrites(Stage $stage, array $origins): void + { + /** @var Migrator */ + $migrator = $this->builder->build()->value; + $migrator->write($stage->value, LDContext::create('user-key'), Stage::LIVE); + + $events = $this->eventProcessor->getEvents(); + + $this->assertCount(2, $events); + + $event = $events[1]; // First event is evaluation result + $invoked = $event['measurements'][0]; + + $this->assertEquals('invoked', $invoked['key']); + + array_map(fn ($origin) => $this->assertTrue($invoked['values'][$origin->value]), $origins); + } + + /** + * @dataProvider writeStageOriginProvider + */ + public function testTrackingLatencyForWrites(Stage $stage, array $origins): void + { + $delayed = function (mixed $payload): Result { + return Result::success(null); + }; + + $this->builder->write($delayed, $delayed); + $this->builder->trackLatency(true); + + /** @var Migrator */ + $migrator = $this->builder->build()->value; + $migrator->write($stage->value, LDContext::create('user-key'), Stage::LIVE); + + $events = $this->eventProcessor->getEvents(); + + $this->assertCount(2, $events); + + $event = $events[1]; // First event is evaluation result + $latencies = $event['measurements'][1]; // First measurement is invoked + + $this->assertEquals('latency_ms', $latencies['key']); + foreach ($origins as $origin) { + $this->assertGreaterThanOrEqual($latencies['values'][$origin->value], 100); + } + } + + public function authoritativeWriteStageOriginProvider(): array + { + return [ + [Stage::OFF, Origin::OLD], + [Stage::DUALWRITE, Origin::OLD], + [Stage::SHADOW, Origin::OLD], + [Stage::LIVE, Origin::NEW], + [Stage::RAMPDOWN, Origin::NEW], + [Stage::COMPLETE, Origin::NEW], + ]; + } + + /** + * @dataProvider authoritativeWriteStageOriginProvider + */ + public function testTrackingErrorsForAuthoritativeWrites(Stage $stage, Origin $origin): void + { + $this->builder->write( + fn () => throw new Exception("old write"), + fn () => throw new Exception("new write"), + ); + $this->builder->trackErrors(true); + + /** @var Migrator */ + $migrator = $this->builder->build()->value; + $migrator->write($stage->value, LDContext::create('user-key'), Stage::LIVE); + + $events = $this->eventProcessor->getEvents(); + + $this->assertCount(2, $events); + + $event = $events[1]; // First event is evaluation result + $errors = $event['measurements'][1]; // First measurement is invoked + + $this->assertEquals('error', $errors['key']); + $this->assertTrue($errors['values'][$origin->value]); + } + + public function nonauthoritativeWriteStageOriginProvider(): array + { + return [ + // Off and Complete only run authoritative writes so there is nothing to test + [Stage::DUALWRITE, Origin::OLD, Origin::NEW], + [Stage::SHADOW, Origin::OLD, Origin::NEW], + [Stage::LIVE, Origin::NEW, Origin::OLD], + [Stage::RAMPDOWN, Origin::NEW, Origin::OLD], + ]; + } + + /** + * @dataProvider nonauthoritativeWriteStageOriginProvider + */ + public function testTrackingErrorsForNonAuthoritativeWrites(Stage $stage, Origin $authoritative, Origin $nonauthoritative): void + { + if ($authoritative == Origin::OLD) { + $this->builder->write( + fn () => Result::success(null), + fn () => throw new Exception("new write"), + ); + } else { + $this->builder->write( + fn () => throw new Exception("old write"), + fn () => Result::success(null), + ); + } + $this->builder->trackErrors(true); + + /** @var Migrator */ + $migrator = $this->builder->build()->value; + $migrator->write($stage->value, LDContext::create('user-key'), Stage::LIVE); + + $events = $this->eventProcessor->getEvents(); + + $this->assertCount(2, $events); + + $event = $events[1]; // First event is evaluation result + $errors = $event['measurements'][1]; // First measurement is invoked + + $this->assertEquals('error', $errors['key']); + $this->assertTrue($errors['values'][$nonauthoritative->value]); + } + + public function trackingConsistencyProvider(): array + { + return [ + // SHADOW and LIVE are the only stages that run both reads and as a + // result, can produce consistency values. + [Stage::SHADOW, "same", "same", true], + [Stage::LIVE, "same", "same", true], + + [Stage::SHADOW, "different", "same", false], + [Stage::LIVE, "different", "same", false], + ]; + } + + /** + * @dataProvider trackingConsistencyProvider + */ + public function testTrackingConsistency(Stage $stage, string $old, string $new, bool $shouldMatch): void + { + $this->builder->read( + fn () => Result::success($old), + fn () => Result::success($new), + fn ($lhs, $rhs) => $lhs == $rhs, + ); + + /** @var Migrator */ + $migrator = $this->builder->build()->value; + $migrator->read($stage->value, LDContext::create('user-key'), Stage::LIVE); + + $events = $this->eventProcessor->getEvents(); + + $this->assertCount(2, $events); + + $event = $events[1]; // First event is evaluation result + $consistency = $event['measurements'][1]; // First measurement is invoked + + $this->assertEquals('consistent', $consistency['key']); + $this->assertEquals($shouldMatch, $consistency['value']); + $this->assertArrayNotHasKey('samplingRatio', $consistency); + } + + public function readHandlerExceptionProvider(): array + { + return [ + [Stage::OFF, "old read"], + [Stage::DUALWRITE, "old read"], + [Stage::SHADOW, "old read"], + [Stage::LIVE, "new read"], + [Stage::RAMPDOWN, "new read"], + [Stage::COMPLETE, "new read"], + ]; + } + + /** + * @dataProvider readHandlerExceptionProvider + */ + public function testReadsHandleExceptionsFromMigrationFunctions(Stage $stage, string $expectedError): void + { + $this->builder->read( + fn () => throw new Exception("old read"), + fn () => throw new Exception("new read"), + ); + + /** @var Migrator */ + $migrator = $this->builder->build()->value; + + $result = $migrator->read($stage->value, LDContext::create('user-key'), Stage::LIVE); + + $this->assertFalse($result->isSuccessful()); + $this->assertEquals($expectedError, $result->error); + } + + public function authoritativeWriteHandlerExceptionProvider(): array + { + return [ + [Stage::OFF, "old write"], + [Stage::DUALWRITE, "old write"], + [Stage::SHADOW, "old write"], + [Stage::LIVE, "new write"], + [Stage::RAMPDOWN, "new write"], + [Stage::COMPLETE, "new write"], + ]; + } + + /** + * @dataProvider authoritativeWriteHandlerExceptionProvider + */ + public function testHandlesExceptionsFromAuthoritativeWrite(Stage $stage, string $expectedError): void + { + $this->builder->write( + fn () => throw new Exception("old write"), + fn () => throw new Exception("new write"), + ); + + /** @var Migrator */ + $migrator = $this->builder->build()->value; + + $result = $migrator->write($stage->value, LDContext::create('user-key'), Stage::LIVE); + + $this->assertFalse($result->authoritative->isSuccessful()); + $this->assertNull($result->nonauthoritative); + $this->assertEquals($expectedError, $result->authoritative->error); + } + + public function nonauthoritativeWriteHandlerExceptionProvider(): array + { + return [ + // Off and Complete only run authoritative writes so there is nothing to test + [Stage::DUALWRITE, "new write", true], + [Stage::SHADOW, "new write", true], + [Stage::LIVE, "old write", false], + [Stage::RAMPDOWN, "old write", false], + ]; + } + + /** + * @dataProvider nonauthoritativeWriteHandlerExceptionProvider + */ + public function testHandlesExceptionsFromNonAuthoritativeWrite(Stage $stage, string $expectedError, bool $oldIsAuthoritative): void + { + $success = fn () => Result::success(null); + + if ($oldIsAuthoritative) { + $this->builder->write( + $success, + fn () => throw new Exception("new write"), + ); + } else { + $this->builder->write( + fn () => throw new Exception("old write"), + $success, + ); + } + + /** @var Migrator */ + $migrator = $this->builder->build()->value; + + $result = $migrator->write($stage->value, LDContext::create('user-key'), Stage::LIVE); + + $this->assertTrue($result->authoritative->isSuccessful()); + $this->assertFalse($result->nonauthoritative?->isSuccessful()); + $this->assertEquals($expectedError, $result->nonauthoritative?->error); + } +} diff --git a/tests/Migrations/OpTrackerTest.php b/tests/Migrations/OpTrackerTest.php new file mode 100644 index 00000000..416ac5fd --- /dev/null +++ b/tests/Migrations/OpTrackerTest.php @@ -0,0 +1,336 @@ +variations('off')->variationForAll(0)->build(0); + $detail = new EvaluationDetail('off', 0, EvaluationReason::fallthrough()); + $this->tracker = new OpTracker( + EvaluatorTestUtil::testLogger(), + 'flag', + FeatureFlag::decode($flag), + LDContext::create('user-key'), + $detail, + Stage::LIVE, + ); + } + + protected function setTrackerDefaults(): void + { + $this->tracker->operation(Operation::READ) + ->invoked(Origin::OLD) + ->invoked(Origin::NEW); + } + + public function testCanBuildSuccessfully(): void + { + $this->setTrackerDefaults(); + $result = $this->tracker->build(); + + $this->assertIsArray($result, 'tracker failed to build event'); + $this->assertEquals('migration_op', $result['kind']); + $this->assertEquals('read', $result['operation']); + $this->assertSame(['user' => 'user-key'], $result['contextKeys']); + + $evaluation = $result['evaluation']; + $this->assertEquals('flag', $evaluation['key']); + $this->assertEquals('off', $evaluation['value']); + $this->assertEquals('live', $evaluation['default']); + $this->assertEquals(0, $evaluation['version']); + $this->assertEquals(0, $evaluation['variation']); + $this->assertEquals('FALLTHROUGH', $evaluation['reason']['kind']); + } + + public function testFailsWithoutOperation(): void + { + $this->tracker->invoked(Origin::OLD); + $result = $this->tracker->build(); + + $this->assertEquals('operation not provided', $result); + } + + public function testFailsWithoutInvocations(): void + { + $this->tracker->operation(Operation::READ); + $result = $this->tracker->build(); + + $this->assertEquals('no origins were invoked', $result); + } + + public function testFailsWithInvalidContext(): void + { + $flag = (new FlagBuilder('flag'))->variations('off')->variationForAll(0)->build(0); + $detail = new EvaluationDetail('off', 0, EvaluationReason::fallthrough()); + $tracker = new OpTracker( + EvaluatorTestUtil::testLogger(), + 'flag', + FeatureFlag::decode($flag), + LDContext::create(''), + $detail, + Stage::OFF, + ); + $tracker->operation(Operation::READ) + ->invoked(Origin::OLD) + ->invoked(Origin::NEW); + $result = $tracker->build(); + + $this->assertEquals('provided context was invalid', $result); + } + + public function originProvider(): array + { + return [ + [Origin::OLD], + [Origin::NEW], + ]; + } + + public function originMismatchProvider(): array + { + return [ + [Origin::OLD, Origin::NEW], + [Origin::NEW, Origin::OLD], + ]; + } + + /** + * @dataProvider originMismatchProvider + */ + public function testLatencyInvokedMismatch(Origin $invoked, Origin $recorded): void + { + $this->tracker->operation(Operation::WRITE); + $this->tracker->invoked($invoked); + $this->tracker->latency($recorded, 10); + + $error = $this->tracker->build(); + + $this->assertEquals("provided latency for origin {$recorded->value} without recording invocation", $error); + } + + /** + * @dataProvider originMismatchProvider + */ + public function testErrorInvokedMismatch(Origin $invoked, Origin $recorded): void + { + $this->tracker->operation(Operation::WRITE); + $this->tracker->invoked($invoked); + $this->tracker->error($recorded); + + $error = $this->tracker->build(); + + $this->assertEquals("provided error for origin {$recorded->value} without recording invocation", $error); + } + + /** + * @dataProvider originProvider + */ + public function testConsistencyInvokedMismatch(Origin $invoked): void + { + $this->tracker->operation(Operation::WRITE); + $this->tracker->invoked($invoked); + $this->tracker->consistent(fn () => true); + + $error = $this->tracker->build(); + + $this->assertEquals("provided consistency without recording both invocations", $error); + } + + /** + * @dataProvider originProvider + */ + public function testCanTrackInvocationsIndividually(Origin $invoked): void + { + $this->tracker->operation(Operation::WRITE); + $this->tracker->invoked($invoked); + $event = $this->tracker->build(); + + $this->assertCount(1, $event['measurements']); + + $measurement = $event['measurements'][0]; + $this->assertEquals('invoked', $measurement['key']); + $this->assertCount(1, $measurement['values']); + $this->assertTrue($measurement['values'][$invoked->value]); + } + + public function testCanTrackBothInvocations(): void + { + $this->tracker->operation(Operation::WRITE); + $this->tracker->invoked(Origin::OLD); + $this->tracker->invoked(Origin::NEW); + $event = $this->tracker->build(); + + $this->assertCount(1, $event['measurements']); + + $measurement = $event['measurements'][0]; + $this->assertEquals('invoked', $measurement['key']); + $this->assertCount(2, $measurement['values']); + $this->assertTrue($measurement['values']['old']); + $this->assertTrue($measurement['values']['new']); + } + + /** + * @dataProvider originProvider + */ + public function testCanTrackErrorsIndividually(Origin $invoked): void + { + $this->setTrackerDefaults(); + $this->tracker->error($invoked); + $event = $this->tracker->build(); + + $this->assertCount(2, $event['measurements']); + + $measurement = $event['measurements'][1]; // Skip invoked measurement + + $this->assertEquals('error', $measurement['key']); + $this->assertCount(1, $measurement['values']); + $this->assertTrue($measurement['values'][$invoked->value]); + } + + public function testCanTrackBothErrors(): void + { + $this->setTrackerDefaults(); + $this->tracker->error(Origin::OLD); + $this->tracker->error(Origin::NEW); + $event = $this->tracker->build(); + + $this->assertCount(2, $event['measurements']); + + $measurement = $event['measurements'][1]; // Skip invoked measurement + + $this->assertEquals('error', $measurement['key']); + $this->assertCount(2, $measurement['values']); + $this->assertTrue($measurement['values']['old']); + $this->assertTrue($measurement['values']['new']); + } + + /** + * @dataProvider originProvider + */ + public function testCanTrackLatenciesIndividually(Origin $invoked): void + { + $this->setTrackerDefaults(); + $this->tracker->latency($invoked, 10); + $event = $this->tracker->build(); + + $this->assertCount(2, $event['measurements']); + + $measurement = $event['measurements'][1]; // Skip invoked measurement + + $this->assertEquals('latency_ms', $measurement['key']); + $this->assertCount(1, $measurement['values']); + $this->assertEquals(10, $measurement['values'][$invoked->value]); + } + + public function testCanTrackBothLatencies(): void + { + $this->setTrackerDefaults(); + $this->tracker->latency(Origin::OLD, 10); + $this->tracker->latency(Origin::NEW, 20); + $event = $this->tracker->build(); + + $this->assertCount(2, $event['measurements']); + + $measurement = $event['measurements'][1]; // Skip invoked measurement + + $this->assertEquals('latency_ms', $measurement['key']); + $this->assertCount(2, $measurement['values']); + $this->assertEquals(10, $measurement['values']['old']); + $this->assertEquals(20, $measurement['values']['new']); + } + + + public function consistencyValuesProvider(): array + { + return [[true], [false]]; + } + + /** + * @dataProvider consistencyValuesProvider + */ + public function testWithoutCheckRatio(bool $consistent): void + { + $this->setTrackerDefaults(); + $this->tracker->consistent(fn () => $consistent); + $event = $this->tracker->build(); + + $this->assertCount(2, $event['measurements']); + + $measurement = $event['measurements'][1]; // Skip invoked measurement + + $this->assertEquals('consistent', $measurement['key']); + $this->assertEquals($consistent, $measurement['value']); + } + + /** + * @dataProvider consistencyValuesProvider + */ + public function testWithCheckRatioOf1(bool $consistent): void + { + $migrationSettings = (new MigrationSettingsBuilder())->setCheckRatio(1); + $flag = (new FlagBuilder('flag'))->variations('off')->variationForAll(0)->migrationSettings($migrationSettings)->build(0); + $detail = new EvaluationDetail('off', 0, EvaluationReason::fallthrough()); + $tracker = new OpTracker( + EvaluatorTestUtil::testLogger(), + 'flag', + FeatureFlag::decode($flag), + LDContext::create('user-key'), + $detail, + Stage::LIVE, + ); + $tracker->operation(Operation::READ) + ->invoked(Origin::OLD) + ->invoked(Origin::NEW); + $tracker->consistent(fn () => $consistent); + $event = $tracker->build(); + + $this->assertCount(2, $event['measurements']); + + $measurement = $event['measurements'][1]; // Skip invoked measurement + + $this->assertEquals('consistent', $measurement['key']); + $this->assertEquals($consistent, $measurement['value']); + $this->assertArrayNotHasKey('samplingRatio', $measurement); + } + + /** + * @dataProvider consistencyValuesProvider + */ + public function testCanDisableConsistencyWithCheckRatioOf0(bool $consistent): void + { + $migrationSettings = (new MigrationSettingsBuilder())->setCheckRatio(0); + $flag = (new FlagBuilder('flag'))->variations('off')->variationForAll(0)->migrationSettings($migrationSettings)->build(0); + $detail = new EvaluationDetail('off', 0, EvaluationReason::fallthrough()); + $tracker = new OpTracker( + EvaluatorTestUtil::testLogger(), + 'flag', + FeatureFlag::decode($flag), + LDContext::create('user-key'), + $detail, + Stage::LIVE, + ); + $tracker->operation(Operation::READ) + ->invoked(Origin::OLD) + ->invoked(Origin::NEW); + $tracker->consistent(fn () => $consistent); + $event = $tracker->build(); + + $this->assertCount(1, $event['measurements']); // We always have invoked + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php deleted file mode 100644 index fe07e9e8..00000000 --- a/tests/bootstrap.php +++ /dev/null @@ -1,5 +0,0 @@ -