Skip to content

Commit fed569c

Browse files
Merge pull request #53 from optimizely/alda/forcedBucketing
Alda/forced bucketing
2 parents 2daa8d6 + 11e6506 commit fed569c

File tree

6 files changed

+660
-104
lines changed

6 files changed

+660
-104
lines changed

src/Optimizely/DecisionService/DecisionService.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,13 @@ public function getVariation(Experiment $experiment, $userId, $attributes = null
9292
return null;
9393
}
9494

95+
// check if a forced variation is set
96+
$forcedVariation = $this->_projectConfig->getForcedVariation($experiment->getKey(), $userId);
97+
if (!is_null($forcedVariation)) {
98+
return $forcedVariation;
99+
}
100+
101+
// check if the user has been whitelisted
95102
$variation = $this->getWhitelistedVariation($experiment, $userId);
96103
if (!is_null($variation)) {
97104
return $variation;

src/Optimizely/Optimizely.php

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ private function getValidExperimentsForEvent($event, $userId, $attributes = null
207207
* @param $userId string ID for user.
208208
* @param $attributes array Attributes of the user.
209209
*
210-
* @return null|string Representing variation.
210+
* @return null|string Representing the variation key.
211211
*/
212212
public function activate($experimentKey, $userId, $attributes = null)
213213
{
@@ -329,7 +329,7 @@ public function track($eventKey, $userId, $attributes = null, $eventTags = null)
329329
* @param $userId string ID for user.
330330
* @param $attributes array Attributes of the user.
331331
*
332-
* @return null|string Representing variation.
332+
* @return null|string Representing the variation key.
333333
*/
334334
public function getVariation($experimentKey, $userId, $attributes = null)
335335
{
@@ -355,4 +355,37 @@ public function getVariation($experimentKey, $userId, $attributes = null)
355355

356356
return $variation->getKey();
357357
}
358+
359+
/**
360+
* Force a user into a variation for a given experiment.
361+
*
362+
* @param $experimentKey string Key identifying the experiment.
363+
* @param $userId string The user ID to be used for bucketing.
364+
* @param $variationKey string The variation key specifies the variation which the user
365+
* will be forced into. If null, then clear the existing experiment-to-variation mapping.
366+
*
367+
* @return boolean A boolean value that indicates if the set completed successfully.
368+
*/
369+
public function setForcedVariation($experimentKey, $userId, $variationKey)
370+
{
371+
return $this->_config->setForcedVariation($experimentKey, $userId, $variationKey);
372+
}
373+
374+
/**
375+
* Gets the forced variation for a given user and experiment.
376+
*
377+
* @param $experimentKey string Key identifying the experiment.
378+
* @param $userId string The user ID to be used for bucketing.
379+
*
380+
* @return string|null The forced variation key.
381+
*/
382+
public function getForcedVariation($experimentKey, $userId)
383+
{
384+
$forcedVariation = $this->_config->getForcedVariation($experimentKey, $userId);
385+
if (isset($forcedVariation)) {
386+
return $forcedVariation->getKey();
387+
} else {
388+
return null;
389+
}
390+
}
358391
}

src/Optimizely/ProjectConfig.php

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,12 @@ class ProjectConfig
112112
*/
113113
private $_errorHandler;
114114

115+
/**
116+
* @var array Associative array of user IDs to an associative array
117+
* of experiments to variations.
118+
*/
119+
private $_forcedVariationMap;
120+
115121
/**
116122
* ProjectConfig constructor to load and set project configuration data.
117123
*
@@ -128,6 +134,7 @@ public function __construct($datafile, $logger, $errorHandler)
128134
$this->_accountId = $config['accountId'];
129135
$this->_projectId = $config['projectId'];
130136
$this->_revision = $config['revision'];
137+
$this->_forcedVariationMap = [];
131138

132139
$groups = $config['groups'] ?: [];
133140
$experiments = $config['experiments'] ?: [];
@@ -339,4 +346,86 @@ public function isVariationIdValid($experimentKey, $variationId)
339346
return isset($this->_variationIdMap[$experimentKey]) &&
340347
isset($this->_variationIdMap[$experimentKey][$variationId]);
341348
}
349+
350+
/**
351+
* Gets the forced variation key for the given user and experiment.
352+
*
353+
* @param $experimentKey string Key for experiment.
354+
* @param $userId string The user Id.
355+
*
356+
* @return Variation The variation which the given user and experiment should be forced into.
357+
*/
358+
public function getForcedVariation($experimentKey, $userId)
359+
{
360+
if (!isset($this->_forcedVariationMap[$userId])) {
361+
$this->_logger->log(Logger::DEBUG, sprintf('User "%s" is not in the forced variation map.', $userId));
362+
return null;
363+
}
364+
365+
$experimentToVariationMap = $this->_forcedVariationMap[$userId];
366+
$experimentId = $this->getExperimentFromKey($experimentKey)->getId();
367+
if (empty($experimentId)) {
368+
// this case is logged in getExperimentFromKey
369+
return null;
370+
}
371+
372+
if (!isset($experimentToVariationMap[$experimentId])) {
373+
$this->_logger->log(Logger::DEBUG, sprintf('No experiment "%s" mapped to user "%s" in the forced variation map.', $experimentKey, $userId));
374+
return null;
375+
}
376+
377+
$variationId = $experimentToVariationMap[$experimentId];
378+
if (empty($variationId)) {
379+
$this->_logger->log(Logger::DEBUG, sprintf('No variation mapped to experiment "%s" in the forced variation map.', $experimentKey));
380+
return null;
381+
}
382+
383+
$variationKey = $this->getVariationFromId($experimentKey, $variationId)->getKey();
384+
if (empty($variationKey)) {
385+
// this case is logged in getVariationFromKey
386+
return null;
387+
}
388+
389+
$this->_logger->log(Logger::DEBUG, sprintf('Variation "%s" is mapped to experiment "%s" and user "%s" in the forced variation map', $variationKey, $experimentKey, $userId));
390+
391+
$variation = $this->getVariationFromKey($experimentKey, $variationKey);
392+
return $variation;
393+
}
394+
395+
/**
396+
* Sets an associative array of user IDs to an associative array of experiments
397+
* to forced variations.
398+
*
399+
* @param $experimentKey string Key for experiment.
400+
* @param $userId string The user Id.
401+
* @param $variationKey string Key for variation. If null, then clear the existing experiment-to-variation mapping.
402+
*
403+
* @return boolean A boolean value that indicates if the set completed successfully.
404+
*/
405+
public function setForcedVariation($experimentKey, $userId, $variationKey)
406+
{
407+
$experimentId = $this->getExperimentFromKey($experimentKey)->getId();
408+
if (empty($experimentId)) {
409+
// this case is logged in getExperimentFromKey
410+
return FALSE;
411+
}
412+
413+
if (empty($variationKey)) {
414+
unset($this->_forcedVariationMap[$userId]);
415+
$this->_logger->log(Logger::DEBUG, sprintf('Variation mapped to experiment "%s" has been removed for user "%s".', $experimentKey, $userId));
416+
return TRUE;
417+
}
418+
419+
$variationId = $this->getVariationFromKey($experimentKey, $variationKey)->getId();
420+
if (empty($variationId)) {
421+
// this case is logged in getVariationFromKey
422+
return FALSE;
423+
}
424+
425+
$this->_forcedVariationMap[$userId] = array($experimentId => $variationId);
426+
$this->_logger->log(Logger::DEBUG, sprintf('Set variation "%s" for experiment "%s" and user "%s" in the forced variation map.', $variationId, $experimentId, $userId));
427+
428+
return TRUE;
429+
}
430+
342431
}

tests/DecisionServiceTests/DecisionServiceTest.php

Lines changed: 77 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
use Optimizely\Entity\Variation;
2525
use Optimizely\ErrorHandler\NoOpErrorHandler;
2626
use Optimizely\Logger\NoOpLogger;
27+
use Optimizely\Optimizely;
2728
use Optimizely\ProjectConfig;
2829
use Optimizely\UserProfile\UserProfileServiceInterface;
2930

@@ -107,9 +108,13 @@ public function testGetVariationReturnsWhitelistedVariation()
107108
$expectedVariation = new Variation('7722370027', 'control');
108109
$runningExperiment = $this->config->getExperimentFromKey('test_experiment');
109110

111+
$callIndex = 0;
110112
$this->bucketerMock->expects($this->never())
111113
->method('bucket');
112-
$this->loggerMock->expects($this->at(0))
114+
$this->loggerMock->expects($this->at($callIndex++))
115+
->method('log')
116+
->with(Logger::DEBUG, 'User "user1" is not in the forced variation map.');
117+
$this->loggerMock->expects($this->at($callIndex++))
113118
->method('log')
114119
->with(Logger::INFO, 'User "user1" is forced in variation "control" of experiment "test_experiment".');
115120

@@ -131,9 +136,13 @@ public function testGetVariationReturnsWhitelistedVariationForGroupedExperiment(
131136
$expectedVariation = new Variation('7722260071', 'group_exp_1_var_1');
132137
$runningExperiment = $this->config->getExperimentFromKey('group_experiment_1');
133138

139+
$callIndex = 0;
134140
$this->bucketerMock->expects($this->never())
135141
->method('bucket');
136-
$this->loggerMock->expects($this->at(0))
142+
$this->loggerMock->expects($this->at($callIndex++))
143+
->method('log')
144+
->with(Logger::DEBUG, 'User "user1" is not in the forced variation map.');
145+
$this->loggerMock->expects($this->at($callIndex++))
137146
->method('log')
138147
->with(Logger::INFO, 'User "user1" is forced in variation "group_exp_1_var_1" of experiment "group_experiment_1".');
139148

@@ -251,10 +260,13 @@ public function testGetVariationReturnsStoredVariationIfAvailable()
251260
$runningExperiment = $this->config->getExperimentFromKey('test_experiment');
252261
$expectedVariation = new Variation('7722370027', 'control');
253262

263+
$callIndex = 0;
254264
$this->bucketerMock->expects($this->never())
255265
->method('bucket');
256-
257-
$this->loggerMock->expects($this->at(0))
266+
$this->loggerMock->expects($this->at($callIndex++))
267+
->method('log')
268+
->with(Logger::DEBUG, 'User "not_whitelisted_user" is not in the forced variation map.');
269+
$this->loggerMock->expects($this->at($callIndex++))
258270
->method('log')
259271
->with(Logger::INFO, 'Returning previously activated variation "control" of experiment "test_experiment" for user "not_whitelisted_user" from user profile.');
260272

@@ -285,14 +297,17 @@ public function testGetVariationBucketsIfNoStoredVariation()
285297
$runningExperiment = $this->config->getExperimentFromKey('test_experiment');
286298
$expectedVariation = new Variation('7722370027', 'control');
287299

300+
$callIndex = 0;
288301
$this->bucketerMock->expects($this->once())
289302
->method('bucket')
290303
->willReturn($expectedVariation);
291-
292-
$this->loggerMock->expects($this->at(0))
304+
$this->loggerMock->expects($this->at($callIndex++))
305+
->method('log')
306+
->with(Logger::DEBUG, sprintf('User "%s" is not in the forced variation map.', $userId));
307+
$this->loggerMock->expects($this->at($callIndex++))
293308
->method('log')
294309
->with(Logger::INFO, 'No previously activated variation of experiment "test_experiment" for user "testUserId" found in user profile.');
295-
$this->loggerMock->expects($this->at(1))
310+
$this->loggerMock->expects($this->at($callIndex++))
296311
->method('log')
297312
->with(Logger::INFO, 'Saved variation "control" of experiment "test_experiment" for user "testUserId".');
298313

@@ -330,14 +345,17 @@ public function testGetVariationBucketsIfStoredVariationIsInvalid()
330345
$runningExperiment = $this->config->getExperimentFromKey('test_experiment');
331346
$expectedVariation = new Variation('7722370027', 'control');
332347

348+
$callIndex = 0;
333349
$this->bucketerMock->expects($this->once())
334350
->method('bucket')
335351
->willReturn($expectedVariation);
336-
337-
$this->loggerMock->expects($this->at(0))
352+
$this->loggerMock->expects($this->at($callIndex++))
353+
->method('log')
354+
->with(Logger::DEBUG, sprintf('User "%s" is not in the forced variation map.', $userId));
355+
$this->loggerMock->expects($this->at($callIndex++))
338356
->method('log')
339357
->with(Logger::INFO, 'User "testUserId" was previously bucketed into variation with ID "invalid" for experiment "test_experiment", but no matching variation was found for that user. We will re-bucket the user.');
340-
$this->loggerMock->expects($this->at(1))
358+
$this->loggerMock->expects($this->at($callIndex++))
341359
->method('log')
342360
->with(Logger::INFO, 'Saved variation "control" of experiment "test_experiment" for user "testUserId".');
343361

@@ -379,14 +397,17 @@ public function testGetVariationBucketsIfUserProfileServiceLookupThrows()
379397
$runningExperiment = $this->config->getExperimentFromKey('test_experiment');
380398
$expectedVariation = new Variation('7722370027', 'control');
381399

400+
$callIndex = 0;
382401
$this->bucketerMock->expects($this->once())
383402
->method('bucket')
384403
->willReturn($expectedVariation);
385-
386-
$this->loggerMock->expects($this->at(0))
404+
$this->loggerMock->expects($this->at($callIndex++))
405+
->method('log')
406+
->with(Logger::DEBUG, sprintf('User "%s" is not in the forced variation map.', $userId));
407+
$this->loggerMock->expects($this->at($callIndex++))
387408
->method('log')
388409
->with(Logger::ERROR, 'The User Profile Service lookup method failed: I am error.');
389-
$this->loggerMock->expects($this->at(1))
410+
$this->loggerMock->expects($this->at($callIndex++))
390411
->method('log')
391412
->with(Logger::INFO, 'Saved variation "control" of experiment "test_experiment" for user "testUserId".');
392413

@@ -428,14 +449,17 @@ public function testGetVariationBucketsIfUserProfileServiceSaveThrows()
428449
$runningExperiment = $this->config->getExperimentFromKey('test_experiment');
429450
$expectedVariation = new Variation('7722370027', 'control');
430451

452+
$callIndex = 0;
431453
$this->bucketerMock->expects($this->once())
432454
->method('bucket')
433455
->willReturn($expectedVariation);
434-
435-
$this->loggerMock->expects($this->at(0))
456+
$this->loggerMock->expects($this->at($callIndex++))
457+
->method('log')
458+
->with(Logger::DEBUG, sprintf('User "%s" is not in the forced variation map.', $userId));
459+
$this->loggerMock->expects($this->at($callIndex++))
436460
->method('log')
437461
->with(Logger::INFO, 'No user profile found for user with ID "testUserId".');
438-
$this->loggerMock->expects($this->at(1))
462+
$this->loggerMock->expects($this->at($callIndex++))
439463
->method('log')
440464
->with(Logger::WARNING, 'Failed to save variation "control" of experiment "test_experiment" for user "testUserId".');
441465

@@ -463,4 +487,41 @@ public function testGetVariationBucketsIfUserProfileServiceSaveThrows()
463487
$variation = $this->decisionService->getVariation($runningExperiment, $userId, $this->testUserAttributes);
464488
$this->assertEquals($expectedVariation, $variation);
465489
}
490+
491+
public function testGetVariationUserWithSetForcedVariation()
492+
{
493+
$experimentKey = 'test_experiment';
494+
$pausedExperimentKey = 'paused_experiment';
495+
$userId = 'test_user';
496+
$forcedVariationKey = 'variation';
497+
$bucketedVariationKey = 'control';
498+
499+
$optlyObject = new Optimizely(DATAFILE, new ValidEventDispatcher(), $this->loggerMock);
500+
501+
$userAttributes = [
502+
'device_type' => 'iPhone',
503+
'location' => 'San Francisco'
504+
];
505+
506+
$optlyObject->activate($experimentKey, $userId, $userAttributes);
507+
508+
// confirm normal bucketing occurs before setting the forced variation
509+
$forcedVariationKey = $optlyObject->getVariation($experimentKey, $userId, $userAttributes);
510+
$this->assertEquals($bucketedVariationKey, $forcedVariationKey);
511+
512+
// test valid experiment
513+
$this->assertTrue($optlyObject->setForcedVariation($experimentKey, $userId, $forcedVariationKey), sprintf('Set variation to "%s" failed.', $forcedVariationKey));
514+
$forcedVariationKey = $optlyObject->getVariation($experimentKey, $userId, $userAttributes);
515+
$this->assertEquals($forcedVariationKey, $forcedVariationKey);
516+
517+
// clear forced variation and confirm that normal bucketing occurs
518+
$this->assertTrue($optlyObject->setForcedVariation($experimentKey, $userId, null), sprintf('Set variation to "%s" failed.', $forcedVariationKey));
519+
$forcedVariationKey = $optlyObject->getVariation($experimentKey, $userId, $userAttributes);
520+
$this->assertEquals($bucketedVariationKey, $forcedVariationKey);
521+
522+
// check that a paused experiment returns null
523+
$this->assertTrue($optlyObject->setForcedVariation($pausedExperimentKey, $userId, 'variation'), sprintf('Set variation to "%s" failed.', $forcedVariationKey));
524+
$forcedVariationKey = $optlyObject->getVariation($pausedExperimentKey, $userId, $userAttributes);
525+
$this->assertNull($forcedVariationKey);
526+
}
466527
}

0 commit comments

Comments
 (0)