From 062ece323e2eed1493e7e2e7e6fe74a6512fdafe Mon Sep 17 00:00:00 2001 From: Volodymyr Nazarkevych Date: Mon, 13 Jan 2025 08:56:51 +0200 Subject: [PATCH] added new Track model to parse remoterEval response, create multiuser models --- GrowthBook-IOS.xcodeproj/project.pbxproj | 6 + GrowthBookTests/ExperimentRunTests.swift | 4 +- GrowthBookTests/FeatureValueTests.swift | 4 +- GrowthBookTests/StickyBucketingTests.swift | 2 +- .../Evaluators/ExperimentEvaluator.swift | 101 ++++++------ .../Evaluators/FeatureEvaluator.swift | 135 ++++++++-------- .../Features/FeaturesDataModel.swift | 2 + Sources/CommonMain/GrowthBookSDK.swift | 10 +- Sources/CommonMain/Model/Experiment.swift | 6 +- Sources/CommonMain/Model/Feature.swift | 47 +++++- Sources/CommonMain/Model/GlobalContext.swift | 145 ++++++++++++++++++ Sources/CommonMain/Utils/Constants.swift | 10 ++ Sources/CommonMain/Utils/Utils.swift | 132 ++++++++++------ 13 files changed, 426 insertions(+), 178 deletions(-) create mode 100644 Sources/CommonMain/Model/GlobalContext.swift diff --git a/GrowthBook-IOS.xcodeproj/project.pbxproj b/GrowthBook-IOS.xcodeproj/project.pbxproj index ac0b106..58e4eb9 100644 --- a/GrowthBook-IOS.xcodeproj/project.pbxproj +++ b/GrowthBook-IOS.xcodeproj/project.pbxproj @@ -49,6 +49,8 @@ 84CDE3412812F454008B3E6F /* Feature.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8433640827F845EB0072BFDC /* Feature.swift */; }; 84CDE3422812F454008B3E6F /* Experiment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8433640927F845EB0072BFDC /* Experiment.swift */; }; 84CDE3432812F454008B3E6F /* Context.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8433640A27F845EB0072BFDC /* Context.swift */; }; + 84D62CCB2D09DA6800C17A6C /* GlobalContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84D62CCA2D09DA6800C17A6C /* GlobalContext.swift */; }; + 84D62CCC2D09DA6800C17A6C /* GlobalContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84D62CCA2D09DA6800C17A6C /* GlobalContext.swift */; }; 84E82C3D2BD2AF8F003F000B /* StickyBucketingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E82C3C2BD2AF8F003F000B /* StickyBucketingTests.swift */; }; 84FEA9F12BC9913700111EE2 /* StickyBucketService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84FEA9EF2BC9913300111EE2 /* StickyBucketService.swift */; }; /* End PBXBuildFile section */ @@ -123,6 +125,7 @@ 84BC2E9C294A11F100289BC2 /* Crypto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Crypto.swift; sourceTree = ""; }; 84CDE3282812F359008B3E6F /* GrowthBook.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = GrowthBook.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 84CDE32A2812F359008B3E6F /* GrowthBook.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GrowthBook.h; sourceTree = ""; }; + 84D62CCA2D09DA6800C17A6C /* GlobalContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalContext.swift; sourceTree = ""; }; 84E82C3C2BD2AF8F003F000B /* StickyBucketingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StickyBucketingTests.swift; sourceTree = ""; }; 84F51E9627F419B000994D1C /* GrowthBook_IOS.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GrowthBook_IOS.h; sourceTree = ""; }; 84FEA9EF2BC9913300111EE2 /* StickyBucketService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StickyBucketService.swift; sourceTree = ""; }; @@ -245,6 +248,7 @@ 8433640727F845EB0072BFDC /* Model */ = { isa = PBXGroup; children = ( + 84D62CCA2D09DA6800C17A6C /* GlobalContext.swift */, 8433640827F845EB0072BFDC /* Feature.swift */, 8433640927F845EB0072BFDC /* Experiment.swift */, 8433640A27F845EB0072BFDC /* Context.swift */, @@ -439,6 +443,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 84D62CCC2D09DA6800C17A6C /* GlobalContext.swift in Sources */, 849114C7280DA73000C0B9A5 /* FeaturesViewModelTests.swift in Sources */, 849114C8280DA73000C0B9A5 /* ConditionTests.swift in Sources */, 849114C9280DA73000C0B9A5 /* ExperimentRunTests.swift in Sources */, @@ -480,6 +485,7 @@ 84CDE33E2812F454008B3E6F /* FeatureEvaluator.swift in Sources */, 84CDE33F2812F454008B3E6F /* ConditionEvaluator.swift in Sources */, 84CDE3402812F454008B3E6F /* CachingManager.swift in Sources */, + 84D62CCB2D09DA6800C17A6C /* GlobalContext.swift in Sources */, 84CDE3412812F454008B3E6F /* Feature.swift in Sources */, 84AE3B1B2BE2466C006BA49B /* StickyAssignmentsDocument.swift in Sources */, 84095C792817EC7800ADDF19 /* JsonManager.swift in Sources */, diff --git a/GrowthBookTests/ExperimentRunTests.swift b/GrowthBookTests/ExperimentRunTests.swift index a0973d7..26a0a90 100644 --- a/GrowthBookTests/ExperimentRunTests.swift +++ b/GrowthBookTests/ExperimentRunTests.swift @@ -29,8 +29,8 @@ class ExperimentRunTests: XCTestCase { backgroundSync: false, savedGroups: testContext.savedGroups) - let evaluator = ExperimentEvaluator(attributeOverrides: JSON()) - let result = evaluator.evaluateExperiment(context: gbContext, experiment: experiment) + let evaluator = ExperimentEvaluator() + let result = evaluator.evaluateExperiment(context: Utils.initializeEvalContext(context: gbContext), experiment: experiment) let status = item[0].stringValue + "\nExpected Result - " + item[3].stringValue + " & " + item[4].stringValue + "\nActual result - " + result.value.stringValue + " & " + String(result.inExperiment) + "\n\n" diff --git a/GrowthBookTests/FeatureValueTests.swift b/GrowthBookTests/FeatureValueTests.swift index 3ab7dab..eef4491 100644 --- a/GrowthBookTests/FeatureValueTests.swift +++ b/GrowthBookTests/FeatureValueTests.swift @@ -31,8 +31,8 @@ class FeatureValueTests: XCTestCase { if let features = testData.features { gbContext.features = features } - - let evaluator = FeatureEvaluator(context: gbContext, featureKey: item[2].stringValue, attributeOverrides: JSON()) + + let evaluator = FeatureEvaluator(context: Utils.initializeEvalContext(context: gbContext), featureKey: item[2].stringValue) let result = evaluator.evaluateFeature() let expectedResult = FeatureResultTest(json: item[3].dictionaryValue) diff --git a/GrowthBookTests/StickyBucketingTests.swift b/GrowthBookTests/StickyBucketingTests.swift index 5d9ce49..f8bfbe4 100644 --- a/GrowthBookTests/StickyBucketingTests.swift +++ b/GrowthBookTests/StickyBucketingTests.swift @@ -44,7 +44,7 @@ class StickyBucketingFeatureTests: XCTestCase { backgroundSync: false) let expectedResult = ExperimentResultTest(json: item[4].dictionaryValue) - let evaluator = FeatureEvaluator(context: gbContext, featureKey: item[3].stringValue, attributeOverrides: attributes) + let evaluator = FeatureEvaluator(context: Utils.initializeEvalContext(context: gbContext), featureKey: item[3].stringValue) let result = evaluator.evaluateFeature().experimentResult let status = "\(item[0].stringValue) \nExpected Result - \(expectedResult.variationId?.description) \(expectedResult.hashValue) \(expectedResult.inExperiment?.description) \(expectedResult.value?.stringValue) \(expectedResult.hashAttribute ?? "") & \(item[4].stringValue) \(expectedResult.hashUsed?.description) \nActual result - \(result?.variationId.description ?? "") \(result?.valueHash ?? "") \(result?.inExperiment.description ?? "") \(result?.value.stringValue ?? "") \(result?.hashAttribute ?? "") \(result?.hashUsed?.description) \n\n" diff --git a/Sources/CommonMain/Evaluators/ExperimentEvaluator.swift b/Sources/CommonMain/Evaluators/ExperimentEvaluator.swift index 365cde2..61d5d69 100644 --- a/Sources/CommonMain/Evaluators/ExperimentEvaluator.swift +++ b/Sources/CommonMain/Evaluators/ExperimentEvaluator.swift @@ -4,51 +4,50 @@ import Foundation /// /// Takes Context & Experiment & returns Experiment Result class ExperimentEvaluator { - var attributeOverrides: JSON = JSON() - - init(attributeOverrides: JSON) { - self.attributeOverrides = attributeOverrides - } /// Takes Context & Experiment & returns Experiment Result - func evaluateExperiment(context: Context, experiment: Experiment) -> ExperimentResult { + func evaluateExperiment(context: EvalContext, experiment: Experiment, featureId: String? = nil) -> ExperimentResult { // If experiment.variations has fewer than 2 variations, return immediately (not in experiment, variationId 0) // // If context.enabled is false, return immediately (not in experiment, variationId 0) - if experiment.variations.count < 2 || !context.isEnabled { - return getExperimentResult(gbContext: context, experiment: experiment, variationIndex: -1, hashUsed: false) + if experiment.variations.count < 2 || !context.options.isEnabled { + return getExperimentResult(gbContext: context, experiment: experiment, variationIndex: -1, hashUsed: false, featureId: featureId) } // If context.forcedVariations[experiment.trackingKey] is defined, return immediately (not in experiment, forced variation) - if let forcedVariation = context.forcedVariations?.dictionaryValue[experiment.key] { - return getExperimentResult(gbContext: context, experiment: experiment, variationIndex: forcedVariation.intValue, hashUsed: false) + if let forcedVariation = context.userContext.forcedVariations?.dictionaryValue[experiment.key] { + return getExperimentResult(gbContext: context, experiment: experiment, variationIndex: forcedVariation.intValue, hashUsed: false, featureId: featureId) } // If experiment.action is set to false, return immediately (not in experiment, variationId 0) - if !experiment.isActive { + if let isActive = experiment.isActive, !isActive { // TODO: check status == draft scenario - return getExperimentResult(gbContext: context, experiment: experiment, variationIndex: -1, hashUsed: false) + return getExperimentResult(gbContext: context, experiment: experiment, variationIndex: -1, hashUsed: false, featureId: featureId) + } + + var fallback: String? = nil + if (isStickyBucketetingEnabledForExperimet(context: context, experiment: experiment)) { + fallback = experiment.fallbackAttribute } - let (hashAttribute, hashValue) = Utils.getHashAttribute(context: context, attr: experiment.hashAttribute, fallback: (context.stickyBucketService != nil && !(experiment.disableStickyBucketing ?? true)) ? experiment.fallbackAttribute : nil, attributeOverrides: attributeOverrides) + let (hashAttribute, hashValue) = Utils.getHashAttribute(attr: experiment.hashAttribute, fallback: fallback, attributes: context.userContext.attributes) if hashValue.isEmpty { - print("Skip because missing hashAttribute") - return getExperimentResult(gbContext: context, experiment: experiment, variationIndex: -1, hashUsed: false) + logger.info("Skip because missing hashAttribute") + return getExperimentResult(gbContext: context, experiment: experiment, variationIndex: -1, hashUsed: false, featureId: featureId) } var assigned = -1 var foundStickyBucket = false var stickyBucketVersionIsBlocked = false - if context.stickyBucketService != nil, !(experiment.disableStickyBucketing ?? true) { + if isStickyBucketetingEnabledForExperimet(context: context, experiment: experiment) { let (variation, versionIsBlocked) = Utils.getStickyBucketVariation(context: context, experimentKey: experiment.key, experimentBucketVersion: experiment.bucketVersion ?? 0, minExperimentBucketVersion: experiment.minBucketVersion ?? 0, meta: experiment.meta ?? [], expFallBackAttribute: experiment.fallbackAttribute, - expHashAttribute: experiment.hashAttribute, - attributeOverrides: attributeOverrides) + expHashAttribute: experiment.hashAttribute) foundStickyBucket = variation >= 0; assigned = variation @@ -58,22 +57,21 @@ class ExperimentEvaluator { // Some checks are not needed if we already have a sticky bucket if !foundStickyBucket { if let filters = experiment.filters { - if Utils.isFilteredOut(filters: filters, context: context, attributeOverrides: attributeOverrides) { - print("Skip because of filters") - return getExperimentResult(gbContext: context, experiment: experiment, variationIndex: -1, hashUsed: false) + if Utils.isFilteredOut(filters: filters, attributes: context.userContext.attributes) { + return getExperimentResult(gbContext: context, experiment: experiment, variationIndex: -1, hashUsed: false, featureId: featureId) } } else if let namespaceExperiment = experiment.namespace, let namespace = Utils.getGBNameSpace(namespace: namespaceExperiment), !Utils.inNamespace(userId: hashValue, namespace: namespace) { - print("Skip because of namespace") - return getExperimentResult(gbContext: context, experiment: experiment, variationIndex: -1, hashUsed: false) + logger.info("Skip because of namespace") + return getExperimentResult(gbContext: context, experiment: experiment, variationIndex: -1, hashUsed: false, featureId: featureId) } //TODO: check what is include // If experiment.condition is set and the condition evaluates to false, return immediately (not in experiment, variationId 0) if let condition = experiment.condition { - if !ConditionEvaluator().isEvalCondition(attributes: context.attributes, conditionObj: condition, savedGroups: context.savedGroups) { - return getExperimentResult(gbContext: context, experiment: experiment, variationIndex: -1, hashUsed: false) + if !ConditionEvaluator().isEvalCondition(attributes: context.userContext.attributes, conditionObj: condition, savedGroups: context.globalContext.savedGroups) { + return getExperimentResult(gbContext: context, experiment: experiment, variationIndex: -1, hashUsed: false, featureId: featureId) } } @@ -81,22 +79,22 @@ class ExperimentEvaluator { for parentCondition in parentConditions { // TODO: option is to not pass attributeOverrides - let parentResult = FeatureEvaluator(context: context, featureKey: parentCondition.id, attributeOverrides: JSON(parentCondition.condition)).evaluateFeature() + let parentResult = FeatureEvaluator(context: context, featureKey: parentCondition.id).evaluateFeature() if parentResult.source == FeatureSource.cyclicPrerequisite.rawValue { - return getExperimentResult(gbContext: context, experiment: experiment, variationIndex: -1, hashUsed: false) + return getExperimentResult(gbContext: context, experiment: experiment, variationIndex: -1, hashUsed: false, featureId: featureId) } let evalObj = ["value": parentResult.value] let evalCondition = ConditionEvaluator().isEvalCondition( attributes: JSON(evalObj), conditionObj: parentCondition.condition, - savedGroups: context.savedGroups + savedGroups: context.globalContext.savedGroups ) // blocking prerequisite eval failed: feature evaluation fails if !evalCondition { - return getExperimentResult(gbContext: context, experiment: experiment, variationIndex: -1, hashUsed: false) + return getExperimentResult(gbContext: context, experiment: experiment, variationIndex: -1, hashUsed: false, featureId: featureId) } } } @@ -105,8 +103,8 @@ class ExperimentEvaluator { let hash = Utils.hash(seed: experiment.seed ?? experiment.key, value: hashValue, version: experiment.hashVersion ?? 1) guard let hash = hash else { - print("Skip because of invalid hash version") - return getExperimentResult(gbContext: context, experiment: experiment, variationIndex: -1, hashUsed: false) + logger.info("Skip because of invalid hash version") + return getExperimentResult(gbContext: context, experiment: experiment, variationIndex: -1, hashUsed: false, featureId: featureId) } if !foundStickyBucket { @@ -115,44 +113,44 @@ class ExperimentEvaluator { } if stickyBucketVersionIsBlocked { - print("Skip because sticky bucket version is blocked") - return getExperimentResult(gbContext: context, experiment: experiment, variationIndex: -1, hashUsed: false, bucket: nil, stickyBucketUsed: true) + logger.info("Skip because sticky bucket version is blocked") + return getExperimentResult(gbContext: context, experiment: experiment, variationIndex: -1, hashUsed: false, featureId: featureId, bucket: nil, stickyBucketUsed: true) } // If not assigned a variation (assigned === -1), return immediately (not in experiment, variationId 0) if assigned < 0 { - print("Skip because of coverage") - return getExperimentResult(gbContext: context, experiment: experiment, variationIndex: -1, hashUsed: false) + logger.info("Skip because of coverage") + return getExperimentResult(gbContext: context, experiment: experiment, variationIndex: -1, hashUsed: false, featureId: featureId) } // If experiment.force is set, return immediately (not in experiment, variationId experiment.force) if let forceExp = experiment.force { - return getExperimentResult(gbContext: context, experiment: experiment, variationIndex: forceExp, hashUsed: false) + return getExperimentResult(gbContext: context, experiment: experiment, variationIndex: forceExp, hashUsed: false, featureId: featureId) } // If context.qaMode is true, return immediately (not in experiment, variationId 0) - if context.isQaMode { - return getExperimentResult(gbContext: context, experiment: experiment, variationIndex: -1, hashUsed: false) + if context.options.isQaMode { + return getExperimentResult(gbContext: context, experiment: experiment, variationIndex: -1, hashUsed: false, featureId: featureId) } - let result = getExperimentResult(gbContext: context, experiment: experiment, variationIndex: assigned, hashUsed: true, bucket: hash, stickyBucketUsed: foundStickyBucket) - print("ExperimentResult: \(result)") - if context.stickyBucketService != nil && !(experiment.disableStickyBucketing ?? true) { + let result = getExperimentResult(gbContext: context, experiment: experiment, variationIndex: assigned, hashUsed: true, featureId: featureId, bucket: hash, stickyBucketUsed: foundStickyBucket) + logger.info("ExperimentResult: \(result)") + if isStickyBucketetingEnabledForExperimet(context: context, experiment: experiment) { let (key, doc, changed) = Utils.generateStickyBucketAssignmentDoc(context: context, attributeName: hashAttribute, attributeValue: hashValue, assignments: [Utils.getStickyBucketExperimentKey(experiment.key, experiment.bucketVersion ?? 0): result.key]) if changed { - context.stickyBucketAssignmentDocs = context.stickyBucketAssignmentDocs ?? [:] - context.stickyBucketAssignmentDocs?[key] = doc - context.stickyBucketService?.saveAssignments(doc: doc) + context.userContext.stickyBucketAssignmentDocs = context.userContext.stickyBucketAssignmentDocs ?? [:] + context.userContext.stickyBucketAssignmentDocs?[key] = doc + context.options.stickyBucketService?.saveAssignments(doc: doc) } } // Fire context.trackingClosure if set and the combination of hashAttribute, hashValue, experiment.key, and variationId has not been tracked before if !ExperimentHelper.shared.isTracked(experiment, result) { - context.trackingClosure(experiment, result) + context.options.trackingClosure(experiment, result) } // Return (in experiment, assigned variation) @@ -160,7 +158,7 @@ class ExperimentEvaluator { } /// This is a helper method to create an ExperimentResult object. - private func getExperimentResult(gbContext: Context, experiment: Experiment, variationIndex: Int = 0, hashUsed: Bool, featureId: String? = nil, bucket: Float? = nil, stickyBucketUsed: Bool? = nil) -> ExperimentResult { + private func getExperimentResult(gbContext: EvalContext, experiment: Experiment, variationIndex: Int = 0, hashUsed: Bool, featureId: String? = nil, bucket: Float? = nil, stickyBucketUsed: Bool? = nil) -> ExperimentResult { var inExperiment = true var variationIndex = variationIndex // If assigned variation is not valid, use the baseline and mark the user as not in the experiment @@ -169,7 +167,12 @@ class ExperimentEvaluator { inExperiment = false } - let (hastAttribute, hashValue) = Utils.getHashAttribute(context: gbContext, attr: experiment.hashAttribute, fallback: (gbContext.stickyBucketService != nil && !(experiment.disableStickyBucketing ?? true)) ? experiment.fallbackAttribute : nil, attributeOverrides: attributeOverrides) + var fallback: String? = nil + if (isStickyBucketetingEnabledForExperimet(context: gbContext, experiment: experiment)) { + fallback = experiment.fallbackAttribute + } + + let (hastAttribute, hashValue) = Utils.getHashAttribute(attr: experiment.hashAttribute, fallback: fallback, attributes: gbContext.userContext.attributes) let experimentMeta = experiment.meta ?? [] let meta = experimentMeta.count > variationIndex ? experimentMeta[variationIndex] : nil @@ -198,4 +201,8 @@ class ExperimentEvaluator { return result } + + private func isStickyBucketetingEnabledForExperimet(context: EvalContext, experiment: Experiment) -> Bool { + return (context.options.stickyBucketService != nil && !(experiment.disableStickyBucketing ?? true)) + } } diff --git a/Sources/CommonMain/Evaluators/FeatureEvaluator.swift b/Sources/CommonMain/Evaluators/FeatureEvaluator.swift index baf45ff..c68729b 100644 --- a/Sources/CommonMain/Evaluators/FeatureEvaluator.swift +++ b/Sources/CommonMain/Evaluators/FeatureEvaluator.swift @@ -7,34 +7,44 @@ import Foundation /// Returns Calculated Feature Result against that key class FeatureEvaluator { - var context: Context - var evalContext: FeatureEvalContext? + var context: EvalContext var featureKey: String - var attributeOverrides: JSON - init(context: Context, featureKey: String, attributeOverrides: JSON, evalContext: FeatureEvalContext? = nil) { + init(context: EvalContext, featureKey: String) { self.context = context self.featureKey = featureKey - self.attributeOverrides = attributeOverrides - self.evalContext = evalContext ?? FeatureEvalContext(evaluatedFeatures: Set()) } /// Takes Context and Feature Key /// /// Returns Calculated Feature Result against that key func evaluateFeature() -> FeatureResult { + + if context.userContext.forcedFeatureValues?.dictionaryValue[featureKey] != nil { + let value = context.userContext.forcedFeatureValues?[featureKey] ?? "nil" + logger.info("Global override for forced feature with key: \(featureKey) and value \(value)") + + return prepareResult(value: context.userContext.forcedFeatureValues?.dictionaryValue[featureKey], source: FeatureSource.override) + } - if (evalContext?.evaluatedFeatures.contains(featureKey) ?? false) { - return prepareResult( + if (context.stackContext.evaluatedFeatures.contains(featureKey)) { + logger.info("evaluateFeature: circular dependency detected:") + + let featureResultWhenCircularDependencyDetected = prepareResult( value: .null, source: FeatureSource.cyclicPrerequisite ) + + return featureResultWhenCircularDependencyDetected } - evalContext?.evaluatedFeatures.insert(featureKey) - evalContext?.id = featureKey + context.stackContext.evaluatedFeatures.insert(featureKey) + context.stackContext.id = featureKey - guard let targetFeature: Feature = context.features[featureKey] else { - return prepareResult(value: JSON.null, source: FeatureSource.unknownFeature) + guard let targetFeature: Feature = context.globalContext.features[featureKey] else { + + let emptyFeatureResult = prepareResult(value: JSON.null, source: FeatureSource.unknownFeature) + + return emptyFeatureResult } // Loop through the feature rules (if any) @@ -44,12 +54,19 @@ class FeatureEvaluator { if let parentConditions = rule.parentConditions { for parentCondition in parentConditions { - let parentResult = FeatureEvaluator(context: context, featureKey: parentCondition.id, attributeOverrides: attributeOverrides, evalContext: evalContext).evaluateFeature() + let parentResult = FeatureEvaluator( + context: context, + featureKey: parentCondition.id + ) + .evaluateFeature() + if parentResult.source == FeatureSource.cyclicPrerequisite.rawValue { - return prepareResult( + let featureResultWhenCircularDependencyDetected = prepareResult( value: .null, source: FeatureSource.cyclicPrerequisite ) + + return featureResultWhenCircularDependencyDetected } let evalObjc = JSON(["value": parentResult.value]) @@ -57,17 +74,19 @@ class FeatureEvaluator { let evalCondition = ConditionEvaluator().isEvalCondition( attributes: evalObjc, conditionObj: parentCondition.condition, - savedGroups: context.savedGroups + savedGroups: context.globalContext.savedGroups ) // blocking prerequisite eval failed: feature evaluation fails if !evalCondition { if let _ = parentCondition.gate { - print("Feature blocked by prerequisite") - return prepareResult( + logger.info("Feature blocked by prerequisite") + let featureResultWhenBlockedByPrerequisite = prepareResult( value: .null, source: FeatureSource.prerequisite ) + + return featureResultWhenBlockedByPrerequisite } // non-blocking prerequisite eval failed: break out of parentConditions loop, jump to the next rule continue ruleLoop @@ -77,8 +96,9 @@ class FeatureEvaluator { // If there are filters for who is included if let filters = rule.filters { - if Utils.isFilteredOut(filters: filters, context: context, attributeOverrides: attributeOverrides) { - print("Skip rule because of filters") + if Utils.isFilteredOut(filters: filters, attributes: context.userContext.attributes + ) { + logger.info("Skip rule because of filters") continue } } @@ -87,29 +107,31 @@ class FeatureEvaluator { if let force = rule.force { // If it's a conditional rule, skip if the condition doesn't pass - if let condition = rule.condition, !ConditionEvaluator().isEvalCondition(attributes: getAttributes(), - conditionObj: condition, - savedGroups: context.savedGroups) { - print("Skip rule because of condition ff") + if let condition = rule.condition, !ConditionEvaluator().isEvalCondition( + attributes: context.userContext.attributes, + conditionObj: condition, + savedGroups: context.globalContext.savedGroups + ) { continue } - - - if !isIncludedInRollout(seed: rule.seed ?? featureKey, - hashAttribute: rule.hashAttribute, - fallbackAttribute: (context.stickyBucketService != nil && !(rule.disableStickyBucketing ?? true)) ? rule.fallbackAttribute : nil, - range: rule.range, - coverage: rule.coverage, - hashVersion: rule.hashVersion) { - print("Skip rule because user not included in rollout") + if !Utils.isIncludedInRollout( + attributes: context.userContext.attributes, + seed: rule.seed ?? featureKey, + hashAttribute: rule.hashAttribute, + fallbackAttribute: (context.options.stickyBucketService != nil && !(rule.disableStickyBucketing ?? true)) ? rule.fallbackAttribute : nil, + range: rule.range, + coverage: rule.coverage, + hashVersion: rule.hashVersion + ) { + logger.info("Skip rule because user not included in rollout") continue } if let tracks = rule.tracks { tracks.forEach { track in - if !ExperimentHelper.shared.isTracked(track.experiment, track.result) { - context.trackingClosure(track.experiment, track.result) + if let experiment = track.result?.experiment, let result = track.result?.experimentResult, !ExperimentHelper.shared.isTracked(experiment, result) { + context.options.trackingClosure(experiment, result) } } } @@ -121,7 +143,7 @@ class FeatureEvaluator { let key = rule.hashAttribute ?? Constants.idAttributeKey // Get the user hash value (context.attributes[rule.hashAttribute || "id"]) and if empty, skip the rule - guard let attributeValue = context.attributes.dictionaryValue[key]?.stringValue, + guard let attributeValue = context.userContext.attributes.dictionaryValue[key]?.stringValue, attributeValue.isEmpty == false else { continue @@ -139,7 +161,9 @@ class FeatureEvaluator { // Return (value = forced value, source = force) - return prepareResult(value: force, source: FeatureSource.force) + let forcedFeatureResult = prepareResult(value: force, source: FeatureSource.force) + + return forcedFeatureResult } else { guard let variations = rule.variations else { @@ -168,10 +192,12 @@ class FeatureEvaluator { ) // Run the experiment. - let result = ExperimentEvaluator(attributeOverrides: attributeOverrides).evaluateExperiment(context: context, experiment: exp) + let result = ExperimentEvaluator().evaluateExperiment(context: context, experiment: exp, featureId: featureKey) if result.inExperiment && !(result.passthrough ?? false) { // If result.inExperiment is false, skip this rule and continue to the next one. - return prepareResult(value: result.value, source: FeatureSource.experiment, experiment: exp, result: result) + let experimentFeatureResult = prepareResult(value: result.value, source: FeatureSource.experiment, experiment: exp, result: result) + + return experimentFeatureResult } } } @@ -179,32 +205,9 @@ class FeatureEvaluator { // Return (value = defaultValue or null, source = defaultValue) let defaultValue = targetFeature.defaultValue ?? .null - return prepareResult(value: defaultValue, source: FeatureSource.defaultValue) - } - - ///Determines if the user is part of a gradual feature rollout. - private func isIncludedInRollout(seed: String, hashAttribute: String?, fallbackAttribute: String?, range: BucketRange?, coverage: Float?, hashVersion: Float?) -> Bool { - if range == nil, coverage == nil { - return true - } - - if range == nil, coverage == 0 { - return false - } - - let hashValue = Utils.getHashAttribute(context: context, attr: hashAttribute, fallback: fallbackAttribute, attributeOverrides: attributeOverrides).hashValue - - let hash = Utils.hash(seed: seed, value: hashValue, version: hashVersion ?? 1) - - guard let hash = hash else { return false } - - if let range = range { - return Utils.inRange(n: hash, range: range) - } else if let coverage = coverage { - return hash <= coverage - } else { - return true - } + let defaultFeatureResult = prepareResult(value: defaultValue, source: FeatureSource.defaultValue) + + return defaultFeatureResult } /// This is a helper method to create a FeatureResult object. @@ -217,10 +220,6 @@ class FeatureEvaluator { } return FeatureResult(value: value, isOn: !isFalse, source: source.rawValue, experiment: experiment, result: result) } - - private func getAttributes() -> JSON { - return (try? context.attributes.merged(with: attributeOverrides)) ?? JSON() - } } diff --git a/Sources/CommonMain/Features/FeaturesDataModel.swift b/Sources/CommonMain/Features/FeaturesDataModel.swift index 53c5f10..43c65ec 100644 --- a/Sources/CommonMain/Features/FeaturesDataModel.swift +++ b/Sources/CommonMain/Features/FeaturesDataModel.swift @@ -7,4 +7,6 @@ struct FeaturesDataModel: Codable { var dateUpdated: String? var savedGroups: JSON? var encryptedSavedGroups: String? + var experiments: [Experiment]? + var encryptedExperiments: String? } diff --git a/Sources/CommonMain/GrowthBookSDK.swift b/Sources/CommonMain/GrowthBookSDK.swift index 28fc56d..99c5723 100644 --- a/Sources/CommonMain/GrowthBookSDK.swift +++ b/Sources/CommonMain/GrowthBookSDK.swift @@ -132,6 +132,7 @@ public struct GrowthBookModel { private var forcedFeatures: JSON = JSON() private var attributeOverrides: JSON = JSON() private var savedGroupsValues: JSON? + private var evalContext: EvalContext? = nil init(context: Context, refreshHandler: CacheRefreshHandler? = nil, @@ -155,6 +156,7 @@ public struct GrowthBookModel { if let savedGroups { context.savedGroups = savedGroups } + self.evalContext = Utils.initializeEvalContext(context: context) // if the SSE URL is available and background sync variable is set to true, then we have to connect to SSE Server if let sseURL = context.getSSEUrl(), context.backgroundSync { @@ -201,7 +203,7 @@ public struct GrowthBookModel { /// Get the value of the feature with a fallback public func getFeatureValue(feature id: String, default defaultValue: JSON) -> JSON { - return FeatureEvaluator(context: gbContext, featureKey: id, attributeOverrides: attributeOverrides).evaluateFeature().value ?? defaultValue + return FeatureEvaluator(context: Utils.initializeEvalContext(context: gbContext), featureKey: id).evaluateFeature().value ?? defaultValue } @objc public func featuresFetchedSuccessfully(features: [String: Feature], isRemote: Bool) { @@ -243,7 +245,7 @@ public struct GrowthBookModel { /// The feature method takes a single string argument, which is the unique identifier for the feature and returns a FeatureResult object. @objc public func evalFeature(id: String) -> FeatureResult { - return FeatureEvaluator(context: gbContext, featureKey: id, attributeOverrides: attributeOverrides).evaluateFeature() + return FeatureEvaluator(context: Utils.initializeEvalContext(context: gbContext), featureKey: id).evaluateFeature() } /// The isOn method takes a single string argument, which is the unique identifier for the feature and returns the feature state on/off @@ -253,7 +255,7 @@ public struct GrowthBookModel { /// The run method takes an Experiment object and returns an experiment result @objc public func run(experiment: Experiment) -> ExperimentResult { - let result = ExperimentEvaluator(attributeOverrides: attributeOverrides).evaluateExperiment(context: gbContext, experiment: experiment) + let result = ExperimentEvaluator().evaluateExperiment(context: Utils.initializeEvalContext(context: gbContext), experiment: experiment) self.subscriptions.forEach { subscription in subscription(experiment, result) @@ -293,7 +295,7 @@ public struct GrowthBookModel { private func refreshStickyBucketService(_ data: FeaturesDataModel? = nil) { if (gbContext.stickyBucketService != nil) { - Utils.refreshStickyBuckets(context: gbContext, attributeOverrides: attributeOverrides, data: data) + Utils.refreshStickyBuckets(context: evalContext!, attributes: evalContext!.userContext.attributes, data: data) } } } diff --git a/Sources/CommonMain/Model/Experiment.swift b/Sources/CommonMain/Model/Experiment.swift index b8844c3..48d6c54 100644 --- a/Sources/CommonMain/Model/Experiment.swift +++ b/Sources/CommonMain/Model/Experiment.swift @@ -25,7 +25,7 @@ import Foundation /// How to weight traffic between variations. Must add to 1. public var weights: [Float]? /// If set to false, always return the control (first variation) - public var isActive: Bool + public var isActive: Bool? /// What percent of users should be included in the experiment (between 0 and 1, inclusive) public var coverage: Float? /// Optional targeting condition @@ -174,9 +174,7 @@ import Foundation force = json["force"]?.intValue - if json["filters"] != nil { - print("") - } + if json["filters"] != nil { } ranges = json["ranges"]?.map({ key, value in BucketRange(json: value) diff --git a/Sources/CommonMain/Model/Feature.swift b/Sources/CommonMain/Model/Feature.swift index bf2deed..89fb485 100644 --- a/Sources/CommonMain/Model/Feature.swift +++ b/Sources/CommonMain/Model/Feature.swift @@ -68,7 +68,7 @@ public struct FeatureRule: Codable { /// The phase id of the experiment public let phase: String? /// Array of tracking calls to fire - public let tracks: [TrackData]? + public let tracks: [Track]? init(id: String? = nil, condition: Condition? = nil, coverage: Float? = nil, @@ -91,7 +91,7 @@ public struct FeatureRule: Codable { seed: String? = nil, name: String? = nil, phase: String? = nil, - tracks: [TrackData]? = nil) { + tracks: [Track]? = nil) { self.id = id self.condition = condition self.coverage = coverage @@ -183,7 +183,7 @@ public struct FeatureRule: Codable { phase = json["phase"]?.stringValue tracks = json["tracks"]?.map({ key, value in - TrackData(json: value.dictionaryValue) + Track(json: value.dictionaryValue) }) } } @@ -202,10 +202,12 @@ enum FeatureSource: String { case cyclicPrerequisite /// Prerequisite Value for the Feature is being processed case prerequisite + /// Override Value for the Feature is being processed + case override } /// Result for Feature -@objc public class FeatureResult: NSObject, Decodable { +@objc public class FeatureResult: NSObject, Codable { /// The assigned value of the feature public let value: JSON? /// The assigned value cast to a boolean @@ -227,4 +229,41 @@ enum FeatureSource: String { self.experiment = experiment self.experimentResult = result } + + init(json: [String: JSON]) { + if let value = json["value"] { + self.value = value + } else { + self.value = JSON() + } + if let on = json["on"] { + self.isOn = on.boolValue + } else { + self.isOn = true + } + if let off = json["off"] { + self.isOff = off.boolValue + } else { + self.isOff = false + } + if let source = json["source"] { + self.source = source.stringValue + } else { + self.source = "" + } + if let experiment = json["experiment"] { + self.experiment = Experiment(json: experiment.dictionaryValue) + } else { + self.experiment = nil + } + if let experimentResult = json["experimentResult"] { + self.experimentResult = ExperimentResult(json: experimentResult.dictionaryValue) + } else { + self.experimentResult = nil + } + } + + enum CodingKeys: String, CodingKey { + case value, isOn = "on", isOff = "off", source, experiment, experimentResult + } } diff --git a/Sources/CommonMain/Model/GlobalContext.swift b/Sources/CommonMain/Model/GlobalContext.swift new file mode 100644 index 0000000..2980952 --- /dev/null +++ b/Sources/CommonMain/Model/GlobalContext.swift @@ -0,0 +1,145 @@ +import Foundation + +@objc public class GlobalContext: NSObject { + var features: Features + public var experiments: [Experiment]? + public var savedGroups: JSON? + + init( + features: Features = [:], + experiments: [Experiment]? = nil, + savedGroups: JSON? = nil + ) { + self.features = features + self.experiments = experiments + self.savedGroups = savedGroups + } +} + +@objc public class MultiUserOptions: NSObject { + /// your api host + public let apiHost: String? + /// unique client key + public let clientKey: String? + /// Encryption key for encrypted features. + public let encryptionKey: String? + /// Switch to globally disable all experiments. Default true. + public let isEnabled: Bool + /// Map of user attributes that are used to assign variations + public var attributes: JSON + /// Force specific experiments to always assign a specific variation (used for QA) + public var forcedVariations: JSON? + /// If true, random assignment is disabled and only explicitly forced variations are used. + public let isQaMode: Bool + /// A function that takes experiment and result as arguments. + public let trackingClosure: (Experiment, ExperimentResult) -> Void + /// Disable background streaming connection + public let backgroundSync: Bool + /// Sticky bucketing is enabled if stickyBucketService is available + public let stickyBucketService: StickyBucketServiceProtocol? + /// Stick bucketing specific configurations for specific keys + public var stickyBucketAssignmentDocs: [String: StickyAssignmentsDocument]? + /// Features that uses sticky bucketing + public var stickyBucketIdentifierAttributes: [String]? + /// Enable to use remote evaluation + public let remoteEval: Bool + // Keys are unique identifiers for the features and the values are Feature objects. + // Feature definitions - To be pulled from API / Cache + var features: Features + + public var savedGroups: JSON? + + init(apiHost: String?, + clientKey: String?, + encryptionKey: String?, + isEnabled: Bool, + attributes: JSON, + forcedVariations: JSON?, + stickyBucketAssignmentDocs: [String: StickyAssignmentsDocument]? = nil, + stickyBucketIdentifierAttributes: [String]? = nil, + stickyBucketService: StickyBucketServiceProtocol? = nil, + isQaMode: Bool, + trackingClosure: @escaping (Experiment, ExperimentResult) -> Void, + features: Features = [:], + backgroundSync: Bool = false, + remoteEval: Bool = false, + savedGroups: JSON? = nil) { + self.apiHost = apiHost + self.clientKey = clientKey + self.encryptionKey = encryptionKey + self.isEnabled = isEnabled + self.attributes = attributes + self.forcedVariations = forcedVariations + self.stickyBucketAssignmentDocs = stickyBucketAssignmentDocs + self.stickyBucketIdentifierAttributes = stickyBucketIdentifierAttributes + self.stickyBucketService = stickyBucketService + self.isQaMode = isQaMode + self.trackingClosure = trackingClosure + self.features = features + self.backgroundSync = backgroundSync + self.remoteEval = remoteEval + self.savedGroups = savedGroups + } + + @objc public func getFeaturesURL() -> String? { + if let apiHost = apiHost, let clientKey = clientKey { + return "\(apiHost)/api/features/\(clientKey)" + } else { + return nil + } + } + + @objc public func getRemoteEvalUrl() -> String? { + if let apiHost = apiHost, let clientKey = clientKey { + return "\(apiHost)/api/eval/\(clientKey)" + } else { + return nil + } + } + + @objc public func getSSEUrl() -> String? { + if let apiHost = apiHost, let clientKey = clientKey { + return "\(apiHost)/sub/\(clientKey)" + } else { + return nil + } + } +} + +@objc public class UserContext: NSObject { + public let attributes: JSON + public var stickyBucketAssignmentDocs: [String: StickyAssignmentsDocument]? + public var forcedVariations: JSON? + public var forcedFeatureValues: JSON? + + init(attributes: JSON, stickyBucketAssignmentDocs: [String : StickyAssignmentsDocument]? = nil, forcedVariations: JSON? = nil, forcedFeatureValues: JSON? = nil) { + self.attributes = attributes + self.stickyBucketAssignmentDocs = stickyBucketAssignmentDocs + self.forcedVariations = forcedVariations + self.forcedFeatureValues = forcedFeatureValues + } +} + +@objc public class StackContext: NSObject { + public var id: String? + public var evaluatedFeatures: Set + + init(id: String? = nil, evaluatedFeatures: Set = []) { + self.id = id + self.evaluatedFeatures = evaluatedFeatures + } +} + +@objc public class EvalContext : NSObject { + public var globalContext: GlobalContext + public var userContext: UserContext + public var stackContext: StackContext + public var options: MultiUserOptions + + init(globalContext: GlobalContext, userContext: UserContext, stackContext: StackContext, options: MultiUserOptions) { + self.globalContext = globalContext + self.userContext = userContext + self.stackContext = stackContext + self.options = options + } +} diff --git a/Sources/CommonMain/Utils/Constants.swift b/Sources/CommonMain/Utils/Constants.swift index 13c75d1..b364ff4 100644 --- a/Sources/CommonMain/Utils/Constants.swift +++ b/Sources/CommonMain/Utils/Constants.swift @@ -90,6 +90,16 @@ public struct VariationMeta: Codable { } } +public struct Track: Codable { + public let experiment: Experiment? + public let result: FeatureResult? + + init(json: [String: JSON]) { + experiment = Experiment(json: json["experiment"]?.dictionaryValue ?? [:]) + result = FeatureResult(json: json["result"]?.dictionaryValue ?? [:]) + } +} + ///Used for remote feature evaluation to trigger the `TrackingCallback` public struct TrackData: Codable { let experiment: Experiment diff --git a/Sources/CommonMain/Utils/Utils.swift b/Sources/CommonMain/Utils/Utils.swift index 9818d2c..202e16e 100644 --- a/Sources/CommonMain/Utils/Utils.swift +++ b/Sources/CommonMain/Utils/Utils.swift @@ -34,9 +34,9 @@ public class Utils { } ///This is a helper method to evaluate `filters` for both feature flags and experiments. - static func isFilteredOut(filters: [Filter], context: Context, attributeOverrides: JSON) -> Bool { + static func isFilteredOut(filters: [Filter], attributes: JSON) -> Bool { return filters.contains { filter in - let hashAttribute = Utils.getHashAttribute(context: context, attr: filter.attribute, attributeOverrides: attributeOverrides) + let hashAttribute = Utils.getHashAttribute(attr: filter.attribute, attributes: attributes) let hashValue = hashAttribute.hashValue let hash = hash(seed: filter.seed, value: hashValue, version: filter.hashVersion) @@ -47,6 +47,31 @@ public class Utils { } } } + + ///Determines if the user is part of a gradual feature rollout. + static func isIncludedInRollout(attributes: JSON, seed: String, hashAttribute: String?, fallbackAttribute: String?, range: BucketRange?, coverage: Float?, hashVersion: Float?) -> Bool { + if range == nil, coverage == nil { + return true + } + + if range == nil, coverage == 0 { + return false + } + + let hashValue = Utils.getHashAttribute(attr: hashAttribute, fallback: fallbackAttribute, attributes: attributes).hashValue + + let hash = Utils.hash(seed: seed, value: hashValue, version: hashVersion ?? 1) + + guard let hash = hash else { return false } + + if let range = range { + return Utils.inRange(n: hash, range: range) + } else if let coverage = coverage { + return hash <= coverage + } else { + return true + } + } /// This checks if a userId is within an experiment namespace or not. static func inNamespace(userId: String, namespace: NameSpace) -> Bool { @@ -165,22 +190,18 @@ public class Utils { } ///Returns tuple out of 2 elements: the attribute itself an its hash value - static func getHashAttribute(context: Context, attr: String?, fallback: String? = nil, attributeOverrides: JSON) -> (hashAttribute: String, hashValue: String) { + static func getHashAttribute(attr: String?, fallback: String? = nil, attributes: JSON) -> (hashAttribute: String, hashValue: String) { var hashAttribute = attr ?? "id" var hashValue = "" - if attributeOverrides[hashAttribute] != .null { - hashValue = attributeOverrides[hashAttribute].stringValue - } else if context.attributes[hashAttribute] != .null { - hashValue = context.attributes[hashAttribute].stringValue + if attributes[hashAttribute] != .null { + hashValue = attributes[hashAttribute].stringValue } // if no match, try fallback if hashValue.isEmpty, let fallback = fallback { - if attributeOverrides[fallback] != .null { - hashValue = attributeOverrides[fallback].stringValue - } else if context.attributes[fallback] != .null { - hashValue = context.attributes[fallback].stringValue + if attributes[fallback] != .null { + hashValue = attributes[fallback].stringValue } if !hashValue.isEmpty { @@ -188,44 +209,44 @@ public class Utils { } } - if let fallback = fallback, let fallbackAttributeValue = context.stickyBucketAssignmentDocs?["\(fallback)||\(attributeOverrides[fallback].stringValue)"]?.attributeValue, - fallbackAttributeValue != attributeOverrides[fallback].stringValue { - context.stickyBucketAssignmentDocs = [:] - } - return (hashAttribute, hashValue) } // Returns assignments for StickyAssignmentsDocuments static func getStickyBucketAssignments( - context: Context, + context: EvalContext, expHashAttribute: String?, - expFallbackAttribute: String? = nil, - attributeOverrides: JSON + expFallbackAttribute: String? = nil ) -> [String: String] { - guard let stickyBucketAssignmentDocs = context.stickyBucketAssignmentDocs else { + guard let stickyBucketAssignmentDocs = context.userContext.stickyBucketAssignmentDocs else { return [:] } let (hashAttribute, hashValue) = getHashAttribute( - context: context, attr: expHashAttribute, fallback: nil, - attributeOverrides: attributeOverrides + attributes: context.userContext.attributes ) let hashKey = "\(hashAttribute)||\(hashValue)" let (fallbackAttribute, fallbackValue) = getHashAttribute( - context: context, attr: expFallbackAttribute, fallback: nil, - attributeOverrides: attributeOverrides + attributes: context.userContext.attributes ) let fallbackKey = fallbackValue.isEmpty ? nil : "\(fallbackAttribute)||\(fallbackValue)" + let key = "\(expFallbackAttribute ?? "" )||\(context.userContext.attributes[expFallbackAttribute ?? ""].stringValue)" + let leftOperand = stickyBucketAssignmentDocs[key]?.attributeValue + + if (leftOperand != context.userContext.attributes[expFallbackAttribute ?? ""].stringValue) { + context.userContext.stickyBucketAssignmentDocs = [:] + + } + var mergedAssignments: [String: String] = [:] if let fallbackKey = fallbackKey, let fallbackAssignments = stickyBucketAssignmentDocs[fallbackKey] { @@ -240,36 +261,35 @@ public class Utils { } // Update sticky bucketing configuration - static func refreshStickyBuckets(context: Context, attributeOverrides: JSON, data: FeaturesDataModel?) { - guard let stickyBucketService = context.stickyBucketService else { + static func refreshStickyBuckets(context: EvalContext, attributes: JSON, data: FeaturesDataModel?) { + guard let stickyBucketService = context.options.stickyBucketService else { return } - let attributes = getStickyBucketAttributes(context: context, attributeOverrides: attributeOverrides, data: data); - context.stickyBucketAssignmentDocs = stickyBucketService.getAllAssignments(attributes: attributes) + let allAttributes = getStickyBucketAttributes(context: context, attributes: attributes, data: data); + context.options.stickyBucketAssignmentDocs = stickyBucketService.getAllAssignments(attributes: allAttributes) } // Returns hash value for every attribute - static func getStickyBucketAttributes(context: Context, attributeOverrides: JSON, data: FeaturesDataModel?) -> [String: String] { + static func getStickyBucketAttributes(context: EvalContext, attributes: JSON, data: FeaturesDataModel?) -> [String: String] { - var attributes: [String: String] = [:] - context.stickyBucketIdentifierAttributes = context.stickyBucketIdentifierAttributes != nil - ? deriveStickyBucketIdentifierAttributes(context: context, data: data) - : context.stickyBucketIdentifierAttributes + var attributesResult: [String: String] = [:] + let stickyBucketIdentifierAttributes = deriveStickyBucketIdentifierAttributes(context: context, data: data) - context.stickyBucketIdentifierAttributes?.forEach { attr in - let hashValue = Utils.getHashAttribute(context: context, attr: attr, attributeOverrides: attributeOverrides) - attributes[attr] = hashValue.hashValue + stickyBucketIdentifierAttributes.forEach { attr in + let hashValue = Utils.getHashAttribute(attr: attr, attributes: attributes) + attributesResult[attr] = hashValue.hashValue } - return attributes + return attributesResult } // Returns fallback attributes for features that have variations - static func deriveStickyBucketIdentifierAttributes(context: Context, data: FeaturesDataModel?) -> [String] { + static func deriveStickyBucketIdentifierAttributes(context: EvalContext, data: FeaturesDataModel?) -> [String] { var attributes: Set = [] - let features = data?.features ?? context.features + let features = data?.features ?? context.globalContext.features + let experiments = data?.experiments ?? context.globalContext.experiments features.keys.forEach({ id in let feature = features[id] @@ -284,27 +304,32 @@ public class Utils { } } }) + + experiments?.forEach({ experiment in + attributes.insert(experiment.hashAttribute ?? "id") + if let fallbackAttribute = experiment.fallbackAttribute { + attributes.insert(fallbackAttribute) + } + }) return Array(attributes) } // Get variation of sticky bucketing to use specific functionality static func getStickyBucketVariation( - context: Context, + context: EvalContext, experimentKey: String, experimentBucketVersion: Int = 0, minExperimentBucketVersion: Int = 0, meta: [VariationMeta] = [], expFallBackAttribute: String? = nil, - expHashAttribute: String? = "id", - attributeOverrides: JSON + expHashAttribute: String? = "id" ) -> (variation: Int, versionIsBlocked: Bool?) { let id = getStickyBucketExperimentKey(experimentKey, experimentBucketVersion) let assignments = getStickyBucketAssignments( context: context, expHashAttribute: expHashAttribute, - expFallbackAttribute: expFallBackAttribute, - attributeOverrides: attributeOverrides + expFallbackAttribute: expFallBackAttribute ) if minExperimentBucketVersion > 0 { @@ -332,11 +357,11 @@ public class Utils { } // Create assignment document - static func generateStickyBucketAssignmentDoc(context: Context, attributeName: String, + static func generateStickyBucketAssignmentDoc(context: EvalContext, attributeName: String, attributeValue: String, assignments: [String: String]) -> (key: String, doc: StickyAssignmentsDocument, changed: Bool) { let key = "\(attributeName)||\(attributeValue)" - let existingAssignments: [String: String] = (context.stickyBucketAssignmentDocs?[key]?.assignments) ?? [:] + let existingAssignments: [String: String] = (context.userContext.stickyBucketAssignmentDocs?[key]?.assignments) ?? [:] var newAssignments = existingAssignments assignments.forEach { newAssignments[$0] = $1 } @@ -353,4 +378,19 @@ public class Utils { ) } + static func initializeEvalContext(context: Context) -> EvalContext { + let options = MultiUserOptions(apiHost: context.apiHost, clientKey: context.clientKey, encryptionKey: context.encryptionKey, isEnabled: context.isEnabled, attributes: context.attributes, forcedVariations: context.forcedVariations, isQaMode: context.isQaMode, trackingClosure: context.trackingClosure + ) + + let globalContext = GlobalContext(features: context.features, savedGroups: context.savedGroups) + + + // should create manual force features + let userContext = UserContext(attributes: context.attributes, stickyBucketAssignmentDocs: context.stickyBucketAssignmentDocs, forcedVariations: context.forcedVariations, forcedFeatureValues: nil) + + let evalContext = EvalContext(globalContext: globalContext, userContext: userContext, stackContext: StackContext(), options: options) + return evalContext + + } + }