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

optimizely / swift-sdk / 24039129239

06 Apr 2026 03:59PM UTC coverage: 93.766% (+0.03%) from 93.734%
24039129239

push

github

web-flow
[AI-FSSDK] [FSSDK-12337] Add Feature Rollout support (#626)

* [FSSDK-12337] Add Feature Rollout support

Add Feature Rollout support to the Swift SDK. Feature Rollouts are a new
experiment rule type that combines Targeted Delivery simplicity with A/B
test measurement capabilities.

- Add optional `type` field (ExperimentType enum) to the Experiment model
  with valid values: ab, mab, cmab, td, fr
- Add config parsing logic to inject the "everyone else" rollout variation
  into feature rollout experiments (type == .featureRollout)
- Add traffic allocation entry (endOfRange=10000) for the injected variation
- Add `getEveryoneElseVariation` helper to extract the last rollout rule's
  first variation
- Rebuild experiment lookup maps after injection so decisions use updated data
- Add 10 unit tests covering injection, edge cases, and backward compatibility

* Remove redaundant rebuild lookup table

* fix rollout id map lookup logic

* chore: trigger CI

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* [FSSDK-12337] Handle unknown experiment types gracefully in datafile parsing

Unknown experiment type values (e.g., "new_unknown_type") no longer crash
datafile parsing. They are silently dropped to nil, aligning with other SDKs
for forward compatibility.

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

---------

Co-authored-by: muzahidul-opti <muzahidul.islam@Optimizely.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

1128 of 1203 relevant lines covered (93.77%)

8814.88 hits per line

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

97.8
/Sources/Data%20Model/ProjectConfig.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
class ProjectConfig {
20
    var project: Project! {
21
        didSet {
377✔
22
            updateProjectDependentProps()
23
        }
24
    }
25
    
26
    let logger = OPTLoggerFactory.getLogger()
3,219✔
27
    
28
    // local runtime forcedVariations [UserId: [ExperimentId: VariationId]]
29
    // NOTE: experiment.forcedVariations use [ExperimentKey: VariationKey] instead of ids
30
    var whitelistUsers = AtomicProperty(property: [String: [String: String]]())
3,219✔
31
    
32
    var experimentKeyMap = [String: Experiment]()
3,219✔
33
    var experimentIdMap = [String: Experiment]()
3,219✔
34
    var experimentFeatureMap = [String: [String]]()
3,219✔
35
    var eventKeyMap = [String: Event]()
3,219✔
36
    var attributeKeyMap = [String: Attribute]()
3,219✔
37
    var attributeIdMap = [String: Attribute]()
3,219✔
38
    var featureFlagKeyMap = [String: FeatureFlag]()
3,219✔
39
    var featureFlagKeys = [String]()
3,219✔
40
    var rolloutIdMap = [String: Rollout]()
3,219✔
41
    var allExperiments = [Experiment]()
3,219✔
42
    var flagVariationsMap = [String: [Variation]]()
3,219✔
43
    var allSegments = [String]()
3,219✔
44
    var holdoutConfig = HoldoutConfig()
3,219✔
45

46
    // MARK: - Init
47
    
48
    init(datafile: Data) throws {
3,193✔
49
        var project: Project
50
        do {
51
            project = try JSONDecoder().decode(Project.self, from: datafile)
52
        } catch {
53
            throw OptimizelyError.dataFileInvalid
54
        }
55
        
56
        if !isValidVersion(version: project.version) {
57
            throw OptimizelyError.dataFileVersionInvalid(project.version)
58
        }
59

60
        self.project = project
61
        updateProjectDependentProps()  // project:didSet is not fired in init. explicitly called.
62
    }
63
    
64
    convenience init(datafile: String) throws {
×
65
        try self.init(datafile: Data(datafile.utf8))
66
    }
67
    
68
    init() {}
26✔
69
    
70
    func updateProjectDependentProps() {
3,550✔
71
        
72
        self.allExperiments = project.experiments + project.groups.map { $0.experiments }.flatMap { $0 }
314✔
73
        
74
        self.rolloutIdMap = {
3,550✔
75
            var map = [String: Rollout]()
76
            project.rollouts.forEach { map[$0.id] = $0 }
3,096✔
77
            return map
78
        }()
79

80
        // Feature Rollout injection: for each feature flag, inject the "everyone else"
81
        // variation into any experiment with type == .featureRollout
82
        injectFeatureRolloutVariations()
83
        
84
        holdoutConfig.allHoldouts = project.holdouts
85
        
86
        self.experimentKeyMap = {
3,550✔
87
            var map = [String: Experiment]()
88
            allExperiments.forEach { exp in
9,539✔
89
                map[exp.key] = exp
90
            }
91
            return map
92
        }()
93
        
94
        self.experimentIdMap = {
3,550✔
95
            var map = [String: Experiment]()
96
            allExperiments.forEach { map[$0.id] = $0 }
9,539✔
97
            return map
98
        }()
99
        
100
        self.experimentFeatureMap = {
3,550✔
101
            var experimentFeatureMap = [String: [String]]()
102
            project.featureFlags.forEach { (ff) in
4,220✔
103
                ff.experimentIds.forEach {
1,384✔
104
                    if var arr = experimentFeatureMap[$0] {
105
                        arr.append(ff.id)
106
                        experimentFeatureMap[$0] = arr
107
                    } else {
108
                        experimentFeatureMap[$0] = [ff.id]
109
                    }
110
                }
111
            }
112
            return experimentFeatureMap
113
        }()
114
        
115
        self.eventKeyMap = {
3,550✔
116
            var eventKeyMap = [String: Event]()
117
            project.events.forEach { eventKeyMap[$0.key] = $0 }
6,138✔
118
            return eventKeyMap
119
        }()
120
        
121
        self.attributeKeyMap = {
3,550✔
122
            var map = [String: Attribute]()
123
            project.attributes.forEach { map[$0.key] = $0 }
4,116✔
124
            return map
125
        }()
126
        
127
        self.attributeIdMap = {
3,550✔
128
            var map = [String: Attribute]()
129
            project.attributes.forEach { map[$0.id] = $0 }
4,116✔
130
            return map
131
        }()
132
        
133
        self.featureFlagKeyMap = {
3,550✔
134
            var map = [String: FeatureFlag]()
135
            project.featureFlags.forEach { map[$0.key] = $0 }
4,220✔
136
            return map
137
        }()
138
        
139
        self.featureFlagKeys = {
3,550✔
140
            return project.featureFlags.map { $0.key }
4,220✔
141
        }()
142

143
        // all variations for each flag
144
        // - datafile does not contain a separate entity for this.
145
        // - we collect variations used in each rule (experiment rules and delivery rules)
146
        
147
        self.flagVariationsMap = {
3,550✔
148
            var map = [String: [Variation]]()
149
            
150
            project.featureFlags.forEach { flag in
4,220✔
151
                var variations = [Variation]()
152
                
153
                getAllRulesForFlag(flag).forEach { rule in
4,922✔
154
                    rule.variations.forEach { variation in
6,321✔
155
                        if variations.filter({ $0.id == variation.id }).first == nil {
4,486✔
156
                            variations.append(variation)
157
                        }
158
                    }
159
                }
160
                map[flag.key] = variations
161
            }
162
            
163
            return map
164
        }()
165
        
166
        self.allSegments = {
3,550✔
167
            let audiences = project.typedAudiences ?? []
3,083✔
168
            return Array(Set(audiences.flatMap { $0.getSegments() }))
5,224✔
169
        }()
170
        
171
    }
172
    
173
    func getHoldoutForFlag(id: String) -> [Holdout] {
4,023✔
174
        return holdoutConfig.getHoldoutForFlag(id: id)
175
    }
176
        
177
    func getAllRulesForFlag(_ flag: FeatureFlag) -> [Experiment] {
4,220✔
178
        var rules = flag.experimentIds.compactMap { experimentIdMap[$0] }
1,384✔
179
        let rollout = self.rolloutIdMap[flag.rolloutId]
180
        rules.append(contentsOf: rollout?.experiments ?? [])
1,156✔
181
        return rules
182
    }
183

184
}
185

186
// MARK: - Feature Rollout Injection
187

188
extension ProjectConfig {
189
    /// Injects the "everyone else" variation from a flag's rollout into any
190
    /// experiment with type == .featureRollout. After injection the existing
191
    /// decision logic evaluates feature rollouts without modification.
192
    func injectFeatureRolloutVariations() {
3,550✔
193
        for flag in project.featureFlags {
194
            guard let everyoneElseVariation = getEveryoneElseVariation(for: flag) else {
195
                continue
196
            }
197

198
            for experimentId in flag.experimentIds {
199
                guard let index = allExperiments.firstIndex(where: { $0.id == experimentId }) else {
734✔
200
                    continue
201
                }
202

203
                guard allExperiments[index].isFeatureRollout else {
204
                    continue
205
                }
206

207
                allExperiments[index].variations.append(everyoneElseVariation)
208
                allExperiments[index].trafficAllocation.append(
209
                    TrafficAllocation(entityId: everyoneElseVariation.id, endOfRange: 10000)
210
                )
211
            }
212
        }
213
    }
214

215
    /// Returns the first variation of the last experiment (the "everyone else"
216
    /// rule) in the rollout associated with the given feature flag. Returns nil
217
    /// if the rollout cannot be resolved or has no variations.
218
    func getEveryoneElseVariation(for flag: FeatureFlag) -> Variation? {
4,220✔
219
        guard !flag.rolloutId.isEmpty,
220
              let rollout = rolloutIdMap[flag.rolloutId],
221
              let everyoneElseRule = rollout.experiments.last,
222
              let variation = everyoneElseRule.variations.first else {
223
            return nil
224
        }
225
        return variation
226
    }
227
}
228

229
// MARK: - Persistent Data
230

231
extension ProjectConfig {
232
    func whitelistUser(userId: String, experimentId: String, variationId: String) {
1,011✔
233
        whitelistUsers.performAtomic { whitelist in
1,011✔
234
            var dict = whitelist[userId] ?? [String: String]()
110✔
235
            dict[experimentId] = variationId
236
            whitelist[userId] = dict
237
        }
238
    }
239
    
240
    func removeFromWhitelist(userId: String, experimentId: String) {
1,001✔
241
        whitelistUsers.performAtomic { whitelist in
1,001✔
242
            whitelist[userId]?.removeValue(forKey: experimentId)
243
        }
244
    }
245
    
246
    func getWhitelistedVariationId(userId: String, experimentId: String) -> String? {
5,650✔
247
        if let dict = whitelistUsers.property?[userId] {
248
            return dict[experimentId]
249
        }
250
        
251
        logger.d(.userHasNoForcedVariation(userId))
252
        return nil
253
    }
254
    
255
    func isValidVersion(version: String) -> Bool {
3,176✔
256
        // old versions (< 4) of datafiles not supported
257
        return ["4"].contains(version)
258
    }
259
}
260

261
// MARK: - Project Access
262

263
extension ProjectConfig {
264
    
265
    /**
266
     * Get the region value. Defaults to US if not specified in the project.
267
     */
268
    public var region: Region {
1,500✔
269
        return project.region ?? .US
1,500✔
270
    }
271
    
272
    /**
273
     * Get sendFlagDecisions value.
274
     */
275
    var sendFlagDecisions: Bool {
2,956✔
276
        return project.sendFlagDecisions ?? false
2,513✔
277
    }
278
    
279
    /**
280
     * ODP API server publicKey.
281
     */
282
    var publicKeyForODP: String? {
3,134✔
283
        return project.integrations?.filter { $0.key == "odp" }.first?.publicKey
37✔
284
    }
285
    
286
    /**
287
     * ODP API server host.
288
     */
289
    var hostForODP: String? {
3,134✔
290
        return project.integrations?.filter { $0.key == "odp" }.first?.host
37✔
291
    }
292
    
293
    /**
294
     * Get an Experiment object for a key.
295
     */
296
    func getExperiment(key: String) -> Experiment? {
8,801✔
297
        return experimentKeyMap[key]
298
    }
299
    
300
    /**
301
     * Get an Experiment object for an Id.
302
     */
303
    func getExperiment(id: String) -> Experiment? {
1,175✔
304
        return experimentIdMap[id]
305
    }
306
    
307
    /**
308
     * Get an experiment Id for the human readable experiment key
309
     **/
310
    func getExperimentId(key: String) -> String? {
5✔
311
        return getExperiment(key: key)?.id
312
    }
313
    
314
    /**
315
     * Get a Group object for an Id.
316
     */
317
    func getGroup(id: String) -> Group? {
6✔
318
        return project.groups.filter { $0.id == id }.first
10✔
319
    }
320
    
321
    /**
322
     * Get a Feature Flag object for a key.
323
     */
324
    func getFeatureFlag(key: String) -> FeatureFlag? {
5,622✔
325
        return featureFlagKeyMap[key]
326
    }
327
    
328
    /**
329
     * Get all Feature Flag objects.
330
     */
331
    func getFeatureFlags() -> [FeatureFlag] {
4✔
332
        return project.featureFlags
333
    }
334
    
335
    /**
336
     * Get a Rollout object for an Id.
337
     */
338
    func getRollout(id: String) -> Rollout? {
2,590✔
339
        return rolloutIdMap[id]
340
    }
341
    
342
    /**
343
     * Get a Holdout object for an Id.
344
     */
345
    func getHoldout(id: String) -> Holdout? {
×
346
        return holdoutConfig.getHoldout(id: id)
347
    }
348
    
349
    /**
350
     * Gets an event for a corresponding event key
351
     */
352
    func getEvent(key: String) -> Event? {
68✔
353
        return eventKeyMap[key]
354
    }
355
    
356
    /**
357
     * Gets an event id for a corresponding event key
358
     */
359
    func getEventId(key: String) -> String? {
1✔
360
        return getEvent(key: key)?.id
361
    }
362
    
363
    /**
364
     * Get an attribute for a given key.
365
     */
366
    func getAttribute(key: String) -> Attribute? {
210✔
367
        return attributeKeyMap[key]
368
    }
369
    
370
    /**
371
     * Get an attribute for a given id.
372
     */
373
    func getAttribute(id: String) -> Attribute? {
45✔
374
        return attributeIdMap[id]
375
    }
376
    
377
    /**
378
     * Get an attribute Id for a given key.
379
     **/
380
    func getAttributeId(key: String) -> String? {
209✔
381
        return getAttribute(key: key)?.id
382
    }
383
    
384
    /**
385
     * Get an audience for a given audience id.
386
     */
387
    func getAudience(id: String) -> Audience? {
1✔
388
        return project.getAudience(id: id)
389
    }
390
    
391
    /**
392
     *  Returns true if experiment belongs to any feature, false otherwise.
393
     */
394
    func isFeatureExperiment(id: String) -> Bool {
2,556✔
395
        return !(experimentFeatureMap[id]?.isEmpty ?? true)
2,533✔
396
    }
397
        
398
    /**
399
     * Get forced variation for a given experiment key and user id.
400
     */
401
    func getForcedVariation(experimentKey: String, userId: String) -> DecisionResponse<Variation> {
3,651✔
402
        let reasons = DecisionReasons()
403
        
404
        guard let experiment = getExperiment(key: experimentKey) else {
405
            return DecisionResponse(result: nil, reasons: reasons)
406
        }
407
        
408
        if let id = getWhitelistedVariationId(userId: userId, experimentId: experiment.id) {
409
            if let variation = experiment.getVariation(id: id) {
410
                let info = LogMessage.userHasForcedVariation(userId, experiment.key, variation.key)
411
                logger.d(info)
412
                reasons.addInfo(info)
413

414
                return DecisionResponse(result: variation, reasons: reasons)
415
            }
416
            
417
            let info = LogMessage.userHasForcedVariationButInvalid(userId, experiment.key)
418
            logger.d(info)
419
            reasons.addInfo(info)
420

421
            return DecisionResponse(result: nil, reasons: reasons)
422
        }
423
        
424
        logger.d(.userHasNoForcedVariationForExperiment(userId, experiment.key))
425
        return DecisionResponse(result: nil, reasons: reasons)
426
    }
427
    
428
    /**
429
     * Set forced variation for a given experiment key and user id according to a given variation key.
430
     */
431
    func setForcedVariation(experimentKey: String, userId: String, variationKey: String?) -> Bool {
15✔
432
        guard let experiment = getExperiment(key: experimentKey) else {
433
            return false
434
        }
435
        
436
        guard var variationKey = variationKey else {
437
            logger.d(.variationRemovedForUser(userId, experimentKey))
438
            self.removeFromWhitelist(userId: userId, experimentId: experiment.id)
439
            return true
440
        }
441
        
442
        // TODO: common function to trim all keys
443
        variationKey = variationKey.trimmingCharacters(in: NSCharacterSet.whitespaces)
444
        
445
        guard !variationKey.isEmpty else {
446
            logger.e(.variationKeyInvalid(experimentKey, variationKey))
447
            return false
448
        }
449
        
450
        guard let variation = experiment.getVariation(key: variationKey) else {
451
            logger.e(.variationKeyInvalid(experimentKey, variationKey))
452
            return false
453
        }
454
        
455
        self.whitelistUser(userId: userId, experimentId: experiment.id, variationId: variation.id)
456
        
457
        logger.d(.userMappedToForcedVariation(userId, experiment.id, variation.id))
458
        return true
459
    }
460
    
461
    func getFlagVariationByKey(flagKey: String, variationKey: String) -> Variation? {
21✔
462
        if let variations = flagVariationsMap[flagKey] {
463
            return variations.filter { $0.key == variationKey }.first
93✔
464
        }
465
        
466
        return nil
467
    }
468
}
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