Skip to content

Commit afa7a55

Browse files
oakbaniwangjoshuah
authored andcommitted
Feature Flag & Rollouts - Decision Service Logic (#69)
* Feature Flags models and parsing from new v4 file * Decision Object introduced
1 parent 38f33df commit afa7a55

File tree

7 files changed

+739
-32
lines changed

7 files changed

+739
-32
lines changed

src/Optimizely/DecisionService/DecisionService.php

Lines changed: 199 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
use Monolog\Logger;
2121
use Optimizely\Bucketer;
2222
use Optimizely\Entity\Experiment;
23+
use Optimizely\Entity\FeatureFlag;
24+
use Optimizely\Entity\Rollout;
2325
use Optimizely\Entity\Variation;
2426
use Optimizely\Logger\LoggerInterface;
2527
use Optimizely\ProjectConfig;
@@ -37,10 +39,11 @@
3739
*
3840
* The decision service contains all logic around how a user decision is made. This includes all of the following (in order):
3941
* 1. Checking experiment status.
40-
* 2. Checking whitelisting.
41-
* 3. Check sticky bucketing.
42-
* 4. Checking audience targeting.
43-
* 5. Using Murmurhash3 to bucket the user.
42+
* 2. Checking force bucketing
43+
* 3. Checking whitelisting.
44+
* 4. Check sticky bucketing.
45+
* 5. Checking audience targeting.
46+
* 6. Using Murmurhash3 to bucket the user.
4447
*
4548
* @package Optimizely
4649
*/
@@ -79,6 +82,28 @@ public function __construct(LoggerInterface $logger, ProjectConfig $projectConfi
7982
$this->_userProfileService = $userProfileService;
8083
}
8184

85+
/**
86+
* Gets the ID for Bucketing
87+
* @param string $userId user ID
88+
* @param array $userAttributes user attributes
89+
*
90+
* @return string the bucketing ID assigned to user
91+
*/
92+
private function getBucketingId($userId, $userAttributes)
93+
{
94+
// By default, the bucketing ID should be the user ID
95+
$bucketingId = $userId;
96+
97+
// If the bucketing ID key is defined in userAttributes, then use that in
98+
// place of the userID for the murmur hash key
99+
if (!empty($userAttributes[RESERVED_ATTRIBUTE_KEY_BUCKETING_ID])) {
100+
$bucketingId = $userAttributes[RESERVED_ATTRIBUTE_KEY_BUCKETING_ID];
101+
$this->_logger->log(Logger::DEBUG, sprintf('Setting the bucketing ID to "%s".', $bucketingId));
102+
}
103+
104+
return $bucketingId;
105+
}
106+
82107
/**
83108
* Determine which variation to show the user.
84109
*
@@ -90,14 +115,8 @@ public function __construct(LoggerInterface $logger, ProjectConfig $projectConfi
90115
*/
91116
public function getVariation(Experiment $experiment, $userId, $attributes = null)
92117
{
93-
// by default, the bucketing ID should be the user ID
94-
$bucketingId = $userId;
95-
96-
// If the bucketing ID key is defined in attributes, then use that in place of the userID for the murmur hash key
97-
if (!empty($attributes[RESERVED_ATTRIBUTE_KEY_BUCKETING_ID])) {
98-
$bucketingId = $attributes[RESERVED_ATTRIBUTE_KEY_BUCKETING_ID];
99-
$this->_logger->log(Logger::DEBUG, sprintf('Setting the bucketing ID to "%s".', $bucketingId));
100-
}
118+
119+
$bucketingId = $this->getBucketingId($userId, $attributes);
101120

102121
if (!$experiment->isExperimentRunning()) {
103122
$this->_logger->log(Logger::INFO, sprintf('Experiment "%s" is not running.', $experiment->getKey()));
@@ -144,6 +163,174 @@ public function getVariation(Experiment $experiment, $userId, $attributes = null
144163
return $variation;
145164
}
146165

166+
/**
167+
* Get the variation the user is bucketed into for the given FeatureFlag
168+
* @param FeatureFlag $featureFlag The feature flag the user wants to access
169+
* @param string $userId user ID
170+
* @param array $userAttributes user attributes
171+
* @return Decision if getVariationForFeatureExperiment or getVariationForFeatureRollout returns a Decision
172+
* null otherwise
173+
*/
174+
public function getVariationForFeature(FeatureFlag $featureFlag, $userId, $userAttributes)
175+
{
176+
//Evaluate in this order:
177+
//1. Attempt to bucket user into experiment using feature flag.
178+
//2. Attempt to bucket user into rollout using the feature flag.
179+
180+
// Check if the feature flag is under an experiment and the the user is bucketed into one of these experiments
181+
$decision = $this->getVariationForFeatureExperiment($featureFlag, $userId, $userAttributes);
182+
if ($decision) {
183+
return $decision;
184+
}
185+
186+
// Check if the feature flag has rollout and the user is bucketed into one of it's rules
187+
$decision = $this->getVariationForFeatureRollout($featureFlag, $userId, $userAttributes);
188+
if ($decision) {
189+
$this->_logger->log(
190+
Logger::INFO,
191+
"User '{$userId}' is bucketed into rollout for feature flag '{$featureFlag->getKey()}'."
192+
);
193+
194+
return $decision;
195+
}
196+
197+
$this->_logger->log(
198+
Logger::INFO,
199+
"User '{$userId}' is not bucketed into rollout for feature flag '{$featureFlag->getKey()}'."
200+
);
201+
202+
return null;
203+
}
204+
205+
/**
206+
* Get the variation if the user is bucketed for one of the experiments on this feature flag
207+
* @param FeatureFlag $featureFlag The feature flag the user wants to access
208+
* @param string $userId user id
209+
* @param array $userAttributes user userAttributes
210+
* @return Decision if a variation is returned for the user
211+
* null if feature flag is not used in any experiments or no variation is returned for the user
212+
*/
213+
public function getVariationForFeatureExperiment(FeatureFlag $featureFlag, $userId, $userAttributes)
214+
{
215+
$feature_flag_key = $featureFlag->getKey();
216+
$experimentIds = $featureFlag->getExperimentIds();
217+
218+
// Check if there are any experiment IDs inside feature flag
219+
if (empty($experimentIds)) {
220+
$this->_logger->log(
221+
Logger::DEBUG,
222+
"The feature flag '{$feature_flag_key}' is not used in any experiments."
223+
);
224+
return null;
225+
}
226+
227+
// Evaluate each experiment ID and return the first bucketed experiment variation
228+
foreach ($experimentIds as $experiment_id) {
229+
$experiment = $this->_projectConfig->getExperimentFromId($experiment_id);
230+
if ($experiment && !($experiment->getKey())) {
231+
// Error logged and exception thrown in ProjectConfig-getExperimentFromId
232+
continue;
233+
}
234+
235+
$variation = $this->getVariation($experiment, $userId, $userAttributes);
236+
if ($variation && $variation->getKey()) {
237+
$this->_logger->log(
238+
Logger::INFO,
239+
"The user '{$userId}' is bucketed into experiment '{$experiment->getKey()}' of feature '{$feature_flag_key}'."
240+
);
241+
242+
return new FeatureDecision($experiment->getId(), $variation->getId(), FeatureDecision::DECISION_SOURCE_EXPERIMENT);
243+
}
244+
}
245+
246+
$this->_logger->log(
247+
Logger::INFO,
248+
"The user '{$userId}' is not bucketed into any of the experiments using the feature '{$feature_flag_key}'."
249+
);
250+
251+
return null;
252+
}
253+
254+
/**
255+
* Get the variation if the user is bucketed into rollout for this feature flag
256+
* Evaluate the user for rules in priority order by seeing if the user satisfies the audience.
257+
* Fall back onto the everyone else rule if the user is ever excluded from a rule due to traffic allocation.
258+
* @param FeatureFlag $featureFlag The feature flag the user wants to access
259+
* @param string $userId user id
260+
* @param array $userAttributes user userAttributes
261+
* @return Decision if a variation is returned for the user
262+
* null if feature flag is not used in a rollout or
263+
* no rollout found against the rollout ID or
264+
* no variation is returned for the user
265+
*/
266+
public function getVariationForFeatureRollout(FeatureFlag $featureFlag, $userId, $userAttributes)
267+
{
268+
$bucketing_id = $this->getBucketingId($userId, $userAttributes);
269+
$feature_flag_key = $featureFlag->getKey();
270+
$rollout_id = $featureFlag->getRolloutId();
271+
if (empty($rollout_id)) {
272+
$this->_logger->log(
273+
Logger::DEBUG,
274+
"Feature flag '{$feature_flag_key}' is not used in a rollout."
275+
);
276+
return null;
277+
}
278+
$rollout = $this->_projectConfig->getRolloutFromId($rollout_id);
279+
if ($rollout && !($rollout->getId())) {
280+
// Error logged and thrown in getRolloutFromId
281+
return null;
282+
}
283+
284+
$rolloutRules = $rollout->getExperiments();
285+
if (sizeof($rolloutRules) == 0) {
286+
return null;
287+
}
288+
289+
// Evaluate all rollout rules except for last one
290+
for ($i = 0; $i < sizeof($rolloutRules) - 1; $i++) {
291+
$experiment = $rolloutRules[$i];
292+
293+
// Evaluate if user meets the audience condition of this rollout rule
294+
if (!Validator::isUserInExperiment($this->_projectConfig, $experiment, $userAttributes)) {
295+
$this->_logger->log(
296+
Logger::DEBUG,
297+
sprintf("User '%s' did not meet the audience conditions to be in rollout rule '%s'.", $userId, $experiment->getKey())
298+
);
299+
// Evaluate this user for the next rule
300+
continue;
301+
}
302+
303+
$this->_logger->log(
304+
Logger::DEBUG,
305+
sprintf("Attempting to bucket user '{$userId}' into rollout rule '%s'.", $experiment->getKey())
306+
);
307+
308+
// Evaluate if user satisfies the traffic allocation for this rollout rule
309+
$variation = $this->_bucketer->bucket($this->_projectConfig, $experiment, $bucketing_id, $userId);
310+
if ($variation && $variation->getKey()) {
311+
return new FeatureDecision($experiment->getId(), $variation->getId(), FeatureDecision::DECISION_SOURCE_ROLLOUT);
312+
} else {
313+
$this->_logger->log(
314+
Logger::DEBUG,
315+
"User '{$userId}' was excluded due to traffic allocation. Checking 'Everyone Else' rule now."
316+
);
317+
break;
318+
}
319+
}
320+
// Evaluate Everyone Else Rule / Last Rule now
321+
$experiment = $rolloutRules[sizeof($rolloutRules)-1];
322+
$variation = $this->_bucketer->bucket($this->_projectConfig, $experiment, $bucketing_id, $userId);
323+
if ($variation && $variation->getKey()) {
324+
return new FeatureDecision($experiment->getId(), $variation->getId(), FeatureDecision::DECISION_SOURCE_ROLLOUT);
325+
} else {
326+
$this->_logger->log(
327+
Logger::DEBUG,
328+
"User '{$userId}' was excluded from the 'Everyone Else' rule for feature flag"
329+
);
330+
return null;
331+
}
332+
}
333+
147334
/**
148335
* Determine variation the user has been forced into.
149336
*
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
<?php
2+
/**
3+
* Copyright 2017, Optimizely Inc and Contributors
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
namespace Optimizely\DecisionService;
18+
19+
class FeatureDecision
20+
{
21+
const DECISION_SOURCE_EXPERIMENT = 'experiment';
22+
const DECISION_SOURCE_ROLLOUT = 'rollout';
23+
24+
/**
25+
* @var string The ID experiment in this decision.
26+
*/
27+
private $_experimentId;
28+
29+
/**
30+
* @var string The ID variation in this decision.
31+
*/
32+
private $_variationId;
33+
34+
/**
35+
* The source of the decision. Either DECISION_SOURCE_EXPERIMENT or DECISION_SOURCE_ROLLOUT
36+
* @var string
37+
*/
38+
private $_source;
39+
40+
/**
41+
* FeatureDecision constructor.
42+
*
43+
* @param $experimentId
44+
* @param $variationId
45+
* @param $source
46+
*/
47+
public function __construct($experimentId, $variationId, $source)
48+
{
49+
$this->_experimentId = $experimentId;
50+
$this->_variationId = $variationId;
51+
$this->_source = $source;
52+
}
53+
54+
public function getExperimentId()
55+
{
56+
return $this->_experimentId;
57+
}
58+
59+
public function getVariationId()
60+
{
61+
return $this->_variationId;
62+
}
63+
64+
public function getSource()
65+
{
66+
return $this->_source;
67+
}
68+
}

src/Optimizely/ProjectConfig.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,7 @@ public function getExperimentFromId($experimentId)
310310

311311
/**
312312
* @param String $featureKey Key of the feature flag
313+
*
313314
* @return FeatureFlag Entity corresponding to the key.
314315
*/
315316
public function getFeatureFlagFromKey($featureKey)
@@ -325,6 +326,7 @@ public function getFeatureFlagFromKey($featureKey)
325326

326327
/**
327328
* @param String $rolloutId
329+
*
328330
* @return Rollout
329331
*/
330332
public function getRolloutFromId($rolloutId)
@@ -333,7 +335,8 @@ public function getRolloutFromId($rolloutId)
333335
return $this->_rolloutIdMap[$rolloutId];
334336
}
335337

336-
$this->_logger->log(Logger::ERROR, sprintf('Rollout ID "%s" is not in datafile.', $rolloutId));
338+
$this->_logger->log(Logger::ERROR, sprintf('Rollout with ID "%s" is not in the datafile.', $rolloutId));
339+
337340
$this->_errorHandler->handleError(new InvalidRolloutException('Provided rollout is not in datafile.'));
338341
return new Rollout();
339342
}

src/Optimizely/Utils/Validator.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ public static function validateJsonSchema($datafile, LoggerInterface $logger = n
4444
$logger->log(Logger::DEBUG, "JSON does not validate. Violations:\n");;
4545
foreach ($validator->getErrors() as $error) {
4646
$logger->log(Logger::DEBUG, "[%s] %s\n", $error['property'], $error['message']);
47-
}
47+
}
4848
}
4949

5050
return false;

0 commit comments

Comments
 (0)