Skip to content

Commit 331ac0d

Browse files
Merge pull request #50 from statsig-io/fix_eb
fix: Fix eb behavior
2 parents a83b1a4 + daae8dc commit 331ac0d

File tree

9 files changed

+159
-48
lines changed

9 files changed

+159
-48
lines changed

build.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ dependencies {
7878
testImplementation 'com.github.tomakehurst:wiremock:2.27.2'
7979
testImplementation "org.slf4j:slf4j-simple:1.8.0-beta4"
8080
testImplementation 'org.junit.jupiter:junit-jupiter:5.8.1'
81+
testImplementation 'com.squareup.okhttp3:mockwebserver:4.9.0'
8182
}
8283

8384

@@ -141,4 +142,3 @@ afterEvaluate {
141142
}
142143
}
143144
}
144-

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

Lines changed: 47 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ package com.statsig.androidlocalevalsdk
22

33
import android.util.Log
44
import kotlinx.coroutines.CoroutineExceptionHandler
5+
import kotlinx.coroutines.CoroutineScope
6+
import kotlinx.coroutines.Dispatchers
7+
import kotlinx.coroutines.launch
58
import java.io.DataOutputStream
69
import java.lang.Math.floor
710
import java.net.HttpURLConnection
@@ -45,6 +48,16 @@ internal class ErrorBoundary() {
4548
}
4649
}
4750

51+
fun getUrl(): String {
52+
return urlString
53+
}
54+
55+
fun getNoopExceptionHandler(): CoroutineExceptionHandler {
56+
return CoroutineExceptionHandler { _, _ ->
57+
// No-op
58+
}
59+
}
60+
4861
fun <T> capture(task: () -> T, tag: String? = null, recover: (() -> T)? = null, configName: String? = null): T? {
4962
var markerID = ""
5063
return try {
@@ -79,39 +92,41 @@ internal class ErrorBoundary() {
7992

8093
internal fun logException(exception: Throwable, message: String? = null, tag: String? = null, configName: String? = null) {
8194
try {
82-
Log.e("STATSIG", "An unexpected exception occured: " + exception)
83-
if (message != null) {
84-
Log.e("STATSIG", message)
95+
CoroutineScope(this.getNoopExceptionHandler() + Dispatchers.IO).launch {
96+
Log.e("STATSIG", "An unexpected exception occured: " + exception)
97+
if (message != null) {
98+
Log.e("STATSIG", message)
99+
}
100+
val name = exception.javaClass.canonicalName ?: exception.javaClass.name
101+
if (seen.contains(name)) {
102+
return@launch
103+
}
104+
seen.add(name)
105+
106+
val metadata = statsigMetadata ?: StatsigMetadata("")
107+
val body = mutableMapOf(
108+
"exception" to name,
109+
"info" to RuntimeException(exception).stackTraceToString(),
110+
"statsigMetadata" to metadata,
111+
)
112+
if (tag != null) {
113+
body["tag"] = tag
114+
}
115+
if (configName != null) {
116+
body["configName"] = configName
117+
}
118+
val postData = StatsigUtils.getGson().toJson(body)
119+
120+
val conn = URL(getUrl()).openConnection() as HttpURLConnection
121+
conn.requestMethod = "POST"
122+
conn.doOutput = true
123+
conn.setRequestProperty("Content-Type", "application/json")
124+
conn.setRequestProperty("STATSIG-API-KEY", apiKey)
125+
conn.useCaches = false
126+
127+
DataOutputStream(conn.outputStream).use { it.writeBytes(postData) }
128+
conn.responseCode // triggers request
85129
}
86-
val name = exception.javaClass.canonicalName ?: exception.javaClass.name
87-
if (seen.contains(name)) {
88-
return
89-
}
90-
seen.add(name)
91-
92-
val metadata = statsigMetadata ?: StatsigMetadata("")
93-
val body = mutableMapOf(
94-
"exception" to name,
95-
"info" to RuntimeException(exception).stackTraceToString(),
96-
"statsigMetadata" to metadata,
97-
)
98-
if (tag != null) {
99-
body["tag"] = tag
100-
}
101-
if (configName != null) {
102-
body["configName"] = configName
103-
}
104-
val postData = StatsigUtils.getGson().toJson(body)
105-
106-
val conn = URL(urlString).openConnection() as HttpURLConnection
107-
conn.requestMethod = "POST"
108-
conn.doOutput = true
109-
conn.setRequestProperty("Content-Type", "application/json")
110-
conn.setRequestProperty("STATSIG-API-KEY", apiKey)
111-
conn.useCaches = false
112-
113-
DataOutputStream(conn.outputStream).use { it.writeBytes(postData) }
114-
conn.responseCode // triggers request
115130
} catch (e: Exception) {
116131
}
117132
}

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ internal data class PersistedEvaluationArgs(
1010
val user: StatsigUser,
1111
val unhashedName: String,
1212
val config: APIConfig,
13-
val persistedValues: PersistedValues?
13+
val persistedValues: PersistedValues?,
1414
)
1515

1616
internal class Evaluator(private val specStore: Store, private val errorBoundary: ErrorBoundary, private val persistentStorage: StatsigUserPersistenStorageHelper?) {
@@ -41,7 +41,7 @@ internal class Evaluator(private val specStore: Store, private val errorBoundary
4141
return evaluateConfig(user, config)
4242
}
4343
return evaluateConfigWithPersistedValues(
44-
PersistedEvaluationArgs(user, configName, config, persistedValues)
44+
PersistedEvaluationArgs(user, configName, config, persistedValues),
4545
)
4646
}
4747

@@ -57,7 +57,7 @@ internal class Evaluator(private val specStore: Store, private val errorBoundary
5757
return evaluateConfig(user, layer)
5858
}
5959
return evaluateLayerWithPersistedValues(
60-
PersistedEvaluationArgs(user, layerName, layer, persistedValues)
60+
PersistedEvaluationArgs(user, layerName, layer, persistedValues),
6161
)
6262
}
6363

@@ -228,7 +228,7 @@ internal class Evaluator(private val specStore: Store, private val errorBoundary
228228
configDelegate = configDelegate,
229229
explicitParameters = config.explicitParameters,
230230
evaluationDetails = this.createEvaluationDetails(this.specStore.initReason),
231-
isExperimentGroup = delegatedResult.isExperimentGroup
231+
isExperimentGroup = delegatedResult.isExperimentGroup,
232232
)
233233
evaluation.undelegatedSecondaryExposures = undelegatedSecondaryExposures
234234
return evaluation

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ class Layer internal constructor(
1414
val allocatedExperiment: String = "",
1515
var evaluationDetails: EvaluationDetails? = null,
1616
private val onExposure: OnLayerExposureInternal? = null,
17-
) {
17+
) {
1818

1919
companion object {
2020
fun empty(name: String): Layer {

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

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import android.content.pm.PackageInfo
77
import android.content.pm.PackageManager
88
import android.util.Log
99
import androidx.annotation.VisibleForTesting
10-
import com.statsig.androidsdk.StatsigNetworkConnectivityListener
1110
import kotlinx.coroutines.CoroutineExceptionHandler
1211
import kotlinx.coroutines.CoroutineScope
1312
import kotlinx.coroutines.Dispatchers
@@ -155,7 +154,7 @@ class StatsigClient {
155154
fun getExperiment(user: StatsigUser, experimentName: String, option: GetExperimentOptions? = null): DynamicConfig {
156155
var result = DynamicConfig.empty()
157156
if (!isInitialized("getExperiment")) {
158-
result.evaluationDetails = EvaluationDetails(0,EvaluationReason.UNINITIALIZED)
157+
result.evaluationDetails = EvaluationDetails(0, EvaluationReason.UNINITIALIZED)
159158
return result
160159
}
161160
errorBoundary.capture({
@@ -195,7 +194,7 @@ class StatsigClient {
195194
fun getConfig(user: StatsigUser, dynamicConfigName: String, option: GetConfigOptions? = null): DynamicConfig {
196195
var result = DynamicConfig.empty()
197196
if (!isInitialized("getExperiment")) {
198-
result.evaluationDetails = EvaluationDetails(0,EvaluationReason.UNINITIALIZED)
197+
result.evaluationDetails = EvaluationDetails(0, EvaluationReason.UNINITIALIZED)
199198
return result
200199
}
201200
errorBoundary.capture({
@@ -234,7 +233,7 @@ class StatsigClient {
234233
fun getLayer(user: StatsigUser, layerName: String, option: GetLayerOptions? = null): Layer {
235234
var result = Layer.empty(layerName)
236235
if (!isInitialized("getExperiment")) {
237-
result.evaluationDetails = EvaluationDetails(0,EvaluationReason.UNINITIALIZED)
236+
result.evaluationDetails = EvaluationDetails(0, EvaluationReason.UNINITIALIZED)
238237
return result
239238
}
240239
errorBoundary.capture({
@@ -365,7 +364,7 @@ class StatsigClient {
365364
diagnostics = Diagnostics(options.disableDiagnosticsLogging)
366365
if (!this::statsigNetwork.isInitialized) {
367366
// For testing purpose, prevent mocked network be overwritten
368-
statsigNetwork = StatsigNetwork(application,sdkKey, options, sharedPrefs, diagnostics)
367+
statsigNetwork = StatsigNetwork(application, sdkKey, options, sharedPrefs, diagnostics)
369368
}
370369
statsigMetadata = StatsigMetadata()
371370
populateStatsigMetadata()

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

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -200,10 +200,9 @@ internal class StatsigNetwork(
200200
statusCode = code,
201201
error = errorMarker,
202202
attempt = retryAttempt,
203-
hasNetwork = connectivityListener.isNetworkAvailable()
203+
hasNetwork = connectivityListener.isNetworkAvailable(),
204204
),
205205
)
206-
207206
when (code) {
208207
in 200..299 -> {
209208
if (code == 204) {
@@ -237,7 +236,7 @@ internal class StatsigNetwork(
237236
Marker(
238237
error = Marker.ErrorMessage(message = e.message, name = e.javaClass.name),
239238
attempt = retryAttempt,
240-
hasNetwork = connectivityListener.isNetworkAvailable()
239+
hasNetwork = connectivityListener.isNetworkAvailable(),
241240
),
242241
)
243242
// Leave to ErrorBoundary to handle

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,4 @@ class StatsigNetworkConnectivityListener(
2020

2121
return connectivityManager.activeNetworkInfo?.isConnectedOrConnecting == true
2222
}
23-
}
23+
}

src/test/java/com/statsig/androidlocalevalsdk/DiagnosticsTest.kt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,6 @@ class DiagnosticsTest {
7171
}
7272
client.shutdown()
7373
val apiMarkers = parseMarkers(ContextType.API_CALL)
74-
println(apiMarkers.size)
7574
assert(apiMarkers.size == MAX_MARKERS)
7675
}
7776

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package com.statsig.androidlocalevalsdk
2+
3+
import android.app.Application
4+
import io.mockk.coEvery
5+
import io.mockk.every
6+
import io.mockk.mockk
7+
import io.mockk.mockkConstructor
8+
import io.mockk.spyk
9+
import kotlinx.coroutines.*
10+
import okhttp3.mockwebserver.Dispatcher
11+
import okhttp3.mockwebserver.MockResponse
12+
import okhttp3.mockwebserver.MockWebServer
13+
import okhttp3.mockwebserver.RecordedRequest
14+
import org.junit.After
15+
import org.junit.Assert.assertTrue
16+
import org.junit.Before
17+
import org.junit.Test
18+
import java.util.concurrent.CountDownLatch
19+
import java.util.concurrent.TimeUnit
20+
21+
class StatsigInitializationTimeoutTest {
22+
23+
private var app: Application = mockk()
24+
private lateinit var client: StatsigClient
25+
private lateinit var network: StatsigNetwork
26+
private lateinit var errorBoundary: ErrorBoundary
27+
private lateinit var mockWebServer: MockWebServer
28+
private val hitErrorBoundary = CountDownLatch(1)
29+
30+
@Before
31+
fun setup() {
32+
mockWebServer = MockWebServer()
33+
val dispatcher = object : Dispatcher() {
34+
override fun dispatch(request: RecordedRequest): MockResponse {
35+
return if (request.path!!.contains("sdk_exception")) {
36+
hitErrorBoundary.countDown()
37+
runBlocking {
38+
delay(1000)
39+
}
40+
MockResponse()
41+
.setBody("{\"result\":\"error logged\"}")
42+
.setResponseCode(200)
43+
} else {
44+
MockResponse().setResponseCode(404)
45+
}
46+
}
47+
}
48+
mockWebServer.dispatcher = dispatcher
49+
mockWebServer.start()
50+
client = spyk(StatsigClient(), recordPrivateCalls = true)
51+
client.errorBoundary = spyk(client.errorBoundary)
52+
errorBoundary = client.errorBoundary
53+
network = TestUtil.mockNetwork()
54+
55+
TestUtil.mockDispatchers()
56+
TestUtil.stubAppFunctions(app)
57+
58+
// Lets get a successful network response, and then trigger error boundary
59+
// so we can test that eb does not block the initialization beyond the init timeout
60+
mockkConstructor(Store::class)
61+
coEvery {
62+
anyConstructed<Store>().fetchAndSave()
63+
} throws(Exception("trigger the error boundary"))
64+
65+
every {
66+
errorBoundary.getUrl()
67+
} returns mockWebServer.url("/v1/sdk_exception").toString()
68+
69+
client.statsigNetwork = network
70+
client.errorBoundary = errorBoundary
71+
}
72+
73+
@After
74+
fun tearDown() {
75+
mockWebServer.shutdown()
76+
}
77+
78+
@Test
79+
fun testInitializeAsyncWithSlowErrorBoundary() = runBlocking {
80+
var initializationDetails: InitializationDetails? = null
81+
var initTimeout = 500
82+
runBlocking {
83+
initializationDetails = client.initialize(app, "client-key", StatsigOptions(initTimeoutMs = initTimeout))
84+
}
85+
// initialize timeout was hit, we got a value back and we are considered initialized
86+
assert(initializationDetails != null)
87+
assert(client.isInitialized())
88+
89+
// error boundary was hit, but has not completed at this point, so the initialization timeout worked
90+
assertTrue(hitErrorBoundary.await(1, TimeUnit.SECONDS))
91+
assertTrue(
92+
"initialization time ${initializationDetails!!.duration} not less than initTimeout $initTimeout",
93+
initializationDetails!!.duration < initTimeout + 100L,
94+
)
95+
96+
// error boundary was hit, but has not completed at this point, so the initialization timeout worked
97+
assert(hitErrorBoundary.await(1, TimeUnit.SECONDS))
98+
}
99+
}

0 commit comments

Comments
 (0)