From 0a81dedceba5a76423bea316b74042a4207cee4b Mon Sep 17 00:00:00 2001
From: Daniel <95646168+daniel-statsig@users.noreply.github.com>
Date: Mon, 10 Jun 2024 12:48:56 -0700
Subject: [PATCH] feat: add support for getParameterStore (#279)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Tested with Kong: https://github.com/statsig-io/kong/pull/2225
---
Sources/Statsig/InternalStore.swift | 36 +++-
Sources/Statsig/Layer.swift | 2 +-
Sources/Statsig/ParameterStore.swift | 180 ++++++++++++++++++
Sources/Statsig/Statsig.swift | 18 ++
Sources/Statsig/StatsigClient.swift | 15 ++
.../Statsig/StatsigDynamicConfigValue.swift | 28 +++
Sources/Statsig/StatsigOptions.swift | 1 +
.../StatsigTests/EvaluationCallbackSpec.swift | 2 +
8 files changed, 280 insertions(+), 2 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..cb91f84
--- /dev/null
+++ b/Sources/Statsig/ParameterStore.swift
@@ -0,0 +1,180 @@
+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 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 = client.getFeatureGate(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 = client.getConfig(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 = client.getExperiment(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 = client.getLayer(layerName)
+ return layer.getValue(forKey: paramName, defaultValue: defaultValue)
+}
diff --git a/Sources/Statsig/Statsig.swift b/Sources/Statsig/Statsig.swift
index fd2db2d..41acef0 100644
--- a/Sources/Statsig/Statsig.swift
+++ b/Sources/Statsig/Statsig.swift
@@ -251,6 +251,24 @@ 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 {
+ let functionName = funcName()
+ var result = ParameterStore(name: storeName, evaluationDetails: .uninitialized())
+
+ errorBoundary.capture(functionName) {
+ guard let client = client else {
+ print("[Statsig]: \(getUnstartedErrorMessage(functionName)). Returning a dummy ParameterStore that will only return default values.")
+ return
+ }
+
+ result = client.getParameterStore(storeName)
+ }
+ return result
+ }
/**
Logs an exposure event for the given layer parameter. Only required if a related getLayerWithExposureLoggingDisabled call has been made.
diff --git a/Sources/Statsig/StatsigClient.swift b/Sources/Statsig/StatsigClient.swift
index ddccd1c..f3053e7 100644
--- a/Sources/Statsig/StatsigClient.swift
+++ b/Sources/Statsig/StatsigClient.swift
@@ -537,6 +537,21 @@ extension StatsigClient {
}
}
+// MARK: Parameter Stores
+
+extension StatsigClient {
+ public func getParameterStore(_ storeName: String) -> ParameterStore {
+ logger.incrementNonExposedCheck(storeName)
+
+ let store = store.getParamStore(client: self, forName: storeName)
+ 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..9f122e0 100644
--- a/Tests/StatsigTests/EvaluationCallbackSpec.swift
+++ b/Tests/StatsigTests/EvaluationCallbackSpec.swift
@@ -54,6 +54,8 @@ final class EvaluationCallbackSpec: BaseSpec {
experimentNameResult = exp.name
case .layer(let layer):
layerNameResult = layer.name
+ case .parameterStore(let _paramStore):
+ break
}
}