• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

optimizely / php-sdk / 4377654340

pending completion
4377654340

Pull #260

github

GitHub
Merge b67fdafbc into fa9a3bf88
Pull Request #260: [FSSDK-8972] Run test cases in PHP v8.2

2881 of 2967 relevant lines covered (97.1%)

63.61 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

98.56
/src/Optimizely/DecisionService/DecisionService.php
1
<?php
2
/**
3
 * Copyright 2017-2022, 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
use Exception;
20
use Monolog\Logger;
21
use Optimizely\Bucketer;
22
use Optimizely\OptimizelyDecisionContext;
23
use Optimizely\Config\ProjectConfigInterface;
24
use Optimizely\Decide\OptimizelyDecideOption;
25
use Optimizely\Entity\Experiment;
26
use Optimizely\Entity\FeatureFlag;
27
use Optimizely\Entity\Rollout;
28
use Optimizely\Entity\Variation;
29
use Optimizely\Enums\ControlAttributes;
30
use Optimizely\ForcedDecision;
31
use Optimizely\Logger\LoggerInterface;
32
use Optimizely\Optimizely;
33
use Optimizely\OptimizelyUserContext;
34
use Optimizely\UserProfile\Decision;
35
use Optimizely\UserProfile\UserProfileServiceInterface;
36
use Optimizely\UserProfile\UserProfile;
37
use Optimizely\UserProfile\UserProfileUtils;
38
use Optimizely\Utils\Errors;
39
use Optimizely\Utils\Validator;
40

41
/**
42
 * Optimizely's decision service that determines which variation of an experiment the user will be allocated to.
43
 *
44
 * The decision service contains all logic around how a user decision is made. This includes all of the following (in order):
45
 *   1. Checking experiment status.
46
 *   2. Checking force bucketing
47
 *   3. Checking whitelisting.
48
 *   4. Check sticky bucketing.
49
 *   5. Checking audience targeting.
50
 *   6. Using Murmurhash3 to bucket the user.
51
 *
52
 * @package Optimizely
53
 */
54
class DecisionService
55
{
56
    /**
57
     * @var LoggerInterface
58
     */
59
    private $_logger;
60

61
    /**
62
     * @var Bucketer
63
     */
64
    private $_bucketer;
65

66
    /**
67
     * @var UserProfileServiceInterface
68
     */
69
    private $_userProfileService;
70

71

72
    /**
73
     * @var array Associative array of user IDs to an associative array
74
     * of experiments to variations. This contains all the forced variations
75
     * set by the user by calling setForcedVariation (it is not the same as the
76
     * whitelisting forcedVariations data structure in the Experiments class).
77
     */
78
    private $_forcedVariationMap;
79

80
    /**
81
     * DecisionService constructor.
82
     *
83
     * @param LoggerInterface       $logger
84
     * @param UserProfileServiceInterface  $userProfileService
85
     */
86
    public function __construct(LoggerInterface $logger, UserProfileServiceInterface $userProfileService = null)
87
    {
88
        $this->_logger = $logger;
255✔
89
        $this->_bucketer = new Bucketer($logger);
255✔
90
        $this->_userProfileService = $userProfileService;
255✔
91
        $this->_forcedVariationMap = [];
255✔
92
    }
255✔
93

94
    /**
95
     * Gets the ID for Bucketing
96
     *
97
     * @param string $userId         user ID
98
     * @param array  $userAttributes user attributes
99
     *
100
     * @return [ String, array ] bucketing ID if it is a String type in attributes else return user ID and
101
     *                           array of log messages representing decision making.
102
     */
103
    protected function getBucketingId($userId, $userAttributes)
104
    {
105
        $decideReasons = [];
84✔
106
        $bucketingIdKey = ControlAttributes::BUCKETING_ID;
84✔
107

108
        if (isset($userAttributes[$bucketingIdKey])) {
84✔
109
            if (is_string($userAttributes[$bucketingIdKey])) {
5✔
110
                return [ $userAttributes[$bucketingIdKey], $decideReasons ];
4✔
111
            }
112

113
            $message = 'Bucketing ID attribute is not a string. Defaulted to user ID.';
1✔
114
            $this->_logger->log(Logger::WARNING, $message);
1✔
115
            $decideReasons[] = $message;
1✔
116
        }
1✔
117
        return [ $userId, $decideReasons ];
82✔
118
    }
119

120
    /**
121
     * Finds a validated forced decision.
122
     *
123
     * @param OptimizelyDecisionContext $context         containing flag and rule key.
124
     * @param ProjectConfigInterface    $projectConfig   Optimizely project config
125
     * @param OptimizelyUserContext     $user            Optimizely user context object.
126
     *
127
     * @return [ variation, array ] variation and decide reasons.
128
     */
129
    public function findValidatedForcedDecision(OptimizelyDecisionContext $context, ProjectConfigInterface $projectConfig, OptimizelyUserContext $user)
130
    {
131
        $decideReasons = [];
52✔
132
        $flagKey = $context->getFlagKey();
52✔
133
        $ruleKey = $context->getRuleKey();
52✔
134
        $variationKey = $user->findForcedDecision($context);
52✔
135
        $variation = null;
52✔
136
        if ($variationKey && $projectConfig) {
52✔
137
            $variation = $projectConfig->getFlagVariationByKey($flagKey, $variationKey);
7✔
138
            if ($variation) {
7✔
139
                array_push($decideReasons, 'Decided by forced decision.');
5✔
140
                array_push($decideReasons, sprintf('Variation (%s) is mapped to %s and user (%s) in the forced decision map.', $variationKey, $ruleKey? 'flag ('.$flagKey.'), rule ('.$ruleKey.')': 'flag ('.$flagKey.')', $user->getUserId()));
5✔
141
            } else {
5✔
142
                array_push($decideReasons, sprintf('Invalid variation is mapped to %s and user (%s) in the forced decision map.', $ruleKey? 'flag ('.$flagKey.'), rule ('.$ruleKey.')': 'flag ('.$flagKey.')', $user->getUserId()));
2✔
143
            }
144
        }
7✔
145
        return [$variation, $decideReasons];
52✔
146
    }
147

148
    /**
149
     * Determine which variation to show the user.
150
     *
151
     * @param $projectConfig    ProjectConfigInterface   ProjectConfigInterface instance.
152
     * @param $experiment       Experiment               Experiment to get the variation for.
153
     * @param $user             OptimizelyUserContext    User identifier.
154
     * @param $decideOptions    array                    Options to customize evaluation.
155
     *
156
     * @return [ Variation, array ]   Variation  which the user is bucketed into and array of log messages representing decision making.
157
     */
158
    public function getVariation(ProjectConfigInterface $projectConfig, Experiment $experiment, OptimizelyUserContext $user, $decideOptions = [])
159
    {
160
        $decideReasons = [];
67✔
161
        $userId = $user->getUserId();
67✔
162
        $attributes = $user->getAttributes();
68✔
163
        list($bucketingId, $reasons) = $this->getBucketingId($userId, $attributes);
67✔
164

165
        $decideReasons = array_merge($decideReasons, $reasons);
67✔
166

167
        if (!$experiment->isExperimentRunning()) {
67✔
168
            $message = sprintf('Experiment "%s" is not running.', $experiment->getKey());
6✔
169
            $this->_logger->log(Logger::INFO, $message);
6✔
170
            $decideReasons[] = $message;
6✔
171
            return [ null, $decideReasons];
6✔
172
        }
173

174
        // check if a forced variation is set
175
        list($forcedVariation, $reasons) = $this->getForcedVariation($projectConfig, $experiment->getKey(), $userId);
63✔
176
        $decideReasons = array_merge($decideReasons, $reasons);
63✔
177
        if (!is_null($forcedVariation)) {
63✔
178
            return [ $forcedVariation, $decideReasons];
8✔
179
        }
180

181
        // check if the user has been whitelisted
182
        list($variation, $reasons) = $this->getWhitelistedVariation($projectConfig, $experiment, $userId);
60✔
183
        $decideReasons = array_merge($decideReasons, $reasons);
60✔
184
        if (!is_null($variation)) {
60✔
185
            return [ $variation, $decideReasons ];
4✔
186
        }
187

188
        // check for sticky bucketing
189
        if (!in_array(OptimizelyDecideOption::IGNORE_USER_PROFILE_SERVICE, $decideOptions)) {
57✔
190
            $userProfile = new UserProfile($userId);
55✔
191
            if (!is_null($this->_userProfileService)) {
55✔
192
                list($storedUserProfile, $reasons) = $this->getStoredUserProfile($userId);
7✔
193
                $decideReasons = array_merge($decideReasons, $reasons);
7✔
194
                if (!is_null($storedUserProfile)) {
7✔
195
                    $userProfile = $storedUserProfile;
4✔
196
                    list($variation, $reasons) = $this->getStoredVariation($projectConfig, $experiment, $userProfile);
4✔
197
                    $decideReasons = array_merge($decideReasons, $reasons);
4✔
198
                    if (!is_null($variation)) {
4✔
199
                        return [ $variation, $decideReasons ];
2✔
200
                    }
201
                }
2✔
202
            }
5✔
203
        }
53✔
204

205
        list($evalResult, $reasons) = Validator::doesUserMeetAudienceConditions($projectConfig, $experiment, $attributes, $this->_logger);
55✔
206
        $decideReasons = array_merge($decideReasons, $reasons);
55✔
207
        if (!$evalResult) {
55✔
208
            $message = sprintf('User "%s" does not meet conditions to be in experiment "%s".', $userId, $experiment->getKey());
17✔
209
            $this->_logger->log(
17✔
210
                Logger::INFO,
17✔
211
                $message
212
            );
17✔
213
            $decideReasons[] = $message;
18✔
214
            return [ null, $decideReasons];
17✔
215
        }
216

217
        list($variation, $reasons) = $this->_bucketer->bucket($projectConfig, $experiment, $bucketingId, $userId);
44✔
218
        $decideReasons = array_merge($decideReasons, $reasons);
44✔
219
        if ($variation === null) {
44✔
220
            $message = sprintf('User "%s" is in no variation.', $userId);
5✔
221
            $this->_logger->log(Logger::INFO, $message);
5✔
222
            $decideReasons[] = $message;
5✔
223
        } else {
5✔
224
            if (!in_array(OptimizelyDecideOption::IGNORE_USER_PROFILE_SERVICE, $decideOptions)) {
43✔
225
                $this->saveVariation($experiment, $variation, $userProfile);
41✔
226
            }
41✔
227
            $message = sprintf(
43✔
228
                'User "%s" is in variation %s of experiment %s.',
43✔
229
                $userId,
43✔
230
                $variation->getKey(),
43✔
231
                $experiment->getKey()
43✔
232
            );
43✔
233
            $this->_logger->log(
43✔
234
                Logger::INFO,
43✔
235
                $message
236
            );
43✔
237
            $decideReasons[] = $message;
43✔
238
        }
239

240
        return [ $variation, $decideReasons ];
44✔
241
    }
242

243
    /**
244
     * Get the variation the user is bucketed into for the given FeatureFlag
245
     *
246
     * @param  ProjectConfigInterface $projectConfig  ProjectConfigInterface instance.
247
     * @param  FeatureFlag            $featureFlag    The feature flag the user wants to access
248
     * @param  OptimizelyUserContext  $user           Optimizely User context containing user id and attribute
249
     * @param  array                  $decideOptions  Options to customize evaluation.
250
     *
251
     * @return FeatureDecision  representing decision.
252
     */
253
    public function getVariationForFeature(ProjectConfigInterface $projectConfig, FeatureFlag $featureFlag, OptimizelyUserContext $user, $decideOptions = [])
254
    {
255
        $decideReasons = [];
29✔
256

257
        //Evaluate in this order:
258
        //1. Attempt to bucket user into experiment using feature flag.
259
        //2. Attempt to bucket user into rollout using the feature flag.
260

261
        // Check if the feature flag is under an experiment and the the user is bucketed into one of these experiments
262
        $decision = $this->getVariationForFeatureExperiment($projectConfig, $featureFlag, $user, $decideOptions);
29✔
263
        if ($decision->getVariation()) {
29✔
264
            return $decision;
16✔
265
        }
266

267
        $decideReasons = array_merge($decideReasons, $decision->getReasons());
17✔
268

269
        // Check if the feature flag has rollout and the user is bucketed into one of it's rules
270
        $decision = $this->getVariationForFeatureRollout($projectConfig, $featureFlag, $user);
17✔
271
        $decideReasons = array_merge($decideReasons, $decision->getReasons());
17✔
272
        $userId = $user->getUserId();
17✔
273
        if ($decision->getVariation()) {
17✔
274
            $message = "User '{$userId}' is bucketed into rollout for feature flag '{$featureFlag->getKey()}'.";
10✔
275
            $this->_logger->log(
10✔
276
                Logger::INFO,
10✔
277
                $message
278
            );
10✔
279

280
            $decideReasons[] = $message;
10✔
281

282
            return new FeatureDecision($decision->getExperiment(), $decision->getVariation(), FeatureDecision::DECISION_SOURCE_ROLLOUT, $decideReasons);
10✔
283
        }
284

285
        $message = "User '{$userId}' is not bucketed into rollout for feature flag '{$featureFlag->getKey()}'.";
11✔
286
        $this->_logger->log(
11✔
287
            Logger::INFO,
11✔
288
            $message
289
        );
11✔
290
        $decideReasons[] = $message;
11✔
291

292
        return new FeatureDecision(null, null, FeatureDecision::DECISION_SOURCE_ROLLOUT, $decideReasons);
11✔
293
    }
294

295
    /**
296
     * Get the variation if the user is bucketed for one of the experiments on this feature flag
297
     *
298
     * @param  ProjectConfigInterface $projectConfig  ProjectConfigInterface instance.
299
     * @param  FeatureFlag   $featureFlag    The feature flag the user wants to access
300
     * @param  OptimizelyUserContext  $user     Optimizely User context containing user id and attribute
301
     * @param  array         $decideOptions   Options to customize evaluation.
302
     *
303
     * @return FeatureDecision  representing decision.
304
     */
305
    public function getVariationForFeatureExperiment(ProjectConfigInterface $projectConfig, FeatureFlag $featureFlag, OptimizelyUserContext $user, $decideOptions = [])
306
    {
307
        $decideReasons = [];
32✔
308
        $featureFlagKey = $featureFlag->getKey();
32✔
309
        $experimentIds = $featureFlag->getExperimentIds();
32✔
310

311
        // Check if there are any experiment IDs inside feature flag
312
        if (empty($experimentIds)) {
32✔
313
            $message = "The feature flag '{$featureFlagKey}' is not used in any experiments.";
13✔
314
            $this->_logger->log(
13✔
315
                Logger::DEBUG,
13✔
316
                $message
317
            );
13✔
318
            $decideReasons[] = $message;
13✔
319
            return new FeatureDecision(null, null, null, $decideReasons);
13✔
320
        }
321
        $userId = $user->getUserId();
23✔
322
        // Evaluate each experiment ID and return the first bucketed experiment variation
323
        foreach ($experimentIds as $experiment_id) {
23✔
324
            $experiment = $projectConfig->getExperimentFromId($experiment_id);
23✔
325
            if ($experiment && !($experiment->getKey())) {
23✔
326
                // Error logged and exception thrown in ProjectConfigInterface-getExperimentFromId
327
                continue;
1✔
328
            }
329

330
            list($variation, $reasons) = $this->getVariationFromExperimentRule($projectConfig, $featureFlagKey, $experiment, $user, $decideOptions);
22✔
331
            $decideReasons = array_merge($decideReasons, $reasons);
22✔
332
            if ($variation && $variation->getKey()) {
22✔
333
                $message = "The user '{$userId}' is bucketed into experiment '{$experiment->getKey()}' of feature '{$featureFlagKey}'.";
17✔
334
                $this->_logger->log(
17✔
335
                    Logger::INFO,
17✔
336
                    $message
337
                );
17✔
338
                $decideReasons[] = $message;
17✔
339

340
                return new FeatureDecision($experiment, $variation, FeatureDecision::DECISION_SOURCE_FEATURE_TEST, $decideReasons);
17✔
341
            }
342
        }
10✔
343

344
        $message = "The user '{$userId}' is not bucketed into any of the experiments using the feature '{$featureFlagKey}'.";
10✔
345
        $this->_logger->log(
10✔
346
            Logger::INFO,
10✔
347
            $message
348
        );
10✔
349
        $decideReasons[] = $message;
10✔
350

351
        return new FeatureDecision(null, null, null, $decideReasons);
10✔
352
    }
353

354
    /**
355
     * Get the variation if the user is bucketed into rollout for this feature flag
356
     * Evaluate the user for rules in priority order by seeing if the user satisfies the audience.
357
     * Fall back onto the everyone else rule if the user is ever excluded from a rule due to traffic allocation.
358
     *
359
     * @param  ProjectConfigInterface $projectConfig  ProjectConfigInterface instance.
360
     * @param  FeatureFlag   $featureFlag    The feature flag the user wants to access
361
     * @param  OptimizelyUserContext  $user  Optimizely User context containing user id and attribute
362
     * @param  array         $decideOptions   Options to customize evaluation.
363
     * @return FeatureDecision  representing decision.
364
     */
365
    public function getVariationForFeatureRollout(ProjectConfigInterface $projectConfig, FeatureFlag $featureFlag, OptimizelyUserContext $user, $decideOptions = [])
366
    {
367
        $decideReasons = [];
24✔
368
        $featureFlagKey = $featureFlag->getKey();
24✔
369
        $rollout_id = $featureFlag->getRolloutId();
24✔
370
        if (empty($rollout_id)) {
24✔
371
            $message = "Feature flag '{$featureFlagKey}' is not used in a rollout.";
7✔
372
            $this->_logger->log(
7✔
373
                Logger::DEBUG,
7✔
374
                $message
375
            );
7✔
376
            $decideReasons[] = $message;
7✔
377
            return new FeatureDecision(null, null, null, $decideReasons);
7✔
378
        }
379
        $rollout = $projectConfig->getRolloutFromId($rollout_id);
21✔
380
        if ($rollout && !($rollout->getId())) {
21✔
381
            // Error logged and thrown in getRolloutFromId
382
            return new FeatureDecision(null, null, null, $decideReasons);
1✔
383
        }
384

385
        $rolloutRules = $rollout->getExperiments();
20✔
386
        if (sizeof($rolloutRules) == 0) {
20✔
387
            return new FeatureDecision(null, null, null, $decideReasons);
1✔
388
        }
389
        $index = 0;
19✔
390
        while ($index < sizeof($rolloutRules)) {
19✔
391
            list($decisionResponses, $skipToEveryoneElse) = $this->getVariationFromDeliveryRule($projectConfig, $featureFlagKey, $rolloutRules, $index, $user, $decideOptions);
19✔
392
            $decideReasons = array_merge($decideReasons, $decisionResponses->getReasons());
19✔
393
            $variation = $decisionResponses->getVariation();
19✔
394
            if ($variation) {
19✔
395
                return new FeatureDecision($rolloutRules[$index], $variation, FeatureDecision::DECISION_SOURCE_ROLLOUT, $decideReasons);
13✔
396
            }
397
            // the last rule is special for "Everyone Else"
398
            $index = $skipToEveryoneElse ? (sizeof($rolloutRules) - 1) : ($index + 1);
16✔
399
        }
16✔
400
        return new FeatureDecision(null, null, null, $decideReasons);
6✔
401
    }
402

403
    private function getVariationFromExperimentRule(ProjectConfigInterface $projectConfig, $flagKey, Experiment $rule, OptimizelyUserContext $user, $decideOptions = [])
404
    {
405
        $decideReasons = [];
22✔
406
        // check forced-decision first
407
        $context = new OptimizelyDecisionContext($flagKey, $rule->getKey());
22✔
408
        list($decisionResponse, $reasons) = $this->findValidatedForcedDecision($context, $projectConfig, $user);
22✔
409
        $decideReasons = array_merge($decideReasons, $reasons);
22✔
410
        if ($decisionResponse) {
22✔
411
            return [$decisionResponse, $decideReasons];
1✔
412
        }
413

414
        // regular decision
415
        list($variation, $reasons) = $this->getVariation($projectConfig, $rule, $user, $decideOptions);
22✔
416
        $decideReasons = array_merge($decideReasons, $reasons);
22✔
417

418
        return [$variation, $decideReasons];
22✔
419
    }
420

421
    /**
422
     * Gets the forced variation key for the given user and experiment.
423
     *
424
     * @param $projectConfig ProjectConfigInterface  ProjectConfigInterface instance.
425
     * @param $flagKey  string             Key of feature flag.
426
     * @param $rules    array              Array of delivery rules.
427
     * @param $ruleIndex  integer          Index of delivery rule of which validation of forced decision is needed.
428
     * @param $user OptimizelyUserContext  Optimizely User context containing user id and attribute
429
     * @param $options array               Options to customize evaluation.
430
     *
431
     * @return [ FeatureDecision, Boolean ] The variation which the given user and experiment should be forced into and
432
     *                              skipToEveryone boolean to  decision making.
433
     */
434
    public function getVariationFromDeliveryRule(ProjectConfigInterface $projectConfig, $flagKey, array $rules, $ruleIndex, OptimizelyUserContext $user, array $options = [])
435
    {
436
        $decideReasons = [];
19✔
437
        $skipToEveryoneElse = false;
19✔
438
        // check forced-decision first
439
        $rule = $rules[$ruleIndex];
19✔
440
        $context = new OptimizelyDecisionContext($flagKey, $rule->getKey());
19✔
441
        list($forcedDecisionResponse, $reasons) = $this->findValidatedForcedDecision($context, $projectConfig, $user);
19✔
442

443
        $decideReasons = array_merge($decideReasons, $reasons);
19✔
444
        if ($forcedDecisionResponse) {
19✔
445
            return [new FeatureDecision($rule, $forcedDecisionResponse, null, $decideReasons), $skipToEveryoneElse];
1✔
446
        }
447

448
        // regular decision
449
        $userId = $user->getUserId();
19✔
450
        $attributes = $user->getAttributes();
19✔
451
        list($bucketingId, $reasons) = $this->getBucketingId($userId, $attributes);
19✔
452
        $decideReasons = array_merge($decideReasons, $reasons);
19✔
453

454
        $everyoneElse = $ruleIndex == sizeof($rules) - 1;
19✔
455
        $loggingKey = $everyoneElse ? "Everyone Else" : $ruleIndex + 1;
19✔
456
        $bucketedVariation = null;
19✔
457

458
        // Evaluate if user meets the audience condition of this rollout rule
459
        list($evalResult, $reasons) = Validator::doesUserMeetAudienceConditions($projectConfig, $rule, $attributes, $this->_logger, 'Optimizely\Enums\RolloutAudienceEvaluationLogs', $loggingKey);
19✔
460
        $decideReasons = array_merge($decideReasons, $reasons);
19✔
461
        if ($evalResult) {
19✔
462
            $message = sprintf('User "%s" meets condition for targeting rule "%s".', $userId, $loggingKey);
16✔
463
            $this->_logger->log(
16✔
464
                Logger::INFO,
16✔
465
                $message
466
            );
16✔
467
            $decideReasons[] = $message;
16✔
468
            list($bucketedVariation, $reasons) = $this->_bucketer->bucket($projectConfig, $rule, $bucketingId, $userId);
16✔
469
            $decideReasons = array_merge($decideReasons, $reasons);
16✔
470
            if ($bucketedVariation) {
16✔
471
                $message = sprintf('User "%s" is in the traffic group of targeting rule "%s".', $userId, $loggingKey);
13✔
472
                $this->_logger->log(Logger::INFO, $message);
13✔
473
                $decideReasons[] = $message;
13✔
474
            } elseif (!$everyoneElse) {
16✔
475
                // skip this logging for EveryoneElse since this has a message not for EveryoneElse
476
                $message = sprintf('User "%s" is not in the traffic group for targeting rule "%s". Checking Everyone Else rule now.', $userId, $loggingKey);
2✔
477
                $this->_logger->log(Logger::INFO, $message);
2✔
478
                $decideReasons[] = $message;
2✔
479
                // skip the rest of rollout rules to the everyone-else rule if audience matches but not bucketed.
480
                $skipToEveryoneElse = true;
2✔
481
            }
2✔
482
        } else {
16✔
483
            $message = sprintf('User "%s" does not meet conditions for targeting rule "%s".', $userId, $loggingKey);
12✔
484
            $this->_logger->log(Logger::DEBUG, $message);
12✔
485
            $decideReasons[] = $message;
12✔
486
        }
487

488
        return [new FeatureDecision($rule, $bucketedVariation, null, $decideReasons), $skipToEveryoneElse];
19✔
489
    }
490

491
    /**
492
     * Gets the forced variation key for the given user and experiment.
493
     *
494
     * @param $projectConfig ProjectConfigInterface  ProjectConfigInterface instance.
495
     * @param $experimentKey string         Key for experiment.
496
     * @param $userId        string         The user Id.
497
     *
498
     * @return [ Variation, array ] The variation which the given user and experiment should be forced into and
499
     *                              array of log messages representing decision making.
500
     */
501
    public function getForcedVariation(ProjectConfigInterface $projectConfig, $experimentKey, $userId)
502
    {
503
        $decideReasons = [];
70✔
504
        if (!isset($this->_forcedVariationMap[$userId])) {
70✔
505
            $this->_logger->log(Logger::DEBUG, sprintf('User "%s" is not in the forced variation map.', $userId));
63✔
506
            return [ null, $decideReasons];
63✔
507
        }
508

509
        $experimentToVariationMap = $this->_forcedVariationMap[$userId];
15✔
510
        $experimentId = $projectConfig->getExperimentFromKey($experimentKey)->getId();
15✔
511

512
        // check for null and empty string experiment ID
513
        if (strlen((string)$experimentId) == 0) {
15✔
514
            // this case is logged in getExperimentFromKey
515
            return [ null, $decideReasons];
2✔
516
        }
517

518
        if (!isset($experimentToVariationMap[$experimentId])) {
15✔
519
            $message = sprintf('No experiment "%s" mapped to user "%s" in the forced variation map.', $experimentKey, $userId);
3✔
520
            $this->_logger->log(Logger::DEBUG, $message);
3✔
521
            return [ null, $decideReasons];
3✔
522
        }
523

524
        $variationId = $experimentToVariationMap[$experimentId];
15✔
525
        $variation = $projectConfig->getVariationFromId($experimentKey, $variationId);
15✔
526
        $variationKey = $variation->getKey();
15✔
527

528
        $message = sprintf('Variation "%s" is mapped to experiment "%s" and user "%s" in the forced variation map', $variationKey, $experimentKey, $userId);
15✔
529
        $this->_logger->log(Logger::DEBUG, $message);
15✔
530
        $decideReasons[] = $message;
15✔
531
        return [ $variation, $decideReasons];
15✔
532
    }
533

534
    /**
535
     * Sets an associative array of user IDs to an associative array of experiments
536
     * to forced variations.
537
     *
538
     * @param $projectConfig ProjectConfigInterface  ProjectConfigInterface instance.
539
     * @param $experimentKey string         Key for experiment.
540
     * @param $userId        string         The user Id.
541
     * @param $variationKey  string         Key for variation. If null, then clear the existing experiment-to-variation mapping.
542
     *
543
     * @return boolean A boolean value that indicates if the set completed successfully.
544
     */
545
    public function setForcedVariation(ProjectConfigInterface $projectConfig, $experimentKey, $userId, $variationKey)
546
    {
547
        // check for empty string Variation key
548
        if (!is_null($variationKey) && !Validator::validateNonEmptyString($variationKey)) {
22✔
549
            $this->_logger->log(Logger::ERROR, sprintf(Errors::INVALID_FORMAT, Optimizely::VARIATION_KEY));
2✔
550
            return false;
2✔
551
        }
552

553
        $experiment = $projectConfig->getExperimentFromKey($experimentKey);
22✔
554
        $experimentId = $experiment->getId();
22✔
555

556
        // check if the experiment exists in the datafile (a new experiment is returned if it is not in the datafile)
557
        if (strlen((string)$experimentId) == 0) {
22✔
558
            // this case is logged in getExperimentFromKey
559
            return false;
4✔
560
        }
561

562
        // clear the forced variation if the variation key is null
563
        if (is_null($variationKey)) {
21✔
564
            unset($this->_forcedVariationMap[$userId][$experimentId]);
6✔
565
            $this->_logger->log(Logger::DEBUG, sprintf('Variation mapped to experiment "%s" has been removed for user "%s".', $experimentKey, $userId));
6✔
566
            return true;
6✔
567
        }
568

569
        $variation = $projectConfig->getVariationFromKey($experimentKey, $variationKey);
21✔
570
        $variationId = $variation->getId();
21✔
571

572
        // check if the variation exists in the datafile (a new variation is returned if it is not in the datafile)
573
        if (strlen((string)$variationId) == 0) {
21✔
574
            // this case is logged in getVariationFromKey
575
            return false;
4✔
576
        }
577

578
        $this->_forcedVariationMap[$userId][$experimentId] = $variationId;
20✔
579
        $this->_logger->log(Logger::DEBUG, sprintf('Set variation "%s" for experiment "%s" and user "%s" in the forced variation map.', $variationId, $experimentId, $userId));
20✔
580
        return true;
20✔
581
    }
582

583
    /**
584
     * Determine variation the user has been forced into.
585
     *
586
     * @param $projectConfig ProjectConfigInterface  ProjectConfigInterface instance.
587
     * @param $experiment    Experiment     Experiment in which user is to be bucketed.
588
     * @param $userId        string         string
589
     *
590
     * @return [ Variation, array ] Representing the variation the user is forced into and
591
     *                              array of log messages representing decision making.
592
     */
593
    private function getWhitelistedVariation(ProjectConfigInterface $projectConfig, Experiment $experiment, $userId)
594
    {
595
        $decideReasons = [];
60✔
596
        // Check if user is whitelisted for a variation.
597
        $forcedVariations = $experiment->getForcedVariations();
60✔
598
        if (!is_null($forcedVariations) && isset($forcedVariations[$userId])) {
60✔
599
            $variationKey = $forcedVariations[$userId];
4✔
600
            $variation = $projectConfig->getVariationFromKeyByExperimentId($experiment->getId(), $variationKey);
4✔
601
            if ($variationKey && !empty($variation->getKey())) {
4✔
602
                $message = sprintf('User "%s" is forced in variation "%s" of experiment "%s".', $userId, $variationKey, $experiment->getKey());
4✔
603

604
                $this->_logger->log(Logger::INFO, $message);
4✔
605
                $decideReasons[] = $message;
4✔
606
            } else {
4✔
607
                return [ null, $decideReasons ];
×
608
            }
609
            return [ $variation, $decideReasons];
4✔
610
        }
611
        return [ null, $decideReasons ];
57✔
612
    }
613

614
    /**
615
     * Get the stored user profile for the given user ID.
616
     *
617
     * @param $userId        string  ID of the user.
618
     *
619
     * @return [UserProfile, array] the stored user profile and array of log messages representing decision making.
620
     */
621
    private function getStoredUserProfile($userId)
622
    {
623
        $decideReasons = [];
7✔
624

625
        if (is_null($this->_userProfileService)) {
7✔
626
            return [ null, $decideReasons ];
×
627
        }
628

629
        try {
630
            $userProfileMap = $this->_userProfileService->lookup($userId);
7✔
631
            if (is_null($userProfileMap)) {
6✔
632
                $this->_logger->log(
2✔
633
                    Logger::INFO,
2✔
634
                    sprintf('No user profile found for user with ID "%s".', $userId)
2✔
635
                );
2✔
636
            } elseif (UserProfileUtils::isValidUserProfileMap($userProfileMap)) {
6✔
637
                $userProfile = UserProfileUtils::convertMapToUserProfile($userProfileMap);
4✔
638
                return [ $userProfile, $decideReasons];
4✔
639
            } else {
640
                $this->_logger->log(
×
641
                    Logger::WARNING,
×
642
                    'The User Profile Service returned an invalid user profile map.'
643
                );
×
644
            }
645
        } catch (Exception $e) {
3✔
646
            $message = sprintf('The User Profile Service lookup method failed: %s.', $e->getMessage());
1✔
647
            $this->_logger->log(Logger::ERROR, $message);
1✔
648
            $decideReasons[] = $message;
1✔
649
        }
650

651
        return [ null, $decideReasons ];
3✔
652
    }
653

654
    /**
655
     * Get the stored variation for the given experiment from the user profile.
656
     *
657
     * @param $projectConfig ProjectConfigInterface  ProjectConfigInterface instance.
658
     * @param $experiment    Experiment     The experiment for which we are getting the stored variation.
659
     * @param $userProfile   UserProfile    The user profile from which we are getting the stored variation.
660
     *
661
     * @return [ Variation, array ] the stored variation or null if not found, and array of log messages representing decision making.
662
     */
663
    private function getStoredVariation(ProjectConfigInterface $projectConfig, Experiment $experiment, UserProfile $userProfile)
664
    {
665
        $decideReasons = [];
4✔
666
        $experimentKey = $experiment->getKey();
4✔
667
        $userId = $userProfile->getUserId();
4✔
668
        $variationId = $userProfile->getVariationForExperiment($experiment->getId());
4✔
669

670
        if (is_null($variationId)) {
4✔
671
            $this->_logger->log(
1✔
672
                Logger::INFO,
1✔
673
                sprintf('No previously activated variation of experiment "%s" for user "%s" found in user profile.', $experimentKey, $userId)
1✔
674
            );
1✔
675
            return [ null, $decideReasons ];
1✔
676
        }
677
        
678
        $variation = $projectConfig->getVariationFromId($experimentKey, $variationId);
3✔
679
        if (!($variation->getId())) {
3✔
680
            $message = sprintf(
1✔
681
                '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.',
1✔
682
                $userId,
1✔
683
                $variationId,
1✔
684
                $experimentKey
685
            );
1✔
686

687
            $this->_logger->log(
1✔
688
                Logger::INFO,
1✔
689
                $message
690
            );
1✔
691

692
            $decideReasons[] = $message;
1✔
693

694
            return [ null, $decideReasons ];
1✔
695
        }
696

697
        $this->_logger->log(
2✔
698
            Logger::INFO,
2✔
699
            sprintf(
2✔
700
                'Returning previously activated variation "%s" of experiment "%s" for user "%s" from user profile.',
2✔
701
                $variation->getKey(),
2✔
702
                $experimentKey,
2✔
703
                $userId
704
            )
2✔
705
        );
2✔
706
        return [ $variation, $decideReasons ];
2✔
707
    }
708

709
    /**
710
     * Save the given variation assignment to the given user profile.
711
     *
712
     * @param $experiment  Experiment  Experiment for which we are storing the variation.
713
     * @param $variation   Variation   Variation the user is bucketed into.
714
     * @param $userProfile UserProfile User profile object to which we are persisting the variation assignment.
715
     */
716
    private function saveVariation(Experiment $experiment, Variation $variation, UserProfile $userProfile)
717
    {
718
        if (is_null($this->_userProfileService)) {
41✔
719
            return;
36✔
720
        }
721

722
        $experimentId = $experiment->getId();
5✔
723
        $decision = $userProfile->getDecisionForExperiment($experimentId);
5✔
724
        $variationId = $variation->getId();
5✔
725
        if (is_null($decision)) {
5✔
726
            $decision = new Decision($variationId);
4✔
727
        } else {
4✔
728
            $decision->setVariationId($variationId);
1✔
729
        }
730

731
        $userProfile->saveDecisionForExperiment($experimentId, $decision);
5✔
732
        $userProfileMap = UserProfileUtils::convertUserProfileToMap($userProfile);
5✔
733

734
        try {
735
            $this->_userProfileService->save($userProfileMap);
5✔
736
            $message = sprintf(
4✔
737
                'Saved variation "%s" of experiment "%s" for user "%s".',
4✔
738
                $variation->getKey(),
4✔
739
                $experiment->getKey(),
4✔
740
                $userProfile->getUserId()
4✔
741
            );
4✔
742

743
            $this->_logger->log(Logger::INFO, $message);
4✔
744
        } catch (Exception $e) {
5✔
745
            $message = sprintf(
1✔
746
                'Failed to save variation "%s" of experiment "%s" for user "%s".',
1✔
747
                $variation->getKey(),
1✔
748
                $experiment->getKey(),
1✔
749
                $userProfile->getUserId()
1✔
750
            );
1✔
751

752
            $this->_logger->log(Logger::WARNING, $message);
1✔
753
        }
754
    }
5✔
755
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc