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
---
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"))
+ }
}
}
}