Skip to content

Commit

Permalink
Multiple Improvements for SDK (#62)
Browse files Browse the repository at this point in the history
- Adds getFeatureGate function which returns additional information
about the evaluation beyond just the true/false value
- Adds support for custom env tier beyond predefined options in enum
- Add support for getting default environment from the download config
specs response if an environment is not specified
- Adds config version to exposure metadata
- Adds rulePassed to exposure metadata for dynamic config

kong test: statsig-io/kong#2997
  • Loading branch information
sroyal-statsig authored Nov 7, 2024
1 parent bdfd041 commit 6efeaa4
Show file tree
Hide file tree
Showing 9 changed files with 100 additions and 22 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ internal class ConfigEvaluation(
val configDelegate: String? = null,
var evaluationDetails: EvaluationDetails? = null,
var isExperimentGroup: Boolean = false,
var configVersion: Int? = null,
) {
var undelegatedSecondaryExposures: ArrayList<Map<String, String>> = secondaryExposures

Expand Down
4 changes: 4 additions & 0 deletions src/main/java/com/statsig/androidlocalevalsdk/DataTypes.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ internal data class APIDownloadedConfigs(
@SerializedName("time") val time: Long = 0,
@SerializedName("has_updates") val hasUpdates: Boolean,
@SerializedName("diagnostics") val diagnostics: Map<String, Int>? = null,
@SerializedName("default_environment") val defaultEnvironment: String? = null,
)

internal data class APIConfig(
Expand All @@ -25,6 +26,7 @@ internal data class APIConfig(
@SerializedName("explicitParameters") val explicitParameters: Array<String>?,
@SerializedName("hasSharedParams") val hasSharedParams: Boolean?,
@SerializedName("targetAppIDs") val targetAppIDs: Array<String>? = null,
@SerializedName("version") val version: Int? = null,
)

internal data class APIRule(
Expand Down Expand Up @@ -58,6 +60,7 @@ internal data class LayerExposureMetadata(
@SerializedName("secondaryExposures") val secondaryExposures: ArrayList<Map<String, String>>,
@SerializedName("isManualExposure") var isManualExposure: String = "false",
@SerializedName("evaluationDetails") val evaluationDetails: EvaluationDetails?,
@SerializedName("configVersion") val configVersion: Int? = null,
) {
fun toStatsigEventMetadataMap(): MutableMap<String, String> {
return mutableMapOf(
Expand All @@ -67,6 +70,7 @@ internal data class LayerExposureMetadata(
"parameterName" to parameterName,
"isExplicitParameter" to isExplicitParameter,
"isManualExposure" to isManualExposure,
"configVersion" to configVersion.toString(),
// secondaryExposures excluded -- StatsigEvent adds secondaryExposures explicitly as a top level key
)
}
Expand Down
5 changes: 5 additions & 0 deletions src/main/java/com/statsig/androidlocalevalsdk/Evaluator.kt
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ internal class Evaluator(
config.defaultValue,
"disabled",
evaluationDetails = evaluationDetails,
configVersion = config.version,
)
}
val secondaryExposures = arrayListOf<Map<String, String>>()
Expand All @@ -191,6 +192,7 @@ internal class Evaluator(
if (result.booleanValue) {
val delegatedEval = this.evaluateDelegate(user, rule, secondaryExposures)
if (delegatedEval != null) {
delegatedEval.configVersion = config.version
return delegatedEval
}
val pass = evaluatePassPercent(user, config, rule)
Expand All @@ -202,6 +204,7 @@ internal class Evaluator(
secondaryExposures,
evaluationDetails = evaluationDetails,
isExperimentGroup = rule.isExperimentGroup ?: false,
configVersion = config.version,
)
}
}
Expand All @@ -212,6 +215,7 @@ internal class Evaluator(
null,
secondaryExposures,
evaluationDetails = evaluationDetails,
configVersion = config.version,
)
} catch (e: UnsupportedEvaluationException) {
// Return default value for unsupported evaluation
Expand All @@ -222,6 +226,7 @@ internal class Evaluator(
ruleID = "default",
explicitParameters = config.explicitParameters ?: arrayOf(),
evaluationDetails = EvaluationDetails(configSyncTime = specStore.lcut, reason = EvaluationReason.UNSUPPORTED),
configVersion = config.version,
)
}
}
Expand Down
17 changes: 17 additions & 0 deletions src/main/java/com/statsig/androidlocalevalsdk/FeatureGate.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.statsig.androidlocalevalsdk

class FeatureGate(
val name: String,
val value: Boolean,
val ruleID: String? = null,
val secondaryExposures: ArrayList<Map<String, String>> = arrayListOf(),
var evaluationDetails: EvaluationDetails? = null,
) {
companion object {
fun empty(name: String = ""): FeatureGate {
return FeatureGate(name, false)
}
}

init { }
}
1 change: 1 addition & 0 deletions src/main/java/com/statsig/androidlocalevalsdk/Layer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -171,5 +171,6 @@ internal fun createLayerExposureMetadata(
isExplicit.toString(),
exposures,
evaluationDetails = configEvaluation.evaluationDetails,
configVersion = configEvaluation.configVersion,
)
}
41 changes: 39 additions & 2 deletions src/main/java/com/statsig/androidlocalevalsdk/StatsigClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,33 @@ class StatsigClient {
return result
}

/**
* Get the result of a gate, evaluated against a given user.
* An exposure event will automatically be logged for the gate.
*
* @param user A StatsigUser object used for evaluation
* @param gateName The name of the gate being evaluated
* @param options advanced setup for checkGate, for example disable exposure logging
*/
@JvmOverloads
fun getFeatureGate(user: StatsigUser?, gateName: String, options: CheckGateOptions? = null): FeatureGate {
var result = FeatureGate.empty(gateName)
if (!isInitialized("getFeatureGate")) {
result.evaluationDetails = EvaluationDetails(0, EvaluationReason.UNINITIALIZED)
return result
}
errorBoundary.capture({
val normalizedUser = normalizeUser(user)
val evaluation = evaluator.checkGate(normalizedUser, gateName, options)
result = getFeatureGateFromEvalResult(evaluation, gateName)
if (options?.disableExposureLogging !== true) {
logGateExposureImpl(normalizedUser, gateName, evaluation)
}
}, tag = "getFeatureGate", configName = gateName)
return result
}


/**
* Log an exposure for a given gate
* @param user A StatsigUser object used for logging
Expand Down Expand Up @@ -495,6 +522,12 @@ class StatsigClient {
if (options.getEnvironment() != null) {
normalizedUser.statsigEnvironment = options.getEnvironment()
}
if (normalizedUser.statsigEnvironment == null) {
val defaultEnv = specStore.getDefaultEnvironment()
if (defaultEnv != null) {
normalizedUser.statsigEnvironment = mutableMapOf("tier" to defaultEnv)
}
}
return normalizedUser
}

Expand Down Expand Up @@ -563,14 +596,18 @@ class StatsigClient {
}

private fun logConfigExposureImpl(user: StatsigUser, configName: String, evaluation: ConfigEvaluation, isManualExposure: Boolean = false) {
statsigLogger.logConfigExposure(user, configName, evaluation.ruleID, evaluation.secondaryExposures, isManualExposure, evaluation.evaluationDetails)
statsigLogger.logConfigExposure(user, configName, evaluation, isManualExposure)
}

private fun logGateExposureImpl(user: StatsigUser, configName: String, evaluation: ConfigEvaluation, isManualExposure: Boolean = false) {
statsigLogger.logGateExposure(user, configName, evaluation.booleanValue, evaluation.ruleID, evaluation.secondaryExposures, isManualExposure, evaluation.evaluationDetails)
statsigLogger.logGateExposure(user, configName, evaluation, isManualExposure)
}

private fun getDynamicConfigFromEvalResult(result: ConfigEvaluation, configName: String): DynamicConfig {
return DynamicConfig(configName, result.jsonValue as? Map<String, Any> ?: mapOf(), result.ruleID, result.groupName, result.secondaryExposures, result.evaluationDetails)
}

private fun getFeatureGateFromEvalResult(result: ConfigEvaluation, gateName: String): FeatureGate {
return FeatureGate(gateName, result.booleanValue, result.ruleID, result.secondaryExposures, result.evaluationDetails)
}
}
43 changes: 23 additions & 20 deletions src/main/java/com/statsig/androidlocalevalsdk/StatsigLogger.kt
Original file line number Diff line number Diff line change
Expand Up @@ -70,34 +70,35 @@ internal class StatsigLogger(
fun logGateExposure(
user: StatsigUser,
gateName: String,
value: Boolean,
ruleID: String,
secondaryExposures: ArrayList<Map<String, String>>,
evaluation: ConfigEvaluation,
isManualExposure: Boolean = false,
evaluationDetails: EvaluationDetails?,
) {
val dedupeKey = "$gateName:$ruleID:${evaluationDetails?.reason}"
val dedupeKey = "$gateName:$evaluation.ruleID:${evaluation.evaluationDetails?.reason}"
if (!shouldLogExposure(user, dedupeKey)) {
return
}
coroutineScope.launch(singleThreadDispatcher) {
val metadata = mutableMapOf(
"gate" to gateName,
"gateValue" to value.toString(),
"ruleID" to ruleID,
"gateValue" to evaluation.booleanValue.toString(),
"ruleID" to evaluation.ruleID,
"isManualExposure" to isManualExposure.toString(),
)
if (evaluationDetails != null) {
metadata["reason"] = evaluationDetails.reason.reason
metadata["time"] = evaluationDetails.configSyncTime.toString()
val evalDetails = evaluation.evaluationDetails
if (evalDetails != null) {
metadata["reason"] = evalDetails.reason.reason
metadata["time"] = evalDetails.configSyncTime.toString()
}
if (evaluation.configVersion != null) {
metadata["configVersion"] = evaluation.configVersion.toString()
}
val event = LogEvent(
GATE_EXPOSURE_EVENT,
eventValue = null,
metadata,
user,
statsigMetadata,
secondaryExposures,
evaluation.secondaryExposures,
)
log(event)
}
Expand All @@ -106,29 +107,31 @@ internal class StatsigLogger(
fun logConfigExposure(
user: StatsigUser,
configName: String,
ruleID: String,
secondaryExposures: ArrayList<Map<String, String>>,
evaluation: ConfigEvaluation,
isManualExposure: Boolean,
evaluationDetails: EvaluationDetails?,
) {
val dedupeKey = "$configName:$ruleID:${evaluationDetails?.reason}"
val dedupeKey = "$configName:$evaluation.ruleID:${evaluation.evaluationDetails?.reason}"
if (!shouldLogExposure(user, dedupeKey)) {
return
}
coroutineScope.launch(singleThreadDispatcher) {
val metadata =
mutableMapOf("config" to configName, "ruleID" to ruleID, "isManualExposure" to isManualExposure.toString())
if (evaluationDetails != null) {
metadata["reason"] = evaluationDetails.reason.reason
metadata["time"] = evaluationDetails.configSyncTime.toString()
mutableMapOf("config" to configName, "ruleID" to evaluation.ruleID, "isManualExposure" to isManualExposure.toString(), "rulePassed" to evaluation.booleanValue.toString())
val evalDetails = evaluation.evaluationDetails
if (evalDetails != null) {
metadata["reason"] = evalDetails.reason.reason
metadata["time"] = evalDetails.configSyncTime.toString()
}
if (evaluation.configVersion != null) {
metadata["configVersion"] = evaluation.configVersion.toString()
}
val event = LogEvent(
CONFIG_EXPOSURE_EVENT,
eventValue = null,
metadata,
user,
statsigMetadata,
secondaryExposures,
evaluation.secondaryExposures,
)
log(event)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ class StatsigOptions(
setEnvironmentParameter(TIER_KEY, tier.toString().lowercase())
}

fun setTier(tier: String) {
setEnvironmentParameter(TIER_KEY, tier)
}

fun setEnvironmentParameter(key: String, value: String) {
val env = environment
if (env == null) {
Expand Down
6 changes: 6 additions & 0 deletions src/main/java/com/statsig/androidlocalevalsdk/Store.kt
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ internal class Store(
private var layerConfigs: Map<String, APIConfig> = emptyMap()
private var experimentToLayer: Map<String, String> = emptyMap()
private var cacheByKey: MutableMap<String, String> = mutableMapOf()
private var defaultEnvironment: String? = null

fun getGate(name: String): APIConfig? {
return this.gates[name]
Expand All @@ -43,6 +44,10 @@ internal class Store(
return this.layerConfigs[name]
}

fun getDefaultEnvironment(): String? {
return this.defaultEnvironment
}

fun syncLoadFromLocalStorage() {
val cachedConfigSpecs = StatsigUtils.syncGetFromSharedPrefs(sharedPrefs, CACHE_BY_SDK_KEY)
if (cachedConfigSpecs != null) {
Expand Down Expand Up @@ -145,6 +150,7 @@ internal class Store(
this.layerConfigs = newLayerConfigs
this.experimentToLayer = newExperimentToLayer
this.lcut = configSpecs.time
this.defaultEnvironment = configSpecs.defaultEnvironment
}

private fun getParsedSpecs(values: Array<APIConfig>): Map<String, APIConfig> {
Expand Down

0 comments on commit 6efeaa4

Please sign in to comment.