Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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