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 @@
-