Skip to content

Commit 947f338

Browse files
feat: support Value in typed experiments (#67)
Provides the ability to specify a strict value type to be used in an experiment. ```kotlin // define a data class data class MyValue( @SerializedName("a_string") val aString: String, ) // define experiment group names enum class Groups { Control, Test } val myExperiment = TypedExperiment( "an_experiment", Groups.values(), valueClass = MyValue::class.java ) val result = client.typed.getExperiment(myExperiment, StatsigUser("a-user")) println(result.value?.aString) ```
1 parent adc93f5 commit 947f338

15 files changed

+368
-53
lines changed

src/main/java/com/statsig/androidlocalevalsdk/ConfigEvaluationData.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import com.google.gson.annotations.SerializedName
55
internal class ConfigEvaluation(
66
val booleanValue: Boolean = false,
77
val jsonValue: Any? = null,
8+
val returnableValue: ReturnableValue? = null,
89
val ruleID: String = "",
910
val groupName: String? = null,
1011
val secondaryExposures: ArrayList<Map<String, String>> = arrayListOf(),
@@ -74,7 +75,7 @@ internal class PersistedValueConfig(
7475
) {
7576
fun toConfigEvaluationData(): ConfigEvaluation {
7677
val evalDetail = EvaluationDetails(this.time ?: StatsigUtils.getTimeInMillis(), EvaluationReason.PERSISTED)
77-
var evaluation = ConfigEvaluation(
78+
val evaluation = ConfigEvaluation(
7879
jsonValue = this.jsonValue,
7980
booleanValue = this.value,
8081
ruleID = this.ruleID,

src/main/java/com/statsig/androidlocalevalsdk/DataTypes.kt

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
11
package com.statsig.androidlocalevalsdk
22

3+
import com.google.gson.JsonDeserializationContext
4+
import com.google.gson.JsonDeserializer
5+
import com.google.gson.JsonElement
6+
import com.google.gson.JsonNull
7+
import com.google.gson.JsonParser
8+
import com.google.gson.JsonPrimitive
9+
import com.google.gson.JsonSerializationContext
10+
import com.google.gson.JsonSerializer
11+
import com.google.gson.annotations.JsonAdapter
312
import com.google.gson.annotations.SerializedName
13+
import java.lang.reflect.Type
414

515
internal data class APIDownloadedConfigs(
616
@SerializedName("dynamic_configs") val dynamicConfigs: Array<APIConfig>,
@@ -13,12 +23,72 @@ internal data class APIDownloadedConfigs(
1323
@SerializedName("default_environment") val defaultEnvironment: String? = null,
1424
)
1525

26+
27+
@JsonAdapter(ReturnableValue.CustomSerializer::class)
28+
internal data class ReturnableValue(
29+
val booleanValue: Boolean? = null,
30+
val rawJson: String = "null",
31+
val mapValue: Map<String, Any>? = null
32+
) {
33+
fun getValue(): Any? {
34+
if (booleanValue != null) {
35+
return booleanValue
36+
}
37+
38+
if (mapValue != null) {
39+
return mapValue
40+
}
41+
42+
return null
43+
}
44+
45+
internal class CustomSerializer : JsonDeserializer<ReturnableValue>, JsonSerializer<ReturnableValue> {
46+
override fun deserialize(
47+
json: JsonElement?,
48+
typeOfT: Type?,
49+
context: JsonDeserializationContext?
50+
): ReturnableValue {
51+
if (json == null) {
52+
return ReturnableValue()
53+
}
54+
55+
if (json.isJsonPrimitive && json.asJsonPrimitive.isBoolean) {
56+
val booleanValue = json.asJsonPrimitive.asBoolean
57+
return ReturnableValue(booleanValue, json.toString(), null)
58+
}
59+
60+
if (json.isJsonObject) {
61+
val jsonObject = json.asJsonObject
62+
val mapValue = context?.deserialize<Map<String, Any>>(jsonObject, Map::class.java) ?: emptyMap()
63+
return ReturnableValue(null, json.toString(), mapValue)
64+
}
65+
66+
return ReturnableValue()
67+
}
68+
69+
override fun serialize(
70+
src: ReturnableValue?,
71+
typeOfSrc: Type?,
72+
context: JsonSerializationContext?
73+
): JsonElement {
74+
if (src == null) {
75+
return JsonNull.INSTANCE
76+
}
77+
78+
return JsonParser.parseString(src.rawJson)
79+
80+
}
81+
82+
}
83+
}
84+
85+
1686
internal data class APIConfig(
1787
@SerializedName("name") val name: String,
1888
@SerializedName("type") val type: String,
1989
@SerializedName("isActive") val isActive: Boolean,
2090
@SerializedName("salt") val salt: String,
21-
@SerializedName("defaultValue") val defaultValue: Any,
91+
@SerializedName("defaultValue") val defaultValue: ReturnableValue,
2292
@SerializedName("enabled") val enabled: Boolean,
2393
@SerializedName("rules") val rules: Array<APIRule>,
2494
@SerializedName("idType") val idType: String,
@@ -29,10 +99,11 @@ internal data class APIConfig(
2999
@SerializedName("version") val version: Int? = null,
30100
)
31101

102+
32103
internal data class APIRule(
33104
@SerializedName("name") val name: String,
34105
@SerializedName("passPercentage") val passPercentage: Double,
35-
@SerializedName("returnValue") val returnValue: Any,
106+
@SerializedName("returnValue") val returnValue: ReturnableValue,
36107
@SerializedName("id") val id: String,
37108
@SerializedName("salt") val salt: String?,
38109
@SerializedName("conditions") val conditions: Array<APICondition>,

src/main/java/com/statsig/androidlocalevalsdk/DynamicConfig.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,11 @@ class DynamicConfig(
1010
val groupName: String? = null,
1111
val secondaryExposures: ArrayList<Map<String, String>> = arrayListOf(),
1212
var evaluationDetails: EvaluationDetails? = null,
13+
val rawValue: String? = null,
1314
) {
1415
companion object {
1516
fun empty(name: String = ""): DynamicConfig {
16-
return DynamicConfig(name, mapOf())
17+
return DynamicConfig(name, mapOf(), "{}")
1718
}
1819
}
1920

src/main/java/com/statsig/androidlocalevalsdk/Evaluator.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ internal class Evaluator(
178178
if (!config.enabled) {
179179
return ConfigEvaluation(
180180
booleanValue = false,
181+
config.defaultValue.getValue(),
181182
config.defaultValue,
182183
"disabled",
183184
evaluationDetails = evaluationDetails,
@@ -198,7 +199,8 @@ internal class Evaluator(
198199
val pass = evaluatePassPercent(user, config, rule)
199200
return ConfigEvaluation(
200201
pass,
201-
if (pass) result.jsonValue else config.defaultValue,
202+
if (pass) result.jsonValue else config.defaultValue.getValue(),
203+
if (pass) result.returnableValue else config.defaultValue,
202204
result.ruleID,
203205
result.groupName,
204206
secondaryExposures,
@@ -210,6 +212,7 @@ internal class Evaluator(
210212
}
211213
return ConfigEvaluation(
212214
booleanValue = false,
215+
config.defaultValue.getValue(),
213216
config.defaultValue,
214217
"default",
215218
null,
@@ -244,6 +247,7 @@ internal class Evaluator(
244247

245248
return ConfigEvaluation(
246249
booleanValue = pass,
250+
rule.returnValue.getValue(),
247251
rule.returnValue,
248252
rule.id,
249253
rule.groupName,
@@ -314,6 +318,7 @@ internal class Evaluator(
314318
!result.booleanValue
315319
},
316320
result.jsonValue,
321+
result.returnableValue,
317322
"",
318323
"",
319324
secondaryExposures,

src/main/java/com/statsig/androidlocalevalsdk/StatsigClient.kt

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,18 +265,22 @@ class StatsigClient {
265265
@JvmOverloads
266266
fun getExperiment(user: StatsigUser?, experimentName: String, option: GetExperimentOptions? = null): DynamicConfig {
267267
var result = DynamicConfig.empty()
268+
268269
if (!isInitialized("getExperiment")) {
269270
result.evaluationDetails = EvaluationDetails(0, EvaluationReason.UNINITIALIZED)
270271
return result
271272
}
273+
272274
errorBoundary.capture({
273275
val normalizedUser = normalizeUser(user)
274276
val evaluation = evaluator.getExperiment(normalizedUser, experimentName, option)
275277
result = getDynamicConfigFromEvalResult(evaluation, experimentName)
278+
276279
if (option?.disableExposureLogging !== true) {
277280
logConfigExposureImpl(normalizedUser, experimentName, evaluation)
278281
}
279282
}, tag = "getExperiment", configName = experimentName)
283+
280284
return result
281285
}
282286

@@ -628,7 +632,15 @@ class StatsigClient {
628632
}
629633

630634
private fun getDynamicConfigFromEvalResult(result: ConfigEvaluation, configName: String): DynamicConfig {
631-
return DynamicConfig(configName, result.jsonValue as? Map<String, Any> ?: mapOf(), result.ruleID, result.groupName, result.secondaryExposures, result.evaluationDetails)
635+
return DynamicConfig(
636+
configName,
637+
result.jsonValue as? Map<String, Any> ?: mapOf(),
638+
result.ruleID,
639+
result.groupName,
640+
result.secondaryExposures,
641+
result.evaluationDetails,
642+
result.returnableValue?.rawJson ?: "{}",
643+
)
632644
}
633645

634646
private fun getFeatureGateFromEvalResult(result: ConfigEvaluation, gateName: String): FeatureGate {

src/main/java/com/statsig/androidlocalevalsdk/StatsigNetwork.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,14 +190,16 @@ internal class StatsigNetwork(
190190
} else {
191191
connection.errorStream
192192
}
193-
var errorMarker = if (code >= HttpURLConnection.HTTP_BAD_REQUEST) {
193+
194+
val errorMarker = if (code >= HttpURLConnection.HTTP_BAD_REQUEST) {
194195
val errorMessage = inputStream.bufferedReader(Charsets.UTF_8).use(
195196
BufferedReader::readText,
196197
)
197198
Marker.ErrorMessage(errorMessage, code.toString(), null)
198199
} else {
199200
null
200201
}
202+
201203
diagnostics.markEnd(
202204
KeyType.DOWNLOAD_CONFIG_SPECS,
203205
code < HttpURLConnection.HTTP_BAD_REQUEST,
@@ -210,6 +212,7 @@ internal class StatsigNetwork(
210212
hasNetwork = connectivityListener.isNetworkAvailable(),
211213
),
212214
)
215+
213216
when (code) {
214217
in 200..299 -> {
215218
if (code == 204) {
Lines changed: 102 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,120 @@
11
package com.statsig.androidlocalevalsdk.typed
22

3-
abstract class TypedExperiment<T>(
4-
val name: String,
5-
private val groups: Array<T>,
6-
val isMemoizable: Boolean = false,
7-
val memoUnitIdType: String = "userID"
8-
) where T : Enum<T> {
9-
val group: T?
3+
import com.google.gson.Gson
4+
5+
open class TypedExperiment<G, V>(
6+
name: String,
7+
groups: Array<G>?,
8+
isMemoizable: Boolean = false,
9+
memoUnitIdType: String = "userID",
10+
valueClass: Class<V>? = null
11+
) where G : Enum<G> {
12+
companion object {
13+
const val INVALID_SUBCLASS = "InvalidTypedExperimentSubclass"
14+
}
15+
16+
val name: String
17+
get() = _name
18+
19+
val isMemoizable: Boolean
20+
get() = _isMemoizable
21+
22+
val memoUnitIdType: String
23+
get() = _memoUnitIdType
24+
25+
val valueClass: Class<V>?
26+
get() = _valueClass
27+
28+
val group: G?
1029
get() = _group
1130

12-
private var _group: T? = null
31+
val value: V?
32+
get() = _value
33+
34+
private var _name: String
35+
private var _groups: Array<G>?
36+
private var _isMemoizable: Boolean
37+
private var _memoUnitIdType: String
38+
private var _valueClass: Class<V>?
39+
private var _group: G? = null
40+
private var _value: V? = null
41+
42+
init {
43+
_name = name
44+
_groups = groups
45+
_isMemoizable = isMemoizable
46+
_memoUnitIdType = memoUnitIdType
47+
_valueClass = valueClass
48+
}
49+
50+
internal constructor() : this(INVALID_SUBCLASS, null)
51+
52+
fun <T : TypedExperiment<*, *>> new(): T? {
53+
return try {
54+
val inst = this.javaClass.getDeclaredConstructor().newInstance()
55+
if (inst::class != this::class) {
56+
return null
57+
}
58+
59+
inst._name = _name
60+
inst._groups = _groups
61+
inst._isMemoizable = _isMemoizable
62+
inst._memoUnitIdType = _memoUnitIdType
63+
inst._valueClass = _valueClass
64+
@Suppress("UNCHECKED_CAST")
65+
inst as? T
66+
} catch (e: ClassCastException) {
67+
null
68+
}
69+
}
70+
71+
fun <T : TypedExperiment<*, *>> clone(): T? {
72+
val inst = this.new() as? TypedExperiment<G, V> ?: return null
73+
if (inst::class != this::class) {
74+
return null
75+
}
76+
77+
return try {
78+
inst._group = group
79+
inst._value = value
80+
@Suppress("UNCHECKED_CAST")
81+
inst as? T
82+
} catch (e: ClassCastException) {
83+
null
84+
}
85+
}
1386

1487
fun trySetGroupFromString(input: String?) {
15-
if (input == null) {
88+
val groups = _groups
89+
if (input == null || groups == null) {
1690
return
1791
}
1892

19-
return try {
93+
try {
2094
_group = groups.find { it.name.equals(input, ignoreCase = true) }
2195
} catch (e: Exception) {
2296
println("Error: Failed to parse group name. $e")
2397
}
2498
}
2599

100+
fun trySetValueFromString(gson: Gson, input: String?) {
101+
val json = input ?: return
26102

103+
try {
104+
_value = gson.fromJson(json, _valueClass)
105+
} catch (e: Exception) {
106+
println("Error: Failed to deserialize value. $e")
107+
}
108+
}
27109
}
28110

111+
class NoValue
112+
open class TypedExperimentWithoutValue<G>(
113+
name: String,
114+
groups: Array<G>,
115+
isMemoizable: Boolean = false,
116+
memoUnitIdType: String = "userID",
117+
) : TypedExperiment<G, NoValue>(
118+
name, groups, isMemoizable, memoUnitIdType
119+
) where G : Enum<G>
120+

0 commit comments

Comments
 (0)