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

optimizely / swift-sdk / 4670339270

pending completion
4670339270

Pull #486

github

GitHub
Merge 88f08851e into 8b43600a3
Pull Request #486: [FSSDK-9062] Jae/empty odp action

2 of 2 new or added lines in 1 file covered. (100.0%)

701 of 736 relevant lines covered (95.24%)

5831.67 hits per line

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

81.82
/Sources/Implementation/DefaultDecisionService.swift
1
//
2
// Copyright 2019-2022, Optimizely, Inc. and contributors
3
//
4
// Licensed under the Apache License, Version 2.0 (the "License");
5
// you may not use this file except in compliance with the License.
6
// You may obtain a copy of the License at
7
//
8
//    http://www.apache.org/licenses/LICENSE-2.0
9
//
10
// Unless required by applicable law or agreed to in writing, software
11
// distributed under the License is distributed on an "AS IS" BASIS,
12
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
// See the License for the specific language governing permissions and
14
// limitations under the License.
15
//
16

17
import Foundation
18

19
struct FeatureDecision {
20
    var experiment: Experiment?
21
    let variation: Variation
22
    let source: String
23
}
24

25
class DefaultDecisionService: OPTDecisionService {
26
    
27
    let bucketer: OPTBucketer
28
    let userProfileService: OPTUserProfileService
29
    
30
    // thread-safe lazy logger load (after HandlerRegisterService ready)
31
    private let threadSafeLogger = ThreadSafeLogger()
451✔
32
    var logger: OPTLogger {
16,440✔
33
        return threadSafeLogger.logger
34
    }
35
    
36
    // user-profile-service read-modify-write lock for supporting multiple clients
37
    static let upsRMWLock = DispatchQueue(label: "ups-rmw")
38
    
39
    init(userProfileService: OPTUserProfileService) {
451✔
40
        self.bucketer = DefaultBucketer()
41
        self.userProfileService = userProfileService
42
    }
43
    
44
    func getVariation(config: ProjectConfig,
45
                      experiment: Experiment,
46
                      user: OptimizelyUserContext,
47
                      options: [OptimizelyDecideOption]? = nil) -> DecisionResponse<Variation> {
3,566✔
48
        let reasons = DecisionReasons(options: options)
49
        
50
        let userId = user.userId
51
        let attributes = user.attributes
52
        let experimentId = experiment.id
53
        
54
        // Acquire bucketingId .
55
        let bucketingId = getBucketingId(userId: userId, attributes: attributes)
56
        
57
        // ---- check if the experiment is running ----
58
        if !experiment.isActivated {
59
            let info = LogMessage.experimentNotRunning(experiment.key)
60
            logger.i(info)
61
            reasons.addInfo(info)
62
            return DecisionResponse(result: nil, reasons: reasons)
63
        }
64
        
65
        // ---- check if the user is forced into a variation ----
66
        let decisionResponse = config.getForcedVariation(experimentKey: experiment.key, userId: userId)
67
        reasons.merge(decisionResponse.reasons)
68
        if let variationId = decisionResponse.result?.id,
69
           let variation = experiment.getVariation(id: variationId) {
70
            return DecisionResponse(result: variation, reasons: reasons)
71
        }
72
        
73
        // ---- check to see if user is white-listed for a certain variation ----
74
        if let variationKey = experiment.forcedVariations[userId] {
75
            if let variation = experiment.getVariation(key: variationKey) {
76
                let info = LogMessage.forcedVariationFound(variationKey, userId)
77
                logger.i(info)
78
                reasons.addInfo(info)
79
                return DecisionResponse(result: variation, reasons: reasons)
80
            }
81
            
82
            // mapped to invalid variation - ignore and continue for other deciesions
83
            let info = LogMessage.forcedVariationFoundButInvalid(variationKey, userId)
84
            logger.e(info)
85
            reasons.addInfo(info)
86
        }
87
        
88
        // ---- check if a valid variation is stored in the user profile ----
89
        let ignoreUPS = (options ?? []).contains(.ignoreUserProfileService)
3,046✔
90
        
91
        if !ignoreUPS,
92
           let variationId = getVariationIdFromProfile(userId: userId, experimentId: experimentId),
93
           let variation = experiment.getVariation(id: variationId) {
94
            
95
            let info = LogMessage.gotVariationFromUserProfile(variation.key, experiment.key, userId)
96
            logger.i(info)
97
            reasons.addInfo(info)
98
            return DecisionResponse(result: variation, reasons: reasons)
99
        }
100
        
101
        var bucketedVariation: Variation?
102
        // ---- check if the user passes audience targeting before bucketing ----
103
        let audienceResponse = doesMeetAudienceConditions(config: config,
104
                                                          experiment: experiment,
105
                                                          user: user)
106
        reasons.merge(audienceResponse.reasons)
107
        if audienceResponse.result ?? false {
×
108
            // bucket user into a variation
109
            let decisionResponse = bucketer.bucketExperiment(config: config,
110
                                                             experiment: experiment,
111
                                                             bucketingId: bucketingId)
112
            reasons.merge(decisionResponse.reasons)
113
            bucketedVariation = decisionResponse.result
114
            
115
            if let variation = bucketedVariation {
116
                let info = LogMessage.userBucketedIntoVariationInExperiment(userId, experiment.key, variation.key)
117
                logger.i(info)
118
                reasons.addInfo(info)
119
                // save to user profile
120
                if !ignoreUPS {
121
                    self.saveProfile(userId: userId, experimentId: experimentId, variationId: variation.id)
122
                }
123
            } else {
124
                let info = LogMessage.userNotBucketedIntoVariation(userId)
125
                logger.i(info)
126
                reasons.addInfo(info)
127
            }
128
            
129
        } else {
130
            let info = LogMessage.userNotInExperiment(userId, experiment.key)
131
            logger.i(info)
132
            reasons.addInfo(info)
133
        }
134
        
135
        return DecisionResponse(result: bucketedVariation, reasons: reasons)
136
    }
137
    
138
    func doesMeetAudienceConditions(config: ProjectConfig,
139
                                    experiment: Experiment,
140
                                    user: OptimizelyUserContext,
141
                                    logType: Constants.EvaluationLogType = .experiment,
142
                                    loggingKey: String? = nil) -> DecisionResponse<Bool> {
5,781✔
143
        let reasons = DecisionReasons()
144
        
145
        var result = true   // success as default (no condition, etc)
146
        let evType = logType.rawValue
147
        let finalLoggingKey = loggingKey ?? experiment.key
3,113✔
148
        
149
        do {
150
            if let conditions = experiment.audienceConditions {
151
                logger.d { () -> String in
×
152
                    return LogMessage.evaluatingAudiencesCombined(evType, finalLoggingKey, Utils.getConditionString(conditions: conditions)).description
153
                }
154
                switch conditions {
155
                case .array(let arrConditions):
156
                    if arrConditions.count > 0 {
157
                        result = try conditions.evaluate(project: config.project, user: user)
158
                    } else {
159
                        // empty conditions (backward compatibility with "audienceIds" is ignored if exists even though empty
160
                        result = true
161
                    }
162
                case .leaf:
163
                    result = try conditions.evaluate(project: config.project, user: user)
164
                default:
165
                    result = true
166
                }
167
            }
168
            // backward compatibility with audienceIds list
169
            else if experiment.audienceIds.count > 0 {
170
                var holder = [ConditionHolder]()
171
                holder.append(.logicalOp(.or))
172
                for id in experiment.audienceIds {
173
                    holder.append(.leaf(.audienceId(id)))
174
                }
175
                logger.d { () -> String in
×
176
                    return LogMessage.evaluatingAudiencesCombined(evType, finalLoggingKey, Utils.getConditionString(conditions: holder)).description
177
                }
178
                result = try holder.evaluate(project: config.project, user: user)
179
            }
180
        } catch {
181
            if let error = error as? OptimizelyError {
182
                logger.i(error)
183
                reasons.addInfo(error)
184
            }
185
            result = false
186
        }
187
        
188
        logger.i(.audienceEvaluationResultCombined(evType, finalLoggingKey, result.description))
189
        
190
        return DecisionResponse(result: result, reasons: reasons)
191
    }
192
    
193
    func getVariationForFeature(config: ProjectConfig,
194
                                featureFlag: FeatureFlag,
195
                                user: OptimizelyUserContext,
196
                                options: [OptimizelyDecideOption]? = nil) -> DecisionResponse<FeatureDecision> {
3,882✔
197
        let reasons = DecisionReasons(options: options)
198
        
199
        // Evaluate in this order:
200
        
201
        // 1. Attempt to bucket user into experiment using feature flag.
202
        // Check if the feature flag is under an experiment and the the user is bucketed into one of these experiments
203
        var decisionResponse = getVariationForFeatureExperiment(config: config,
204
                                                                featureFlag: featureFlag,
205
                                                                user: user,
206
                                                                options: options)
207
        reasons.merge(decisionResponse.reasons)
208
        if let decision = decisionResponse.result {
209
            return DecisionResponse(result: decision, reasons: reasons)
210
        }
211
        
212
        // 2. Attempt to bucket user into rollout using the feature flag.
213
        // Check if the feature flag has rollout and the user is bucketed into one of it's rules
214
        decisionResponse = getVariationForFeatureRollout(config: config,
215
                                                         featureFlag: featureFlag,
216
                                                         user: user,
217
                                                         options: options)
218
        reasons.merge(decisionResponse.reasons)
219
        if let decision = decisionResponse.result {
220
            return DecisionResponse(result: decision, reasons: reasons)
221
        }
222
        
223
        return DecisionResponse(result: nil, reasons: reasons)
224
    }
225
    
226
    func getVariationForFeatureExperiment(config: ProjectConfig,
227
                                          featureFlag: FeatureFlag,
228
                                          user: OptimizelyUserContext,
229
                                          options: [OptimizelyDecideOption]? = nil) -> DecisionResponse<FeatureDecision> {
3,884✔
230
        let reasons = DecisionReasons(options: options)
231
        
232
        let experimentIds = featureFlag.experimentIds
233
        if experimentIds.isEmpty {
234
            let info = LogMessage.featureHasNoExperiments(featureFlag.key)
235
            logger.d(info)
236
            reasons.addInfo(info)
237
        }
238
        
239
        // Check if there are any experiment IDs inside feature flag
240
        // Evaluate each experiment ID and return the first bucketed experiment variation
241
        for experimentId in experimentIds {
242
            if let experiment = config.getExperiment(id: experimentId) {
243
                let decisionResponse = getVariationFromExperimentRule(config: config,
244
                                                                      flagKey: featureFlag.key,
245
                                                                      rule: experiment,
246
                                                                      user: user,
247
                                                                      options: options)
248
                reasons.merge(decisionResponse.reasons)
249
                if let variation = decisionResponse.result {
250
                    let featureDecision = FeatureDecision(experiment: experiment, variation: variation, source: Constants.DecisionSource.featureTest.rawValue)
251
                    return DecisionResponse(result: featureDecision, reasons: reasons)
252
                }
253
            }
254
        }
255
        
256
        return DecisionResponse(result: nil, reasons: reasons)
257
    }
258
    
259
    func getVariationForFeatureRollout(config: ProjectConfig,
260
                                       featureFlag: FeatureFlag,
261
                                       user: OptimizelyUserContext,
262
                                       options: [OptimizelyDecideOption]? = nil) -> DecisionResponse<FeatureDecision> {
2,961✔
263
        let reasons = DecisionReasons(options: options)
264
        
265
        let rolloutId = featureFlag.rolloutId.trimmingCharacters(in: CharacterSet.whitespaces)
266
        
267
        guard !rolloutId.isEmpty else {
268
            let info = LogMessage.noRolloutExists(featureFlag.key)
269
            logger.d(info)
270
            reasons.addInfo(info)
271
            return DecisionResponse(result: nil, reasons: reasons)
272
        }
273
        
274
        guard let rollout = config.getRollout(id: rolloutId) else {
275
            let info = OptimizelyError.rolloutIdInvalid(rolloutId, featureFlag.key)
276
            logger.d(info)
277
            reasons.addInfo(info)
278
            return DecisionResponse(result: nil, reasons: reasons)
279
        }
280
        
281
        let rolloutRules = rollout.experiments
282
        if rolloutRules.isEmpty {
283
            let info = LogMessage.rolloutHasNoExperiments(rolloutId)
284
            logger.e(info)
285
            reasons.addInfo(info)
286
            return DecisionResponse(result: nil, reasons: reasons)
287
        }
288
        
289
        var index = 0
290
        while index < rolloutRules.count {
291
            let decisionResponse = getVariationFromDeliveryRule(config: config,
292
                                                                flagKey: featureFlag.key,
293
                                                                rules: rolloutRules,
294
                                                                ruleIndex: index,
295
                                                                user: user,
296
                                                                options: options)
297
            reasons.merge(decisionResponse.reasons)
298
            let (variation, skipToEveryoneElse) = decisionResponse.result!
299
            
300
            if let variation = variation {
301
                let rule = rolloutRules[index]
302
                let featureDecision = FeatureDecision(experiment: rule, variation: variation, source: Constants.DecisionSource.rollout.rawValue)
303
                return DecisionResponse(result: featureDecision, reasons: reasons)
304
            }
305
            
306
            // the last rule is special for "Everyone Else"
307
            index = skipToEveryoneElse ? (rolloutRules.count - 1) : (index + 1)
308
        }
309
        
310
        return DecisionResponse(result: nil, reasons: reasons)
311
    }
312
    
313
    func getVariationFromExperimentRule(config: ProjectConfig,
314
                                        flagKey: String,
315
                                        rule: Experiment,
316
                                        user: OptimizelyUserContext,
317
                                        options: [OptimizelyDecideOption]? = nil) -> DecisionResponse<Variation> {
1,006✔
318
        let reasons = DecisionReasons(options: options)
319
        
320
        // check forced-decision first
321
        
322
        let forcedDecisionResponse = findValidatedForcedDecision(config: config,
323
                                                                 user: user,
324
                                                                 context: OptimizelyDecisionContext(flagKey: flagKey, ruleKey: rule.key))
325
        reasons.merge(forcedDecisionResponse.reasons)
326
        
327
        if let variation = forcedDecisionResponse.result {
328
            return DecisionResponse(result: variation, reasons: reasons)
329
        }
330
        
331
        // regular decision
332
        
333
        let decisionResponse = getVariation(config: config,
334
                                            experiment: rule,
335
                                            user: user,
336
                                            options: options)
337
        reasons.merge(decisionResponse.reasons)
338
        let variation = decisionResponse.result
339
        
340
        return DecisionResponse(result: variation, reasons: reasons)
341
    }
342
    
343
    func getVariationFromDeliveryRule(config: ProjectConfig,
344
                                      flagKey: String,
345
                                      rules: [Experiment],
346
                                      ruleIndex: Int,
347
                                      user: OptimizelyUserContext,
348
                                      options: [OptimizelyDecideOption]? = nil) -> DecisionResponse<(Variation?, Bool)> {
2,673✔
349
        let reasons = DecisionReasons(options: options)
350
        var skipToEveryoneElse = false
351

352
        // check forced-decision first
353
        
354
        let rule = rules[ruleIndex]
355
        let forcedDecisionResponse = findValidatedForcedDecision(config: config,
356
                                                                 user: user,
357
                                                                 context: OptimizelyDecisionContext(flagKey: flagKey, ruleKey: rule.key))
358
        reasons.merge(forcedDecisionResponse.reasons)
359
        
360
        if let variation = forcedDecisionResponse.result {
361
            return DecisionResponse(result: (variation, skipToEveryoneElse), reasons: reasons)
362
        }
363
        
364
        // regular decision
365
        
366
        let userId = user.userId
367
        let attributes = user.attributes
368
        let bucketingId = getBucketingId(userId: userId, attributes: attributes)
369
        
370
        let everyoneElse = (ruleIndex == rules.count - 1)
371
        let loggingKey = everyoneElse ? "Everyone Else" : String(ruleIndex + 1)
372
        
373
        var bucketedVariation: Variation?
374
        
375
        let audienceDecisionResponse = doesMeetAudienceConditions(config: config,
376
                                                                  experiment: rule,
377
                                                                  user: user,
378
                                                                  logType: .rolloutRule,
379
                                                                  loggingKey: loggingKey)
380
        reasons.merge(audienceDecisionResponse.reasons)
381
        if audienceDecisionResponse.result ?? false {
×
382
            var info = LogMessage.userMeetsConditionsForTargetingRule(userId, loggingKey)
383
            logger.d(info)
384
            reasons.addInfo(info)
385
            
386
            let decisionResponse = bucketer.bucketExperiment(config: config,
387
                                                             experiment: rule,
388
                                                             bucketingId: bucketingId)
389
            reasons.merge(decisionResponse.reasons)
390
            bucketedVariation = decisionResponse.result
391
            
392
            if bucketedVariation != nil {
393
                info = LogMessage.userBucketedIntoTargetingRule(userId, loggingKey)
394
                logger.d(info)
395
                reasons.addInfo(info)
396
            } else if !everyoneElse {
397
                // skip this logging for EveryoneElse since this has a message not for EveryoneElse
398
                info = LogMessage.userNotBucketedIntoTargetingRule(userId, loggingKey)
399
                logger.d(info)
400
                reasons.addInfo(info)
401
                
402
                // skip the rest of rollout rules to the everyone-else rule if audience matches but not bucketed.
403
                skipToEveryoneElse = true
404
            }
405
        } else {
406
            let info = LogMessage.userDoesntMeetConditionsForTargetingRule(userId, loggingKey)
407
            logger.d(info)
408
            reasons.addInfo(info)
409
        }
410
        
411
        return DecisionResponse(result: (bucketedVariation, skipToEveryoneElse), reasons: reasons)
412
    }
413
    
414
    func getBucketingId(userId: String, attributes: OptimizelyAttributes) -> String {
415
        
416
        // By default, the bucketing ID should be the user ID .
417
        var bucketingId = userId
418
        // If the bucketing ID key is defined in attributes, then use that
419
        // in place of the userID for the murmur hash key
420
        if let newBucketingId = attributes[Constants.Attributes.reservedBucketIdAttribute] as? String {
421
            bucketingId = newBucketingId
422
        }
423
        
424
        return bucketingId
425
    }
426
    
427
    func findValidatedForcedDecision(config: ProjectConfig,
428
                                     user: OptimizelyUserContext,
429
                                     context: OptimizelyDecisionContext) -> DecisionResponse<Variation> {
4,411✔
430
        let reasons = DecisionReasons()
431
        
432
        if let variationKey = user.getForcedDecision(context: context)?.variationKey {
433
            let userId = user.userId
434
            
435
            if let variation = config.getFlagVariationByKey(flagKey: context.flagKey, variationKey: variationKey) {
436
                let info = LogMessage.userHasForcedDecision(userId, context.flagKey, context.ruleKey, variationKey)
437
                logger.i(info)
438
                reasons.addInfo(info)
439
                return DecisionResponse(result: variation, reasons: reasons)
440
            } else {
441
                let info = LogMessage.userHasForcedDecisionButInvalid(userId, context.flagKey, context.ruleKey)
442
                logger.i(info)
443
                reasons.addInfo(info)
444
            }
445
        }
446
        
447
        return DecisionResponse(result: nil, reasons: reasons)
448
    }
449
    
450
}
451

452
// MARK: - UserProfileService Helpers
453

454
extension DefaultDecisionService {
455
    
456
    func getVariationIdFromProfile(userId: String,
457
                                   experimentId: String) -> String? {
3,721✔
458
        if let profile = userProfileService.lookup(userId: userId),
459
           let bucketMap = profile[UserProfileKeys.kBucketMap] as? OPTUserProfileService.UPBucketMap,
460
           let experimentMap = bucketMap[experimentId],
461
           let variationId = experimentMap[UserProfileKeys.kVariationId] {
462
            return variationId
463
        } else {
464
            return nil
465
        }
466
    }
467
    
468
    func saveProfile(userId: String,
469
                     experimentId: String,
470
                     variationId: String) {
656✔
471
        DefaultDecisionService.upsRMWLock.sync {
656✔
472
            var profile = self.userProfileService.lookup(userId: userId) ?? OPTUserProfileService.UPProfile()
372✔
473
            
474
            var bucketMap = profile[UserProfileKeys.kBucketMap] as? OPTUserProfileService.UPBucketMap ?? OPTUserProfileService.UPBucketMap()
372✔
475
            bucketMap[experimentId] = [UserProfileKeys.kVariationId: variationId]
476
            
477
            profile[UserProfileKeys.kBucketMap] = bucketMap
478
            profile[UserProfileKeys.kUserId] = userId
479
            
480
            self.userProfileService.save(userProfile: profile)
481
            
482
            self.logger.i(.savedVariationInUserProfile(variationId, experimentId, userId))
483
        }
484
    }
485
    
486
}
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

© 2026 Coveralls, Inc