Skip to content

Commit f93b858

Browse files
Add overrides and localmode (#92)
* Add overrides and localmode * Update Evaluator.kt * rollback network changes * feedback
1 parent 0103953 commit f93b858

File tree

8 files changed

+347
-217
lines changed

8 files changed

+347
-217
lines changed

src/main/kotlin/com/statsig/sdk/ErrorBoundary.kt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@ import okhttp3.Request
66
import okhttp3.RequestBody.Companion.toRequestBody
77
import java.net.URI
88

9-
internal class ErrorBoundary(private val apiKey: String) {
9+
internal class ErrorBoundary(private val apiKey: String, private val options: StatsigOptions) {
1010
internal var uri = URI("https://statsigapi.net/v1/sdk_exception")
11-
internal val seen = HashSet<String>()
11+
private val seen = HashSet<String>()
1212

1313
private val client = OkHttpClient()
1414
private companion object {
@@ -40,7 +40,7 @@ internal class ErrorBoundary(private val apiKey: String) {
4040

4141
internal fun logException(ex: Throwable) {
4242
try {
43-
if (seen.contains(ex.javaClass.name)) {
43+
if (options.localMode || seen.contains(ex.javaClass.name)) {
4444
return
4545
}
4646

@@ -68,7 +68,7 @@ internal class ErrorBoundary(private val apiKey: String) {
6868
if (ex is StatsigIllegalStateException
6969
|| ex is StatsigUninitializedException
7070
) {
71-
throw ex;
71+
throw ex
7272
}
7373

7474
println("[Statsig]: An unexpected exception occurred.")

src/main/kotlin/com/statsig/sdk/Evaluator.kt

Lines changed: 211 additions & 196 deletions
Large diffs are not rendered by default.

src/main/kotlin/com/statsig/sdk/Statsig.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,16 @@ class Statsig {
7474
statsigServer.shutdownSuspend()
7575
}
7676

77+
fun overrideGate(gateName: String, gateValue: Boolean) {
78+
enforceInitialized()
79+
statsigServer.overrideGate(gateName, gateValue)
80+
}
81+
82+
fun overrideConfig(configName: String, configValue: Map<String, Any>) {
83+
enforceInitialized()
84+
statsigServer.overrideConfig(configName, configValue)
85+
}
86+
7787
@JvmStatic
7888
@JvmOverloads
7989
fun logEvent(

src/main/kotlin/com/statsig/sdk/StatsigNetwork.kt

Lines changed: 51 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,12 @@ import okhttp3.Interceptor
1414
import okhttp3.MediaType
1515
import okhttp3.MediaType.Companion.toMediaType
1616
import okhttp3.OkHttpClient
17+
import okhttp3.Protocol
1718
import okhttp3.Request
1819
import okhttp3.RequestBody
1920
import okhttp3.RequestBody.Companion.toRequestBody
21+
import okhttp3.Response
22+
import okhttp3.ResponseBody.Companion.toResponseBody
2023
import java.util.UUID
2124

2225
private const val BACKOFF_MULTIPLIER: Int = 10
@@ -47,7 +50,7 @@ internal class StatsigNetwork(
4750
private val gson = GsonBuilder().setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE).create()
4851
private val serverSessionID = UUID.randomUUID().toString();
4952

50-
private inline fun <reified T> Gson.fromJson(json: String) = fromJson<T>(json, object: TypeToken<T>() {}.type)
53+
private inline fun <reified T> Gson.fromJson(json: String) = fromJson<T>(json, object : TypeToken<T>() {}.type)
5154

5255
init {
5356
val clientBuilder = OkHttpClient.Builder()
@@ -64,6 +67,19 @@ internal class StatsigNetwork(
6467
.build()
6568
it.proceed(request)
6669
})
70+
71+
clientBuilder.addInterceptor(Interceptor {
72+
if (options.localMode) {
73+
return@Interceptor Response.Builder().code(200)
74+
.body("{}".toResponseBody("application/json; charset=utf-8".toMediaType()))
75+
.protocol(Protocol.HTTP_2)
76+
.request(it.request())
77+
.message("Request blocked due to localMode being active")
78+
.build()
79+
}
80+
it.proceed(it.request())
81+
})
82+
6783
statsigHttpClient = clientBuilder.build()
6884
httpClient = OkHttpClient.Builder().build()
6985
}
@@ -84,7 +100,12 @@ internal class StatsigNetwork(
84100
.build()
85101
statsigHttpClient.newCall(request).await().use { response ->
86102
val apiGate = gson.fromJson(response.body?.charStream(), APIFeatureGate::class.java)
87-
return ConfigEvaluation(fetchFromServer = false, booleanValue = apiGate.value, apiGate.value.toString(), apiGate.ruleID ?: "")
103+
return ConfigEvaluation(
104+
fetchFromServer = false,
105+
booleanValue = apiGate.value,
106+
apiGate.value.toString(),
107+
apiGate.ruleID ?: ""
108+
)
88109
}
89110
}
90111

@@ -104,7 +125,12 @@ internal class StatsigNetwork(
104125
.build()
105126
statsigHttpClient.newCall(request).await().use { response ->
106127
val apiConfig = gson.fromJson(response.body?.charStream(), APIDynamicConfig::class.java)
107-
return ConfigEvaluation(fetchFromServer = false, booleanValue = false, apiConfig.value, apiConfig.ruleID ?: "")
128+
return ConfigEvaluation(
129+
fetchFromServer = false,
130+
booleanValue = false,
131+
apiConfig.value,
132+
apiConfig.ruleID ?: ""
133+
)
108134
}
109135
}
110136

@@ -114,7 +140,8 @@ internal class StatsigNetwork(
114140
}
115141
try {
116142
return gson.fromJson(specs, APIDownloadedConfigs::class.java)
117-
} catch (e: Exception) {}
143+
} catch (_: Exception) {
144+
}
118145
return null
119146
}
120147

@@ -134,7 +161,8 @@ internal class StatsigNetwork(
134161
lastSyncTime = configs.time
135162
return configs
136163
}
137-
} catch (e: Exception) {}
164+
} catch (_: Exception) {
165+
}
138166

139167
return null
140168
}
@@ -177,7 +205,8 @@ internal class StatsigNetwork(
177205
list.size = list.size + contentLength
178206
}
179207
}
180-
} catch (e: Exception) {}
208+
} catch (_: Exception) {
209+
}
181210
}
182211

183212
suspend fun getAllIDLists(evaluator: Evaluator) {
@@ -196,7 +225,7 @@ internal class StatsigNetwork(
196225
for ((name, serverList) in jsonResponse) {
197226
var localList = allLocalLists[name]
198227
if (localList == null) {
199-
localList = IDList(name=name)
228+
localList = IDList(name = name)
200229
allLocalLists[name] = localList
201230
}
202231
if (serverList.url == null || serverList.fileID == null || serverList.creationTime < localList.creationTime) {
@@ -206,10 +235,10 @@ internal class StatsigNetwork(
206235
// check if fileID has changed and it is indeed a newer file. If so, reset the list
207236
if (serverList.fileID != localList.fileID && serverList.creationTime >= localList.creationTime) {
208237
localList = IDList(
209-
name=name,
210-
url=serverList.url,
211-
fileID=serverList.fileID,
212-
size=0,
238+
name = name,
239+
url = serverList.url,
240+
fileID = serverList.fileID,
241+
size = 0,
213242
creationTime = serverList.creationTime
214243
)
215244
allLocalLists[name] = localList
@@ -234,7 +263,8 @@ internal class StatsigNetwork(
234263
}
235264
}
236265
}
237-
} catch (e: Exception) {}
266+
} catch (_: Exception) {
267+
}
238268
}
239269
}
240270

@@ -262,7 +292,12 @@ internal class StatsigNetwork(
262292
retryPostLogs(events, statsigMetadata, 5, 1)
263293
}
264294

265-
suspend fun retryPostLogs(events: List<StatsigEvent>, statsigMetadata: Map<String, String>, retries: Int, backoff: Int) {
295+
suspend fun retryPostLogs(
296+
events: List<StatsigEvent>,
297+
statsigMetadata: Map<String, String>,
298+
retries: Int,
299+
backoff: Int
300+
) {
266301
if (events.isEmpty()) {
267302
return
268303
}
@@ -285,7 +320,8 @@ internal class StatsigNetwork(
285320
return@coroutineScope
286321
}
287322
}
288-
} catch (e: Exception) { }
323+
} catch (_: Exception) {
324+
}
289325

290326
val count = retries - --currRetry
291327
delay(backoff * (backoffMultiplier * count) * MS_IN_S)
@@ -297,4 +333,4 @@ internal class StatsigNetwork(
297333
statsigHttpClient.dispatcher.executorService.shutdown()
298334
httpClient.dispatcher.executorService.shutdown()
299335
}
300-
}
336+
}

src/main/kotlin/com/statsig/sdk/StatsigOptions.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ class StatsigOptions(
2323
var initTimeoutMs: Long? = DEFAULT_INIT_TIME_OUT_MS,
2424
var bootstrapValues: String? = null,
2525
var rulesUpdatedCallback: ((rules: String) -> Unit)? = null,
26+
var localMode: Boolean = false
2627
) {
2728
constructor(api: String) : this(api, DEFAULT_INIT_TIME_OUT_MS)
2829

src/main/kotlin/com/statsig/sdk/StatsigServer.kt

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ sealed class StatsigServer {
4949

5050
@JvmSynthetic abstract suspend fun shutdownSuspend()
5151

52+
@JvmSynthetic abstract fun overrideGate(gateName: String, gateValue: Boolean)
53+
54+
@JvmSynthetic abstract fun overrideConfig(configName: String, configValue: Map<String, Any>)
55+
5256
fun logEvent(user: StatsigUser?, eventName: String) {
5357
logEvent(user, eventName, null)
5458
}
@@ -148,7 +152,7 @@ private class StatsigServerImpl(serverSecret: String, private val options: Stats
148152
}
149153
}
150154

151-
override val errorBoundary = ErrorBoundary(serverSecret)
155+
override val errorBoundary = ErrorBoundary(serverSecret, options)
152156
private val coroutineExceptionHandler =
153157
CoroutineExceptionHandler { _, ex ->
154158
// no-op - supervisor job should not throw when a child fails
@@ -374,6 +378,14 @@ private class StatsigServerImpl(serverSecret: String, private val options: Stats
374378
}
375379
}
376380

381+
override fun overrideGate(gateName: String, gateValue: Boolean) {
382+
configEvaluator.overrideGate(gateName, gateValue)
383+
}
384+
385+
override fun overrideConfig(configName: String, configValue: Map<String, Any>) {
386+
configEvaluator.overrideConfig(configName, configValue)
387+
}
388+
377389
override fun initializeAsync(): CompletableFuture<Unit> {
378390
return statsigScope.future { initialize() }
379391
}

src/test/java/com/statsig/sdk/ErrorBoundaryTest.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ class ErrorBoundaryTest {
3030
}
3131
}
3232

33-
boundary = ErrorBoundary("secret-key")
33+
boundary = ErrorBoundary("secret-key", StatsigOptions())
3434
boundary.uri = server.url("/v1/sdk_exception").toUri()
3535
}
3636

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package com.statsig.sdk
2+
3+
import kotlinx.coroutines.runBlocking
4+
import org.junit.AfterClass
5+
import org.junit.Assert.*
6+
import org.junit.BeforeClass
7+
import org.junit.Test
8+
9+
class LocalOverridesTest {
10+
private val userA = StatsigUser(userID = "user-a")
11+
private val userB = StatsigUser(userID = "user-b")
12+
13+
companion object {
14+
@BeforeClass
15+
@JvmStatic
16+
internal fun beforeAll() = runBlocking {
17+
val options = StatsigOptions(localMode = true)
18+
Statsig.initialize("secret-local", options)
19+
}
20+
21+
@AfterClass
22+
@JvmStatic
23+
fun afterAll() {
24+
Statsig.shutdown()
25+
}
26+
}
27+
28+
@Test
29+
fun testGateOverrides() = runBlocking {
30+
assertFalse(Statsig.checkGate(userA, "override_me"))
31+
Statsig.overrideGate("override_me", true)
32+
assertTrue(Statsig.checkGate(userA, "override_me"))
33+
assertTrue(Statsig.checkGate(userB, "override_me"))
34+
Statsig.overrideGate("override_me", false)
35+
assertFalse(Statsig.checkGate(userB, "override_me"))
36+
}
37+
38+
@Test
39+
fun testConfigOverrides() = runBlocking {
40+
val emptyMap = mapOf<String, Any>()
41+
42+
assertEquals(Statsig.getConfig(userA, "override_me").value, emptyMap)
43+
44+
var overriddenValue = mapOf("hello" to "its me")
45+
Statsig.overrideConfig("override_me", overriddenValue)
46+
47+
assertEquals(Statsig.getConfig(userA, "override_me").value, overriddenValue)
48+
49+
overriddenValue = mapOf("hello" to "its no longer me")
50+
Statsig.overrideConfig("override_me", overriddenValue)
51+
assertEquals(Statsig.getConfig(userB, "override_me").value, overriddenValue)
52+
53+
Statsig.overrideConfig("override_me", emptyMap)
54+
assertEquals(Statsig.getConfig(userB, "override_me").value, emptyMap)
55+
}
56+
}

0 commit comments

Comments
 (0)