diff --git a/OptimizelySwiftSDK.xcodeproj/project.pbxproj b/OptimizelySwiftSDK.xcodeproj/project.pbxproj index d5c85cdd..af0854c4 100644 --- a/OptimizelySwiftSDK.xcodeproj/project.pbxproj +++ b/OptimizelySwiftSDK.xcodeproj/project.pbxproj @@ -1984,6 +1984,22 @@ 984E2FDD2B27199C001F477A /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 987F11D92AF3F56F0083D3F9 /* PrivacyInfo.xcprivacy */; }; 984E2FDE2B27199D001F477A /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 987F11D92AF3F56F0083D3F9 /* PrivacyInfo.xcprivacy */; }; 984E2FDF2B27199D001F477A /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 987F11D92AF3F56F0083D3F9 /* PrivacyInfo.xcprivacy */; }; + 984FE5112CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984FE5102CC8AA88004F6F41 /* UserProfileTracker.swift */; }; + 984FE5122CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984FE5102CC8AA88004F6F41 /* UserProfileTracker.swift */; }; + 984FE5132CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984FE5102CC8AA88004F6F41 /* UserProfileTracker.swift */; }; + 984FE5142CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984FE5102CC8AA88004F6F41 /* UserProfileTracker.swift */; }; + 984FE5152CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984FE5102CC8AA88004F6F41 /* UserProfileTracker.swift */; }; + 984FE5162CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984FE5102CC8AA88004F6F41 /* UserProfileTracker.swift */; }; + 984FE5172CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984FE5102CC8AA88004F6F41 /* UserProfileTracker.swift */; }; + 984FE5182CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984FE5102CC8AA88004F6F41 /* UserProfileTracker.swift */; }; + 984FE5192CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984FE5102CC8AA88004F6F41 /* UserProfileTracker.swift */; }; + 984FE51A2CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984FE5102CC8AA88004F6F41 /* UserProfileTracker.swift */; }; + 984FE51B2CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984FE5102CC8AA88004F6F41 /* UserProfileTracker.swift */; }; + 984FE51C2CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984FE5102CC8AA88004F6F41 /* UserProfileTracker.swift */; }; + 984FE51D2CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984FE5102CC8AA88004F6F41 /* UserProfileTracker.swift */; }; + 984FE51E2CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984FE5102CC8AA88004F6F41 /* UserProfileTracker.swift */; }; + 984FE51F2CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984FE5102CC8AA88004F6F41 /* UserProfileTracker.swift */; }; + 984FE5202CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984FE5102CC8AA88004F6F41 /* UserProfileTracker.swift */; }; BD1C3E8524E4399C0084B4DA /* SemanticVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B97DD93249D327F003DE606 /* SemanticVersion.swift */; }; BD64853C2491474500F30986 /* Optimizely.h in Headers */ = {isa = PBXBuildFile; fileRef = 6E75167A22C520D400B2B157 /* Optimizely.h */; settings = {ATTRIBUTES = (Public, ); }; }; BD64853E2491474500F30986 /* Audience.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E75169822C520D400B2B157 /* Audience.swift */; }; @@ -2422,6 +2438,7 @@ 84F6BADC27FD011B004BE62A /* OptimizelyUserContextTests_ODP_Decide.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OptimizelyUserContextTests_ODP_Decide.swift; sourceTree = ""; }; 98137C542A41E86F004896EB /* OptimizelyClientTests_Init_Async_Await.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyClientTests_Init_Async_Await.swift; sourceTree = ""; }; 98137C562A42BA0F004896EB /* OptimizelyUserContextTests_ODP_Aync_Await.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyUserContextTests_ODP_Aync_Await.swift; sourceTree = ""; }; + 984FE5102CC8AA88004F6F41 /* UserProfileTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileTracker.swift; sourceTree = ""; }; 987F11D92AF3F56F0083D3F9 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; BD6485812491474500F30986 /* Optimizely.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Optimizely.framework; sourceTree = BUILT_PRODUCTS_DIR; }; C78CAF572445AD8D009FE876 /* OptimizelyJSON.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyJSON.swift; sourceTree = ""; }; @@ -2762,6 +2779,7 @@ 6E75167E22C520D400B2B157 /* DefaultBucketer.swift */, 6E75167F22C520D400B2B157 /* DefaultNotificationCenter.swift */, 6E75168022C520D400B2B157 /* DefaultDecisionService.swift */, + 984FE5102CC8AA88004F6F41 /* UserProfileTracker.swift */, 6EF8DE3024BF7D69008B9488 /* DecisionReasons.swift */, 6E994B3325A3E6EA00999262 /* DecisionResponse.swift */, 6E75168122C520D400B2B157 /* Datastore */, @@ -4129,6 +4147,7 @@ 6E14CDA22423F9C300010234 /* Array+Extension.swift in Sources */, 848617CF2863DC2700B7F41B /* OdpSegmentManager.swift in Sources */, 6E14CD952423F9A700010234 /* Group.swift in Sources */, + 984FE5142CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */, 84E2E96828540B5E001114AB /* OptimizelySdkSettings.swift in Sources */, 6E14CD9A2423F9C300010234 /* DataStoreQueueStack.swift in Sources */, 6E14CD732423F96F00010234 /* OptimizelyResult.swift in Sources */, @@ -4273,6 +4292,7 @@ 6E424D1126324B620081004A /* Variable.swift in Sources */, 6E424D1226324B620081004A /* Attribute.swift in Sources */, 6E424D1326324B620081004A /* BackgroundingCallbacks.swift in Sources */, + 984FE5112CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */, 845945C2287758A000D13E11 /* OdpConfig.swift in Sources */, 6E424D1426324B620081004A /* OPTNotificationCenter.swift in Sources */, 6E424D5026324C4D0081004A /* OptimizelyDecideOption.swift in Sources */, @@ -4343,6 +4363,7 @@ 8464087128130D3200CCF97D /* Integration.swift in Sources */, 6E623F03253F9045000617D0 /* DecisionInfo.swift in Sources */, 845945BD2877589E00D13E11 /* OdpConfig.swift in Sources */, + 984FE51C2CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */, 6E75171322C520D400B2B157 /* OptimizelyClient+ObjC.swift in Sources */, 6E75191922C520D500B2B157 /* OPTNotificationCenter.swift in Sources */, 6E7518A122C520D400B2B157 /* FeatureFlag.swift in Sources */, @@ -4433,6 +4454,7 @@ 6E75173222C520D400B2B157 /* Constants.swift in Sources */, 848617D42863DC2700B7F41B /* OdpSegmentManager.swift in Sources */, 6E75184822C520D400B2B157 /* Event.swift in Sources */, + 984FE5172CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */, 84E2E96D28540B5E001114AB /* OptimizelySdkSettings.swift in Sources */, 6E75170E22C520D400B2B157 /* OptimizelyClient.swift in Sources */, 6E75177A22C520D400B2B157 /* SDKVersion.swift in Sources */, @@ -4601,6 +4623,7 @@ 6E20050C26B4D28500278087 /* MockLogger.swift in Sources */, 6E75176A22C520D400B2B157 /* Utils.swift in Sources */, 6E75171622C520D400B2B157 /* OptimizelyClient+ObjC.swift in Sources */, + 984FE5152CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */, 6E7517F022C520D400B2B157 /* DataStoreMemory.swift in Sources */, 6E9B11D922C548A200C22D81 /* OptimizelyClientTests_Invalid.swift in Sources */, 848617D02863DC2700B7F41B /* OdpSegmentManager.swift in Sources */, @@ -4702,6 +4725,7 @@ 6E7518EF22C520D400B2B157 /* ConditionHolder.swift in Sources */, 6E75182F22C520D400B2B157 /* BatchEvent.swift in Sources */, 6E75191F22C520D500B2B157 /* OPTNotificationCenter.swift in Sources */, + 984FE5202CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */, 6E7518B322C520D400B2B157 /* Group.swift in Sources */, 6E20050F26B4D28500278087 /* MockLogger.swift in Sources */, 6EC6DD3A24ABF6990017D296 /* OptimizelyClient+Decide.swift in Sources */, @@ -4870,6 +4894,7 @@ 6E20051126B4D28600278087 /* MockLogger.swift in Sources */, 6E7516DF22C520D400B2B157 /* OPTUserProfileService.swift in Sources */, 6EF8DE3C24BF7D69008B9488 /* DecisionReasons.swift in Sources */, + 984FE5182CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */, 6E7518B522C520D400B2B157 /* Group.swift in Sources */, 6E9B116B22C5487100C22D81 /* NotificationCenterTests.swift in Sources */, 6E7516F722C520D400B2B157 /* OptimizelyError.swift in Sources */, @@ -4972,6 +4997,7 @@ 84E2E96F28540B5E001114AB /* OptimizelySdkSettings.swift in Sources */, 6E7517A022C520D400B2B157 /* DataStoreQueueStackImpl+Extension.swift in Sources */, 6E7517AC22C520D400B2B157 /* Array+Extension.swift in Sources */, + 984FE5132CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */, 6EA425A52218E6AE00B074B5 /* (null) in Sources */, 6E8A3D522637408500DAEA13 /* MockDatafileHandler.swift in Sources */, 6E75180E22C520D400B2B157 /* DataStoreFile.swift in Sources */, @@ -5070,6 +5096,7 @@ 6E6522E3278E4F3800954EA1 /* OdpManager.swift in Sources */, 6EA2CC272345618E001E7531 /* OptimizelyConfig.swift in Sources */, 84861815286D0B8900B7F41B /* OdpVuidManagerTests.swift in Sources */, + 984FE51E2CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */, C78CAFA724486E0A009FE876 /* OptimizelyJSON+ObjC.swift in Sources */, 6E75185B22C520D400B2B157 /* FeatureVariable.swift in Sources */, 6E7516B522C520D400B2B157 /* DefaultUserProfileService.swift in Sources */, @@ -5239,6 +5266,7 @@ 84E2E96A28540B5E001114AB /* OptimizelySdkSettings.swift in Sources */, 6E75179B22C520D400B2B157 /* DataStoreQueueStackImpl+Extension.swift in Sources */, 6E7517A722C520D400B2B157 /* Array+Extension.swift in Sources */, + 984FE5162CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */, 6EA425962218E6AD00B074B5 /* (null) in Sources */, 6E8A3D4D2637408500DAEA13 /* MockDatafileHandler.swift in Sources */, 6E75180922C520D400B2B157 /* DataStoreFile.swift in Sources */, @@ -5307,6 +5335,7 @@ 84B4D75A27E2A7550078CDA4 /* OptimizelySegmentOption.swift in Sources */, 6E7517DA22C520D400B2B157 /* DefaultNotificationCenter.swift in Sources */, 6E7517E622C520D400B2B157 /* DefaultDecisionService.swift in Sources */, + 984FE51F2CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */, 6E75171822C520D400B2B157 /* OptimizelyClient+ObjC.swift in Sources */, 6E75174822C520D400B2B157 /* HandlerRegistryService.swift in Sources */, 84E2E94C2852A378001114AB /* OdpVuidManager.swift in Sources */, @@ -5408,6 +5437,7 @@ 84B4D75F27E2A7550078CDA4 /* OptimizelySegmentOption.swift in Sources */, 6E7517DF22C520D400B2B157 /* DefaultNotificationCenter.swift in Sources */, 6E7517EB22C520D400B2B157 /* DefaultDecisionService.swift in Sources */, + 984FE51D2CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */, 6E75171D22C520D400B2B157 /* OptimizelyClient+ObjC.swift in Sources */, 6E75174D22C520D400B2B157 /* HandlerRegistryService.swift in Sources */, 84E2E9512852A378001114AB /* OdpVuidManager.swift in Sources */, @@ -5493,6 +5523,7 @@ 8464087028130D3200CCF97D /* Integration.swift in Sources */, 6E623F02253F9045000617D0 /* DecisionInfo.swift in Sources */, 845945BC2877589D00D13E11 /* OdpConfig.swift in Sources */, + 984FE5192CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */, 6E75184022C520D400B2B157 /* Event.swift in Sources */, 6E7516E222C520D400B2B157 /* OPTEventDispatcher.swift in Sources */, 6E7517D422C520D400B2B157 /* DefaultNotificationCenter.swift in Sources */, @@ -5583,6 +5614,7 @@ 6E75172C22C520D400B2B157 /* Constants.swift in Sources */, 848617CC2863DC2700B7F41B /* OdpSegmentManager.swift in Sources */, 6E75184222C520D400B2B157 /* Event.swift in Sources */, + 984FE5122CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */, 84E2E96528540B5E001114AB /* OptimizelySdkSettings.swift in Sources */, 6E75170822C520D400B2B157 /* OptimizelyClient.swift in Sources */, 6E75177422C520D400B2B157 /* SDKVersion.swift in Sources */, @@ -5727,6 +5759,7 @@ 75C71A2925E454460084187E /* ProjectConfig.swift in Sources */, 75C71A2A25E454460084187E /* FeatureVariable.swift in Sources */, 75C71A2B25E454460084187E /* Rollout.swift in Sources */, + 984FE51B2CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */, 6E424BFF263228FD0081004A /* AtomicDictionary.swift in Sources */, 75C71A2C25E454460084187E /* Variation.swift in Sources */, 75C71A2D25E454460084187E /* TrafficAllocation.swift in Sources */, @@ -5782,6 +5815,7 @@ 8464087228130D3200CCF97D /* Integration.swift in Sources */, 6E623F04253F9045000617D0 /* DecisionInfo.swift in Sources */, 845945BE2877589E00D13E11 /* OdpConfig.swift in Sources */, + 984FE51A2CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */, BD6485462491474500F30986 /* Event.swift in Sources */, BD6485472491474500F30986 /* OPTEventDispatcher.swift in Sources */, BD6485482491474500F30986 /* DefaultNotificationCenter.swift in Sources */, diff --git a/Sources/Implementation/DefaultDecisionService.swift b/Sources/Implementation/DefaultDecisionService.swift index 276d3a15..9267a4f6 100644 --- a/Sources/Implementation/DefaultDecisionService.swift +++ b/Sources/Implementation/DefaultDecisionService.swift @@ -22,31 +22,54 @@ struct FeatureDecision { let source: String } +typealias UserProfile = OPTUserProfileService.UPProfile + class DefaultDecisionService: OPTDecisionService { - let bucketer: OPTBucketer let userProfileService: OPTUserProfileService - // thread-safe lazy logger load (after HandlerRegisterService ready) private let threadSafeLogger = ThreadSafeLogger() - var logger: OPTLogger { - return threadSafeLogger.logger - } // user-profile-service read-modify-write lock for supporting multiple clients static let upsRMWLock = DispatchQueue(label: "ups-rmw") + var logger: OPTLogger { + return threadSafeLogger.logger + } + init(userProfileService: OPTUserProfileService) { self.bucketer = DefaultBucketer() self.userProfileService = userProfileService } + /// Public Method func getVariation(config: ProjectConfig, experiment: Experiment, user: OptimizelyUserContext, options: [OptimizelyDecideOption]? = nil) -> DecisionResponse { - let reasons = DecisionReasons(options: options) + let userId = user.userId + let ignoreUPS = (options ?? []).contains(.ignoreUserProfileService) + var profileTracker: UserProfileTracker? + if !ignoreUPS { + profileTracker = UserProfileTracker(userId: userId, userProfileService: self.userProfileService, logger: self.logger) + profileTracker?.loadUserProfile() + } + + let response = getVariation(config: config, experiment: experiment, user: user, userProfileTracker: profileTracker) + if (!ignoreUPS) { + profileTracker?.save() + } + + return response + } + + func getVariation(config: ProjectConfig, + experiment: Experiment, + user: OptimizelyUserContext, + options: [OptimizelyDecideOption]? = nil, + userProfileTracker: UserProfileTracker?) -> DecisionResponse { + let reasons = DecisionReasons(options: options) let userId = user.userId let attributes = user.attributes let experimentId = experiment.id @@ -64,7 +87,9 @@ class DefaultDecisionService: OPTDecisionService { // ---- check if the user is forced into a variation ---- let decisionResponse = config.getForcedVariation(experimentKey: experiment.key, userId: userId) + reasons.merge(decisionResponse.reasons) + if let variationId = decisionResponse.result?.id, let variation = experiment.getVariation(id: variationId) { return DecisionResponse(result: variation, reasons: reasons) @@ -85,11 +110,9 @@ class DefaultDecisionService: OPTDecisionService { reasons.addInfo(info) } - // ---- check if a valid variation is stored in the user profile ---- - let ignoreUPS = (options ?? []).contains(.ignoreUserProfileService) - - if !ignoreUPS, - let variationId = getVariationIdFromProfile(userId: userId, experimentId: experimentId), + /// Load variation from tracker + if let profile = userProfileTracker?.userProfile, + let variationId = getVariationIdFromProfile(profile: profile, experimentId: experimentId), let variation = experiment.getVariation(id: variationId) { let info = LogMessage.gotVariationFromUserProfile(variation.key, experiment.key, userId) @@ -104,22 +127,21 @@ class DefaultDecisionService: OPTDecisionService { experiment: experiment, user: user) reasons.merge(audienceResponse.reasons) + if audienceResponse.result ?? false { // bucket user into a variation let decisionResponse = bucketer.bucketExperiment(config: config, experiment: experiment, bucketingId: bucketingId) reasons.merge(decisionResponse.reasons) + bucketedVariation = decisionResponse.result if let variation = bucketedVariation { let info = LogMessage.userBucketedIntoVariationInExperiment(userId, experiment.key, variation.key) logger.i(info) reasons.addInfo(info) - // save to user profile - if !ignoreUPS { - self.saveProfile(userId: userId, experimentId: experimentId, variationId: variation.id) - } + userProfileTracker?.updateProfile(experiment: experiment, variation: variation) } else { let info = LogMessage.userNotBucketedIntoVariation(userId) logger.i(info) @@ -190,42 +212,72 @@ class DefaultDecisionService: OPTDecisionService { return DecisionResponse(result: result, reasons: reasons) } + /// Public Method func getVariationForFeature(config: ProjectConfig, featureFlag: FeatureFlag, user: OptimizelyUserContext, options: [OptimizelyDecideOption]? = nil) -> DecisionResponse { + + let response = getVariationForFeatureList(config: config, featureFlags: [featureFlag], user: user, options: options).first + + guard response?.result != nil else { + let reasons = response?.reasons ?? DecisionReasons(options: options) + return DecisionResponse(result: nil, reasons: reasons) + } + + return response! + } + + func getVariationForFeatureList(config: ProjectConfig, + featureFlags: [FeatureFlag], + user: OptimizelyUserContext, + options: [OptimizelyDecideOption]? = nil) -> [DecisionResponse] { + let reasons = DecisionReasons(options: options) + let userId = user.userId + let ignoreUPS = (options ?? []).contains(.ignoreUserProfileService) + var profileTracker: UserProfileTracker? + if !ignoreUPS { + profileTracker = UserProfileTracker(userId: userId, userProfileService: self.userProfileService, logger: self.logger) + profileTracker?.loadUserProfile() + } - // Evaluate in this order: + var decisions = [DecisionResponse]() - // 1. Attempt to bucket user into experiment using feature flag. - // Check if the feature flag is under an experiment and the the user is bucketed into one of these experiments - var decisionResponse = getVariationForFeatureExperiment(config: config, - featureFlag: featureFlag, - user: user, - options: options) - reasons.merge(decisionResponse.reasons) - if let decision = decisionResponse.result { - return DecisionResponse(result: decision, reasons: reasons) + for featureFlag in featureFlags { + var decisionResponse = getVariationForFeatureExperiment(config: config, featureFlag: featureFlag, user: user, userProfileTracker: profileTracker) + + reasons.merge(decisionResponse.reasons) + + if let decision = decisionResponse.result { + decisions.append(DecisionResponse(result: decision, reasons: reasons)) + continue + } + + decisionResponse = getVariationForFeatureRollout(config: config, featureFlag: featureFlag, user: user) + + reasons.merge(decisionResponse.reasons) + + if let decision = decisionResponse.result { + decisions.append(DecisionResponse(result: decision, reasons: reasons)) + } else { + decisions.append(DecisionResponse(result: nil, reasons: reasons)) + } } - // 2. Attempt to bucket user into rollout using the feature flag. - // Check if the feature flag has rollout and the user is bucketed into one of it's rules - decisionResponse = getVariationForFeatureRollout(config: config, - featureFlag: featureFlag, - user: user, - options: options) - reasons.merge(decisionResponse.reasons) - if let decision = decisionResponse.result { - return DecisionResponse(result: decision, reasons: reasons) + // save profile + if !ignoreUPS { + profileTracker?.save() } - return DecisionResponse(result: nil, reasons: reasons) + return decisions } + func getVariationForFeatureExperiment(config: ProjectConfig, featureFlag: FeatureFlag, user: OptimizelyUserContext, + userProfileTracker: UserProfileTracker? = nil, options: [OptimizelyDecideOption]? = nil) -> DecisionResponse { let reasons = DecisionReasons(options: options) @@ -244,6 +296,7 @@ class DefaultDecisionService: OPTDecisionService { flagKey: featureFlag.key, rule: experiment, user: user, + userProfileTracker: userProfileTracker, options: options) reasons.merge(decisionResponse.reasons) if let variation = decisionResponse.result { @@ -314,11 +367,10 @@ class DefaultDecisionService: OPTDecisionService { flagKey: String, rule: Experiment, user: OptimizelyUserContext, + userProfileTracker: UserProfileTracker?, options: [OptimizelyDecideOption]? = nil) -> DecisionResponse { let reasons = DecisionReasons(options: options) - // check forced-decision first - let forcedDecisionResponse = findValidatedForcedDecision(config: config, user: user, context: OptimizelyDecisionContext(flagKey: flagKey, ruleKey: rule.key)) @@ -328,18 +380,16 @@ class DefaultDecisionService: OPTDecisionService { return DecisionResponse(result: variation, reasons: reasons) } - // regular decision - let decisionResponse = getVariation(config: config, experiment: rule, user: user, - options: options) - reasons.merge(decisionResponse.reasons) + userProfileTracker: userProfileTracker) let variation = decisionResponse.result - + reasons.merge(decisionResponse.reasons) return DecisionResponse(result: variation, reasons: reasons) } + func getVariationFromDeliveryRule(config: ProjectConfig, flagKey: String, rules: [Experiment], @@ -424,6 +474,7 @@ class DefaultDecisionService: OPTDecisionService { return bucketingId } + /// Public Method func findValidatedForcedDecision(config: ProjectConfig, user: OptimizelyUserContext, context: OptimizelyDecisionContext) -> DecisionResponse { @@ -452,7 +503,6 @@ class DefaultDecisionService: OPTDecisionService { // MARK: - UserProfileService Helpers extension DefaultDecisionService { - func getVariationIdFromProfile(userId: String, experimentId: String) -> String? { if let profile = userProfileService.lookup(userId: userId), @@ -465,6 +515,18 @@ extension DefaultDecisionService { } } + func getVariationIdFromProfile(profile: UserProfile?, + experimentId: String) -> String? { + if let _profile = profile, + let bucketMap = _profile[UserProfileKeys.kBucketMap] as? OPTUserProfileService.UPBucketMap, + let experimentMap = bucketMap[experimentId], + let variationId = experimentMap[UserProfileKeys.kVariationId] { + return variationId + } else { + return nil + } + } + func saveProfile(userId: String, experimentId: String, variationId: String) { @@ -482,5 +544,4 @@ extension DefaultDecisionService { self.logger.i(.savedVariationInUserProfile(variationId, experimentId, userId)) } } - } diff --git a/Sources/Implementation/UserProfileTracker.swift b/Sources/Implementation/UserProfileTracker.swift new file mode 100644 index 00000000..e632418f --- /dev/null +++ b/Sources/Implementation/UserProfileTracker.swift @@ -0,0 +1,67 @@ +// +// Copyright 2024, Optimizely, Inc. and contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +class UserProfileTracker { + var userId: String + var profileUpdated: Bool = false + var userProfileService: OPTUserProfileService + var userProfile: UserProfile? + var logger: OPTLogger + + // user-profile-service read-modify-write lock for supporting multiple clients + static let upsRMWLock = DispatchQueue(label: "ups-rmw") + + init(userId: String, userProfileService: OPTUserProfileService, logger: OPTLogger) { + self.userId = userId + self.userProfileService = userProfileService + self.logger = logger + } + + func loadUserProfile() { + userProfile = userProfileService.lookup(userId: userId) ?? [String: Any]() + } + + func updateProfile(experiment: Experiment, variation: Variation) { + let experimentId = experiment.id + let variationId = variation.id + var bucketMap = userProfile?[UserProfileKeys.kBucketMap] as? OPTUserProfileService.UPBucketMap ?? OPTUserProfileService.UPBucketMap() + bucketMap[experimentId] = [UserProfileKeys.kVariationId: variationId] + userProfile?[UserProfileKeys.kBucketMap] = bucketMap + userProfile?[UserProfileKeys.kUserId] = userId + profileUpdated = true + logger.i("Update variation of experiment \(experimentId) for user \(userId)") + } + + func save() { + UserProfileTracker.upsRMWLock.sync { + guard profileUpdated else { + logger.w("Profile not updated for \(userId)") + return + } + + guard let profile = userProfile else { + logger.e("Failed to save user profile for \(userId)") + return + } + + userProfileService.save(userProfile: profile) + logger.i("Saved user profile for \(userId)") + } + + } +} diff --git a/Sources/Optimizely+Decide/OptimizelyClient+Decide.swift b/Sources/Optimizely+Decide/OptimizelyClient+Decide.swift index da7e3c04..be4732a6 100644 --- a/Sources/Optimizely+Decide/OptimizelyClient+Decide.swift +++ b/Sources/Optimizely+Decide/OptimizelyClient+Decide.swift @@ -66,53 +66,131 @@ extension OptimizelyClient { return OptimizelyDecision.errorDecision(key: key, user: user, error: .sdkNotReady) } - guard let feature = config.getFeatureFlag(key: key) else { + guard let _ = config.getFeatureFlag(key: key) else { return OptimizelyDecision.errorDecision(key: key, user: user, error: .featureKeyInvalid(key)) } + + var allOptions = defaultDecideOptions + (options ?? []) + allOptions.removeAll(where: { $0 == .enabledFlagsOnly }) - let userId = user.userId - let attributes = user.attributes - let allOptions = defaultDecideOptions + (options ?? []) - let reasons = DecisionReasons(options: allOptions) - var decisionEventDispatched = false - var enabled = false + let decisionMap = decide(user: user, keys: [key], options: allOptions, ignoreDefaultOptions: true) + return decisionMap[key] ?? OptimizelyDecision.errorDecision(key: key, user: user, error: .generic) + } + + func decide(user: OptimizelyUserContext, + keys: [String], + options: [OptimizelyDecideOption]? = nil) -> [String: OptimizelyDecision] { + return decide(user: user, keys: keys, options: options, ignoreDefaultOptions: false) + } + + func decide(user: OptimizelyUserContext, + keys: [String], + options: [OptimizelyDecideOption]? = nil, + ignoreDefaultOptions: Bool) -> [String: OptimizelyDecision] { + guard let config = self.config else { + logger.e(OptimizelyError.sdkNotReady) + return [:] + } - var decision: FeatureDecision? + var decisionMap = [String : OptimizelyDecision]() - // check forced-decisions first + guard keys.count > 0 else { return decisionMap } - let forcedDecisionResponse = decisionService.findValidatedForcedDecision(config: config, - user: user, - context: OptimizelyDecisionContext(flagKey: key)) - reasons.merge(forcedDecisionResponse.reasons) + var validKeys = [String]() + var flagsWithoutForceDecision = [FeatureFlag]() + var flagDecisions = [String : FeatureDecision]() + var decisionReasonMap = [String : DecisionReasons]() - if let variation = forcedDecisionResponse.result { - decision = FeatureDecision(experiment: nil, variation: variation, source: Constants.DecisionSource.featureTest.rawValue) - } else { - // regular decision - - let decisionResponse = decisionService.getVariationForFeature(config: config, - featureFlag: feature, - user: user, - options: allOptions) - reasons.merge(decisionResponse.reasons) - decision = decisionResponse.result - } - - if let featureEnabled = decision?.variation.featureEnabled { - enabled = featureEnabled + let allOptions = ignoreDefaultOptions ? (options ?? []) : defaultDecideOptions + (options ?? []) + + for key in keys { + guard let flags = config.getFeatureFlag(key: key) else { + decisionMap[key] = OptimizelyDecision.errorDecision(key: key, user: user, error: .featureKeyInvalid(key)) + continue + } + + validKeys.append(key) + + // check forced-decisions first + let forcedDecisionResponse = decisionService.findValidatedForcedDecision(config: config, + user: user, + context: OptimizelyDecisionContext(flagKey: key)) + + let decisionReasons = DecisionReasons(options: allOptions) + decisionReasons.merge(forcedDecisionResponse.reasons) + decisionReasonMap[key] = decisionReasons + + if let variation = forcedDecisionResponse.result { + let featureDecision = FeatureDecision(experiment: nil, variation: variation, source: Constants.DecisionSource.featureTest.rawValue) + flagDecisions[key] = featureDecision + } else { + flagsWithoutForceDecision.append(flags) + } + } + + let decisionList = (decisionService as? DefaultDecisionService)?.getVariationForFeatureList(config: config, featureFlags: flagsWithoutForceDecision, user: user, options: allOptions) + + for index in 0.. OptimizelyDecision { + + guard let feature = config.getFeatureFlag(key: flagKey) else { + return OptimizelyDecision.errorDecision(key: flagKey, user: user, error: .featureKeyInvalid(flagKey)) + } + + let userId = user.userId + let attributes = user.attributes + let flagEnabled = flagDecision?.variation.featureEnabled ?? false + + logger.i("Feature \(flagKey) is enabled for user \(userId) \(flagEnabled)") + + var decisionEventDispatched = false + if !allOptions.contains(.disableDecisionEvent) { - let ruleType = decision?.source ?? Constants.DecisionSource.rollout.rawValue - if shouldSendDecisionEvent(source: ruleType, decision: decision) { - sendImpressionEvent(experiment: decision?.experiment, - variation: decision?.variation, + let ruleType = flagDecision?.source ?? Constants.DecisionSource.rollout.rawValue + if shouldSendDecisionEvent(source: ruleType, decision: flagDecision) { + sendImpressionEvent(experiment: flagDecision?.experiment, + variation: flagDecision?.variation, userId: userId, attributes: attributes, flagKey: feature.key, ruleType: ruleType, - enabled: enabled) + enabled: flagEnabled) decisionEventDispatched = true } } @@ -120,9 +198,9 @@ extension OptimizelyClient { var variableMap = [String: Any]() if !allOptions.contains(.excludeVariables) { let decisionResponse = getDecisionVariableMap(feature: feature, - variation: decision?.variation, - enabled: enabled) - reasons.merge(decisionResponse.reasons) + variation: flagDecision?.variation, + enabled: flagEnabled) + decisionReasons.merge(decisionResponse.reasons) variableMap = decisionResponse.result ?? [:] } @@ -130,27 +208,27 @@ extension OptimizelyClient { if let opt = OptimizelyJSON(map: variableMap) { optimizelyJSON = opt } else { - reasons.addError(OptimizelyError.invalidJSONVariable) + decisionReasons.addError(OptimizelyError.invalidJSONVariable) optimizelyJSON = OptimizelyJSON.createEmpty() } - let ruleKey = decision?.experiment?.key - let reasonsToReport = reasons.toReport() + let ruleKey = flagDecision?.experiment?.key + let reasonsToReport = decisionReasons.toReport() sendDecisionNotification(userId: userId, attributes: attributes, decisionInfo: DecisionInfo(decisionType: .flag, - experiment: decision?.experiment, - variation: decision?.variation, + experiment: flagDecision?.experiment, + variation: flagDecision?.variation, feature: feature, - featureEnabled: enabled, + featureEnabled: flagEnabled, variableValues: variableMap, ruleKey: ruleKey, reasons: reasonsToReport, decisionEventDispatched: decisionEventDispatched)) - return OptimizelyDecision(variationKey: decision?.variation.key, - enabled: enabled, + return OptimizelyDecision(variationKey: flagDecision?.variation.key, + enabled: flagEnabled, variables: optimizelyJSON, ruleKey: ruleKey, flagKey: feature.key, @@ -158,31 +236,6 @@ extension OptimizelyClient { reasons: reasonsToReport) } - func decide(user: OptimizelyUserContext, - keys: [String], - options: [OptimizelyDecideOption]? = nil) -> [String: OptimizelyDecision] { - guard config != nil else { - logger.e(OptimizelyError.sdkNotReady) - return [:] - } - - guard keys.count > 0 else { return [:] } - - let allOptions = defaultDecideOptions + (options ?? []) - - var decisions = [String: OptimizelyDecision]() - - let enabledFlagsOnly = allOptions.contains(.enabledFlagsOnly) - keys.forEach { key in - let decision = decide(user: user, key: key, options: options) - if !enabledFlagsOnly || decision.enabled { - decisions[key] = decision - } - } - - return decisions - } - func decideAll(user: OptimizelyUserContext, options: [OptimizelyDecideOption]? = nil) -> [String: OptimizelyDecision] { guard let config = self.config else { diff --git a/Tests/OptimizelyTests-Common/DecisionListenerTests.swift b/Tests/OptimizelyTests-Common/DecisionListenerTests.swift index d5871a5a..3837ff15 100644 --- a/Tests/OptimizelyTests-Common/DecisionListenerTests.swift +++ b/Tests/OptimizelyTests-Common/DecisionListenerTests.swift @@ -1262,6 +1262,16 @@ class FakeDecisionService: DefaultDecisionService { let featureDecision = FeatureDecision(experiment: experiment, variation: tmpVariation, source: source) return DecisionResponse.responseNoReasons(result: featureDecision) } + + override func getVariationForFeatureExperiment(config: ProjectConfig, featureFlag: FeatureFlag, user: OptimizelyUserContext, userProfileTracker: UserProfileTracker? = nil, options: [OptimizelyDecideOption]? = nil) -> DecisionResponse { + guard let experiment = self.experiment, let tmpVariation = self.variation else { + return DecisionResponse.nilNoReasons() + } + + let featureDecision = FeatureDecision(experiment: experiment, variation: tmpVariation, source: source) + return DecisionResponse.responseNoReasons(result: featureDecision) + } + } fileprivate extension HandlerRegistryService { diff --git a/Tests/OptimizelyTests-Common/DecisionServiceTests_Features.swift b/Tests/OptimizelyTests-Common/DecisionServiceTests_Features.swift index 2f243b02..4101578d 100644 --- a/Tests/OptimizelyTests-Common/DecisionServiceTests_Features.swift +++ b/Tests/OptimizelyTests-Common/DecisionServiceTests_Features.swift @@ -289,6 +289,61 @@ extension DecisionServiceTests_Features { } +// MARK: - Test getVariationForFeatureList() + +extension DecisionServiceTests_Features { + func testGetVariationForFeatureListBatchUPSLoadAndSave() { + let mockProfileService = MocProfileService() + + let ups_service = DefaultDecisionService(userProfileService: mockProfileService) + + let flag1: FeatureFlag = try! OTUtils.model( + from:[ + "id": "553339214", + "key": "house", + "experimentIds": [kExperimentId], + "rolloutId": "", + "variables": [] + ] + ) + + let flag2: FeatureFlag = try! OTUtils.model( + from:[ + "id": "553339215", + "key": "house", + "experimentIds": [kExperimentId], + "rolloutId": "", + "variables": [] + ] + ) + + let flag3: FeatureFlag = try! OTUtils.model( + from:[ + "id": "553339216", + "key": "house", + "experimentIds": [kExperimentId], + "rolloutId": "", + "variables": [] + ] + ) + + let pair = ups_service.getVariationForFeatureList( + config: config, + featureFlags: [flag1, flag2, flag3], + user: optimizely.createUserContext(userId: kUserId, + attributes: kAttributesCountryMatch) + ) + + + XCTAssertEqual(mockProfileService.lookupCount, 1) + XCTAssertEqual(mockProfileService.saveCount, 1) + XCTAssertEqual(pair.count, 3) + XCTAssert(pair[0].result?.experiment?.key == kExperimentKey) + XCTAssert(pair[0].result?.variation.key == kVariationKeyD) + XCTAssert(pair[0].result?.source == Constants.DecisionSource.featureTest.rawValue) + } +} + // MARK: - Test getVariationForFeatureRollout() extension DecisionServiceTests_Features { @@ -466,3 +521,19 @@ extension DecisionServiceTests_Features { } } + +class MocProfileService: DefaultUserProfileService { + var lookupCount = 0 + var saveCount = 0 + + override func lookup(userId: String) -> DefaultUserProfileService.UPProfile? { + lookupCount += 1 + return super.lookup(userId: userId) + } + + override func save(userProfile: DefaultUserProfileService.UPProfile) { + super.save(userProfile: userProfile) + saveCount += 1 + } + +}