From 1f5be3c6ed2ac182786309f71a91897ee8afa61a Mon Sep 17 00:00:00 2001 From: Daniel <95646168+daniel-statsig@users.noreply.github.com> Date: Thu, 13 Jun 2024 15:52:53 -0700 Subject: [PATCH] feat: add disabled exposure getParamStore func (#282) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ability to check a param store without logging any exposures. Tested by Kong: https://github.com/statsig-io/kong/pull/2225 Screenshot 2024-06-13 at 1 45 21 PM --- Sources/Statsig/InternalStore.swift | 36 +++- Sources/Statsig/Layer.swift | 2 +- Sources/Statsig/ParameterStore.swift | 190 ++++++++++++++++++ Sources/Statsig/Statsig.swift | 50 ++++- Sources/Statsig/StatsigClient.swift | 27 +++ .../Statsig/StatsigDynamicConfigValue.swift | 28 +++ Sources/Statsig/StatsigOptions.swift | 1 + .../StatsigTests/EvaluationCallbackSpec.swift | 10 + 8 files changed, 341 insertions(+), 3 deletions(-) create mode 100644 Sources/Statsig/ParameterStore.swift diff --git a/Sources/Statsig/InternalStore.swift b/Sources/Statsig/InternalStore.swift index c2b7594..100e7b8 100644 --- a/Sources/Statsig/InternalStore.swift +++ b/Sources/Statsig/InternalStore.swift @@ -26,6 +26,7 @@ struct StatsigValuesCache { var gates: [String: [String: Any]]? = nil var configs: [String: [String: Any]]? = nil var layers: [String: [String: Any]]? = nil + var paramStores: [String: [String: Any]]? = nil var hashUsed: String? = nil var sdkKey: String var options: StatsigOptions @@ -37,6 +38,7 @@ struct StatsigValuesCache { gates = userCache[InternalStore.gatesKey] as? [String: [String: Any]] configs = userCache[InternalStore.configsKey] as? [String: [String: Any]] layers = userCache[InternalStore.layerConfigsKey] as? [String: [String: Any]] + paramStores = userCache[InternalStore.paramStoresKey] as? [String: [String: Any]] hashUsed = userCache[InternalStore.hashUsedKey] as? String } } @@ -100,13 +102,33 @@ struct StatsigValuesCache { return Layer( client: client, name: layerName, - configObj: configObj, evalDetails: getEvaluationDetails(.Recognized) + configObj: configObj, + evalDetails: getEvaluationDetails(.Recognized) ) } print("[Statsig]: The layer with name \(layerName) does not exist. Returning an empty Layer.") return createUnfoundLayer(client, layerName) } + + func getParamStore(_ client: StatsigClient?, _ storeName: String) -> ParameterStore { + guard let stores = paramStores else { + print("[Statsig]: Failed to get parameter store with name \(storeName). Returning an empty ParameterStore.") + return createUnfoundParamStore(client, storeName) + } + + if let config = stores[storeName] ?? stores[storeName.hashSpecName(hashUsed)] { + return ParameterStore( + name: storeName, + evaluationDetails: getEvaluationDetails(.Recognized), + client: client, + configuration: config + ) + } + + print("[Statsig]: The parameter store with name \(storeName) does not exist. Returning an empty ParameterStore.") + return createUnfoundParamStore(client, storeName) + } func getStickyExperiment(_ expName: String) -> [String: Any]? { let expNameHash = expName.hashSpecName(hashUsed) @@ -159,6 +181,7 @@ struct StatsigValuesCache { cache[InternalStore.gatesKey] = values[InternalStore.gatesKey] cache[InternalStore.configsKey] = values[InternalStore.configsKey] cache[InternalStore.layerConfigsKey] = values[InternalStore.layerConfigsKey] + cache[InternalStore.paramStoresKey] = values[InternalStore.paramStoresKey] cache[InternalStore.lcutKey] = Time.parse(values[InternalStore.lcutKey]) cache[InternalStore.evalTimeKey] = Time.now() cache[InternalStore.userHashKey] = userHash @@ -336,6 +359,10 @@ struct StatsigValuesCache { evalDetails: getEvaluationDetails(.Unrecognized) ) } + + private func createUnfoundParamStore(_ client: StatsigClient?, _ name: String) -> ParameterStore { + ParameterStore(name: name, evaluationDetails: getEvaluationDetails(.Unrecognized)) + } } class InternalStore { @@ -353,6 +380,7 @@ class InternalStore { static let configsKey = "dynamic_configs" static let stickyExpKey = "sticky_experiments" static let layerConfigsKey = "layer_configs" + static let paramStoresKey = "param_stores" static let lcutKey = "time" static let evalTimeKey = "evaluation_time" static let userHashKey = "user_hash" @@ -453,6 +481,12 @@ class InternalStore { ) }) } + + func getParamStore(client: StatsigClient?, forName storeName: String) -> ParameterStore { + storeQueue.sync { + return cache.getParamStore(client, storeName) + } + } func finalizeValues(completion: (() -> Void)? = nil) { storeQueue.async(flags: .barrier) { [weak self] in diff --git a/Sources/Statsig/Layer.swift b/Sources/Statsig/Layer.swift index cea0c86..7faece7 100644 --- a/Sources/Statsig/Layer.swift +++ b/Sources/Statsig/Layer.swift @@ -45,7 +45,7 @@ public struct Layer: ConfigProtocol { public let allocatedExperimentName: String /** - (For debug purposes) Why did Statsig return this DynamicConfig + (For debug purposes) Why did Statsig return this Layer */ public let evaluationDetails: EvaluationDetails diff --git a/Sources/Statsig/ParameterStore.swift b/Sources/Statsig/ParameterStore.swift new file mode 100644 index 0000000..6899d3e --- /dev/null +++ b/Sources/Statsig/ParameterStore.swift @@ -0,0 +1,190 @@ +import Foundation + +typealias ParamStoreConfiguration = [String: [String: Any]] + +fileprivate struct RefType { + static let staticValue = "static" + static let gate = "gate" + static let dynamicConfig = "dynamic_config" + static let experiment = "experiment" + static let layer = "layer" +} + +fileprivate struct ParamKeys { + static let paramType = "param_type" + static let refType = "ref_type" + + // Gate + static let gateName = "gate_name" + static let passValue = "pass_value" + static let failValue = "fail_value" + + // Static Value + static let value = "value" + + // Dynamic Config / Experiment / Layer + static let paramName = "param_name" + static let configName = "config_name" + static let experimentName = "experiment_name" + static let layerName = "layer_name" +} + +public struct ParameterStore { + /** + The name used to retrieve this ParameterStore. + */ + public let name: String + + /** + (For debug purposes) Why did Statsig return this ParameterStore + */ + public let evaluationDetails: EvaluationDetails + + internal let configuration: ParamStoreConfiguration + weak internal var client: StatsigClient? + internal var shouldExpose = true + + internal init( + name: String, + evaluationDetails: EvaluationDetails, + client: StatsigClient? = nil, + configuration: [String: Any] = [:] + ) { + self.name = name + self.evaluationDetails = evaluationDetails + self.client = client + self.configuration = configuration as? ParamStoreConfiguration ?? ParamStoreConfiguration() + } + + /** + Get the value for the given key. If the value cannot be found, or is found to have a different type than the defaultValue, the defaultValue will be returned. + If a valid value is found, a layer exposure event will be fired. + + Parameters: + - forKey: The key of parameter being fetched + - defaultValue: The fallback value if the key cannot be found + */ + public func getValue( + forKey paramName: String, + defaultValue: T + ) -> T { + if configuration.isEmpty { + return defaultValue + } + + guard + let client = client, + let param = configuration[paramName], + let refType = param[ParamKeys.refType] as? String, + let paramType = param[ParamKeys.paramType] as? String, + getTypeOf(defaultValue) == paramType + else { + return defaultValue + } + + switch refType { + case RefType.staticValue: + return getMappedStaticValue(param, defaultValue) + + case RefType.gate: + return getMappedGateValue(client, param, defaultValue) + + case RefType.dynamicConfig: + return getMappedDynamicConfigValue(client, param, defaultValue) + + case RefType.experiment: + return getMappedExperimentValue(client, param, defaultValue) + + case RefType.layer: + return getMappedLayerValue(client, param, defaultValue) + + default: + return defaultValue + } + } + + fileprivate func getMappedStaticValue( + _ param: [String: Any], + _ defaultValue: T + ) -> T { + return param[ParamKeys.value] as? T ?? defaultValue + } + + + fileprivate func getMappedGateValue( + _ client: StatsigClient, + _ param: [String: Any], + _ defaultValue: T + ) -> T { + guard + let gateName = param[ParamKeys.gateName] as? String, + let passValue = param[ParamKeys.passValue] as? T, + let failValue = param[ParamKeys.failValue] as? T + else { + return defaultValue + } + + let gate = shouldExpose + ? client.getFeatureGate(gateName) + : client.getFeatureGateWithExposureLoggingDisabled(gateName) + return gate.value ? passValue : failValue + } + + + fileprivate func getMappedDynamicConfigValue( + _ client: StatsigClient, + _ param: [String: Any], + _ defaultValue: T + ) -> T { + guard + let configName = param[ParamKeys.configName] as? String, + let paramName = param[ParamKeys.paramName] as? String + else { + return defaultValue + } + + let config = shouldExpose + ? client.getConfig(configName) + : client.getConfigWithExposureLoggingDisabled(configName) + return config.getValue(forKey: paramName, defaultValue: defaultValue) + } + + + fileprivate func getMappedExperimentValue( + _ client: StatsigClient, + _ param: [String: Any], + _ defaultValue: T + ) -> T { + guard + let experimentName = param[ParamKeys.experimentName] as? String, + let paramName = param[ParamKeys.paramName] as? String + else { + return defaultValue + } + + let experiment = shouldExpose + ? client.getExperiment(experimentName) + : client.getExperimentWithExposureLoggingDisabled(experimentName) + return experiment.getValue(forKey: paramName, defaultValue: defaultValue) + } + + + fileprivate func getMappedLayerValue( + _ client: StatsigClient, + _ param: [String: Any], + _ defaultValue: T + ) -> T { + guard + let layerName = param[ParamKeys.layerName] as? String, + let paramName = param[ParamKeys.paramName] as? String + else { + return defaultValue + } + + let layer = shouldExpose + ? client.getLayer(layerName) + : client.getLayerWithExposureLoggingDisabled(layerName) + return layer.getValue(forKey: paramName, defaultValue: defaultValue) + } + +} diff --git a/Sources/Statsig/Statsig.swift b/Sources/Statsig/Statsig.swift index fd2db2d..72292c7 100644 --- a/Sources/Statsig/Statsig.swift +++ b/Sources/Statsig/Statsig.swift @@ -251,6 +251,29 @@ public class Statsig { public static func getLayerWithExposureLoggingDisabled(_ layerName: String, keepDeviceValue: Bool = false) -> Layer { return getLayerImpl(layerName, keepDeviceValue: keepDeviceValue, withExposures: false, functionName: funcName()) } + + /** + + */ + public static func getParameterStore( + _ storeName: String + ) -> ParameterStore { + return getParameterStoreImpl( + storeName, + withExposures: true, + functionName: funcName() + ) + } + + public static func getParameterStoreWithExposureLoggingDisabled( + _ storeName: String + ) -> ParameterStore { + return getParameterStoreImpl( + storeName, + withExposures: false, + functionName: funcName() + ) + } /** Logs an exposure event for the given layer parameter. Only required if a related getLayerWithExposureLoggingDisabled call has been made. @@ -588,7 +611,12 @@ public class Statsig { return result } - private static func getLayerImpl(_ layerName: String, keepDeviceValue: Bool, withExposures: Bool, functionName: String) -> Layer { + private static func getLayerImpl( + _ layerName: String, + keepDeviceValue: Bool, + withExposures: Bool, + functionName: String + ) -> Layer { var result: Layer = Layer(client: nil, name: layerName, evalDetails: .uninitialized()) errorBoundary.capture(functionName) { guard let client = client else { @@ -602,6 +630,26 @@ public class Statsig { } return result } + + private static func getParameterStoreImpl( + _ storeName: String, + withExposures: Bool, + functionName: String + ) -> ParameterStore { + var result: ParameterStore? = nil + errorBoundary.capture(functionName) { + guard let client = client else { + print("[Statsig]: \(getUnstartedErrorMessage(functionName)). Returning a dummy ParameterStore that will only return default values.") + return + } + + result = withExposures ? client.getParameterStore(storeName) : client.getParameterStoreWithExposureLoggingDisabled(storeName) + } + return result ?? ParameterStore( + name: storeName, + evaluationDetails: .uninitialized() + ) + } private static func getEmptyConfig(_ name: String) -> DynamicConfig { return DynamicConfig(configName: name, evalDetails: .uninitialized()) diff --git a/Sources/Statsig/StatsigClient.swift b/Sources/Statsig/StatsigClient.swift index ddccd1c..f64593f 100644 --- a/Sources/Statsig/StatsigClient.swift +++ b/Sources/Statsig/StatsigClient.swift @@ -537,6 +537,33 @@ extension StatsigClient { } } +// MARK: Parameter Stores + +extension StatsigClient { + public func getParameterStore(_ storeName: String) -> ParameterStore { + return getParameterStoreImpl(storeName, shouldExpose: true) + } + + public func getParameterStoreWithExposureLoggingDisabled(_ storeName: String) -> ParameterStore { + return getParameterStoreImpl(storeName, shouldExpose: false) + } + + private func getParameterStoreImpl(_ storeName: String, shouldExpose: Bool) -> ParameterStore { + logger.incrementNonExposedCheck(storeName) + + var store = store.getParamStore(client: self, forName: storeName) + + store.shouldExpose = shouldExpose + + if let cb = statsigOptions.evaluationCallback { + cb(.parameterStore(store)) + } + + return store + } + +} + // MARK: Log Event extension StatsigClient { diff --git a/Sources/Statsig/StatsigDynamicConfigValue.swift b/Sources/Statsig/StatsigDynamicConfigValue.swift index 3e75470..a628ae6 100644 --- a/Sources/Statsig/StatsigDynamicConfigValue.swift +++ b/Sources/Statsig/StatsigDynamicConfigValue.swift @@ -17,3 +17,31 @@ extension Int: StatsigDynamicConfigValue {} extension String: StatsigDynamicConfigValue {} extension String?: StatsigDynamicConfigValue {} + + +fileprivate struct TypeString { + static let boolean = "boolean" + static let number = "number" + static let string = "string" + static let array = "array" + static let object = "object" +} + +func getTypeOf(_ value: T) -> String? { + switch value { + case is Bool: + return TypeString.boolean + case is Int, is Double: + return TypeString.number + case is String, is Optional: + return TypeString.string + case is Array: + return TypeString.array + case is Dictionary: + return TypeString.object + default: + return nil + } +} + + diff --git a/Sources/Statsig/StatsigOptions.swift b/Sources/Statsig/StatsigOptions.swift index f17e87e..61b3683 100644 --- a/Sources/Statsig/StatsigOptions.swift +++ b/Sources/Statsig/StatsigOptions.swift @@ -11,6 +11,7 @@ public class StatsigOptions { case config (DynamicConfig) case experiment (DynamicConfig) case layer (Layer) + case parameterStore (ParameterStore) } /** diff --git a/Tests/StatsigTests/EvaluationCallbackSpec.swift b/Tests/StatsigTests/EvaluationCallbackSpec.swift index 2d3a219..ec755fd 100644 --- a/Tests/StatsigTests/EvaluationCallbackSpec.swift +++ b/Tests/StatsigTests/EvaluationCallbackSpec.swift @@ -32,6 +32,7 @@ final class EvaluationCallbackSpec: BaseSpec { var configNameResult: String? = nil var experimentNameResult: String? = nil var layerNameResult: String? = nil + var paramStoreNameResult: String? = nil beforeEach { // Setup Event Capture @@ -54,6 +55,8 @@ final class EvaluationCallbackSpec: BaseSpec { experimentNameResult = exp.name case .layer(let layer): layerNameResult = layer.name + case .parameterStore(let paramStore): + paramStoreNameResult = paramStore.name } } @@ -93,6 +96,13 @@ final class EvaluationCallbackSpec: BaseSpec { _ = Statsig.getLayerWithExposureLoggingDisabled("b_layer") expect(layerNameResult).to(equal("b_layer")) } + + it("works with parameter stores") { + _ = Statsig.getParameterStore("a_param_store") + expect(paramStoreNameResult).to(equal("a_param_store")) + _ = Statsig.getParameterStoreWithExposureLoggingDisabled("b_param_store_layer") + expect(paramStoreNameResult).to(equal("b_param_store_layer")) + } } } }