Skip to content

Commit e2697f3

Browse files
authored
Add user profile service (#49)
1 parent 79d69bc commit e2697f3

11 files changed

+842
-8
lines changed

src/Optimizely/DecisionService/DecisionService.php

+153-6
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<?php
22
/**
3-
* Copyright 2017, Optimizely
3+
* Copyright 2017, Optimizely Inc and Contributors
44
*
55
* Licensed under the Apache License, Version 2.0 (the "License");
66
* you may not use this file except in compliance with the License.
@@ -16,22 +16,28 @@
1616
*/
1717
namespace Optimizely\DecisionService;
1818

19+
use Exception;
1920
use Monolog\Logger;
2021
use Optimizely\Bucketer;
2122
use Optimizely\Entity\Experiment;
2223
use Optimizely\Entity\Variation;
2324
use Optimizely\Logger\LoggerInterface;
2425
use Optimizely\ProjectConfig;
26+
use Optimizely\UserProfile\Decision;
27+
use Optimizely\UserProfile\UserProfileServiceInterface;
28+
use Optimizely\UserProfile\UserProfile;
29+
use Optimizely\UserProfile\UserProfileUtils;
2530
use Optimizely\Utils\Validator;
2631

2732
/**
2833
* Optimizely's decision service that determines which variation of an experiment the user will be allocated to.
2934
*
3035
* The decision service contains all logic around how a user decision is made. This includes all of the following (in order):
31-
* 1. Checking experiment status
32-
* 2. Checking whitelisting
33-
* 3. Checking audience targeting
34-
* 4. Using Murmurhash3 to bucket the user.
36+
* 1. Checking experiment status.
37+
* 2. Checking whitelisting.
38+
* 3. Check sticky bucketing.
39+
* 4. Checking audience targeting.
40+
* 5. Using Murmurhash3 to bucket the user.
3541
*
3642
* @package Optimizely
3743
*/
@@ -52,16 +58,22 @@ class DecisionService
5258
*/
5359
private $_bucketer;
5460

61+
/**
62+
* @var UserProfileServiceInterface
63+
*/
64+
private $_userProfileService;
65+
5566
/**
5667
* DecisionService constructor.
5768
* @param LoggerInterface $logger
5869
* @param ProjectConfig $projectConfig
5970
*/
60-
public function __construct(LoggerInterface $logger, ProjectConfig $projectConfig)
71+
public function __construct(LoggerInterface $logger, ProjectConfig $projectConfig, UserProfileServiceInterface $userProfileService = null)
6172
{
6273
$this->_logger = $logger;
6374
$this->_projectConfig = $projectConfig;
6475
$this->_bucketer = new Bucketer($logger);
76+
$this->_userProfileService = $userProfileService;
6577
}
6678

6779
/**
@@ -85,6 +97,19 @@ public function getVariation(Experiment $experiment, $userId, $attributes = null
8597
return $variation;
8698
}
8799

100+
// check for sticky bucketing
101+
$userProfile = new UserProfile($userId);
102+
if (!is_null($this->_userProfileService)) {
103+
$storedUserProfile = $this->getStoredUserProfile($userId);
104+
if (!is_null($storedUserProfile)) {
105+
$userProfile = $storedUserProfile;
106+
$variation = $this->getStoredVariation($experiment, $userProfile);
107+
if (!is_null($variation)) {
108+
return $variation;
109+
}
110+
}
111+
}
112+
88113
if (!Validator::isUserInExperiment($this->_projectConfig, $experiment, $attributes)) {
89114
$this->_logger->log(
90115
Logger::INFO,
@@ -94,6 +119,9 @@ public function getVariation(Experiment $experiment, $userId, $attributes = null
94119
}
95120

96121
$variation = $this->_bucketer->bucket($this->_projectConfig, $experiment, $userId);
122+
if (!is_null($variation)) {
123+
$this->saveVariation($experiment, $variation, $userProfile);
124+
}
97125
return $variation;
98126
}
99127

@@ -122,4 +150,123 @@ private function getWhitelistedVariation(Experiment $experiment, $userId)
122150
}
123151
return null;
124152
}
153+
154+
/**
155+
* Get the stored user profile for the given user ID.
156+
*
157+
* @param $userId string the ID of the user.
158+
*
159+
* @return null|UserProfile the stored user profile.
160+
*/
161+
private function getStoredUserProfile($userId)
162+
{
163+
if (is_null($this->_userProfileService)) {
164+
return null;
165+
}
166+
167+
try {
168+
$userProfileMap = $this->_userProfileService->lookup($userId);
169+
if (is_null($userProfileMap)) {
170+
$this->_logger->log(
171+
Logger::INFO,
172+
sprintf('No user profile found for user with ID "%s".', $userId)
173+
);
174+
} else if (UserProfileUtils::isValidUserProfileMap($userProfileMap)) {
175+
return UserProfileUtils::convertMapToUserProfile($userProfileMap);
176+
} else {
177+
$this->_logger->log(
178+
Logger::WARNING,
179+
'The User Profile Service returned an invalid user profile map.'
180+
);
181+
}
182+
} catch (Exception $e) {
183+
$this->_logger->log(
184+
Logger::ERROR,
185+
sprintf('The User Profile Service lookup method failed: %s.', $e->getMessage())
186+
);
187+
}
188+
189+
return null;
190+
}
191+
192+
/**
193+
* Get the stored variation for the given experiment from the user profile.
194+
*
195+
* @param $experiment Experiment The experiment for which we are getting the stored variation.
196+
* @param $userProfile UserProfile The user profile from which we are getting the stored variation.
197+
*
198+
* @return null|Variation the stored variation or null if not found.
199+
*/
200+
private function getStoredVariation(Experiment $experiment, UserProfile $userProfile)
201+
{
202+
$experimentKey = $experiment->getKey();
203+
$userId = $userProfile->getUserId();
204+
$variationId = $userProfile->getVariationForExperiment($experiment->getId());
205+
206+
if (is_null($variationId)) {
207+
$this->_logger->log(
208+
Logger::INFO,
209+
sprintf('No previously activated variation of experiment "%s" for user "%s" found in user profile.', $experimentKey, $userId)
210+
);
211+
return null;
212+
}
213+
214+
if (!$this->_projectConfig->isVariationIdValid($experimentKey, $variationId)) {
215+
$this->_logger->log(
216+
Logger::INFO,
217+
sprintf('User "%s" was previously bucketed into variation with ID "%s" for experiment "%s", but no matching variation was found for that user. We will re-bucket the user.',
218+
$userId, $variationId, $experimentKey)
219+
);
220+
return null;
221+
}
222+
223+
$variation = $this->_projectConfig->getVariationFromId($experimentKey, $variationId);
224+
$this->_logger->log(
225+
Logger::INFO,
226+
sprintf('Returning previously activated variation "%s" of experiment "%s" for user "%s" from user profile.',
227+
$variation->getKey(), $experimentKey, $userId)
228+
);
229+
return $variation;
230+
}
231+
232+
/**
233+
* Save the given variation assignment to the given user profile.
234+
*
235+
* @param $experiment Experiment Experiment for which we are storing the variation.
236+
* @param $variation Variation Variation the user is bucketed into.
237+
* @param $userProfile UserProfile User profile object to which we are persisting the variation assignment.
238+
*/
239+
private function saveVariation(Experiment $experiment, Variation $variation, UserProfile $userProfile)
240+
{
241+
if (is_null($this->_userProfileService)) {
242+
return;
243+
}
244+
245+
$experimentId = $experiment->getId();
246+
$decision = $userProfile->getDecisionForExperiment($experimentId);
247+
$variationId = $variation->getId();
248+
if (is_null($decision)) {
249+
$decision = new Decision($variationId);
250+
} else {
251+
$decision->setVariationId($variationId);
252+
}
253+
254+
$userProfile->saveDecisionForExperiment($experimentId, $decision);
255+
$userProfileMap = UserProfileUtils::convertUserProfileToMap($userProfile);
256+
257+
try {
258+
$this->_userProfileService->save($userProfileMap);
259+
$this->_logger->log(
260+
Logger::INFO,
261+
sprintf('Saved variation "%s" of experiment "%s" for user "%s".',
262+
$variation->getKey(), $experiment->getKey(), $userProfile->getUserId())
263+
);
264+
} catch (Exception $e) {
265+
$this->_logger->log(
266+
Logger::WARNING,
267+
sprintf('Failed to save variation "%s" of experiment "%s" for user "%s".',
268+
$variation->getKey(), $experiment->getKey(), $userProfile->getUserId())
269+
);
270+
}
271+
}
125272
}

src/Optimizely/Optimizely.php

+5-2
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
use Optimizely\Event\Dispatcher\EventDispatcherInterface;
3232
use Optimizely\Logger\LoggerInterface;
3333
use Optimizely\Logger\NoOpLogger;
34+
use Optimizely\UserProfile\UserProfileServiceInterface;
3435
use Optimizely\Utils\EventTagUtils;
3536
use Optimizely\Utils\Validator;
3637

@@ -84,12 +85,14 @@ class Optimizely
8485
* @param $logger LoggerInterface
8586
* @param $errorHandler ErrorHandlerInterface
8687
* @param $skipJsonValidation boolean representing whether JSON schema validation needs to be performed.
88+
* @param $userProfileService UserProfileServiceInterface
8789
*/
8890
public function __construct($datafile,
8991
EventDispatcherInterface $eventDispatcher = null,
9092
LoggerInterface $logger = null,
9193
ErrorHandlerInterface $errorHandler = null,
92-
$skipJsonValidation = false)
94+
$skipJsonValidation = false,
95+
UserProfileServiceInterface $userProfileService = null)
9396
{
9497
$this->_isValid = true;
9598
$this->_eventDispatcher = $eventDispatcher ?: new DefaultEventDispatcher();
@@ -120,7 +123,7 @@ public function __construct($datafile,
120123
}
121124

122125
$this->_eventBuilder = new EventBuilder();
123-
$this->_decisionService = new DecisionService($this->_logger, $this->_config);
126+
$this->_decisionService = new DecisionService($this->_logger, $this->_config, $userProfileService);
124127
}
125128

126129
/**

src/Optimizely/ProjectConfig.php

+6
Original file line numberDiff line numberDiff line change
@@ -333,4 +333,10 @@ public function getVariationFromId($experimentKey, $variationId)
333333
$this->_errorHandler->handleError(new InvalidVariationException('Provided variation is not in datafile.'));
334334
return new Variation();
335335
}
336+
337+
public function isVariationIdValid($experimentKey, $variationId)
338+
{
339+
return isset($this->_variationIdMap[$experimentKey]) &&
340+
isset($this->_variationIdMap[$experimentKey][$variationId]);
341+
}
336342
}
+46
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
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+
18+
namespace Optimizely\UserProfile;
19+
20+
class Decision
21+
{
22+
/**
23+
* @var string The ID variation in this decision.
24+
*/
25+
private $_variationId;
26+
27+
/**
28+
* Decision constructor.
29+
*
30+
* @param $variationId
31+
*/
32+
public function __construct($variationId)
33+
{
34+
$this->_variationId = $variationId;
35+
}
36+
37+
public function getVariationId()
38+
{
39+
return $this->_variationId;
40+
}
41+
42+
public function setVariationId($variationId)
43+
{
44+
$this->_variationId = $variationId;
45+
}
46+
}

0 commit comments

Comments
 (0)