diff --git a/src/api/FeatureFlags/Client.php b/src/api/FeatureFlags/Client.php index 8bc2cdc6fc..37a3e1cb0d 100644 --- a/src/api/FeatureFlags/Client.php +++ b/src/api/FeatureFlags/Client.php @@ -3,7 +3,10 @@ namespace DDTrace\FeatureFlags; use DDTrace\FeatureFlags\Internal\Evaluator; +use DDTrace\FeatureFlags\Internal\EvaluationCompleted; +use DDTrace\FeatureFlags\Internal\EvaluationCompletedHook; use DDTrace\FeatureFlags\Internal\NativeEvaluator; +use DDTrace\FeatureFlags\Internal\NoopEvaluationCompletedHook; use DDTrace\FeatureFlags\Internal\TriggerErrorWarningEmitter; use DDTrace\FeatureFlags\Internal\WarningEmitter; @@ -11,14 +14,17 @@ final class Client { private $evaluator; private $warningEmitter; + private $evaluationCompletedHook; private $warnedAboutNonProductionRuntime = false; private function __construct( Evaluator $evaluator, - WarningEmitter $warningEmitter + WarningEmitter $warningEmitter, + EvaluationCompletedHook $evaluationCompletedHook ) { $this->evaluator = $evaluator; $this->warningEmitter = $warningEmitter; + $this->evaluationCompletedHook = $evaluationCompletedHook; } public static function create() @@ -31,7 +37,8 @@ public static function create() */ public static function createWithDependencies( $evaluator = null, - $warningEmitter = null + $warningEmitter = null, + $evaluationCompletedHook = null ) { if ($evaluator !== null && !$evaluator instanceof Evaluator) { throw new \InvalidArgumentException('Expected an Evaluator instance'); @@ -41,9 +48,14 @@ public static function createWithDependencies( throw new \InvalidArgumentException('Expected a WarningEmitter instance'); } + if ($evaluationCompletedHook !== null && !$evaluationCompletedHook instanceof EvaluationCompletedHook) { + throw new \InvalidArgumentException('Expected an EvaluationCompletedHook instance'); + } + return new self( $evaluator ?: NativeEvaluator::createOrUnavailable(), - $warningEmitter ?: new TriggerErrorWarningEmitter() + $warningEmitter ?: new TriggerErrorWarningEmitter(), + $evaluationCompletedHook ?: new NoopEvaluationCompletedHook() ); } @@ -111,10 +123,27 @@ private function evaluate($flagKey, $expectedType, $defaultValue, array $context ); $this->warnIfNonProductionRuntime($details); + $this->evaluationCompleted(new EvaluationCompleted( + $flagKey, + $expectedType, + $defaultValue, + $targetingKey, + $attributes, + $details + )); return $details; } + private function evaluationCompleted(EvaluationCompleted $evaluation) + { + try { + $this->evaluationCompletedHook->evaluationCompleted($evaluation); + } catch (\Throwable $throwable) { + // Internal exposure/metric hooks must never affect flag evaluation results. + } + } + private function normalizeContext(array $context) { $targetingKey = null; diff --git a/src/api/FeatureFlags/Internal/EvaluationCompleted.php b/src/api/FeatureFlags/Internal/EvaluationCompleted.php new file mode 100644 index 0000000000..7c8c971e5b --- /dev/null +++ b/src/api/FeatureFlags/Internal/EvaluationCompleted.php @@ -0,0 +1,118 @@ + $attributes + */ + public function __construct( + $flagKey, + $valueType, + $defaultValue, + $targetingKey, + array $attributes, + EvaluationDetails $details + ) { + if (!is_string($flagKey) || $flagKey === '') { + throw new \InvalidArgumentException('Feature flag key must be a non-empty string'); + } + + if (!EvaluationType::isValid($valueType)) { + throw new \InvalidArgumentException('Unknown feature flag value type: ' . (string) $valueType); + } + + if ($targetingKey !== null && !is_string($targetingKey)) { + throw new \InvalidArgumentException('Feature flag targeting key must be a string or null'); + } + + $this->flagKey = $flagKey; + $this->valueType = $valueType; + $this->defaultValue = $defaultValue; + $this->targetingKey = $targetingKey; + $this->attributes = $attributes; + $this->details = $details; + } + + public function getFlagKey() + { + return $this->flagKey; + } + + public function getValueType() + { + return $this->valueType; + } + + public function getDefaultValue() + { + return $this->defaultValue; + } + + public function getTargetingKey() + { + return $this->targetingKey; + } + + public function getAttributes() + { + return $this->attributes; + } + + public function getValue() + { + return $this->details->getValue(); + } + + public function getReason() + { + return $this->details->getReason(); + } + + public function getVariant() + { + return $this->details->getVariant(); + } + + public function getErrorCode() + { + return $this->details->getErrorCode(); + } + + public function getErrorMessage() + { + return $this->details->getErrorMessage(); + } + + public function getAllocationKey() + { + $exposureData = $this->details->getExposureData(); + if (!isset($exposureData['allocationKey']) || !is_string($exposureData['allocationKey'])) { + return null; + } + + return $exposureData['allocationKey'] === '' ? null : $exposureData['allocationKey']; + } + + public function shouldLogExposure() + { + $exposureData = $this->details->getExposureData(); + + return isset($exposureData['doLog']) && $exposureData['doLog'] === true; + } +} diff --git a/src/api/FeatureFlags/Internal/EvaluationCompletedHook.php b/src/api/FeatureFlags/Internal/EvaluationCompletedHook.php new file mode 100644 index 0000000000..4b2dbafe0b --- /dev/null +++ b/src/api/FeatureFlags/Internal/EvaluationCompletedHook.php @@ -0,0 +1,11 @@ +setSuccess( + 'openfeature.completed', + true, + EvaluationReason::TARGETING_MATCH, + 'enabled', + ['owner' => 'ffe'], + ['allocationKey' => 'alloc-openfeature', 'doLog' => true] + ); + $hook = new OpenFeatureRecordingEvaluationCompletedHook(); + $featureFlagsClient = FeatureFlagsClient::createWithDependencies( + $evaluator, + new NoopWarningEmitter(), + $hook + ); + $provider = DataDogProvider::createWithDependencies($featureFlagsClient); + + $details = $provider->resolveBooleanValue('openfeature.completed', false, new EvaluationContext( + '', + new Attributes([ + 'plan' => 'pro', + 'nested' => ['drop'], + ]) + )); + + self::assertTrue($details->getValue()); + + $evaluations = $hook->evaluations(); + self::assertCount(1, $evaluations); + $evaluation = $evaluations[0]; + self::assertSame('openfeature.completed', $evaluation->getFlagKey()); + self::assertSame(EvaluationType::BOOLEAN, $evaluation->getValueType()); + self::assertSame('', $evaluation->getTargetingKey()); + self::assertSame(['plan' => 'pro'], $evaluation->getAttributes()); + self::assertSame(EvaluationReason::TARGETING_MATCH, $evaluation->getReason()); + self::assertSame('enabled', $evaluation->getVariant()); + self::assertSame('alloc-openfeature', $evaluation->getAllocationKey()); + self::assertTrue($evaluation->shouldLogExposure()); + } + public function testUnavailableRuntimeReturnsDefaultDetailsAndOneWarning(): void { $warnings = new OpenFeatureRecordingWarningEmitter(); @@ -186,13 +230,19 @@ public function setSuccess( string $flagKey, mixed $value, string $reason = EvaluationReason::STATIC_REASON, - ?string $variant = null + ?string $variant = null, + array $metadata = [], + array $exposureData = [] ): self { $this->details[$flagKey] = new EvaluationDetails( $value, $this->typeForValue($value), $reason, - $variant + $variant, + null, + null, + $metadata, + $exposureData ); return $this; @@ -323,4 +373,23 @@ public function warnings(): array return $this->warnings; } } + +final class OpenFeatureRecordingEvaluationCompletedHook implements EvaluationCompletedHook +{ + /** @var list */ + private array $evaluations = []; + + public function evaluationCompleted(EvaluationCompleted $evaluation) + { + $this->evaluations[] = $evaluation; + } + + /** + * @return list + */ + public function evaluations(): array + { + return $this->evaluations; + } +} } diff --git a/tests/api/Unit/FeatureFlags/ClientTest.php b/tests/api/Unit/FeatureFlags/ClientTest.php index b5fc53b72e..6284bdc2da 100644 --- a/tests/api/Unit/FeatureFlags/ClientTest.php +++ b/tests/api/Unit/FeatureFlags/ClientTest.php @@ -8,6 +8,8 @@ use DDTrace\FeatureFlags\EvaluationReason; use DDTrace\FeatureFlags\EvaluationType; use DDTrace\FeatureFlags\Internal\Evaluator; +use DDTrace\FeatureFlags\Internal\EvaluationCompleted; +use DDTrace\FeatureFlags\Internal\EvaluationCompletedHook; use DDTrace\FeatureFlags\Internal\NativeEvaluator; use DDTrace\FeatureFlags\Internal\UnavailableEvaluator; use DDTrace\FeatureFlags\Internal\WarningEmitter; @@ -95,6 +97,61 @@ public function testContextNormalizesTargetingKeyAndPrimitiveAttributes() ), $calls[0]['attributes']); } + public function testEvaluationCompletedHookReceivesNormalizedContextAndDetails() + { + $evaluator = new ClientTestEvaluator(); + $evaluator->setSuccess( + 'flag.completed', + true, + EvaluationReason::SPLIT, + 'treatment', + array('owner' => 'ffe'), + array('allocationKey' => 'alloc-1', 'doLog' => true) + ); + $hook = new RecordingEvaluationCompletedHook(); + + $client = Client::createWithDependencies($evaluator, new RecordingWarningEmitter(), $hook); + $details = $client->getBooleanDetails('flag.completed', false, array( + 'targetingKey' => '', + 'attributes' => array( + 'plan' => 'pro', + 'age' => 41, + 'nested' => array('drop'), + ), + )); + + $evaluations = $hook->evaluations(); + $this->assertCount(1, $evaluations); + $evaluation = $evaluations[0]; + + $this->assertSame('flag.completed', $evaluation->getFlagKey()); + $this->assertSame(EvaluationType::BOOLEAN, $evaluation->getValueType()); + $this->assertFalse($evaluation->getDefaultValue()); + $this->assertSame('', $evaluation->getTargetingKey()); + $this->assertSame(array('plan' => 'pro', 'age' => 41), $evaluation->getAttributes()); + $this->assertSame($details->getValue(), $evaluation->getValue()); + $this->assertSame(EvaluationReason::SPLIT, $evaluation->getReason()); + $this->assertSame('treatment', $evaluation->getVariant()); + $this->assertNull($evaluation->getErrorCode()); + $this->assertNull($evaluation->getErrorMessage()); + $this->assertSame('alloc-1', $evaluation->getAllocationKey()); + $this->assertTrue($evaluation->shouldLogExposure()); + } + + public function testEvaluationCompletedHookFailureDoesNotChangeEvaluationResult() + { + $evaluator = new ClientTestEvaluator(); + $evaluator->setSuccess('flag.completed.failure', 'on'); + + $client = Client::createWithDependencies( + $evaluator, + new RecordingWarningEmitter(), + new ThrowingEvaluationCompletedHook() + ); + + $this->assertSame('on', $client->getStringValue('flag.completed.failure', 'off')); + } + public function testUnavailableRuntimeReturnsDefaultWithProviderNotReadyDetailsAndWarning() { $warnings = new RecordingWarningEmitter(); @@ -248,3 +305,26 @@ public function warnings() return $this->warnings; } } + +final class RecordingEvaluationCompletedHook implements EvaluationCompletedHook +{ + private $evaluations = array(); + + public function evaluationCompleted(EvaluationCompleted $evaluation) + { + $this->evaluations[] = $evaluation; + } + + public function evaluations() + { + return $this->evaluations; + } +} + +final class ThrowingEvaluationCompletedHook implements EvaluationCompletedHook +{ + public function evaluationCompleted(EvaluationCompleted $evaluation) + { + throw new \RuntimeException('hook failed'); + } +} diff --git a/tests/ext/ffe/system_test_data_evaluate.phpt b/tests/ext/ffe/system_test_data_evaluate.phpt index 7cf653f947..9fc470f524 100644 --- a/tests/ext/ffe/system_test_data_evaluate.phpt +++ b/tests/ext/ffe/system_test_data_evaluate.phpt @@ -103,6 +103,9 @@ function require_feature_flag_api($root) 'UnavailableEvaluator', 'TriggerErrorWarningEmitter', 'NativeEvaluator', + 'EvaluationCompleted', + 'EvaluationCompletedHook', + 'NoopEvaluationCompletedHook', ) as $classFile) { require_once $internalRoot . '/' . $classFile . '.php'; }