Skip to content

Commit cb7e69a

Browse files
authored
(U2C 6) factor evaluation logic out of model classes (#107)
1 parent 8e5ddd6 commit cb7e69a

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

42 files changed

+1728
-1366
lines changed

src/LaunchDarkly/FeatureFlagsState.php

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,16 +41,15 @@ public function __construct(bool $valid)
4141
public function addFlag(
4242
FeatureFlag $flag,
4343
EvaluationDetail $detail,
44+
bool $forceReasonTracking = false,
4445
bool $withReason = false,
4546
bool $detailsOnlyIfTracked = false
4647
): void {
47-
$requireExperimentData = $flag->isExperiment($detail->getReason());
48-
4948
$this->_flagValues[$flag->getKey()] = $detail->getValue();
5049
$meta = [];
5150

52-
$trackEvents = $flag->isTrackEvents() || $requireExperimentData;
53-
$trackReason = $requireExperimentData;
51+
$trackEvents = $flag->isTrackEvents() || $forceReasonTracking;
52+
$trackReason = $forceReasonTracking;
5453

5554
$omitDetails = false;
5655
if ($detailsOnlyIfTracked) {

src/LaunchDarkly/Impl/EvalResult.php

Lines changed: 0 additions & 35 deletions
This file was deleted.
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace LaunchDarkly\Impl\Evaluation;
6+
7+
use LaunchDarkly\EvaluationDetail;
8+
9+
/**
10+
* Internal class that holds intermediate flag evaluation results.
11+
*
12+
* @ignore
13+
* @internal
14+
*/
15+
class EvalResult
16+
{
17+
private EvaluationDetail $_detail;
18+
private bool $_forceReasonTracking;
19+
20+
/**
21+
* @param EvaluationDetail $detail
22+
* @param bool $forceReasonTracking
23+
*/
24+
public function __construct(EvaluationDetail $detail, bool $forceReasonTracking = false)
25+
{
26+
$this->_detail = $detail;
27+
$this->_forceReasonTracking = $forceReasonTracking;
28+
}
29+
30+
public function getDetail(): EvaluationDetail
31+
{
32+
return $this->_detail;
33+
}
34+
35+
public function isForceReasonTracking(): bool
36+
{
37+
return $this->_forceReasonTracking;
38+
}
39+
}
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
<?php
2+
3+
namespace LaunchDarkly\Impl\Evaluation;
4+
5+
use LaunchDarkly\EvaluationReason;
6+
use LaunchDarkly\FeatureRequester;
7+
use LaunchDarkly\Impl\Model\Clause;
8+
use LaunchDarkly\Impl\Model\FeatureFlag;
9+
use LaunchDarkly\Impl\Model\Rule;
10+
use LaunchDarkly\Impl\Model\Segment;
11+
use LaunchDarkly\Impl\Model\SegmentRule;
12+
use LaunchDarkly\LDContext;
13+
14+
/**
15+
* Encapsulates the feature flag evaluation logic. The Evaluator has no direct access to the
16+
* rest of the SDK environment; if it needs to retrieve flags or segments that are referenced
17+
* by a flag, it does so through a FeatureRequester that is provided in the constructor. It also
18+
* produces evaluation events as appropriate for any referenced prerequisite flags.
19+
* @ignore
20+
* @internal
21+
*/
22+
class Evaluator
23+
{
24+
private FeatureRequester $_featureRequester;
25+
26+
public function __construct(FeatureRequester $featureRequester)
27+
{
28+
$this->_featureRequester = $featureRequester;
29+
}
30+
31+
/**
32+
* The client's entry point for evaluating a flag. No other Evaluator methods should be exposed.
33+
*
34+
* @param FeatureFlag $flag an existing feature flag; any other referenced flags or segments will be
35+
* queried via the FeatureRequester
36+
* @param LDContext $context the evaluation context
37+
* @param ?callable $prereqEvalSink a function that may be called with a
38+
* PrerequisiteEvaluationRecord parameter for any prerequisite flags that are evaluated as a side
39+
* effect of evaluating this flag
40+
* @return EvalResult the outputs of evaluation
41+
*/
42+
public function evaluate(FeatureFlag $flag, LDContext $context, ?callable $prereqEvalSink): EvalResult
43+
{
44+
return $this->evaluateInternal($flag, $context, $prereqEvalSink);
45+
}
46+
47+
private function evaluateInternal(
48+
FeatureFlag $flag,
49+
LDContext $context,
50+
?callable $prereqEvalSink
51+
): EvalResult {
52+
if (!$flag->isOn()) {
53+
return EvaluatorHelpers::getOffResult($flag, EvaluationReason::off());
54+
}
55+
56+
$prereqFailureReason = $this->checkPrerequisites($flag, $context, $prereqEvalSink);
57+
if ($prereqFailureReason !== null) {
58+
return EvaluatorHelpers::getOffResult($flag, $prereqFailureReason);
59+
}
60+
61+
// Check to see if targets match
62+
foreach ($flag->getTargets() as $target) {
63+
foreach ($target->getValues() as $value) {
64+
if ($value === $context->getKey()) {
65+
$detail = EvaluatorHelpers::evaluationDetailForVariation(
66+
$flag,
67+
$target->getVariation(),
68+
EvaluationReason::targetMatch()
69+
);
70+
return new EvalResult($detail, false);
71+
}
72+
}
73+
}
74+
75+
// Now walk through the rules and see if any match
76+
foreach ($flag->getRules() as $i => $rule) {
77+
if ($this->ruleMatchesContext($rule, $context)) {
78+
return EvaluatorHelpers::getResultForVariationOrRollout(
79+
$flag,
80+
$rule,
81+
$rule->isTrackEvents(),
82+
$context,
83+
EvaluationReason::ruleMatch($i, $rule->getId())
84+
);
85+
}
86+
}
87+
return EvaluatorHelpers::getResultForVariationOrRollout(
88+
$flag,
89+
$flag->getFallthrough(),
90+
$flag->isTrackEventsFallthrough(),
91+
$context,
92+
EvaluationReason::fallthrough()
93+
);
94+
}
95+
96+
private function checkPrerequisites(
97+
FeatureFlag $flag,
98+
LDContext $context,
99+
?callable $prereqEvalSink
100+
): ?EvaluationReason {
101+
foreach ($flag->getPrerequisites() as $prereq) {
102+
$prereqOk = true;
103+
try {
104+
$prereqFeatureFlag = $this->_featureRequester->getFeature($prereq->getKey());
105+
if ($prereqFeatureFlag == null) {
106+
$prereqOk = false;
107+
} else {
108+
$prereqEvalResult = $this->evaluateInternal($prereqFeatureFlag, $context, $prereqEvalSink);
109+
$variation = $prereq->getVariation();
110+
if (!$prereqFeatureFlag->isOn() || $prereqEvalResult->getDetail()->getVariationIndex() !== $variation) {
111+
$prereqOk = false;
112+
}
113+
if ($prereqEvalSink !== null) {
114+
$prereqEvalSink(new PrerequisiteEvaluationRecord($prereqFeatureFlag, $flag, $prereqEvalResult));
115+
}
116+
}
117+
} catch (\Exception $e) {
118+
$prereqOk = false;
119+
}
120+
if (!$prereqOk) {
121+
return EvaluationReason::prerequisiteFailed($prereq->getKey());
122+
}
123+
}
124+
return null;
125+
}
126+
127+
private function ruleMatchesContext(Rule $rule, LDContext $context): bool
128+
{
129+
foreach ($rule->getClauses() as $clause) {
130+
if (!$this->clauseMatchesContext($clause, $context)) {
131+
return false;
132+
}
133+
}
134+
return true;
135+
}
136+
137+
private function clauseMatchesContext(Clause $clause, LDContext $context): bool
138+
{
139+
if ($clause->getOp() === 'segmentMatch') {
140+
foreach ($clause->getValues() as $value) {
141+
$segment = $this->_featureRequester->getSegment($value);
142+
if ($segment) {
143+
if ($this->segmentMatchesContext($segment, $context)) {
144+
return EvaluatorHelpers::maybeNegate($clause, true);
145+
}
146+
}
147+
}
148+
return EvaluatorHelpers::maybeNegate($clause, false);
149+
}
150+
return EvaluatorHelpers::matchClauseWithoutSegments($clause, $context);
151+
}
152+
153+
private function segmentMatchesContext(Segment $segment, LDContext $context): bool
154+
{
155+
$key = $context->getKey();
156+
if (!$key) {
157+
return false;
158+
}
159+
if (in_array($key, $segment->getIncluded(), true)) {
160+
return true;
161+
}
162+
if (in_array($key, $segment->getExcluded(), true)) {
163+
return false;
164+
}
165+
foreach ($segment->getRules() as $rule) {
166+
if ($this->segmentRuleMatchesContext($rule, $context, $segment->getKey(), $segment->getSalt())) {
167+
return true;
168+
}
169+
}
170+
return false;
171+
}
172+
173+
private function segmentRuleMatchesContext(
174+
SegmentRule $rule,
175+
LDContext $context,
176+
string $segmentKey,
177+
string $segmentSalt
178+
): bool {
179+
foreach ($rule->getClauses() as $clause) {
180+
if (!EvaluatorHelpers::matchClauseWithoutSegments($clause, $context)) {
181+
return false;
182+
}
183+
}
184+
// If the weight is absent, this rule matches
185+
if ($rule->getWeight() === null) {
186+
return true;
187+
}
188+
// All of the clauses are met. See if the user buckets in
189+
$bucketBy = $rule->getBucketBy() ?: 'key';
190+
$bucket = EvaluatorBucketing::getBucketValueForContext($context, $segmentKey, $bucketBy, $segmentSalt, null);
191+
$weight = $rule->getWeight() / 100000.0;
192+
return $bucket < $weight;
193+
}
194+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
<?php
2+
3+
namespace LaunchDarkly\Impl\Evaluation;
4+
5+
use LaunchDarkly\Impl\Model\VariationOrRollout;
6+
use LaunchDarkly\LDContext;
7+
8+
/**
9+
* Encapsulates the logic for percentage rollouts and experiments.
10+
* @ignore
11+
* @internal
12+
*/
13+
class EvaluatorBucketing
14+
{
15+
const LONG_SCALE = 0xFFFFFFFFFFFFFFF;
16+
17+
public static function variationIndexForContext(
18+
VariationOrRollout $vr,
19+
LDContext $context,
20+
string $_key,
21+
?string $_salt
22+
): array {
23+
$variation = $vr->getVariation();
24+
if ($variation !== null) {
25+
return [$variation, false];
26+
}
27+
$rollout = $vr->getRollout();
28+
if ($rollout === null) {
29+
return [null, false];
30+
}
31+
$variations = $rollout->getVariations();
32+
if ($variations) {
33+
$bucketBy = $rollout->getBucketBy() ?: "key";
34+
$bucket = self::getBucketValueForContext($context, $_key, $bucketBy, $_salt, $rollout->getSeed());
35+
$sum = 0.0;
36+
foreach ($variations as $wv) {
37+
$sum += $wv->getWeight() / 100000.0;
38+
if ($bucket < $sum) {
39+
return [$wv->getVariation(), $rollout->isExperiment() && !$wv->isUntracked()];
40+
}
41+
}
42+
$lastVariation = $variations[count($variations) - 1];
43+
return [$lastVariation->getVariation(), $rollout->isExperiment() && !$lastVariation->isUntracked()];
44+
}
45+
return [null, false];
46+
}
47+
48+
public static function getBucketValueForContext(
49+
LDContext $context,
50+
string $_key,
51+
string $attr,
52+
?string $_salt,
53+
?int $seed
54+
): float {
55+
$contextValue = $context->get($attr);
56+
if ($contextValue === null) {
57+
return 0.0;
58+
}
59+
if (is_int($contextValue)) {
60+
$contextValue = (string) $contextValue;
61+
} elseif (!is_string($contextValue)) {
62+
return 0.0;
63+
}
64+
$idHash = $contextValue;
65+
if (isset($seed)) {
66+
$prefix = (string) $seed;
67+
} else {
68+
$prefix = $_key . "." . ($_salt ?: '');
69+
}
70+
$hash = substr(sha1($prefix . "." . $idHash), 0, 15);
71+
$longVal = (int)base_convert($hash, 16, 10);
72+
$result = $longVal / self::LONG_SCALE;
73+
74+
return $result;
75+
}
76+
}

0 commit comments

Comments
 (0)