Skip to content
35 changes: 32 additions & 3 deletions src/api/FeatureFlags/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,28 @@
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;

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()
Expand All @@ -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');
Expand All @@ -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()
);
}

Expand Down Expand Up @@ -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;
Expand Down
118 changes: 118 additions & 0 deletions src/api/FeatureFlags/Internal/EvaluationCompleted.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
<?php

namespace DDTrace\FeatureFlags\Internal;

use DDTrace\FeatureFlags\EvaluationDetails;
use DDTrace\FeatureFlags\EvaluationType;

final class EvaluationCompleted
{
private $flagKey;
private $valueType;
private $defaultValue;
private $targetingKey;
private $attributes;
private $details;

/**
* @param string $flagKey
* @param string $valueType One of EvaluationType::*.
* @param mixed $defaultValue
* @param string|null $targetingKey
* @param array<string, bool|int|float|string> $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;
}
}
11 changes: 11 additions & 0 deletions src/api/FeatureFlags/Internal/EvaluationCompletedHook.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

namespace DDTrace\FeatureFlags\Internal;

interface EvaluationCompletedHook
{
/**
* @return void
*/
public function evaluationCompleted(EvaluationCompleted $evaluation);
}
10 changes: 10 additions & 0 deletions src/api/FeatureFlags/Internal/NoopEvaluationCompletedHook.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

namespace DDTrace\FeatureFlags\Internal;

final class NoopEvaluationCompletedHook implements EvaluationCompletedHook
{
public function evaluationCompleted(EvaluationCompleted $evaluation)
{
}
}
73 changes: 71 additions & 2 deletions tests/OpenFeature/DataDogProviderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,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\NoopWarningEmitter;
use DDTrace\FeatureFlags\Internal\UnavailableEvaluator;
Expand Down Expand Up @@ -98,6 +100,48 @@ public function testEvaluationContextIsNormalizedForDatadogClient(): void
], $calls[0]['attributes']);
}

public function testOpenFeatureBridgeUsesSharedEvaluationCompletedHook(): void
{
$evaluator = new OpenFeatureTestEvaluator();
$evaluator->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();
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -323,4 +373,23 @@ public function warnings(): array
return $this->warnings;
}
}

final class OpenFeatureRecordingEvaluationCompletedHook implements EvaluationCompletedHook
{
/** @var list<EvaluationCompleted> */
private array $evaluations = [];

public function evaluationCompleted(EvaluationCompleted $evaluation)
{
$this->evaluations[] = $evaluation;
}

/**
* @return list<EvaluationCompleted>
*/
public function evaluations(): array
{
return $this->evaluations;
}
}
}
Loading
Loading