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

optimizely / swift-sdk / 26820357558

02 Jun 2026 12:41PM UTC coverage: 93.828% (+0.06%) from 93.766%
26820357558

push

github

web-flow
[AI-FSSDK] [FSSDK-12670] Block ODP identify event for single identifier (#636)

* [AI-FSSDK] [FSSDK-12670] Block ODP identify event for single identifier

* [FSSDK-12670] Address review feedback: fix log message

* [FSSDK-12670] Retrigger CI

* [FSSDK-12670] Add comment explaining the < 2 identifiers guard

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* [FSSDK-12670] retrigger CI

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

1125 of 1199 relevant lines covered (93.83%)

8911.52 hits per line

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

85.0
/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: ExperimentCore?
21
    let variation: Variation?
22
    let source: String
23
    var cmabUUID: String?
24
    var error = false
1,173✔
25
}
26

27
struct VariationDecision {
28
    var variation: Variation?
29
    var cmabError: Bool = false
1,061✔
30
    var cmabUUID: String?
31
    var holdout: ExperimentCore? = nil  // If variation came from a holdout, store the holdout here
1,039✔
32
}
33

34
struct DeliveryRuleDecision {
35
    var variation: Variation?
36
    var skipToEveryoneElse: Bool
37
    var holdout: ExperimentCore? = nil  // If variation came from a holdout, store the holdout here
2,731✔
38
}
39

40
typealias UserProfile = OPTUserProfileService.UPProfile
41

42
class DefaultDecisionService: OPTDecisionService {
43
    let bucketer: OPTBucketer
44
    let userProfileService: OPTUserProfileService
45
    var cmabService: CmabService
46
    let group: DispatchGroup = DispatchGroup()
869✔
47
    // thread-safe lazy logger load (after HandlerRegisterService ready)
48
    private let threadSafeLogger = ThreadSafeLogger()
869✔
49
    // user-profile-service read-modify-write lock for supporting multiple clients
50
    static let upsRMWLock = DispatchQueue(label: "ups-rmw")
51
    
52
    var logger: OPTLogger {
23,320✔
53
        return threadSafeLogger.logger
54
    }
55
    
56
    init(userProfileService: OPTUserProfileService,
57
         cmabService: CmabService = DefaultCmabService.createDefault()) {
794✔
58
        self.bucketer = DefaultBucketer()
59
        self.userProfileService = userProfileService
60
        self.cmabService = cmabService
61
    }
62
    
63
    init(userProfileService: OPTUserProfileService,
64
         bucketer: OPTBucketer,
65
         cmabService: CmabService = DefaultCmabService.createDefault()) {
75✔
66
        self.bucketer = bucketer
67
        self.userProfileService = userProfileService
68
        self.cmabService = cmabService
69
    }
70
    
71
    // MARK: - CMAB decision
72
    
73
    /// Get decision for CMAB Experiment
74
    /// - Parameters:
75
    ///   - config: The project configuration containing experiment and feature details.
76
    ///   - experiment: The CMAB experiment to evaluate.
77
    ///   - user: The user context containing user ID and attributes.
78
    ///   - bucketingId: User bucketing id
79
    ///   - isAsync: Controls synchronous or asynchronous decision.
80
    ///   - options: Optional decision options (e.g., ignore user profile service).
81
    /// - Returns: A `CMABDecisionResult` containing the CMAB decisions( variation id, cmabUUID) with reasons
82
    
83
    private func getDecisionForCmabExperiment(config: ProjectConfig,
84
                                              experiment: Experiment,
85
                                              user: OptimizelyUserContext,
86
                                              bucketingId: String,
87
                                              isAsync: Bool,
88
                                              options: [OptimizelyDecideOption]?) -> DecisionResponse<VariationDecision> {
16✔
89
        let reasons = DecisionReasons(options: options)
90
        guard let cmab = experiment.cmab else {
91
            logger.e("The experiment isn't a CMAB experiment")
92
            return DecisionResponse(result: nil, reasons: reasons)
93
        }
94
        
95
        // We do not choose the alternative solution (checking and rejecting if on the main thread)
96
        // because it would lead to inconsistent decision results:
97
        // - If the decision is called from the main thread, CMAB logic is skipped and an error is logged.
98
        // - If called from a background thread, CMAB logic is included.
99
        // This means the same API could return different results based on thread context, which is confusing for users.
100
        // Instead, we pass an `isAsync` boolean to ensure that CMAB will be evaluated only for async calls.
101
        
102
        guard isAsync else {
103
            let info = LogMessage.cmabNotSupportedInSyncMode
104
            logger.e(info)
105
            reasons.addInfo(info)
106
            return DecisionResponse(result: nil, reasons: reasons)
107
        }
108
        
109
        let dummyEntityId = "$"
110
        let cmabTrafficAllocation = TrafficAllocation(entityId: dummyEntityId, endOfRange: cmab.trafficAllocation)
111
        let bucketedResponse = (bucketer as? DefaultBucketer)?.bucketToEntityId(config: config, experiment: experiment, bucketingId: bucketingId, trafficAllocation: [cmabTrafficAllocation])
112
        
113
        if let _reasons = bucketedResponse?.reasons {
114
            reasons.merge(_reasons)
115
        }
116
        
117
        let entityId = bucketedResponse?.result
118
        
119
        // This means the user is not in the cmab experiment
120
        if entityId == nil {
121
            let info = LogMessage.userNotInCmabExperiment(user.userId, experiment.key)
122
            logger.d(info)
123
            reasons.addInfo(info)
124
            return DecisionResponse(result: nil, reasons: reasons)
125
        }
126
        
127
        // Fetch CMAB decision
128
        let response = cmabService.getDecision(config: config, userContext: user, ruleId: experiment.id, options: options ?? [])
4✔
129
        var cmabDecision: CmabDecision?
130
        switch response {
131
            case .success(let decision):
132
                cmabDecision = decision
133
                let info = LogMessage.cmabFetchSuccess(decision.variationId, decision.cmabUUID, _expKey: experiment.key)
134
                self.logger.d(info)
135
                reasons.addInfo(info)
136
            case .failure:
137
                let info = LogMessage.cmabFetchFailed(experiment.key)
138
                self.logger.e(info)
139
                reasons.addError(info)
140
                let nilVariation = VariationDecision(variation: nil, cmabError: true, cmabUUID: nil)
141
                return DecisionResponse(result: nilVariation, reasons: reasons)
142
        }
143
        
144
        if let cmabDecision = cmabDecision,
145
           let experiment = config.getExperiment(id: experiment.id),
146
           let bucketedVariation = experiment.getVariation(id: cmabDecision.variationId) {
147
            let variationDecision = VariationDecision(variation: bucketedVariation, cmabUUID: cmabDecision.cmabUUID)
148
            return DecisionResponse(result: variationDecision, reasons: reasons)
149
        }
150
        
151
        return DecisionResponse(result: nil, reasons: reasons)
152
    }
153
    
154
    // MARK: - Experiment Decision
155
    
156
    /// Determines the variation for a user in a given experiment.
157
    /// - Parameters:
158
    ///   - config: The project configuration containing experiment and feature details.
159
    ///   - experiment: The experiment to evaluate.
160
    ///   - user: The user context containing user ID and attributes.
161
    ///   - options: Optional decision options (e.g., ignore user profile service).
162
    /// - Returns: A `DecisionResponse` containing the assigned variation (if any) and decision reasons.
163
    func getVariation(config: ProjectConfig,
164
                      experiment: Experiment,
165
                      user: OptimizelyUserContext,
166
                      options: [OptimizelyDecideOption]? = nil) -> DecisionResponse<Variation> {
2,563✔
167
        let userId = user.userId
168
        let ignoreUPS = (options ?? []).contains(.ignoreUserProfileService)
2,563✔
169
        var profileTracker: UserProfileTracker?
170
        if !ignoreUPS {
171
            profileTracker = UserProfileTracker(userId: userId, userProfileService: self.userProfileService, logger: self.logger)
172
            profileTracker?.loadUserProfile()
173
        }
174
        
175
        // isAsync to `false` for backward compatibility
176
        let response = getVariation(config: config, experiment: experiment, user: user, isAsync: false, userProfileTracker: profileTracker)
177
        
178
        if (!ignoreUPS) {
179
            profileTracker?.save()
180
        }
181
        
182
        return DecisionResponse(result: response.result?.variation, reasons: response.reasons)
183
    }
184
    
185
    /// Determines the variation for a user in an experiment, considering user profile and decision rules.
186
    /// - Parameters:
187
    ///   - config: The project configuration.
188
    ///   - experiment: The experiment to evaluate.
189
    ///   - user: The user context.
190
    ///   - options: Optional decision options.
191
    ///   - isAsync: Controls synchronous or asynchronous decision.
192
    ///   - userProfileTracker: Optional tracker for user profile data.
193
    /// - Returns: A `DecisionResponse` with the variation (if any) and decision reasons.
194
    func getVariation(config: ProjectConfig,
195
                      experiment: Experiment,
196
                      user: OptimizelyUserContext,
197
                      options: [OptimizelyDecideOption]? = nil,
198
                      isAsync: Bool,
199
                      userProfileTracker: UserProfileTracker?) -> DecisionResponse<VariationDecision> {
3,653✔
200
        let reasons = DecisionReasons(options: options)
201
        let userId = user.userId
202
        let attributes = user.attributes
203
        let experimentId = experiment.id
204
        
205
        // ---- check if the experiment is running ----
206
        if !experiment.isActivated {
207
            let info = LogMessage.experimentNotRunning(experiment.key)
208
            logger.i(info)
209
            reasons.addInfo(info)
210
            return DecisionResponse(result: nil, reasons: reasons)
211
        }
212
        
213
        // ---- check if the user is forced into a variation ----
214
        let decisionResponse = config.getForcedVariation(experimentKey: experiment.key, userId: userId)
215
        
216
        reasons.merge(decisionResponse.reasons)
217
        
218
        if let variationId = decisionResponse.result?.id,
219
           let variation = experiment.getVariation(id: variationId) {
220
            let variationDecision = VariationDecision(variation: variation)
221
            return DecisionResponse(result: variationDecision, reasons: reasons)
222
        }
223
        
224
        // ---- check to see if user is white-listed for a certain variation ----
225
        if let variationKey = experiment.forcedVariations[userId] {
226
            if let variation = experiment.getVariation(key: variationKey) {
227
                let info = LogMessage.forcedVariationFound(variationKey, userId)
228
                logger.i(info)
229
                reasons.addInfo(info)
230
                let variationDecision = VariationDecision(variation: variation)
231
                return DecisionResponse(result: variationDecision, reasons: reasons)
232
            }
233
            
234
            // mapped to invalid variation - ignore and continue for other deciesions
235
            let info = LogMessage.forcedVariationFoundButInvalid(variationKey, userId)
236
            logger.e(info)
237
            reasons.addInfo(info)
238
        }
239
        
240
        // Load variation from tracker
241
        if let profile = userProfileTracker?.userProfile,
242
           let variationId = getVariationIdFromProfile(profile: profile, experimentId: experimentId),
243
           let variation = experiment.getVariation(id: variationId) {
244
            
245
            let info = LogMessage.gotVariationFromUserProfile(variation.key, experiment.key, userId)
246
            logger.i(info)
247
            reasons.addInfo(info)
248
            let variationDecision = VariationDecision(variation: variation)
249
            return DecisionResponse(result: variationDecision, reasons: reasons)
250
        }
251
        
252
        var variationDecision: VariationDecision?
253
        // ---- check if the user passes audience targeting before bucketing ----
254
        let audienceResponse = doesMeetAudienceConditions(config: config,
255
                                                          experiment: experiment,
256
                                                          user: user)
257
        reasons.merge(audienceResponse.reasons)
258
        
259
        if audienceResponse.result ?? false {
×
260
            // Acquire bucketingId
261
            let bucketingId = getBucketingId(userId: userId, attributes: attributes)
262
            
263
            if experiment.isCmab {
264
                let cmabDecisionResponse = getDecisionForCmabExperiment(config: config,
265
                                                                        experiment: experiment,
266
                                                                        user: user,
267
                                                                        bucketingId: bucketingId,
268
                                                                        isAsync: isAsync,
269
                                                                        options: options)
270
                reasons.merge(cmabDecisionResponse.reasons)
271
                variationDecision = cmabDecisionResponse.result
272
            } else {
273
                // bucket user into a variation
274
                let decisionResponse = bucketer.bucketExperiment(config: config,
275
                                                                 experiment: experiment,
276
                                                                 bucketingId: bucketingId)
277
                reasons.merge(decisionResponse.reasons)
278
                if let variation = decisionResponse.result {
279
                    variationDecision = VariationDecision(variation: variation)
280
                }
281
            }
282
            
283
            if let variation = variationDecision?.variation {
284
                let info = LogMessage.userBucketedIntoVariationInExperiment(userId, experiment.key, variation.key)
285
                logger.i(info)
286
                reasons.addInfo(info)
287
                
288
                // CMAB decision shouldn't be in the UPS
289
                if !experiment.isCmab {
290
                    userProfileTracker?.updateProfile(experiment: experiment, variation: variation)
291
                }
292
                
293
            } else {
294
                let info = LogMessage.userNotBucketedIntoVariation(userId)
295
                logger.i(info)
296
                reasons.addInfo(info)
297
            }
298
            
299
        } else {
300
            let info = LogMessage.userNotInExperiment(userId, experiment.key)
301
            logger.i(info)
302
            reasons.addInfo(info)
303
        }
304
        
305
        return DecisionResponse(result: variationDecision, reasons: reasons)
306
    }
307
    
308
    // MARK: - Feature Flag Decision
309
    
310
    /// Determines the feature decision for a user for a specific feature flag.
311
    /// - Parameters:
312
    ///   - config: The project configuration.
313
    ///   - featureFlag: The feature flag to evaluate.
314
    ///   - user: The user context.
315
    ///   - options: Optional decision options.
316
    /// - Returns: A `DecisionResponse` with the feature decision (if any) and reasons.
317
    func getVariationForFeature(config: ProjectConfig,
318
                                featureFlag: FeatureFlag,
319
                                user: OptimizelyUserContext,
320
                                options: [OptimizelyDecideOption]? = nil) -> DecisionResponse<FeatureDecision> {
3,215✔
321
        // isAsync to `false` for backward compatibility
322
        self.getVariationForFeature(config: config, featureFlag: featureFlag, user: user, isAsync: false, options: options)
323
    }
324
    
325
    /// Determines the feature decision for a user for a specific feature flag.
326
    /// - Parameters:
327
    ///   - config: The project configuration.
328
    ///   - featureFlag: The feature flag to evaluate.
329
    ///   - user: The user context.
330
    ///   - isAsync: Controls synchronous or asynchronous decision.
331
    ///   - options: Optional decision options.
332
    /// - Returns: A `DecisionResponse` with the feature decision (if any) and reasons.
333
    func getVariationForFeature(config: ProjectConfig,
334
                                featureFlag: FeatureFlag,
335
                                user: OptimizelyUserContext,
336
                                isAsync: Bool,
337
                                options: [OptimizelyDecideOption]? = nil) -> DecisionResponse<FeatureDecision> {
3,215✔
338
        
339
        let response = getVariationForFeatureList(config: config, featureFlags: [featureFlag], user: user, isAsync: isAsync, options: options).first
340
        
341
        guard response?.result != nil else {
342
            let reasons = response?.reasons ?? DecisionReasons(options: options)
×
343
            return DecisionResponse(result: nil, reasons: reasons)
344
        }
345
        
346
        return response!
347
    }
348
    
349
    /// Determines feature decisions for a list of feature flags.
350
    /// - Parameters:
351
    ///   - config: The project configuration.
352
    ///   - featureFlags: The list of feature flags to evaluate.
353
    ///   - user: The user context.
354
    ///   - isAsync: Controls synchronous or asynchronous decision
355
    ///   - options: Optional decision options.
356
    /// - Returns: An array of `DecisionResponse` objects, each containing a feature decision and reasons.
357
    func getVariationForFeatureList(config: ProjectConfig,
358
                                    featureFlags: [FeatureFlag],
359
                                    user: OptimizelyUserContext,
360
                                    isAsync: Bool,
361
                                    options: [OptimizelyDecideOption]? = nil) -> [DecisionResponse<FeatureDecision>] {
4,005✔
362
        
363
        let userId = user.userId
364
        let ignoreUPS = (options ?? []).contains(.ignoreUserProfileService)
3,216✔
365
        var profileTracker: UserProfileTracker?
366
        if !ignoreUPS {
367
            profileTracker = UserProfileTracker(userId: userId, userProfileService: self.userProfileService, logger: self.logger)
368
            profileTracker?.loadUserProfile()
369
        }
370
        
371
        var decisions = [DecisionResponse<FeatureDecision>]()
372
        
373
        for featureFlag in featureFlags {
374
            let flagDecisionResponse = getDecisionForFlag(config: config, featureFlag: featureFlag, user: user, userProfileTracker: profileTracker, isAsync: isAsync, options: options)
375
            decisions.append(flagDecisionResponse)
376
        }
377
        
378
        // save profile
379
        if !ignoreUPS {
380
            profileTracker?.save()
381
        }
382
        
383
        return decisions
384
    }
385
    
386
    /// Determines the feature decision for a feature flag, considering holdout, experiment and rollout
387
    /// - Parameters:
388
    ///   - config: The project configuration.
389
    ///   - featureFlag: The feature flag to evaluate.
390
    ///   - user: The user context.
391
    ///   - userProfileTracker: Optional tracker for user profile data.
392
    ///   - isAsync: Controls synchronous or asynchronous decision
393
    ///   - options: Optional decision options.
394
    /// - Returns: A `DecisionResponse` with the feature decision (if any) and reasons.
395
    func getDecisionForFlag(config: ProjectConfig,
396
                            featureFlag: FeatureFlag,
397
                            user: OptimizelyUserContext,
398
                            userProfileTracker: UserProfileTracker? = nil,
399
                            isAsync: Bool,
400
                            options: [OptimizelyDecideOption]? = nil) -> DecisionResponse<FeatureDecision> {
4,042✔
401
        let reasons = DecisionReasons(options: options)
402

403
        let holdouts = config.getGlobalHoldouts()
404
        for holdout in holdouts {
405
            let holdoutDecision = getVariationForHoldout(config: config,
406
                                                         flagKey: featureFlag.key,
407
                                                         holdout: holdout,
408
                                                         user: user,
409
                                                         options: options)
410
            reasons.merge(holdoutDecision.reasons)
411
            if let variation = holdoutDecision.result {
412
                let featureDecision = FeatureDecision(experiment: holdout, variation: variation, source: Constants.DecisionSource.holdout.rawValue)
413
                return DecisionResponse(result: featureDecision, reasons: reasons)
414
            }
415
        }
416
        
417
        let flagExpDecision = getVariationForFeatureExperiments(config: config, featureFlag: featureFlag, user: user, userProfileTracker: userProfileTracker, isAsync: isAsync, options: options)
418
        
419
        reasons.merge(flagExpDecision.reasons)
420
        
421
        if let decision = flagExpDecision.result {
422
            return DecisionResponse(result: decision, reasons: reasons)
423
        }
424
        
425
        let rolloutDecision = getVariationForFeatureRollout(config: config, featureFlag: featureFlag, user: user, options: options)
426
        reasons.merge(rolloutDecision.reasons)
427
        
428
        if let decision = rolloutDecision.result {
429
            return DecisionResponse(result: decision, reasons: reasons)
430
        } else {
431
            return DecisionResponse(result: nil, reasons: reasons)
432
        }
433
    }
434
    
435
    /// Determines the feature decision for a feature flag, considering experiments
436
    /// - Parameters:
437
    ///   - config: The project configuration.
438
    ///   - featureFlag: The feature flag to evaluate.
439
    ///   - user: The user context.
440
    ///   - userProfileTracker: Optional tracker for user profile data.
441
    ///   - isAsync: Controls synchronous or asynchronous decision
442
    ///   - options: Optional decision options.
443
    /// - Returns: A `DecisionResponse` with the feature decision (if any) and reasons.
444
    func getVariationForFeatureExperiments(config: ProjectConfig,
445
                                           featureFlag: FeatureFlag,
446
                                           user: OptimizelyUserContext,
447
                                           userProfileTracker: UserProfileTracker? = nil,
448
                                           isAsync: Bool,
449
                                           options: [OptimizelyDecideOption]? = nil) -> DecisionResponse<FeatureDecision> {
3,995✔
450
        let reasons = DecisionReasons(options: options)
451
        
452
        let experimentIds = featureFlag.experimentIds
453
        if experimentIds.isEmpty {
454
            let info = LogMessage.featureHasNoExperiments(featureFlag.key)
455
            logger.d(info)
456
            reasons.addInfo(info)
457
        }
458
        
459
        // Check if there are any experiment IDs inside feature flag
460
        // Evaluate each experiment ID and return the first bucketed experiment variation
461
        for experimentId in experimentIds {
462
            if let experiment = config.getExperiment(id: experimentId) {
463
                let decisionResponse = getVariationFromExperimentRule(config: config,
464
                                                                      flagKey: featureFlag.key,
465
                                                                      rule: experiment,
466
                                                                      user: user,
467
                                                                      userProfileTracker: userProfileTracker,
468
                                                                      isAsync: isAsync,
469
                                                                      options: options)
470
                reasons.merge(decisionResponse.reasons)
471
                if let result = decisionResponse.result {
472
                    if result.cmabError {
473
                        // For CMAB - we're supposed to get decision from the server.
474
                        // If failed, return decision with nil variation, so the client can take care of them.
475
                        let featureDecision = FeatureDecision(experiment: experiment, variation: nil, source: Constants.DecisionSource.featureTest.rawValue, error: true)
476
                        return DecisionResponse(result: featureDecision, reasons: reasons)
477
                    } else if let variation = result.variation {
478
                        // Check if this variation came from a holdout
479
                        if let holdout = result.holdout {
480
                            let featureDecision = FeatureDecision(experiment: holdout, variation: variation, source: Constants.DecisionSource.holdout.rawValue, cmabUUID: result.cmabUUID)
481
                            return DecisionResponse(result: featureDecision, reasons: reasons)
482
                        } else {
483
                            let featureDecision = FeatureDecision(experiment: experiment, variation: variation, source: Constants.DecisionSource.featureTest.rawValue, cmabUUID: result.cmabUUID)
484
                            return DecisionResponse(result: featureDecision, reasons: reasons)
485
                        }
486
                    }
487
                }
488
            }
489
        }
490
        
491
        return DecisionResponse(result: nil, reasons: reasons)
492
    }
493
    
494
    /// Determines the feature decision for a feature flag's rollout rules.
495
    /// - Parameters:
496
    ///   - config: The project configuration.
497
    ///   - featureFlag: The feature flag to evaluate.
498
    ///   - user: The user context.
499
    ///   - options: Optional decision options.
500
    /// - Returns: A `DecisionResponse` with the feature decision (if any) and reasons.
501
    func getVariationForFeatureRollout(config: ProjectConfig,
502
                                       featureFlag: FeatureFlag,
503
                                       user: OptimizelyUserContext,
504
                                       options: [OptimizelyDecideOption]? = nil) -> DecisionResponse<FeatureDecision> {
2,999✔
505
        let reasons = DecisionReasons(options: options)
506
        
507
        let rolloutId = featureFlag.rolloutId.trimmingCharacters(in: CharacterSet.whitespaces)
508
        
509
        guard !rolloutId.isEmpty else {
510
            let info = LogMessage.noRolloutExists(featureFlag.key)
511
            logger.d(info)
512
            reasons.addInfo(info)
513
            return DecisionResponse(result: nil, reasons: reasons)
514
        }
515
        
516
        guard let rollout = config.getRollout(id: rolloutId) else {
517
            let info = OptimizelyError.rolloutIdInvalid(rolloutId, featureFlag.key)
518
            logger.d(info)
519
            reasons.addInfo(info)
520
            return DecisionResponse(result: nil, reasons: reasons)
521
        }
522
        
523
        let rolloutRules = rollout.experiments
524
        if rolloutRules.isEmpty {
525
            let info = LogMessage.rolloutHasNoExperiments(rolloutId)
526
            logger.e(info)
527
            reasons.addInfo(info)
528
            return DecisionResponse(result: nil, reasons: reasons)
529
        }
530
        
531
        var index = 0
532
        while index < rolloutRules.count {
533
            let decisionResponse = getVariationFromDeliveryRule(config: config,
534
                                                                flagKey: featureFlag.key,
535
                                                                rules: rolloutRules,
536
                                                                ruleIndex: index,
537
                                                                user: user,
538
                                                                options: options)
539
            reasons.merge(decisionResponse.reasons)
540
            let result = decisionResponse.result!
541

542
            if let variation = result.variation {
543
                let rule = rolloutRules[index]
544
                // Check if this variation came from a holdout
545
                if let holdout = result.holdout {
546
                    let featureDecision = FeatureDecision(experiment: holdout, variation: variation, source: Constants.DecisionSource.holdout.rawValue)
547
                    return DecisionResponse(result: featureDecision, reasons: reasons)
548
                } else {
549
                    let featureDecision = FeatureDecision(experiment: rule, variation: variation, source: Constants.DecisionSource.rollout.rawValue)
550
                    return DecisionResponse(result: featureDecision, reasons: reasons)
551
                }
552
            }
553

554
            // the last rule is special for "Everyone Else"
555
            index = result.skipToEveryoneElse ? (rolloutRules.count - 1) : (index + 1)
556
        }
557
        
558
        return DecisionResponse(result: nil, reasons: reasons)
559
    }
560
    
561
    
562
    // MARK: - Holdout and Rule Decisions
563
    
564
    /// Determines the variation for a holdout group.
565
    /// - Parameters:
566
    ///   - config: The project configuration.
567
    ///   - flagKey: The feature flag key.
568
    ///   - holdout: The holdout group to evaluate.
569
    ///   - user: The user context.
570
    ///   - options: Optional decision options.
571
    /// - Returns: A `DecisionResponse` with the variation (if any) and reasons.
572
    func getVariationForHoldout(config: ProjectConfig,
573
                                flagKey: String,
574
                                holdout: Holdout,
575
                                user: OptimizelyUserContext,
576
                                options: [OptimizelyDecideOption]? = nil) -> DecisionResponse<Variation> {
92✔
577
        let reasons = DecisionReasons(options: options)
578
        
579
        guard holdout.isActivated else {
580
            let info = LogMessage.holdoutNotRunning(holdout.key)
581
            reasons.addInfo(info)
582
            logger.i(info)
583
            return DecisionResponse(result: nil, reasons: reasons)
584
        }
585
        
586
        // ---- check if the user passes audience targeting before bucketing ----
587
        let audienceResponse = doesMeetAudienceConditions(config: config,
588
                                                          experiment: holdout,
589
                                                          user: user)
590
        
591
        reasons.merge(audienceResponse.reasons)
592
        
593
        let userId = user.userId
594
        let attributes = user.attributes
595
        
596
        // Acquire bucketingId .
597
        let bucketingId = getBucketingId(userId: userId, attributes: attributes)
598
        var bucketedVariation: Variation?
599
        
600
        if audienceResponse.result ?? false {
×
601
            let info = LogMessage.userMeetsConditionsForHoldout(userId, holdout.key)
602
            reasons.addInfo(info)
603
            logger.i(info)
604
            
605
            // bucket user into holdout variation
606
            let decisionResponse = (bucketer as? DefaultBucketer)?.bucketToVariation(experiment: holdout, bucketingId: bucketingId)
607
            if let reason = decisionResponse?.reasons {
608
                reasons.merge(reason)
609
            }
610
            
611
            bucketedVariation = decisionResponse?.result
612
            
613
            if let variation = bucketedVariation {
614
                let info = LogMessage.userBucketedIntoVariationInHoldout(userId, holdout.key, variation.key)
615
                reasons.addInfo(info)
616
                logger.i(info)
617
            } else {
618
                let info = LogMessage.userNotBucketedIntoHoldoutVariation(userId)
619
                reasons.addInfo(info)
620
                logger.i(info)
621
            }
622
            
623
        } else {
624
            let info = LogMessage.userDoesntMeetConditionsForHoldout(userId, holdout.key)
625
            reasons.addInfo(info)
626
            logger.i(info)
627
        }
628
        
629
        return DecisionResponse(result: bucketedVariation, reasons: reasons)
630
    }
631
    
632
    /// Determines the variation for an experiment rule within a feature flag.
633
    /// - Parameters:
634
    ///   - config: The project configuration.
635
    ///   - flagKey: The feature flag key.
636
    ///   - rule: The experiment rule to evaluate.
637
    ///   - user: The user context.
638
    ///   - userProfileTracker: Optional tracker for user profile data.
639
    ///   - options: Optional decision options.
640
    /// - Returns: A `DecisionResponse` with the variation (if any) and reasons.
641
    func getVariationFromExperimentRule(config: ProjectConfig,
642
                                        flagKey: String,
643
                                        rule: Experiment,
644
                                        user: OptimizelyUserContext,
645
                                        userProfileTracker: UserProfileTracker?,
646
                                        isAsync: Bool,
647
                                        options: [OptimizelyDecideOption]? = nil) -> DecisionResponse<VariationDecision> {
1,110✔
648
        let reasons = DecisionReasons(options: options)
649
        // check forced-decision first
650
        let forcedDecisionResponse = findValidatedForcedDecision(config: config,
651
                                                                 user: user,
652
                                                                 context: OptimizelyDecisionContext(flagKey: flagKey, ruleKey: rule.key))
653
        reasons.merge(forcedDecisionResponse.reasons)
654
        
655
        if let variation = forcedDecisionResponse.result {
656
            let variationDecision = VariationDecision(variation: variation)
657
            return DecisionResponse(result: variationDecision, reasons: reasons)
658
        }
659

660
        // check local holdouts targeting this rule
661
        if FeatureGates.localHoldouts {
662
            let localHoldouts = config.getHoldoutsForRule(ruleId: rule.id)
663
            for holdout in localHoldouts {
664
                let holdoutDecision = getVariationForHoldout(config: config,
665
                                                             flagKey: flagKey,
666
                                                             holdout: holdout,
667
                                                             user: user,
668
                                                             options: options)
669
                reasons.merge(holdoutDecision.reasons)
670
                if let variation = holdoutDecision.result {
671
                    // User is in holdout — return holdout variation immediately, skip this rule
672
                    let variationDecision = VariationDecision(variation: variation, holdout: holdout)
673
                    return DecisionResponse(result: variationDecision, reasons: reasons)
674
                }
675
            }
676
        }
677

678
        let decisionResponse = getVariation(config: config,
679
                                            experiment: rule,
680
                                            user: user,
681
                                            options: options,
682
                                            isAsync: isAsync,
683
                                            userProfileTracker: userProfileTracker)
684
        let variationResult = decisionResponse.result
685
        reasons.merge(decisionResponse.reasons)
686
        return DecisionResponse(result: variationResult, reasons: reasons)
687
    }
688
    
689
    /// Determines the variation for a delivery rule in a rollout.
690
    /// - Parameters:
691
    ///   - config: The project configuration.
692
    ///   - flagKey: The feature flag key.
693
    ///   - rules: The list of rollout rules.
694
    ///   - ruleIndex: The index of the rule to evaluate.
695
    ///   - user: The user context.
696
    ///   - options: Optional decision options.
697
    /// - Returns: A `DecisionResponse` with the delivery rule decision and reasons.
698
    func getVariationFromDeliveryRule(config: ProjectConfig,
699
                                      flagKey: String,
700
                                      rules: [Experiment],
701
                                      ruleIndex: Int,
702
                                      user: OptimizelyUserContext,
703
                                      options: [OptimizelyDecideOption]? = nil) -> DecisionResponse<DeliveryRuleDecision> {
2,733✔
704
        let reasons = DecisionReasons(options: options)
705
        var skipToEveryoneElse = false
706
        
707
        // check forced-decision first
708
        
709
        let rule = rules[ruleIndex]
710
        let forcedDecisionResponse = findValidatedForcedDecision(config: config,
711
                                                                 user: user,
712
                                                                 context: OptimizelyDecisionContext(flagKey: flagKey, ruleKey: rule.key))
713
        reasons.merge(forcedDecisionResponse.reasons)
714
        
715
        if let variation = forcedDecisionResponse.result {
716
            let decision = DeliveryRuleDecision(variation: variation, skipToEveryoneElse: skipToEveryoneElse)
717
            return DecisionResponse(result: decision, reasons: reasons)
718
        }
719

720
        // check local holdouts targeting this delivery rule
721
        if FeatureGates.localHoldouts {
722
            let localHoldouts = config.getHoldoutsForRule(ruleId: rule.id)
723
            for holdout in localHoldouts {
724
                let holdoutDecision = getVariationForHoldout(config: config,
725
                                                             flagKey: flagKey,
726
                                                             holdout: holdout,
727
                                                             user: user,
728
                                                             options: options)
729
                reasons.merge(holdoutDecision.reasons)
730
                if let variation = holdoutDecision.result {
731
                    // User is in holdout — return holdout variation with holdout info
732
                    let decision = DeliveryRuleDecision(variation: variation, skipToEveryoneElse: skipToEveryoneElse, holdout: holdout)
733
                    return DecisionResponse(result: decision, reasons: reasons)
734
                }
735
            }
736
        }
737

738
        // regular decision
739
        
740
        let userId = user.userId
741
        let attributes = user.attributes
742
        let bucketingId = getBucketingId(userId: userId, attributes: attributes)
743
        
744
        let everyoneElse = (ruleIndex == rules.count - 1)
745
        let loggingKey = everyoneElse ? "Everyone Else" : String(ruleIndex + 1)
746
        
747
        var bucketedVariation: Variation?
748
        
749
        let audienceDecisionResponse = doesMeetAudienceConditions(config: config,
750
                                                                  experiment: rule,
751
                                                                  user: user,
752
                                                                  logType: .rolloutRule,
753
                                                                  loggingKey: loggingKey)
754
        reasons.merge(audienceDecisionResponse.reasons)
755
        if audienceDecisionResponse.result ?? false {
×
756
            var info = LogMessage.userMeetsConditionsForTargetingRule(userId, loggingKey)
757
            logger.d(info)
758
            reasons.addInfo(info)
759
            
760
            let decisionResponse = bucketer.bucketExperiment(config: config,
761
                                                             experiment: rule,
762
                                                             bucketingId: bucketingId)
763
            reasons.merge(decisionResponse.reasons)
764
            bucketedVariation = decisionResponse.result
765
            
766
            if bucketedVariation != nil {
767
                info = LogMessage.userBucketedIntoTargetingRule(userId, loggingKey)
768
                logger.d(info)
769
                reasons.addInfo(info)
770
            } else if !everyoneElse {
771
                // skip this logging for EveryoneElse since this has a message not for EveryoneElse
772
                info = LogMessage.userNotBucketedIntoTargetingRule(userId, loggingKey)
773
                logger.d(info)
774
                reasons.addInfo(info)
775
                
776
                // skip the rest of rollout rules to the everyone-else rule if audience matches but not bucketed.
777
                skipToEveryoneElse = true
778
            }
779
        } else {
780
            let info = LogMessage.userDoesntMeetConditionsForTargetingRule(userId, loggingKey)
781
            logger.d(info)
782
            reasons.addInfo(info)
783
        }
784

785
        let decision = DeliveryRuleDecision(variation: bucketedVariation, skipToEveryoneElse: skipToEveryoneElse)
786
        return DecisionResponse(result: decision, reasons: reasons)
787
    }
788
    
789
    // MARK: - Audience Evaluation
790
    
791
    /// Evaluates whether a user meets the audience conditions for an experiment or rule.
792
    /// - Parameters:
793
    ///   - config: The project configuration.
794
    ///   - experiment: The experiment or rule to evaluate.
795
    ///   - user: The user context.
796
    ///   - logType: The type of evaluation for logging (e.g., experiment or rollout rule).
797
    ///   - loggingKey: Optional key for logging.
798
    /// - Returns: A `DecisionResponse` with a boolean indicating whether conditions are met and reasons.
799
    func doesMeetAudienceConditions(config: ProjectConfig,
800
                                    experiment: ExperimentCore,
801
                                    user: OptimizelyUserContext,
802
                                    logType: Constants.EvaluationLogType = .experiment,
803
                                    loggingKey: String? = nil) -> DecisionResponse<Bool> {
6,010✔
804
        let reasons = DecisionReasons()
805
        
806
        var result = true   // success as default (no condition, etc)
807
        let evType = logType.rawValue
808
        let finalLoggingKey = loggingKey ?? experiment.key
3,289✔
809
        
810
        do {
811
            if let conditions = experiment.audienceConditions {
812
                logger.d { () -> String in
×
813
                    return LogMessage.evaluatingAudiencesCombined(evType, finalLoggingKey, Utils.getConditionString(conditions: conditions)).description
814
                }
815
                switch conditions {
816
                    case .array(let arrConditions):
817
                        if arrConditions.count > 0 {
818
                            result = try conditions.evaluate(project: config.project, user: user)
819
                        } else {
820
                            // empty conditions (backward compatibility with "audienceIds" is ignored if exists even though empty
821
                            result = true
822
                        }
823
                    case .leaf:
824
                        result = try conditions.evaluate(project: config.project, user: user)
825
                    default:
826
                        result = true
827
                }
828
            }
829
            // backward compatibility with audienceIds list
830
            else if experiment.audienceIds.count > 0 {
831
                var holder = [ConditionHolder]()
832
                holder.append(.logicalOp(.or))
833
                for id in experiment.audienceIds {
834
                    holder.append(.leaf(.audienceId(id)))
835
                }
836
                logger.d { () -> String in
×
837
                    return LogMessage.evaluatingAudiencesCombined(evType, finalLoggingKey, Utils.getConditionString(conditions: holder)).description
838
                }
839
                result = try holder.evaluate(project: config.project, user: user)
840
            }
841
        } catch {
842
            if let error = error as? OptimizelyError {
843
                logger.i(error)
844
                reasons.addInfo(error)
845
            }
846
            result = false
847
        }
848
        
849
        logger.i(.audienceEvaluationResultCombined(evType, finalLoggingKey, result.description))
850
        
851
        return DecisionResponse(result: result, reasons: reasons)
852
    }
853
    
854
    // MARK: - Utilities
855
    
856
    /// Retrieves the bucketing ID for a user, defaulting to user ID unless overridden in attributes.
857
    /// - Parameters:
858
    ///   - userId: The user's ID.
859
    ///   - attributes: The user's attributes.
860
    /// - Returns: The bucketing ID to use for variation assignment.
861
    func getBucketingId(userId: String, attributes: OptimizelyAttributes) -> String {
5,871✔
862
        // By default, the bucketing ID should be the user ID .
863
        var bucketingId = userId
864
        // If the bucketing ID key is defined in attributes, then use that
865
        // in place of the userID for the murmur hash key
866
        if let newBucketingId = attributes[Constants.Attributes.reservedBucketIdAttribute] as? String {
867
            bucketingId = newBucketingId
868
        }
869
        
870
        return bucketingId
871
    }
872
    
873
    /// Finds and validates a forced decision for a given context.
874
    /// - Parameters:
875
    ///   - config: The project configuration.
876
    ///   - user: The user context.
877
    ///   - context: The decision context (flag and rule keys).
878
    /// - Returns: A `DecisionResponse` with the forced variation (if valid) and reasons.
879
    func findValidatedForcedDecision(config: ProjectConfig,
880
                                     user: OptimizelyUserContext,
881
                                     context: OptimizelyDecisionContext) -> DecisionResponse<Variation> {
4,677✔
882
        let reasons = DecisionReasons()
883
        
884
        if let variationKey = user.getForcedDecision(context: context)?.variationKey {
885
            let userId = user.userId
886
            
887
            if let variation = config.getFlagVariationByKey(flagKey: context.flagKey, variationKey: variationKey) {
888
                let info = LogMessage.userHasForcedDecision(userId, context.flagKey, context.ruleKey, variationKey)
889
                logger.i(info)
890
                reasons.addInfo(info)
891
                return DecisionResponse(result: variation, reasons: reasons)
892
            } else {
893
                let info = LogMessage.userHasForcedDecisionButInvalid(userId, context.flagKey, context.ruleKey)
894
                logger.i(info)
895
                reasons.addInfo(info)
896
            }
897
        }
898
        
899
        return DecisionResponse(result: nil, reasons: reasons)
900
    }
901
    
902
}
903

904
// MARK: - UserProfileService Helpers
905

906
extension DefaultDecisionService {
907
    func getVariationIdFromProfile(userId: String,
908
                                   experimentId: String) -> String? {
174✔
909
        if let profile = userProfileService.lookup(userId: userId),
910
           let bucketMap = profile[UserProfileKeys.kBucketMap] as? OPTUserProfileService.UPBucketMap,
911
           let experimentMap = bucketMap[experimentId],
912
           let variationId = experimentMap[UserProfileKeys.kVariationId] {
913
            return variationId
914
        } else {
915
            return nil
916
        }
917
    }
918
    
919
    func getVariationIdFromProfile(profile: UserProfile?,
920
                                   experimentId: String) -> String? {
3,625✔
921
        if let _profile = profile,
922
           let bucketMap = _profile[UserProfileKeys.kBucketMap] as? OPTUserProfileService.UPBucketMap,
923
           let experimentMap = bucketMap[experimentId],
924
           let variationId = experimentMap[UserProfileKeys.kVariationId] {
925
            return variationId
926
        } else {
927
            return nil
928
        }
929
    }
930
    
931
    func saveProfile(userId: String,
932
                     experimentId: String,
933
                     variationId: String) {
164✔
934
        DefaultDecisionService.upsRMWLock.sync {
164✔
935
            var profile = self.userProfileService.lookup(userId: userId) ?? OPTUserProfileService.UPProfile()
6✔
936
            
937
            var bucketMap = profile[UserProfileKeys.kBucketMap] as? OPTUserProfileService.UPBucketMap ?? OPTUserProfileService.UPBucketMap()
6✔
938
            bucketMap[experimentId] = [UserProfileKeys.kVariationId: variationId]
939
            
940
            profile[UserProfileKeys.kBucketMap] = bucketMap
941
            profile[UserProfileKeys.kUserId] = userId
942
            
943
            self.userProfileService.save(userProfile: profile)
944
            
945
            self.logger.i(.savedVariationInUserProfile(variationId, experimentId, userId))
946
        }
947
    }
948
}
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