Skip to content

Commit 629bc39

Browse files
authored
test: O11Y-741 - Replace direct dispatcher usage with a provider for testing (#287)
## Summary This commit replaces direct calls to `kotlinx.coroutines.Dispatchers` with a `DispatcherProvider` abstraction. This change facilitates testing by allowing coroutine dispatchers to be overridden. ## How did you test this change? These changes are part of the test suite ## Are there any deployment considerations? No <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Replace direct coroutine dispatcher usage with an injectable provider, and add test hooks and e2e rule to override dispatchers in tests. > > - **SDK**: > - Replace direct `Dispatchers` usage with `DispatcherProviderHolder.current.io` in `InstrumentationManager`, `GraphQLClient`, and `RRwebGraphQLReplayLogExporter`. > - Add `coroutines/DispatcherProvider` abstraction and `DispatcherProviderHolder`. > - **Testing**: > - Add test fixture `ObservabilityDispatcherTestHooks` to override SDK dispatchers. > - Introduce `TestCoroutineRule` in e2e tests; update `SamplingE2ETest` to use `@Rule` and `runTest`. > - Minor test cleanup in `DisablingConfigOptionsE2ETest` (null-check compactness). > - **Build/Config**: > - Enable `testFixtures` in `lib/build.gradle.kts`; add `testFixturesImplementation` for coroutines-test. > - In e2e app, depend on `testFixtures(project(":observability-android"))`. > - Set `android.experimental.enableTestFixturesKotlinSupport=true` in `gradle.properties`. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit a2e32b5. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 648ad26 commit 629bc39

File tree

11 files changed

+137
-10
lines changed

11 files changed

+137
-10
lines changed

e2e/android/app/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ dependencies {
8989
testImplementation(libs.robolectric)
9090
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
9191
testImplementation("io.opentelemetry:opentelemetry-sdk-testing:1.51.0")
92+
testImplementation(testFixtures(project(":observability-android")))
9293

9394
androidTestImplementation(libs.androidx.junit)
9495
androidTestImplementation(libs.androidx.espresso.core)

e2e/android/app/src/test/java/com/example/androidobservability/DisablingConfigOptionsE2ETest.kt

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -184,8 +184,7 @@ class DisablingConfigOptionsE2ETest {
184184

185185
private fun requestsContainsUrl(url: String): Boolean {
186186
while (true) {
187-
val request = application.mockWebServer?.takeRequest(100, TimeUnit.MILLISECONDS)
188-
if (request == null) return false
187+
val request = application.mockWebServer?.takeRequest(100, TimeUnit.MILLISECONDS) ?: return false
189188
if (request.requestUrl.toString() == url) return true
190189
}
191190
}

e2e/android/app/src/test/java/com/example/androidobservability/SamplingE2ETest.kt

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ import io.opentelemetry.api.common.AttributeKey
1010
import io.opentelemetry.api.common.Attributes
1111
import io.opentelemetry.api.logs.Severity
1212
import junit.framework.TestCase.assertEquals
13+
import kotlinx.coroutines.test.runTest
1314
import org.junit.Before
15+
import org.junit.Rule
1416
import org.junit.Test
1517
import org.junit.runner.RunWith
1618
import org.robolectric.RobolectricTestRunner
@@ -39,6 +41,9 @@ import org.robolectric.annotation.Config
3941
@Config(application = TestApplication::class)
4042
class SamplingE2ETest {
4143

44+
@get:Rule
45+
val testCoroutineRule = TestCoroutineRule()
46+
4247
private val application = ApplicationProvider.getApplicationContext<Application>() as TestApplication
4348
private var telemetryInspector: TelemetryInspector? = null
4449

@@ -49,7 +54,7 @@ class SamplingE2ETest {
4954
}
5055

5156
@Test
52-
fun `should avoid exporting logs matching sampling configuration for logs`() {
57+
fun `should avoid exporting logs matching sampling configuration for logs`() = runTest {
5358
triggerLogs()
5459
telemetryInspector?.logExporter?.flush()
5560
waitForTelemetryData(telemetryInspector = application.telemetryInspector, telemetryType = TelemetryType.LOGS)
@@ -65,7 +70,7 @@ class SamplingE2ETest {
6570
}
6671

6772
@Test
68-
fun `should avoid exporting spans matching sampling configuration for spans`() {
73+
fun `should avoid exporting spans matching sampling configuration for spans`() = runTest {
6974
triggerSpans()
7075
telemetryInspector?.spanExporter?.flush()
7176
waitForTelemetryData(telemetryInspector = application.telemetryInspector, telemetryType = TelemetryType.SPANS)
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package com.example.androidobservability
2+
3+
import com.launchdarkly.observability.testing.ObservabilityDispatcherTestHooks
4+
import kotlinx.coroutines.ExperimentalCoroutinesApi
5+
import kotlinx.coroutines.test.TestDispatcher
6+
import kotlinx.coroutines.test.UnconfinedTestDispatcher
7+
import org.junit.rules.TestWatcher
8+
import org.junit.runner.Description
9+
10+
@OptIn(ExperimentalCoroutinesApi::class)
11+
class TestCoroutineRule : TestWatcher() {
12+
private val testDispatcher: TestDispatcher = UnconfinedTestDispatcher()
13+
14+
val dispatcher: TestDispatcher
15+
get() = testDispatcher
16+
17+
override fun starting(description: Description) {
18+
ObservabilityDispatcherTestHooks.overrideWith(testDispatcher)
19+
}
20+
21+
override fun finished(description: Description) {
22+
ObservabilityDispatcherTestHooks.reset()
23+
}
24+
}

e2e/android/gradle.properties

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,4 @@ kotlin.code.style=official
2121
# resources declared in the library itself and none from the library's dependencies,
2222
# thereby reducing the size of the R class for that library
2323
android.nonTransitiveRClass=true
24+
android.experimental.enableTestFixturesKotlinSupport=true

sdk/@launchdarkly/observability-android/lib/build.gradle.kts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ dependencies {
6767

6868
testImplementation("io.mockk:mockk:1.14.5")
6969
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
70+
71+
testFixturesImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
7072
}
7173

7274
val releaseVersion = version.toString()
@@ -109,6 +111,10 @@ android {
109111
withSourcesJar()
110112
}
111113
}
114+
115+
testFixtures {
116+
enable = true
117+
}
112118
}
113119

114120
publishing {

sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/InstrumentationManager.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import com.launchdarkly.observability.sampling.ExportSampler
1111
import com.launchdarkly.observability.sampling.SamplingConfig
1212
import com.launchdarkly.observability.sampling.SamplingLogExporter
1313
import com.launchdarkly.observability.sampling.SamplingTraceExporter
14+
import com.launchdarkly.observability.coroutines.DispatcherProviderHolder
1415
import io.opentelemetry.android.OpenTelemetryRum
1516
import io.opentelemetry.android.config.OtelRumConfig
1617
import io.opentelemetry.android.features.diskbuffering.DiskBufferingConfig
@@ -43,7 +44,6 @@ import io.opentelemetry.sdk.trace.SdkTracerProviderBuilder
4344
import io.opentelemetry.sdk.trace.export.BatchSpanProcessor
4445
import io.opentelemetry.sdk.trace.export.SpanExporter
4546
import kotlinx.coroutines.CoroutineScope
46-
import kotlinx.coroutines.Dispatchers
4747
import kotlinx.coroutines.SupervisorJob
4848
import kotlinx.coroutines.launch
4949
import java.util.concurrent.ConcurrentHashMap
@@ -83,7 +83,7 @@ class InstrumentationManager(
8383
private val upDownCounterCache = ConcurrentHashMap<String, LongUpDownCounter>()
8484

8585
//TODO: Evaluate if this class should have a close/shutdown method to close this scope
86-
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
86+
private val scope = CoroutineScope(DispatcherProviderHolder.current.io + SupervisorJob())
8787

8888
init {
8989
initializeTelemetryInspector()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package com.launchdarkly.observability.coroutines
2+
3+
import kotlinx.coroutines.CoroutineDispatcher
4+
import kotlinx.coroutines.Dispatchers
5+
6+
internal interface DispatcherProvider {
7+
val main: CoroutineDispatcher
8+
val io: CoroutineDispatcher
9+
val default: CoroutineDispatcher
10+
val unconfined: CoroutineDispatcher
11+
}
12+
13+
internal object DefaultDispatcherProvider : DispatcherProvider {
14+
override val main: CoroutineDispatcher = Dispatchers.Main
15+
override val io: CoroutineDispatcher = Dispatchers.IO
16+
override val default: CoroutineDispatcher = Dispatchers.Default
17+
override val unconfined: CoroutineDispatcher = Dispatchers.Unconfined
18+
}
19+
20+
internal object DispatcherProviderHolder {
21+
@Volatile
22+
private var provider: DispatcherProvider = DefaultDispatcherProvider
23+
24+
val current: DispatcherProvider
25+
get() = provider
26+
27+
internal fun set(provider: DispatcherProvider) {
28+
this.provider = provider
29+
}
30+
31+
internal fun reset() {
32+
provider = DefaultDispatcherProvider
33+
}
34+
}

sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/network/GraphQLClient.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.launchdarkly.observability.network
22

3+
import com.launchdarkly.observability.coroutines.DispatcherProviderHolder
34
import kotlinx.coroutines.Dispatchers
45
import kotlinx.coroutines.withContext
56
import kotlinx.serialization.KSerializer
@@ -72,7 +73,7 @@ class GraphQLClient(
7273
queryFileName: String,
7374
variables: Map<String, JsonElement> = emptyMap(),
7475
dataSerializer: KSerializer<T>
75-
): GraphQLResponse<T> = withContext(Dispatchers.IO) {
76+
): GraphQLResponse<T> = withContext(DispatcherProviderHolder.current.io) {
7677
try {
7778
val query = loadQuery(queryFileName)
7879
val request = GraphQLRequest(

sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/RRwebGraphQLReplayLogExporter.kt

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.launchdarkly.observability.replay
22

3+
import com.launchdarkly.observability.coroutines.DispatcherProviderHolder
34
import com.launchdarkly.observability.network.GraphQLClient
45
import io.opentelemetry.api.common.AttributeKey
56
import io.opentelemetry.sdk.common.CompletableResultCode
@@ -31,7 +32,7 @@ class RRwebGraphQLReplayLogExporter(
3132
val serviceVersion: String,
3233
private val injectedReplayApiService: SessionReplayApiService? = null
3334
) : LogRecordExporter {
34-
private val coroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
35+
private val coroutineScope = CoroutineScope(DispatcherProviderHolder.current.io + SupervisorJob())
3536

3637
private var graphqlClient: GraphQLClient = GraphQLClient(backendUrl)
3738
private val replayApiService: SessionReplayApiService = injectedReplayApiService ?: SessionReplayApiService(
@@ -136,7 +137,7 @@ class RRwebGraphQLReplayLogExporter(
136137
*
137138
* @param capture the capture to be sent
138139
*/
139-
suspend fun sendCaptureIncremental(capture: Capture): Boolean = withContext(Dispatchers.IO) {
140+
suspend fun sendCaptureIncremental(capture: Capture): Boolean = withContext(DispatcherProviderHolder.current.io) {
140141
try {
141142
val eventsBatch = mutableListOf<Event>()
142143
val timestamp = System.currentTimeMillis()
@@ -188,7 +189,7 @@ class RRwebGraphQLReplayLogExporter(
188189
*
189190
* @param capture the capture to be sent
190191
*/
191-
suspend fun sendCaptureFull(capture: Capture): Boolean = withContext(Dispatchers.IO) {
192+
suspend fun sendCaptureFull(capture: Capture): Boolean = withContext(DispatcherProviderHolder.current.io) {
192193
try {
193194
replayApiService.initializeReplaySession(organizationVerboseId, capture.session)
194195
replayApiService.identifyReplaySession(capture.session)

0 commit comments

Comments
 (0)