Skip to content

Commit 5b3e6f0

Browse files
wip: holdout bucketing logic added
1 parent 7c4fee7 commit 5b3e6f0

10 files changed

+99
-16
lines changed

Sources/Data Model/FeatureFlag.swift

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,6 @@ struct FeatureFlag: Codable, Equatable, OptimizelyFeature {
3535
case variables
3636
}
3737

38-
// var holdoutIds: [String] = []
39-
4038
// MARK: - OptimizelyConfig
4139

4240
var experimentsMap: [String: OptimizelyExperiment] = [:]

Sources/Data Model/HoldoutConfig.swift

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,4 +115,3 @@ struct HoldoutConfig {
115115
return holdoutIdMap[id]
116116
}
117117
}
118-

Sources/Implementation/DecisionInfo.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ struct DecisionInfo {
2222
let decisionType: Constants.DecisionType
2323

2424
/// The experiment that the decision variation belongs to.
25-
var experiment: Experiment?
25+
var experiment: ExperimentCore?
2626

2727
/// The variation selected by the decision.
2828
var variation: Variation?
@@ -58,7 +58,7 @@ struct DecisionInfo {
5858
var decisionEventDispatched: Bool
5959

6060
init(decisionType: Constants.DecisionType,
61-
experiment: Experiment? = nil,
61+
experiment: ExperimentCore? = nil,
6262
variation: Variation? = nil,
6363
source: String? = nil,
6464
feature: FeatureFlag? = nil,

Sources/Implementation/DefaultBucketer.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ class DefaultBucketer: OPTBucketer {
120120
return DecisionResponse(result: nil, reasons: reasons)
121121
}
122122

123-
func bucketToVariation(experiment: Experiment,
123+
func bucketToVariation(experiment: ExperimentCore,
124124
bucketingId: String) -> DecisionResponse<Variation> {
125125
let reasons = DecisionReasons()
126126

Sources/Implementation/DefaultDecisionService.swift

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
import Foundation
1818

1919
struct FeatureDecision {
20-
var experiment: Experiment?
20+
var experiment: ExperimentCore?
2121
let variation: Variation
2222
let source: String
2323
}
@@ -158,7 +158,7 @@ class DefaultDecisionService: OPTDecisionService {
158158
}
159159

160160
func doesMeetAudienceConditions(config: ProjectConfig,
161-
experiment: Experiment,
161+
experiment: ExperimentCore,
162162
user: OptimizelyUserContext,
163163
logType: Constants.EvaluationLogType = .experiment,
164164
loggingKey: String? = nil) -> DecisionResponse<Bool> {
@@ -273,13 +273,24 @@ class DefaultDecisionService: OPTDecisionService {
273273
return decisions
274274
}
275275

276-
277276
func getVariationForFeatureExperiment(config: ProjectConfig,
278277
featureFlag: FeatureFlag,
279278
user: OptimizelyUserContext,
280279
userProfileTracker: UserProfileTracker? = nil,
281280
options: [OptimizelyDecideOption]? = nil) -> DecisionResponse<FeatureDecision> {
282281
let reasons = DecisionReasons(options: options)
282+
let holdouts = config.getHoldoutForFlag(id: featureFlag.id)
283+
for holdout in holdouts {
284+
let dicisionResponse = getVariationForHoldout(config: config,
285+
flagKey: featureFlag.key,
286+
holdout: holdout,
287+
user: user)
288+
reasons.merge(dicisionResponse.reasons)
289+
if let variation = dicisionResponse.result {
290+
let featureDicision = FeatureDecision(experiment: holdout, variation: variation, source: Constants.DecisionSource.holdout.rawValue)
291+
return DecisionResponse(result: featureDicision, reasons: reasons)
292+
}
293+
}
283294

284295
let experimentIds = featureFlag.experimentIds
285296
if experimentIds.isEmpty {
@@ -363,6 +374,61 @@ class DefaultDecisionService: OPTDecisionService {
363374
return DecisionResponse(result: nil, reasons: reasons)
364375
}
365376

377+
func getVariationForHoldout(config: ProjectConfig,
378+
flagKey: String,
379+
holdout: Holdout,
380+
user: OptimizelyUserContext,
381+
options: [OptimizelyDecideOption]? = nil) -> DecisionResponse<Variation> {
382+
guard holdout.isActivated else {
383+
return DecisionResponse(result: nil, reasons: DecisionReasons(options: options))
384+
}
385+
386+
let userId = user.userId
387+
let attributes = user.attributes
388+
let reasons = DecisionReasons(options: options)
389+
390+
// ---- check if the user passes audience targeting before bucketing ----
391+
let audienceResponse = doesMeetAudienceConditions(config: config,
392+
experiment: holdout,
393+
user: user)
394+
395+
reasons.merge(audienceResponse.reasons)
396+
397+
// Acquire bucketingId .
398+
let bucketingId = getBucketingId(userId: userId, attributes: attributes)
399+
var bucketedVariation: Variation?
400+
401+
if audienceResponse.result ?? false {
402+
let info = LogMessage.userMeetsConditionsForHoldout(userId, holdout.key)
403+
logger.i(info)
404+
reasons.addInfo(info)
405+
406+
// bucket user into holdout variation
407+
let decisionResponse = bucketer.bucketToVariation(experiment: holdout, bucketingId: bucketingId)
408+
409+
reasons.merge(decisionResponse.reasons)
410+
411+
bucketedVariation = decisionResponse.result
412+
413+
if let variation = bucketedVariation {
414+
let info = LogMessage.userBucketedIntoVariationInHoldout(userId, holdout.key, variation.key)
415+
logger.i(info)
416+
reasons.addInfo(info)
417+
} else {
418+
let info = LogMessage.userNotBucketedIntoHoldoutVariation(userId)
419+
logger.i(info)
420+
reasons.addInfo(info)
421+
}
422+
423+
} else {
424+
let info = LogMessage.userDoesntMeetConditionsForHoldout(userId, holdout.key)
425+
logger.i(info)
426+
reasons.addInfo(info)
427+
}
428+
429+
return DecisionResponse(result: bucketedVariation, reasons: reasons)
430+
}
431+
366432
func getVariationFromExperimentRule(config: ProjectConfig,
367433
flagKey: String,
368434
rule: Experiment,

Sources/Implementation/Events/BatchEventBuilder.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ class BatchEventBuilder {
2222
// MARK: - Impression Event
2323

2424
static func createImpressionEvent(config: ProjectConfig,
25-
experiment: Experiment?,
25+
experiment: ExperimentCore?,
2626
variation: Variation?,
2727
userId: String,
2828
attributes: OptimizelyAttributes?,

Sources/Optimizely/OptimizelyClient.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -804,7 +804,7 @@ extension OptimizelyClient {
804804
return (source == Constants.DecisionSource.featureTest.rawValue && decision?.variation != nil) || config.sendFlagDecisions
805805
}
806806

807-
func sendImpressionEvent(experiment: Experiment?,
807+
func sendImpressionEvent(experiment: ExperimentCore?,
808808
variation: Variation?,
809809
userId: String,
810810
attributes: OptimizelyAttributes? = nil,
@@ -892,7 +892,7 @@ extension OptimizelyClient {
892892

893893
extension OptimizelyClient {
894894

895-
func sendActivateNotification(experiment: Experiment,
895+
func sendActivateNotification(experiment: ExperimentCore,
896896
variation: Variation,
897897
userId: String,
898898
attributes: OptimizelyAttributes?,

Sources/Protocols/OPTBucketer.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,15 @@ protocol OPTBucketer {
3636
func bucketExperiment(config: ProjectConfig,
3737
experiment: Experiment,
3838
bucketingId: String) -> DecisionResponse<Variation>
39+
40+
/**
41+
Bucket a bucketingId into an experiment.
42+
- Parameter experiment: The rule in which to bucket the bucketingId.
43+
- Parameter bucketingId: The ID to bucket. This must be a non-null, non-empty string.
44+
- Returns: The variation the bucketingId was bucketed into.
45+
*/
46+
func bucketToVariation(experiment: ExperimentCore,
47+
bucketingId: String) -> DecisionResponse<Variation>
3948

4049
/**
4150
Hash the bucketing ID and map it to the range [0, 10000).

Sources/Utils/Constants.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ struct Constants {
5757
case experiment = "experiment"
5858
case featureTest = "feature-test"
5959
case rollout = "rollout"
60+
case holdout = "holdout"
6061
}
6162

6263
struct DecisionInfoKeys {

Sources/Utils/LogMessage.swift

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import Foundation
1818

1919
enum LogMessage {
2020
case experimentNotRunning(_ key: String)
21+
case holdoutNotRunning(_ key: String)
2122
case featureEnabledForUser(_ key: String, _ userId: String)
2223
case featureNotEnabledForUser(_ key: String, _ userId: String)
2324
case featureHasNoExperiments(_ key: String)
@@ -34,7 +35,9 @@ enum LogMessage {
3435
case userAssignedToBucketValue(_ bucket: Int, _ userId: String)
3536
case userMappedToForcedVariation(_ userId: String, _ expId: String, _ varId: String)
3637
case userMeetsConditionsForTargetingRule(_ userId: String, _ rule: String)
38+
case userMeetsConditionsForHoldout(_ userId: String, _ holdoutKey: String)
3739
case userDoesntMeetConditionsForTargetingRule(_ userId: String, _ rule: String)
40+
case userDoesntMeetConditionsForHoldout(_ userId: String, _ holdoutKey: String)
3841
case userBucketedIntoTargetingRule(_ userId: String, _ rule: String)
3942
case userNotBucketedIntoTargetingRule(_ userId: String, _ rule: String)
4043
case userHasForcedDecision(_ userId: String, _ flagKey: String, _ ruleKey: String?, _ varKey: String)
@@ -44,8 +47,10 @@ enum LogMessage {
4447
case userHasNoForcedVariation(_ userId: String)
4548
case userHasNoForcedVariationForExperiment(_ userId: String, _ expKey: String)
4649
case userBucketedIntoVariationInExperiment(_ userId: String, _ expKey: String, _ varKey: String)
50+
case userBucketedIntoVariationInHoldout(_ userId: String, _ expKey: String, _ varKey: String)
4751
case userNotBucketedIntoVariation(_ userId: String)
4852
case userBucketedIntoInvalidVariation(_ id: String)
53+
case userNotBucketedIntoHoldoutVariation(_ userId: String)
4954
case userBucketedIntoExperimentInGroup(_ userId: String, _ expKey: String, _ group: String)
5055
case userNotBucketedIntoExperimentInGroup(_ userId: String, _ expKey: String, _ group: String)
5156
case userNotBucketedIntoAnyExperimentInGroup(_ userId: String, _ group: String)
@@ -76,6 +81,7 @@ extension LogMessage: CustomStringConvertible {
7681

7782
switch self {
7883
case .experimentNotRunning(let key): message = "Experiment (\(key)) is not running."
84+
case .holdoutNotRunning(let key): message = "Holdout (\(key)) is not running."
7985
case .featureEnabledForUser(let key, let userId): message = "Feature (\(key)) is enabled for user (\(userId))."
8086
case .featureNotEnabledForUser(let key, let userId): message = "Feature (\(key)) is not enabled for user (\(userId))."
8187
case .featureHasNoExperiments(let key): message = "Feature (\(key)) is not attached to any experiments."
@@ -91,10 +97,12 @@ extension LogMessage: CustomStringConvertible {
9197
case .savedVariationInUserProfile(let varId, let expId, let userId): message = "Saved variation (\(varId)) of experiment (\(expId)) for user (\(userId))."
9298
case .userAssignedToBucketValue(let bucket, let userId): message = "Assigned bucket (\(bucket)) to user with bucketing ID (\(userId))."
9399
case .userMappedToForcedVariation(let userId, let expId, let varId): message = "Set variation (\(varId)) for experiment (\(expId)) and user (\(userId)) in the forced variation map."
94-
case .userMeetsConditionsForTargetingRule(let userId, let rule): message = "User (\(userId)) meets conditions for targeting rule (\(rule))."
95-
case .userDoesntMeetConditionsForTargetingRule(let userId, let rule): message = "User (\(userId)) does not meet conditions for targeting rule (\(rule))."
96-
case .userBucketedIntoTargetingRule(let userId, let rule): message = "User (\(userId)) is in the traffic group of targeting rule (\(rule))."
97-
case .userNotBucketedIntoTargetingRule(let userId, let rule): message = "User (\(userId)) is not in the traffic group for targeting rule (\(rule)). Checking (Everyone Else) rule now."
100+
case .userMeetsConditionsForTargetingRule(let userId, let rule): message = "User (\(userId)) meets conditions for targeting rule (\(rule))."
101+
case .userMeetsConditionsForHoldout(let userId, let holdoutKey): message = "User (\(userId)) meets conditions for holdout(\(holdoutKey))."
102+
case .userDoesntMeetConditionsForTargetingRule(let userId, let rule): message = "User (\(userId)) does not meet conditions for targeting rule (\(rule))."
103+
case .userDoesntMeetConditionsForHoldout(let userId, let holdoutKey): message = "User (\(userId)) does not meet conditions for holdout (\(holdoutKey))."
104+
case .userBucketedIntoTargetingRule(let userId, let rule): message = "User (\(userId)) is in the traffic group of targeting rule (\(rule))."
105+
case .userNotBucketedIntoTargetingRule(let userId, let rule): message = "User (\(userId)) is not in the traffic group for targeting rule (\(rule)). Checking (Everyone Else) rule now."
98106
case .userHasForcedDecision(let userId, let flagKey, let ruleKey, let varKey):
99107
let target = (ruleKey != nil) ? "flag (\(flagKey)), rule (\(ruleKey!))" : "flag (\(flagKey))"
100108
message = "Variation (\(varKey)) is mapped to \(target) and user (\(userId)) in the forced decision map."
@@ -106,7 +114,9 @@ extension LogMessage: CustomStringConvertible {
106114
case .userHasNoForcedVariation(let userId): message = "User (\(userId)) is not in the forced variation map."
107115
case .userHasNoForcedVariationForExperiment(let userId, let expKey): message = "No experiment (\(expKey)) mapped to user (\(userId)) in the forced variation map."
108116
case .userBucketedIntoVariationInExperiment(let userId, let expKey, let varKey): message = "User (\(userId)) is in variation (\(varKey)) of experiment (\(expKey))"
117+
case .userBucketedIntoVariationInHoldout(let userId, let holdoutKey, let varKey): message = "User (\(userId)) is in variation (\(varKey)) of holdout (\(holdoutKey))"
109118
case .userNotBucketedIntoVariation(let userId): message = "User (\(userId)) is in no variation."
119+
case .userNotBucketedIntoHoldoutVariation(let userId): message = "User (\(userId)) is in no holdout variation."
110120
case .userBucketedIntoInvalidVariation(let id): message = "Bucketed into an invalid variation id (\(id))"
111121
case .userBucketedIntoExperimentInGroup(let userId, let expId, let group): message = "User (\(userId)) is in experiment (\(expId)) of group (\(group))."
112122
case .userNotBucketedIntoExperimentInGroup(let userId, let expKey, let group): message = "User (\(userId)) is not in experiment (\(expKey)) of group (\(group))."

0 commit comments

Comments
 (0)