Skip to content

Commit a08f0d5

Browse files
committed
feat: callback for setEvaluationContext as current method is fire-and-forget
1 parent 10e070c commit a08f0d5

File tree

9 files changed

+167
-10
lines changed

9 files changed

+167
-10
lines changed

features/dd-sdk-android-flags/api/apiSurface

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
interface com.datadog.android.flags.EvaluationContextCallback
2+
fun onSuccess()
3+
fun onFailure(Throwable)
14
object com.datadog.android.flags.Flags
25
fun enable(FlagsConfiguration = FlagsConfiguration.default, com.datadog.android.api.SdkCore = Datadog.getInstance())
36
interface com.datadog.android.flags.FlagsClient
4-
fun setEvaluationContext(com.datadog.android.flags.model.EvaluationContext)
7+
fun setEvaluationContext(com.datadog.android.flags.model.EvaluationContext, EvaluationContextCallback? = null)
58
fun resolveBooleanValue(String, Boolean): Boolean
69
fun resolveStringValue(String, String): String
710
fun resolveDoubleValue(String, Double): Double

features/dd-sdk-android-flags/api/dd-sdk-android-flags.api

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
public abstract interface class com/datadog/android/flags/EvaluationContextCallback {
2+
public abstract fun onFailure (Ljava/lang/Throwable;)V
3+
public abstract fun onSuccess ()V
4+
}
5+
16
public final class com/datadog/android/flags/Flags {
27
public static final field INSTANCE Lcom/datadog/android/flags/Flags;
38
public static final fun enable ()V
@@ -18,7 +23,7 @@ public abstract interface class com/datadog/android/flags/FlagsClient {
1823
public abstract fun resolveIntValue (Ljava/lang/String;I)I
1924
public abstract fun resolveStringValue (Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;
2025
public abstract fun resolveStructureValue (Ljava/lang/String;Lorg/json/JSONObject;)Lorg/json/JSONObject;
21-
public abstract fun setEvaluationContext (Lcom/datadog/android/flags/model/EvaluationContext;)V
26+
public abstract fun setEvaluationContext (Lcom/datadog/android/flags/model/EvaluationContext;Lcom/datadog/android/flags/EvaluationContextCallback;)V
2227
}
2328

2429
public final class com/datadog/android/flags/FlagsClient$Builder {
@@ -34,6 +39,10 @@ public final class com/datadog/android/flags/FlagsClient$Companion {
3439
public static synthetic fun get$default (Lcom/datadog/android/flags/FlagsClient$Companion;Ljava/lang/String;Lcom/datadog/android/api/SdkCore;ILjava/lang/Object;)Lcom/datadog/android/flags/FlagsClient;
3540
}
3641

42+
public final class com/datadog/android/flags/FlagsClient$DefaultImpls {
43+
public static synthetic fun setEvaluationContext$default (Lcom/datadog/android/flags/FlagsClient;Lcom/datadog/android/flags/model/EvaluationContext;Lcom/datadog/android/flags/EvaluationContextCallback;ILjava/lang/Object;)V
44+
}
45+
3746
public final class com/datadog/android/flags/FlagsConfiguration {
3847
public static final field Companion Lcom/datadog/android/flags/FlagsConfiguration$Companion;
3948
public final fun copy (ZLjava/lang/String;Ljava/lang/String;ZZ)Lcom/datadog/android/flags/FlagsConfiguration;

features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsClient.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,13 @@ interface FlagsClient {
6767
* The context is used to determine which flag values to return based on user targeting
6868
* rules. This method triggers a background fetch of updated flag evaluations.
6969
*
70+
* This method returns immediately without blocking. The actual context update and flag
71+
* fetching happen asynchronously on a background thread.
72+
*
7073
* @param context The [EvaluationContext] containing targeting key and attributes.
74+
* @param callback Optional callback to notify when the operation completes or fails.
7175
*/
72-
fun setEvaluationContext(context: EvaluationContext)
76+
fun setEvaluationContext(context: EvaluationContext, callback: EvaluationContextCallback? = null)
7377

7478
/**
7579
* Resolves a boolean flag value.

features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/DatadogFlagsClient.kt

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ package com.datadog.android.flags.internal
88

99
import com.datadog.android.api.InternalLogger
1010
import com.datadog.android.api.feature.FeatureSdkCore
11+
import com.datadog.android.flags.EvaluationContextCallback
1112
import com.datadog.android.flags.FlagsClient
1213
import com.datadog.android.flags.FlagsConfiguration
1314
import com.datadog.android.flags.StateObservable
@@ -66,9 +67,11 @@ internal class DatadogFlagsClient(
6667
*
6768
* @param context The evaluation context containing targeting key and attributes.
6869
* Must contain a valid targeting key; invalid contexts are logged and ignored.
70+
* @param callback Optional callback to notify when the operation completes or fails.
71+
* Invoked on a background executor thread after the state transition.
6972
*/
70-
override fun setEvaluationContext(context: EvaluationContext) {
71-
evaluationsManager.updateEvaluationsForContext(context)
73+
override fun setEvaluationContext(context: EvaluationContext, callback: EvaluationContextCallback?) {
74+
evaluationsManager.updateEvaluationsForContext(context, callback)
7275
}
7376

7477
/**

features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/NoOpFlagsClient.kt

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
package com.datadog.android.flags.internal
88

99
import com.datadog.android.api.InternalLogger
10+
import com.datadog.android.flags.EvaluationContextCallback
1011
import com.datadog.android.flags.FlagsClient
1112
import com.datadog.android.flags.FlagsStateListener
1213
import com.datadog.android.flags.StateObservable
@@ -43,10 +44,17 @@ internal class NoOpFlagsClient(
4344

4445
/**
4546
* No-op implementation that ignores context updates and logs a warning.
47+
*
48+
* Consistent with the graceful degradation pattern, this method succeeds silently
49+
* (invoking the success callback) while logging the no-op behavior. The actual error
50+
* state is communicated via [state.getCurrentState] which returns [FlagsClientState.Error].
51+
*
4652
* @param context Ignored evaluation context.
53+
* @param callback Optional callback invoked immediately with success.
4754
*/
48-
override fun setEvaluationContext(context: EvaluationContext) {
55+
override fun setEvaluationContext(context: EvaluationContext, callback: EvaluationContextCallback?) {
4956
logOperation("setEvaluationContext", InternalLogger.Level.WARN)
57+
callback?.onSuccess()
5058
}
5159

5260
/**

features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManager.kt

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ package com.datadog.android.flags.internal.evaluation
88

99
import com.datadog.android.api.InternalLogger
1010
import com.datadog.android.core.internal.utils.executeSafe
11+
import com.datadog.android.flags.EvaluationContextCallback
1112
import com.datadog.android.flags.internal.FlagsStateManager
1213
import com.datadog.android.flags.internal.net.PrecomputedAssignmentsReader
1314
import com.datadog.android.flags.internal.repository.FlagsRepository
@@ -50,8 +51,9 @@ internal class EvaluationsManager(
5051
*
5152
* @param context The evaluation context to process. Must be non-null and contain
5253
* a valid targeting key.
54+
* @param callback Optional callback invoked when the context is set and the flags have been fetched successfully or not.
5355
*/
54-
fun updateEvaluationsForContext(context: EvaluationContext) {
56+
fun updateEvaluationsForContext(context: EvaluationContext, callback: EvaluationContextCallback? = null) {
5557
flagStateManager.updateState(FlagsClientState.Reconciling)
5658

5759
executorService.executeSafe(
@@ -76,18 +78,21 @@ internal class EvaluationsManager(
7678
)
7779

7880
flagStateManager.updateState(FlagsClientState.Ready)
81+
callback?.onSuccess()
7982
} else {
8083
internalLogger.log(
8184
InternalLogger.Level.WARN,
8285
InternalLogger.Target.USER,
8386
{ NETWORK_REQUEST_FAILED_MESSAGE }
8487
)
8588

89+
val throwable = Throwable(NETWORK_REQUEST_FAILED_MESSAGE)
8690
if (hadFlags) {
8791
flagStateManager.updateState(FlagsClientState.Stale)
8892
} else {
89-
flagStateManager.updateState(FlagsClientState.Error(Throwable(NETWORK_REQUEST_FAILED_MESSAGE)))
93+
flagStateManager.updateState(FlagsClientState.Error(throwable))
9094
}
95+
callback?.onFailure(throwable)
9196
}
9297
}
9398
}

features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/NoOpFlagsClientTest.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import org.mockito.Mock
2525
import org.mockito.junit.jupiter.MockitoExtension
2626
import org.mockito.junit.jupiter.MockitoSettings
2727
import org.mockito.kotlin.argThat
28+
import org.mockito.kotlin.argumentCaptor
2829
import org.mockito.kotlin.eq
2930
import org.mockito.kotlin.mock
3031
import org.mockito.kotlin.verify
@@ -95,6 +96,20 @@ internal class NoOpFlagsClientTest {
9596
)
9697
}
9798

99+
@Test
100+
fun `M invoke onSuccess W setEvaluationContext() { with callback }`(forge: Forge) {
101+
// Given
102+
val fakeContext = EvaluationContext(forge.anAlphabeticalString(), emptyMap())
103+
val mockCallback = mock<EvaluationContextCallback>()
104+
105+
// When
106+
testedClient.setEvaluationContext(fakeContext, callback = mockCallback)
107+
108+
// Then
109+
// NoOp client follows graceful degradation pattern - succeeds silently
110+
verify(mockCallback).onSuccess()
111+
}
112+
98113
// endregion
99114

100115
// region resolveBooleanValue()

features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ package com.datadog.android.flags.internal
99
import com.datadog.android.api.InternalLogger
1010
import com.datadog.android.api.feature.Feature.Companion.RUM_FEATURE_NAME
1111
import com.datadog.android.api.feature.FeatureSdkCore
12+
import com.datadog.android.flags.EvaluationContextCallback
1213
import com.datadog.android.flags.FlagsConfiguration
1314
import com.datadog.android.flags.FlagsStateListener
1415
import com.datadog.android.flags.internal.evaluation.EvaluationsManager
@@ -34,6 +35,7 @@ import org.mockito.Mockito.mock
3435
import org.mockito.junit.jupiter.MockitoExtension
3536
import org.mockito.junit.jupiter.MockitoSettings
3637
import org.mockito.kotlin.any
38+
import org.mockito.kotlin.anyOrNull
3739
import org.mockito.kotlin.argumentCaptor
3840
import org.mockito.kotlin.doReturn
3941
import org.mockito.kotlin.eq
@@ -871,7 +873,10 @@ internal class DatadogFlagsClientTest {
871873

872874
// Then
873875
val contextCaptor = argumentCaptor<EvaluationContext>()
874-
verify(mockEvaluationsManager).updateEvaluationsForContext(contextCaptor.capture())
876+
verify(mockEvaluationsManager).updateEvaluationsForContext(
877+
contextCaptor.capture(),
878+
anyOrNull()
879+
)
875880

876881
val capturedContext = contextCaptor.firstValue
877882
assertThat(capturedContext.targetingKey).isEqualTo(fakeTargetingKey)
@@ -894,7 +899,10 @@ internal class DatadogFlagsClientTest {
894899
// Then
895900
// Verify that blank targeting keys are allowed and processed
896901
val contextCaptor = argumentCaptor<EvaluationContext>()
897-
verify(mockEvaluationsManager).updateEvaluationsForContext(contextCaptor.capture())
902+
verify(mockEvaluationsManager).updateEvaluationsForContext(
903+
contextCaptor.capture(),
904+
anyOrNull()
905+
)
898906

899907
val capturedContext = contextCaptor.firstValue
900908
assertThat(capturedContext.targetingKey).isEmpty()
@@ -919,6 +927,37 @@ internal class DatadogFlagsClientTest {
919927
verify(mockEvaluationsManager).updateEvaluationsForContext(fakeContext)
920928
}
921929

930+
@Test
931+
fun `M delegate callback W setEvaluationContext() { with callback }`(forge: Forge) {
932+
// Given
933+
val fakeContext = EvaluationContext(forge.anAlphabeticalString(), emptyMap())
934+
val mockCallback = mock<EvaluationContextCallback>()
935+
936+
// When
937+
testedClient.setEvaluationContext(fakeContext, mockCallback)
938+
939+
// Then
940+
verify(mockEvaluationsManager).updateEvaluationsForContext(
941+
eq(fakeContext),
942+
eq(mockCallback)
943+
)
944+
}
945+
946+
@Test
947+
fun `M delegate with null callback W setEvaluationContext() { without callback }`(forge: Forge) {
948+
// Given
949+
val fakeContext = EvaluationContext(forge.anAlphabeticalString(), emptyMap())
950+
951+
// When
952+
testedClient.setEvaluationContext(fakeContext)
953+
954+
// Then
955+
verify(mockEvaluationsManager).updateEvaluationsForContext(
956+
eq(fakeContext),
957+
eq(null)
958+
)
959+
}
960+
922961
// endregion
923962

924963
// region exposure event logging configuration

features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManagerTest.kt

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
package com.datadog.android.flags.internal.evaluation
88

99
import com.datadog.android.api.InternalLogger
10+
import com.datadog.android.flags.EvaluationContextCallback
1011
import com.datadog.android.flags.internal.FlagsStateManager
1112
import com.datadog.android.flags.internal.model.PrecomputedFlag
1213
import com.datadog.android.flags.internal.net.PrecomputedAssignmentsReader
@@ -284,5 +285,75 @@ internal class EvaluationsManagerTest {
284285
}
285286
}
286287

288+
@Test
289+
fun `M invoke onSuccess W updateEvaluationsForContext() { success }`() {
290+
// Given
291+
val publicContext = EvaluationContext(fakeTargetingKey, emptyMap())
292+
val mockCallback = org.mockito.kotlin.mock<EvaluationContextCallback>()
293+
val jsonResponse = "{\"data\": {\"attributes\": {\"flags\": {}}}}"
294+
val flagsMap = emptyMap<String, PrecomputedFlag>()
295+
296+
whenever(mockAssignmentsDownloader.readPrecomputedFlags(publicContext)).thenReturn(jsonResponse)
297+
whenever(mockPrecomputeMapper.map(jsonResponse)).thenReturn(flagsMap)
298+
299+
// When
300+
evaluationsManager.updateEvaluationsForContext(publicContext, callback = mockCallback)
301+
302+
// Then
303+
verify(mockCallback).onSuccess()
304+
}
305+
306+
@Test
307+
fun `M invoke onFailure W updateEvaluationsForContext() { network failure, no cached flags }`() {
308+
// Given
309+
val publicContext = EvaluationContext(fakeTargetingKey, emptyMap())
310+
val mockCallback = org.mockito.kotlin.mock<EvaluationContextCallback>()
311+
312+
whenever(mockFlagsRepository.hasFlags()).thenReturn(false)
313+
whenever(mockAssignmentsDownloader.readPrecomputedFlags(publicContext)).thenReturn(null)
314+
315+
// When
316+
evaluationsManager.updateEvaluationsForContext(publicContext, callback = mockCallback)
317+
318+
// Then
319+
argumentCaptor<Throwable>().apply {
320+
verify(mockCallback).onFailure(capture())
321+
assertThat(firstValue.message).contains("Unable to fetch feature flags")
322+
}
323+
}
324+
325+
@Test
326+
fun `M invoke onFailure W updateEvaluationsForContext() { network failure, has cached flags }`() {
327+
// Given
328+
val publicContext = EvaluationContext(fakeTargetingKey, emptyMap())
329+
val mockCallback = org.mockito.kotlin.mock<EvaluationContextCallback>()
330+
331+
whenever(mockFlagsRepository.hasFlags()).thenReturn(true)
332+
whenever(mockAssignmentsDownloader.readPrecomputedFlags(publicContext)).thenReturn(null)
333+
334+
// When
335+
evaluationsManager.updateEvaluationsForContext(publicContext, callback = mockCallback)
336+
337+
// Then
338+
argumentCaptor<Throwable>().apply {
339+
verify(mockCallback).onFailure(capture())
340+
assertThat(firstValue.message).contains("Unable to fetch feature flags")
341+
}
342+
}
343+
344+
@Test
345+
fun `M not invoke callback W updateEvaluationsForContext() { callback is null }`() {
346+
// Given
347+
val publicContext = EvaluationContext(fakeTargetingKey, emptyMap())
348+
val jsonResponse = "{\"data\": {\"attributes\": {\"flags\": {}}}}"
349+
val flagsMap = emptyMap<String, PrecomputedFlag>()
350+
351+
whenever(mockAssignmentsDownloader.readPrecomputedFlags(publicContext)).thenReturn(jsonResponse)
352+
whenever(mockPrecomputeMapper.map(jsonResponse)).thenReturn(flagsMap)
353+
354+
// When/Then - should not throw
355+
evaluationsManager.updateEvaluationsForContext(publicContext, callback = null)
356+
}
357+
287358
// endregion
288359
}

0 commit comments

Comments
 (0)