From d23f56a1296107b1f8577cdabbfb71338b639172 Mon Sep 17 00:00:00 2001 From: Sameeran Kunche Date: Tue, 20 Oct 2026 23:34:20 -0700 Subject: [PATCH 1/7] FFL-3355: Add independent native flag tracking operations --- features/dd-sdk-android-flags/api/apiSurface | 2 + .../api/dd-sdk-android-flags.api | 2 + .../android/flags/_FlagsInternalProxy.kt | 26 ++ .../flags/internal/DatadogFlagsClient.kt | 52 ++-- .../flags/internal/FlagsTrackingBridgeTest.kt | 246 ++++++++++++++++++ 5 files changed, 311 insertions(+), 17 deletions(-) create mode 100644 features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagsTrackingBridgeTest.kt diff --git a/features/dd-sdk-android-flags/api/apiSurface b/features/dd-sdk-android-flags/api/apiSurface index d2a9d5ffac..4baf5cf780 100644 --- a/features/dd-sdk-android-flags/api/apiSurface +++ b/features/dd-sdk-android-flags/api/apiSurface @@ -47,6 +47,8 @@ class com.datadog.android.flags._FlagsInternalProxy constructor(FlagsClient) fun getFlagAssignmentsSnapshot(): Map fun trackFlagSnapshotEvaluation(String, com.datadog.android.flags.model.UnparsedFlag, com.datadog.android.flags.model.EvaluationContext) + fun trackFlagExposure(String, com.datadog.android.flags.model.UnparsedFlag, com.datadog.android.flags.model.EvaluationContext) + fun trackFlagEvaluationEvent(String, com.datadog.android.flags.model.UnparsedFlag?, com.datadog.android.flags.model.EvaluationContext, String?) enum com.datadog.android.flags.model.ErrorCode - PROVIDER_NOT_READY - FLAG_NOT_FOUND diff --git a/features/dd-sdk-android-flags/api/dd-sdk-android-flags.api b/features/dd-sdk-android-flags/api/dd-sdk-android-flags.api index 9f148c60f6..c5df2e10b7 100644 --- a/features/dd-sdk-android-flags/api/dd-sdk-android-flags.api +++ b/features/dd-sdk-android-flags/api/dd-sdk-android-flags.api @@ -91,6 +91,8 @@ public abstract interface class com/datadog/android/flags/StateObservable { public final class com/datadog/android/flags/_FlagsInternalProxy { public fun (Lcom/datadog/android/flags/FlagsClient;)V public final fun getFlagAssignmentsSnapshot ()Ljava/util/Map; + public final fun trackFlagEvaluationEvent (Ljava/lang/String;Lcom/datadog/android/flags/model/UnparsedFlag;Lcom/datadog/android/flags/model/EvaluationContext;Ljava/lang/String;)V + public final fun trackFlagExposure (Ljava/lang/String;Lcom/datadog/android/flags/model/UnparsedFlag;Lcom/datadog/android/flags/model/EvaluationContext;)V public final fun trackFlagSnapshotEvaluation (Ljava/lang/String;Lcom/datadog/android/flags/model/UnparsedFlag;Lcom/datadog/android/flags/model/EvaluationContext;)V } diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/_FlagsInternalProxy.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/_FlagsInternalProxy.kt index 7175b0616d..4860496799 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/_FlagsInternalProxy.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/_FlagsInternalProxy.kt @@ -30,9 +30,35 @@ class _FlagsInternalProxy(private val client: FlagsClient) { emptyMap() } + /** + * Records exposure, evaluation, and RUM tracking according to the enabled native features. + * Use the separate operations below for independently selected tracking hooks. + */ fun trackFlagSnapshotEvaluation(flagKey: String, flag: UnparsedFlag, context: EvaluationContext) { if (client is DatadogFlagsClient) { client.trackFlagSnapshotEvaluation(flagKey, flag, context) } } + + /** Records only an exposure, using the flag and context captured by the caller, without fetching configuration. */ + fun trackFlagExposure(flagKey: String, flag: UnparsedFlag, context: EvaluationContext) { + if (client is DatadogFlagsClient) { + client.trackFlagExposure(flagKey, flag, context) + } + } + + /** + * Records only an evaluation event, without fetching configuration or reporting to RUM. + * A missing flag represents a runtime default. Unknown OpenFeature error codes are reported as `GENERAL`. + */ + fun trackFlagEvaluationEvent( + flagKey: String, + flag: UnparsedFlag?, + context: EvaluationContext, + errorCode: String? + ) { + if (client is DatadogFlagsClient) { + client.trackFlagEvaluationEvent(flagKey, flag, context, errorCode) + } + } } diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/DatadogFlagsClient.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/DatadogFlagsClient.kt index 311e798731..ac57035720 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/DatadogFlagsClient.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/DatadogFlagsClient.kt @@ -480,29 +480,14 @@ internal class DatadogFlagsClient( flag: UnparsedFlag, context: EvaluationContext ) { - // Exposure logging (only when doLog=true) - if (flag.doLog) { - if (flagsConfiguration.trackExposures) { - writeExposureEvent(flagKey, flag, context) - } - } + trackFlagExposure(flagKey, flag, context) // RUM feature flag tracking - fires for all successful evaluations with a variant key if (flagsConfiguration.rumIntegrationEnabled && flag.variationKey.isNotBlank()) { logEvaluation(flagKey, flag.variationKey) } - // Evaluation logging for all evaluations (when enabled) - if (flagsConfiguration.trackEvaluations) { - writeEvaluationEvent( - flagKey = flagKey, - context = context, - variantKey = flag.variationKey, - allocationKey = flag.allocationKey, - errorCode = null, - errorMessage = null - ) - } + trackFlagEvaluationEvent(flagKey, flag, context, errorCode = null) } private fun trackErrorResolution(resolution: InternalResolution.Error) { @@ -544,5 +529,38 @@ internal class DatadogFlagsClient( trackResolution(flagKey, flag, context) } + internal fun trackFlagExposure(flagKey: String, flag: UnparsedFlag, context: EvaluationContext) { + if (flagsConfiguration.trackExposures && flag.doLog) { + writeExposureEvent(flagKey, flag, context) + } + } + + internal fun trackFlagEvaluationEvent( + flagKey: String, + flag: UnparsedFlag?, + context: EvaluationContext, + errorCode: String? + ) { + if (!flagsConfiguration.trackEvaluations) { + return + } + + // Hook errors can contain arbitrary customer data. Only forward standard OpenFeature codes. + val sanitizedErrorCode = when (errorCode) { + null -> null + "PROVIDER_NOT_READY", "PROVIDER_FATAL", "FLAG_NOT_FOUND", "PARSE_ERROR", "TYPE_MISMATCH", + "TARGETING_KEY_MISSING", "INVALID_CONTEXT", "GENERAL" -> errorCode + else -> "GENERAL" + } + writeEvaluationEvent( + flagKey = flagKey, + context = context, + variantKey = flag?.variationKey.takeIf { sanitizedErrorCode == null }, + allocationKey = flag?.allocationKey.takeIf { sanitizedErrorCode == null }, + errorCode = sanitizedErrorCode, + errorMessage = sanitizedErrorCode + ) + } + // endregion } diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagsTrackingBridgeTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagsTrackingBridgeTest.kt new file mode 100644 index 0000000000..64fba5002c --- /dev/null +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagsTrackingBridgeTest.kt @@ -0,0 +1,246 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.flags.internal + +import com.datadog.android.api.feature.FeatureSdkCore +import com.datadog.android.flags.FlagsConfiguration +import com.datadog.android.flags._FlagsInternalProxy +import com.datadog.android.flags.internal.evaluation.EvaluationsManager +import com.datadog.android.flags.internal.model.PrecomputedFlag +import com.datadog.android.flags.internal.repository.FlagsRepository +import com.datadog.android.flags.model.EvaluationContext +import com.datadog.android.flags.utils.forge.ForgeConfigurator +import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.annotation.StringForgery +import fr.xgouchet.elmyr.junit5.ForgeConfiguration +import fr.xgouchet.elmyr.junit5.ForgeExtension +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.extension.Extensions +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.NullSource +import org.junit.jupiter.params.provider.ValueSource +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.verifyNoMoreInteractions + +@Extensions( + ExtendWith(MockitoExtension::class), + ExtendWith(ForgeExtension::class) +) +@ForgeConfiguration(ForgeConfigurator::class) +internal class FlagsTrackingBridgeTest { + + @Mock + lateinit var mockFeatureSdkCore: FeatureSdkCore + + @Mock + lateinit var mockFlagsRepository: FlagsRepository + + @Mock + lateinit var mockEvaluationsManager: EvaluationsManager + + @Mock + lateinit var mockExposureProcessor: EventsProcessor + + @Mock + lateinit var mockEvaluationsFeature: EvaluationsFeature + + @Mock + lateinit var mockRumEvaluationLogger: RumEvaluationLogger + + @Mock + lateinit var mockFlagsStateManager: FlagsStateManager + + @StringForgery + lateinit var fakeFlagKey: String + + private lateinit var fakeFlag: PrecomputedFlag + private lateinit var fakeContext: EvaluationContext + private lateinit var fakeConfiguration: FlagsConfiguration + private lateinit var testedProxy: _FlagsInternalProxy + + @BeforeEach + fun `set up`(forge: Forge) { + // Given + fakeFlag = forge.getForgery().copy(doLog = true) + fakeContext = forge.getForgery() + fakeConfiguration = forge.getForgery().copy( + trackExposures = true, + trackEvaluations = true, + rumIntegrationEnabled = true + ) + testedProxy = newProxy() + } + + @AfterEach + fun `verify tracking never fetches or replaces configuration`() { + // Then + verifyNoInteractions(mockFlagsRepository, mockEvaluationsManager, mockFlagsStateManager, mockFeatureSdkCore) + verifyNoMoreInteractions(mockExposureProcessor, mockEvaluationsFeature, mockRumEvaluationLogger) + } + + @Test + fun `M record only exposure W trackFlagExposure`() { + // When + testedProxy.trackFlagExposure(fakeFlagKey, fakeFlag, fakeContext) + + // Then + verify(mockExposureProcessor).processEvent(fakeFlagKey, fakeContext, fakeFlag) + } + + @Test + fun `M record only evaluation W trackFlagEvaluationEvent {doLog false}`() { + // When + testedProxy.trackFlagEvaluationEvent(fakeFlagKey, fakeFlag.copy(doLog = false), fakeContext, null) + + // Then + verify(mockEvaluationsFeature).processEvaluation( + fakeFlagKey, + fakeContext, + fakeFlag.variationKey, + fakeFlag.allocationKey, + null, + null + ) + } + + @Test + fun `M record each event once W independent hooks are both selected`() { + // When + testedProxy.trackFlagExposure(fakeFlagKey, fakeFlag, fakeContext) + testedProxy.trackFlagEvaluationEvent(fakeFlagKey, fakeFlag, fakeContext, null) + + // Then + verify(mockExposureProcessor).processEvent(fakeFlagKey, fakeContext, fakeFlag) + verify(mockEvaluationsFeature).processEvaluation( + fakeFlagKey, + fakeContext, + fakeFlag.variationKey, + fakeFlag.allocationKey, + null, + null + ) + } + + @Test + fun `M retain combined tracking W trackFlagSnapshotEvaluation`() { + // When + testedProxy.trackFlagSnapshotEvaluation(fakeFlagKey, fakeFlag, fakeContext) + + // Then + verify(mockExposureProcessor).processEvent(fakeFlagKey, fakeContext, fakeFlag) + verify(mockEvaluationsFeature).processEvaluation( + fakeFlagKey, + fakeContext, + fakeFlag.variationKey, + fakeFlag.allocationKey, + null, + null + ) + verify(mockRumEvaluationLogger).logEvaluation(fakeFlagKey, fakeFlag.variationKey) + } + + @ParameterizedTest + @NullSource + @ValueSource( + strings = [ + "PROVIDER_NOT_READY", "PROVIDER_FATAL", "FLAG_NOT_FOUND", "PARSE_ERROR", "TYPE_MISMATCH", + "TARGETING_KEY_MISSING", "INVALID_CONTEXT", "GENERAL" + ] + ) + fun `M record runtime default W trackFlagEvaluationEvent {missing assignment}`(errorCode: String?) { + // When + testedProxy.trackFlagEvaluationEvent(fakeFlagKey, null, fakeContext, errorCode) + + // Then + verify(mockEvaluationsFeature).processEvaluation(fakeFlagKey, fakeContext, null, null, errorCode, errorCode) + } + + @ParameterizedTest + @ValueSource(strings = ["", "customer-sensitive-error"]) + fun `M sanitize error W trackFlagEvaluationEvent {unknown error code}`(errorCode: String) { + // When + testedProxy.trackFlagEvaluationEvent(fakeFlagKey, null, fakeContext, errorCode) + + // Then + verify(mockEvaluationsFeature).processEvaluation(fakeFlagKey, fakeContext, null, null, "GENERAL", "GENERAL") + } + + @Test + fun `M omit assignment metadata W trackFlagEvaluationEvent {evaluation error}`() { + // When + testedProxy.trackFlagEvaluationEvent(fakeFlagKey, fakeFlag, fakeContext, "TYPE_MISMATCH") + + // Then + verify(mockEvaluationsFeature).processEvaluation( + fakeFlagKey, + fakeContext, + null, + null, + "TYPE_MISMATCH", + "TYPE_MISMATCH" + ) + } + + @Test + fun `M skip exposure W trackFlagExposure {doLog false}`() { + // When + testedProxy.trackFlagExposure(fakeFlagKey, fakeFlag.copy(doLog = false), fakeContext) + } + + @Test + fun `M skip disabled tracking W independent hooks called`() { + // Given + testedProxy = newProxy(fakeConfiguration.copy(trackExposures = false, trackEvaluations = false)) + + // When + testedProxy.trackFlagExposure(fakeFlagKey, fakeFlag, fakeContext) + testedProxy.trackFlagEvaluationEvent(fakeFlagKey, fakeFlag, fakeContext, null) + testedProxy.trackFlagEvaluationEvent(fakeFlagKey, null, fakeContext, "FLAG_NOT_FOUND") + } + + @Test + fun `M skip evaluation W evaluations feature unavailable`() { + // Given + testedProxy = newProxy(evaluationsFeature = null) + + // When + testedProxy.trackFlagEvaluationEvent(fakeFlagKey, fakeFlag, fakeContext, null) + } + + @Test + fun `M do nothing W independent hooks called {fallback client}`() { + // Given + testedProxy = _FlagsInternalProxy(NoOpFlagsClient("test", "not initialized") { _, _ -> }) + + // When + testedProxy.trackFlagExposure(fakeFlagKey, fakeFlag, fakeContext) + testedProxy.trackFlagEvaluationEvent(fakeFlagKey, fakeFlag, fakeContext, null) + testedProxy.trackFlagEvaluationEvent(fakeFlagKey, null, fakeContext, "PROVIDER_NOT_READY") + } + + private fun newProxy( + configuration: FlagsConfiguration = fakeConfiguration, + evaluationsFeature: EvaluationsFeature? = mockEvaluationsFeature + ): _FlagsInternalProxy = _FlagsInternalProxy( + DatadogFlagsClient( + featureSdkCore = mockFeatureSdkCore, + evaluationsManager = mockEvaluationsManager, + flagsRepository = mockFlagsRepository, + flagsConfiguration = configuration, + rumEvaluationLogger = mockRumEvaluationLogger, + exposureProcessor = mockExposureProcessor, + evaluationsFeature = evaluationsFeature, + flagStateManager = mockFlagsStateManager + ) + ) +} From b14b48a4b446920e4c81b676475dfc9ff17b3442 Mon Sep 17 00:00:00 2001 From: Sameeran Kunche Date: Wed, 21 Oct 2026 01:19:28 -0700 Subject: [PATCH 2/7] FFL-3355: Cover independent tracking configuration combinations --- .../flags/internal/FlagsTrackingBridgeTest.kt | 60 ++++++++++++------- 1 file changed, 38 insertions(+), 22 deletions(-) diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagsTrackingBridgeTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagsTrackingBridgeTest.kt index 64fba5002c..ddae5004f4 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagsTrackingBridgeTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagsTrackingBridgeTest.kt @@ -24,6 +24,7 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.api.extension.Extensions import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource import org.junit.jupiter.params.provider.NullSource import org.junit.jupiter.params.provider.ValueSource import org.mockito.Mock @@ -113,22 +114,48 @@ internal class FlagsTrackingBridgeTest { ) } - @Test - fun `M record each event once W independent hooks are both selected`() { + @ParameterizedTest + @CsvSource("false, false", "false, true", "true, false", "true, true") + fun `M respect each tracking setting W independent hooks called`( + trackExposures: Boolean, + trackEvaluations: Boolean + ) { + // Given + testedProxy = newProxy( + fakeConfiguration.copy(trackExposures = trackExposures, trackEvaluations = trackEvaluations) + ) + // When testedProxy.trackFlagExposure(fakeFlagKey, fakeFlag, fakeContext) testedProxy.trackFlagEvaluationEvent(fakeFlagKey, fakeFlag, fakeContext, null) + testedProxy.trackFlagEvaluationEvent(fakeFlagKey, null, fakeContext, "FLAG_NOT_FOUND") // Then - verify(mockExposureProcessor).processEvent(fakeFlagKey, fakeContext, fakeFlag) - verify(mockEvaluationsFeature).processEvaluation( - fakeFlagKey, - fakeContext, - fakeFlag.variationKey, - fakeFlag.allocationKey, - null, - null - ) + if (trackExposures) { + verify(mockExposureProcessor).processEvent(fakeFlagKey, fakeContext, fakeFlag) + } else { + verifyNoInteractions(mockExposureProcessor) + } + if (trackEvaluations) { + verify(mockEvaluationsFeature).processEvaluation( + fakeFlagKey, + fakeContext, + fakeFlag.variationKey, + fakeFlag.allocationKey, + null, + null + ) + verify(mockEvaluationsFeature).processEvaluation( + fakeFlagKey, + fakeContext, + null, + null, + "FLAG_NOT_FOUND", + "FLAG_NOT_FOUND" + ) + } else { + verifyNoInteractions(mockEvaluationsFeature) + } } @Test @@ -197,17 +224,6 @@ internal class FlagsTrackingBridgeTest { testedProxy.trackFlagExposure(fakeFlagKey, fakeFlag.copy(doLog = false), fakeContext) } - @Test - fun `M skip disabled tracking W independent hooks called`() { - // Given - testedProxy = newProxy(fakeConfiguration.copy(trackExposures = false, trackEvaluations = false)) - - // When - testedProxy.trackFlagExposure(fakeFlagKey, fakeFlag, fakeContext) - testedProxy.trackFlagEvaluationEvent(fakeFlagKey, fakeFlag, fakeContext, null) - testedProxy.trackFlagEvaluationEvent(fakeFlagKey, null, fakeContext, "FLAG_NOT_FOUND") - } - @Test fun `M skip evaluation W evaluations feature unavailable`() { // Given From 11ebea03bc497fbbe41868a2f0e478484b084c1d Mon Sep 17 00:00:00 2001 From: Sameeran Kunche Date: Thu, 22 Oct 2026 07:55:10 -0700 Subject: [PATCH 3/7] FFL-3355: Make tracking bridge test assertions explicit --- .../flags/internal/FlagsTrackingBridgeTest.kt | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagsTrackingBridgeTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagsTrackingBridgeTest.kt index ddae5004f4..6d688d587c 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagsTrackingBridgeTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagsTrackingBridgeTest.kt @@ -21,6 +21,7 @@ import fr.xgouchet.elmyr.junit5.ForgeExtension import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertDoesNotThrow import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.api.extension.Extensions import org.junit.jupiter.params.ParameterizedTest @@ -222,6 +223,9 @@ internal class FlagsTrackingBridgeTest { fun `M skip exposure W trackFlagExposure {doLog false}`() { // When testedProxy.trackFlagExposure(fakeFlagKey, fakeFlag.copy(doLog = false), fakeContext) + + // Then + verifyNoInteractions(mockExposureProcessor, mockEvaluationsFeature, mockRumEvaluationLogger) } @Test @@ -231,6 +235,9 @@ internal class FlagsTrackingBridgeTest { // When testedProxy.trackFlagEvaluationEvent(fakeFlagKey, fakeFlag, fakeContext, null) + + // Then + verifyNoInteractions(mockExposureProcessor, mockEvaluationsFeature, mockRumEvaluationLogger) } @Test @@ -238,10 +245,12 @@ internal class FlagsTrackingBridgeTest { // Given testedProxy = _FlagsInternalProxy(NoOpFlagsClient("test", "not initialized") { _, _ -> }) - // When - testedProxy.trackFlagExposure(fakeFlagKey, fakeFlag, fakeContext) - testedProxy.trackFlagEvaluationEvent(fakeFlagKey, fakeFlag, fakeContext, null) - testedProxy.trackFlagEvaluationEvent(fakeFlagKey, null, fakeContext, "PROVIDER_NOT_READY") + // When / Then + assertDoesNotThrow { + testedProxy.trackFlagExposure(fakeFlagKey, fakeFlag, fakeContext) + testedProxy.trackFlagEvaluationEvent(fakeFlagKey, fakeFlag, fakeContext, null) + testedProxy.trackFlagEvaluationEvent(fakeFlagKey, null, fakeContext, "PROVIDER_NOT_READY") + } } private fun newProxy( From e899563bc179133ae026e7cb6119ad5cd5f397ae Mon Sep 17 00:00:00 2001 From: Sameeran Kunche Date: Thu, 22 Oct 2026 09:03:25 -0700 Subject: [PATCH 4/7] FFL-3355: Align tracking bridge tests with repository conventions Add the standard Mockito strictness annotation and name the exercised tracking methods explicitly. Ref: FFL-3355 --- .../flags/internal/FlagsTrackingBridgeTest.kt | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagsTrackingBridgeTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagsTrackingBridgeTest.kt index 970423f7fb..2882053bb3 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagsTrackingBridgeTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagsTrackingBridgeTest.kt @@ -30,14 +30,17 @@ import org.junit.jupiter.params.provider.NullSource import org.junit.jupiter.params.provider.ValueSource import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.verifyNoMoreInteractions +import org.mockito.quality.Strictness @Extensions( ExtendWith(MockitoExtension::class), ExtendWith(ForgeExtension::class) ) +@MockitoSettings(strictness = Strictness.LENIENT) @ForgeConfiguration(ForgeConfigurator::class) internal class FlagsTrackingBridgeTest { @@ -91,7 +94,7 @@ internal class FlagsTrackingBridgeTest { } @Test - fun `M record only exposure W trackFlagExposure`() { + fun `M record only exposure W trackFlagExposure()`() { // When testedProxy.trackFlagExposure(fakeFlagKey, fakeFlag, fakeContext) @@ -100,7 +103,7 @@ internal class FlagsTrackingBridgeTest { } @Test - fun `M record only evaluation W trackFlagEvaluationEvent {doLog false}`() { + fun `M record only evaluation W trackFlagEvaluationEvent() {doLog false}`() { // When testedProxy.trackFlagEvaluationEvent(fakeFlagKey, fakeFlag.copy(doLog = false), fakeContext, null) @@ -117,7 +120,7 @@ internal class FlagsTrackingBridgeTest { @ParameterizedTest @CsvSource("false, false", "false, true", "true, false", "true, true") - fun `M respect each tracking setting W independent hooks called`( + fun `M respect each tracking setting W trackFlagExposure() + trackFlagEvaluationEvent()`( trackExposures: Boolean, trackEvaluations: Boolean ) { @@ -160,7 +163,7 @@ internal class FlagsTrackingBridgeTest { } @Test - fun `M retain combined tracking W trackFlagSnapshotEvaluation`() { + fun `M retain combined tracking W trackFlagSnapshotEvaluation()`() { // When testedProxy.trackFlagSnapshotEvaluation(fakeFlagKey, fakeFlag, fakeContext) @@ -227,7 +230,7 @@ internal class FlagsTrackingBridgeTest { "TARGETING_KEY_MISSING", "INVALID_CONTEXT", "GENERAL" ] ) - fun `M record runtime default W trackFlagEvaluationEvent {missing assignment}`(errorCode: String?) { + fun `M record runtime default W trackFlagEvaluationEvent() {missing assignment}`(errorCode: String?) { // When testedProxy.trackFlagEvaluationEvent(fakeFlagKey, null, fakeContext, errorCode) @@ -237,7 +240,7 @@ internal class FlagsTrackingBridgeTest { @ParameterizedTest @ValueSource(strings = ["", "customer-sensitive-error"]) - fun `M sanitize error W trackFlagEvaluationEvent {unknown error code}`(errorCode: String) { + fun `M sanitize error W trackFlagEvaluationEvent() {unknown error code}`(errorCode: String) { // When testedProxy.trackFlagEvaluationEvent(fakeFlagKey, null, fakeContext, errorCode) @@ -246,7 +249,7 @@ internal class FlagsTrackingBridgeTest { } @Test - fun `M omit assignment metadata W trackFlagEvaluationEvent {evaluation error}`() { + fun `M omit assignment metadata W trackFlagEvaluationEvent() {evaluation error}`() { // When testedProxy.trackFlagEvaluationEvent(fakeFlagKey, fakeFlag, fakeContext, "TYPE_MISMATCH") @@ -262,7 +265,7 @@ internal class FlagsTrackingBridgeTest { } @Test - fun `M skip exposure W trackFlagExposure {doLog false}`() { + fun `M skip exposure W trackFlagExposure() {doLog false}`() { // When testedProxy.trackFlagExposure(fakeFlagKey, fakeFlag.copy(doLog = false), fakeContext) @@ -271,7 +274,7 @@ internal class FlagsTrackingBridgeTest { } @Test - fun `M skip evaluation W evaluations feature unavailable`() { + fun `M skip evaluation W trackFlagEvaluationEvent() {feature unavailable}`() { // Given testedProxy = newProxy(evaluationsFeature = null) @@ -283,7 +286,7 @@ internal class FlagsTrackingBridgeTest { } @Test - fun `M do nothing W independent hooks called {fallback client}`() { + fun `M do nothing W trackFlagExposure() + trackFlagEvaluationEvent() {fallback client}`() { // Given testedProxy = _FlagsInternalProxy(NoOpFlagsClient("test", "not initialized") { _, _ -> }) From 36c353859497cc8bba305f5d3f0bbdbb6221c986 Mon Sep 17 00:00:00 2001 From: Sameeran Kunche Date: Thu, 22 Oct 2026 10:02:47 -0700 Subject: [PATCH 5/7] FFL-3355: Complete tracking test method-name formatting Add parentheses to both methods in the telemetry opt-out test name. Ref: FFL-3355 --- .../datadog/android/flags/internal/FlagsTrackingBridgeTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagsTrackingBridgeTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagsTrackingBridgeTest.kt index 2882053bb3..f8d7210a3a 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagsTrackingBridgeTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagsTrackingBridgeTest.kt @@ -182,7 +182,7 @@ internal class FlagsTrackingBridgeTest { @ParameterizedTest @CsvSource("false, false", "false, true", "true, false", "true, true") - fun `M respect telemetry opt-out W trackFlagExposure + trackFlagEvaluationEvent`( + fun `M respect telemetry opt-out W trackFlagExposure() + trackFlagEvaluationEvent()`( optOutParameterEnabled: Boolean, telemetryOptedOut: Boolean ) { From af36bd8f26e0cd59dcef02282e608e93f1de8576 Mon Sep 17 00:00:00 2001 From: Sameeran Kunche Date: Thu, 22 Oct 2026 19:11:16 -0700 Subject: [PATCH 6/7] FFL-3355: Cover per-user tracking opt-out and share policy checks Test global and per-subject tracking opt-out through the RN proxy. Share the policy check, extract allowed error codes, and clarify tracking documentation and test naming. Ref: FFL-3355 --- .../android/flags/_FlagsInternalProxy.kt | 6 +- .../flags/internal/DatadogFlagsClient.kt | 27 +++-- ...ridgeTest.kt => FlagsInternalProxyTest.kt} | 62 ++-------- .../flags/internal/DatadogFlagsClientTest.kt | 106 ++++++++++++++++++ 4 files changed, 142 insertions(+), 59 deletions(-) rename features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/{internal/FlagsTrackingBridgeTest.kt => FlagsInternalProxyTest.kt} (83%) diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/_FlagsInternalProxy.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/_FlagsInternalProxy.kt index f322e457dc..dd813c7bd5 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/_FlagsInternalProxy.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/_FlagsInternalProxy.kt @@ -55,7 +55,10 @@ class _FlagsInternalProxy(private val client: FlagsClient) { } } - /** Records only an exposure, using the flag and context captured by the caller, without fetching configuration. */ + /** + * Records only an exposure, using the flag and context captured by the caller, without fetching configuration. + * Evaluation telemetry opt-out intentionally does not suppress exposures. + */ fun trackFlagExposure(flagKey: String, flag: UnparsedFlag, context: EvaluationContext) { if (client is DatadogFlagsClient) { client.trackFlagExposure(flagKey, flag, context) @@ -66,6 +69,7 @@ class _FlagsInternalProxy(private val client: FlagsClient) { * Records only an evaluation event, without fetching configuration or reporting to RUM. * Respects the client's evaluation telemetry opt-out settings. * A missing flag represents a runtime default. Unknown OpenFeature error codes are reported as `GENERAL`. + * The sanitized code is sent as the event's error message, matching the iOS bridge. */ fun trackFlagEvaluationEvent( flagKey: String, diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/DatadogFlagsClient.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/DatadogFlagsClient.kt index efb5a6cd53..ddcda0fa09 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/DatadogFlagsClient.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/DatadogFlagsClient.kt @@ -148,6 +148,10 @@ internal class DatadogFlagsClient( evaluationTelemetryOptOut || (subjectOptOut[requestedSubject] == true) || (subjectOptOut[subject] == true) + private fun isEvaluationTelemetryOptedOut(context: EvaluationContext): Boolean = + evaluationTelemetryOptOutParameterEnabled && + synchronized(telemetryLock) { isSubjectOptedOut(context.targetingKey) } + /** * Resolves a boolean flag value. * @@ -616,8 +620,7 @@ internal class DatadogFlagsClient( * Supposed to be used by internal Datadog packages to track flag evaluations from an exact flags state snapshot. */ internal fun trackFlagSnapshotEvaluation(flagKey: String, flag: UnparsedFlag, context: EvaluationContext) { - val optedOut = synchronized(telemetryLock) { isSubjectOptedOut(context.targetingKey) } - trackResolution(flagKey, flag, context, optedOut) + trackResolution(flagKey, flag, context, isEvaluationTelemetryOptedOut(context)) } internal fun trackFlagExposure(flagKey: String, flag: UnparsedFlag, context: EvaluationContext) { @@ -632,17 +635,14 @@ internal class DatadogFlagsClient( context: EvaluationContext, errorCode: String? ) { - val telemetryOptedOut = evaluationTelemetryOptOutParameterEnabled && - synchronized(telemetryLock) { isSubjectOptedOut(context.targetingKey) } - if (!flagsConfiguration.trackEvaluations || telemetryOptedOut) { + if (!flagsConfiguration.trackEvaluations || isEvaluationTelemetryOptedOut(context)) { return } // Hook errors can contain arbitrary customer data. Only forward standard OpenFeature codes. val sanitizedErrorCode = when (errorCode) { null -> null - "PROVIDER_NOT_READY", "PROVIDER_FATAL", "FLAG_NOT_FOUND", "PARSE_ERROR", "TYPE_MISMATCH", - "TARGETING_KEY_MISSING", "INVALID_CONTEXT", "GENERAL" -> errorCode + in ALLOWED_EVALUATION_ERROR_CODES -> errorCode else -> "GENERAL" } writeEvaluationEvent( @@ -656,4 +656,17 @@ internal class DatadogFlagsClient( } // endregion + + private companion object { + val ALLOWED_EVALUATION_ERROR_CODES = setOf( + "PROVIDER_NOT_READY", + "PROVIDER_FATAL", + "FLAG_NOT_FOUND", + "PARSE_ERROR", + "TYPE_MISMATCH", + "TARGETING_KEY_MISSING", + "INVALID_CONTEXT", + "GENERAL" + ) + } } diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagsTrackingBridgeTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/FlagsInternalProxyTest.kt similarity index 83% rename from features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagsTrackingBridgeTest.kt rename to features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/FlagsInternalProxyTest.kt index f8d7210a3a..08903a6a0c 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagsTrackingBridgeTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/FlagsInternalProxyTest.kt @@ -4,11 +4,15 @@ * Copyright 2016-Present Datadog, Inc. */ -package com.datadog.android.flags.internal +package com.datadog.android.flags import com.datadog.android.api.feature.FeatureSdkCore -import com.datadog.android.flags.FlagsConfiguration -import com.datadog.android.flags._FlagsInternalProxy +import com.datadog.android.flags.internal.DatadogFlagsClient +import com.datadog.android.flags.internal.EvaluationsFeature +import com.datadog.android.flags.internal.EventsProcessor +import com.datadog.android.flags.internal.FlagsStateManager +import com.datadog.android.flags.internal.NoOpFlagsClient +import com.datadog.android.flags.internal.RumEvaluationLogger import com.datadog.android.flags.internal.evaluation.EvaluationsManager import com.datadog.android.flags.internal.model.PrecomputedFlag import com.datadog.android.flags.internal.repository.FlagsRepository @@ -42,7 +46,7 @@ import org.mockito.quality.Strictness ) @MockitoSettings(strictness = Strictness.LENIENT) @ForgeConfiguration(ForgeConfigurator::class) -internal class FlagsTrackingBridgeTest { +internal class FlagsInternalProxyTest { @Mock lateinit var mockFeatureSdkCore: FeatureSdkCore @@ -180,48 +184,6 @@ internal class FlagsTrackingBridgeTest { verify(mockRumEvaluationLogger).logEvaluation(fakeFlagKey, fakeFlag.variationKey) } - @ParameterizedTest - @CsvSource("false, false", "false, true", "true, false", "true, true") - fun `M respect telemetry opt-out W trackFlagExposure() + trackFlagEvaluationEvent()`( - optOutParameterEnabled: Boolean, - telemetryOptedOut: Boolean - ) { - // Given - testedProxy = newProxy( - optOutParameterEnabled = optOutParameterEnabled, - telemetryOptedOut = telemetryOptedOut - ) - - // When - testedProxy.trackFlagExposure(fakeFlagKey, fakeFlag, fakeContext) - testedProxy.trackFlagEvaluationEvent(fakeFlagKey, fakeFlag, fakeContext, null) - testedProxy.trackFlagEvaluationEvent(fakeFlagKey, null, fakeContext, "FLAG_NOT_FOUND") - - // Then - verify(mockExposureProcessor).processEvent(fakeFlagKey, fakeContext, fakeFlag) - verifyNoInteractions(mockRumEvaluationLogger) - if (optOutParameterEnabled && telemetryOptedOut) { - verifyNoInteractions(mockEvaluationsFeature) - } else { - verify(mockEvaluationsFeature).processEvaluation( - fakeFlagKey, - fakeContext, - fakeFlag.variationKey, - fakeFlag.allocationKey, - null, - null - ) - verify(mockEvaluationsFeature).processEvaluation( - fakeFlagKey, - fakeContext, - null, - null, - "FLAG_NOT_FOUND", - "FLAG_NOT_FOUND" - ) - } - } - @ParameterizedTest @NullSource @ValueSource( @@ -300,9 +262,7 @@ internal class FlagsTrackingBridgeTest { private fun newProxy( configuration: FlagsConfiguration = fakeConfiguration, - evaluationsFeature: EvaluationsFeature? = mockEvaluationsFeature, - optOutParameterEnabled: Boolean = false, - telemetryOptedOut: Boolean = false + evaluationsFeature: EvaluationsFeature? = mockEvaluationsFeature ): _FlagsInternalProxy = _FlagsInternalProxy( DatadogFlagsClient( featureSdkCore = mockFeatureSdkCore, @@ -313,7 +273,7 @@ internal class FlagsTrackingBridgeTest { exposureProcessor = mockExposureProcessor, evaluationsFeature = evaluationsFeature, flagStateManager = mockFlagsStateManager, - evaluationTelemetryOptOutParameterEnabled = optOutParameterEnabled - ).apply { setEvaluationTelemetryOptOut(telemetryOptedOut) } + evaluationTelemetryOptOutParameterEnabled = false + ) ) } diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt index 9dcf7a844f..fcf03c9171 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt @@ -12,6 +12,7 @@ import com.datadog.android.api.feature.FeatureSdkCore import com.datadog.android.flags.EvaluationContextCallback import com.datadog.android.flags.FlagsConfiguration import com.datadog.android.flags.FlagsStateListener +import com.datadog.android.flags._FlagsInternalProxy import com.datadog.android.flags.internal.evaluation.EvaluationsManager import com.datadog.android.flags.internal.model.PrecomputedFlag import com.datadog.android.flags.internal.model.VariationType @@ -33,6 +34,7 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.api.extension.Extensions import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource import org.junit.jupiter.params.provider.ValueSource import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension @@ -47,6 +49,7 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.verifyNoMoreInteractions import org.mockito.kotlin.whenever import org.mockito.quality.Strictness import java.util.concurrent.CountDownLatch @@ -266,6 +269,109 @@ internal class DatadogFlagsClientTest { } } + @ParameterizedTest + @CsvSource( + "false, false, false", + "false, false, true", + "false, true, false", + "false, true, true", + "true, false, false", + "true, false, true", + "true, true, false", + "true, true, true" + ) + fun `M respect opt-out W trackFlagExposure() + trackFlagEvaluationEvent() {global or subject policy}`( + enabled: Boolean, + telemetryOptedOut: Boolean, + perSubject: Boolean, + forge: Forge + ) { + // Given + val fakeKey = forge.anAlphabeticalString() + val fakeContext = forge.getForgery() + val fakeFlag = forge.getForgery().copy(doLog = true) + val mockEvaluationsFeature = mock() + testedClient = DatadogFlagsClient( + mockFeatureSdkCore, mockEvaluationsManager, mockFlagsRepository, + FlagsConfiguration.Builder().build(), mockRumEvaluationLogger, mockProcessor, + mockEvaluationsFeature, mockFlagsStateManager, + evaluationTelemetryOptOutParameterEnabled = enabled + ) + val testedProxy = _FlagsInternalProxy(testedClient) + if (perSubject) { + testedProxy.setEvaluationContext(fakeContext, null, telemetryOptedOut) + } else { + testedClient.setEvaluationTelemetryOptOut(telemetryOptedOut) + } + + // When + testedProxy.trackFlagExposure(fakeKey, fakeFlag, fakeContext) + testedProxy.trackFlagEvaluationEvent(fakeKey, fakeFlag, fakeContext, null) + testedProxy.trackFlagEvaluationEvent(fakeKey, null, fakeContext, null) + testedProxy.trackFlagEvaluationEvent(fakeKey, null, fakeContext, "FLAG_NOT_FOUND") + + // Then + verify(mockProcessor).processEvent(fakeKey, fakeContext, fakeFlag) + verifyNoInteractions(mockRumEvaluationLogger) + if (enabled && telemetryOptedOut) { + verifyNoInteractions(mockEvaluationsFeature) + } else { + verify(mockEvaluationsFeature).processEvaluation( + fakeKey, + fakeContext, + fakeFlag.variationKey, + fakeFlag.allocationKey, + null, + null + ) + verify(mockEvaluationsFeature).processEvaluation(fakeKey, fakeContext, null, null, null, null) + verify(mockEvaluationsFeature).processEvaluation( + fakeKey, + fakeContext, + null, + null, + "FLAG_NOT_FOUND", + "FLAG_NOT_FOUND" + ) + } + verifyNoMoreInteractions(mockProcessor, mockEvaluationsFeature) + } + + @ParameterizedTest + @ValueSource(booleans = [false, true]) + fun `M respect opt-out W trackFlagSnapshotEvaluation() + trackFlagEvaluationEvent() {subject switch}`( + requestedSubjectOptedOut: Boolean, + forge: Forge + ) { + // Given + val fakeKey = forge.anAlphabeticalString() + val fakeContext = forge.getForgery() + val fakeNextContext = forge.getForgery().copy( + targetingKey = "${fakeContext.targetingKey}-next" + ) + val fakeFlag = forge.getForgery().copy(doLog = true) + val mockEvaluationsFeature = mock() + testedClient = DatadogFlagsClient( + mockFeatureSdkCore, mockEvaluationsManager, mockFlagsRepository, + FlagsConfiguration.Builder().build(), mockRumEvaluationLogger, mockProcessor, + mockEvaluationsFeature, mockFlagsStateManager, + evaluationTelemetryOptOutParameterEnabled = true + ) + val testedProxy = _FlagsInternalProxy(testedClient) + testedProxy.setEvaluationContext(fakeContext, null, !requestedSubjectOptedOut) + testedProxy.setEvaluationContext(fakeNextContext, null, requestedSubjectOptedOut) + + // When + testedProxy.trackFlagSnapshotEvaluation(fakeKey, fakeFlag, fakeContext) + testedProxy.trackFlagEvaluationEvent(fakeKey, fakeFlag, fakeContext, null) + testedProxy.trackFlagEvaluationEvent(fakeKey, null, fakeContext, "FLAG_NOT_FOUND") + + // Then + verify(mockProcessor).processEvent(fakeKey, fakeContext, fakeFlag) + verifyNoInteractions(mockEvaluationsFeature, mockRumEvaluationLogger) + verifyNoMoreInteractions(mockProcessor) + } + @Test fun `M return default value W resolveBooleanValue() { flag exists with invalid boolean string }`(forge: Forge) { // Given From 6cca7803019e916a5034dbf32b08f6706f7b2686 Mon Sep 17 00:00:00 2001 From: Sameeran Kunche Date: Thu, 22 Oct 2026 23:18:10 -0700 Subject: [PATCH 7/7] FFL-3355: Respect telemetry opt-out in independent RUM tracking Add an independent RUM tracking bridge that honors the native client's telemetry policy and RUM integration setting. Cover opt-out and fallback behavior and update API snapshots. Ref: FFL-3355 --- features/dd-sdk-android-flags/api/apiSurface | 1 + .../api/dd-sdk-android-flags.api | 1 + .../android/flags/_FlagsInternalProxy.kt | 10 ++++++ .../flags/internal/DatadogFlagsClient.kt | 8 +++++ .../android/flags/FlagsInternalProxyTest.kt | 31 ++++++++++++++++++- .../flags/internal/DatadogFlagsClientTest.kt | 12 ++++--- 6 files changed, 57 insertions(+), 6 deletions(-) diff --git a/features/dd-sdk-android-flags/api/apiSurface b/features/dd-sdk-android-flags/api/apiSurface index fa4108f133..6e87e9ddcd 100644 --- a/features/dd-sdk-android-flags/api/apiSurface +++ b/features/dd-sdk-android-flags/api/apiSurface @@ -53,6 +53,7 @@ class com.datadog.android.flags._FlagsInternalProxy fun trackFlagSnapshotEvaluation(String, com.datadog.android.flags.model.UnparsedFlag, com.datadog.android.flags.model.EvaluationContext) fun trackFlagExposure(String, com.datadog.android.flags.model.UnparsedFlag, com.datadog.android.flags.model.EvaluationContext) fun trackFlagEvaluationEvent(String, com.datadog.android.flags.model.UnparsedFlag?, com.datadog.android.flags.model.EvaluationContext, String?) + fun trackFlagRumEvaluation(String, String, com.datadog.android.flags.model.EvaluationContext) enum com.datadog.android.flags.model.ErrorCode - PROVIDER_NOT_READY - FLAG_NOT_FOUND diff --git a/features/dd-sdk-android-flags/api/dd-sdk-android-flags.api b/features/dd-sdk-android-flags/api/dd-sdk-android-flags.api index 537a7e73e3..a8c54bf818 100644 --- a/features/dd-sdk-android-flags/api/dd-sdk-android-flags.api +++ b/features/dd-sdk-android-flags/api/dd-sdk-android-flags.api @@ -100,6 +100,7 @@ public final class com/datadog/android/flags/_FlagsInternalProxy { public final fun setEvaluationContext (Lcom/datadog/android/flags/model/EvaluationContext;Lcom/datadog/android/flags/EvaluationContextCallback;Z)V public final fun trackFlagEvaluationEvent (Ljava/lang/String;Lcom/datadog/android/flags/model/UnparsedFlag;Lcom/datadog/android/flags/model/EvaluationContext;Ljava/lang/String;)V public final fun trackFlagExposure (Ljava/lang/String;Lcom/datadog/android/flags/model/UnparsedFlag;Lcom/datadog/android/flags/model/EvaluationContext;)V + public final fun trackFlagRumEvaluation (Ljava/lang/String;Ljava/lang/String;Lcom/datadog/android/flags/model/EvaluationContext;)V public final fun trackFlagSnapshotEvaluation (Ljava/lang/String;Lcom/datadog/android/flags/model/UnparsedFlag;Lcom/datadog/android/flags/model/EvaluationContext;)V } diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/_FlagsInternalProxy.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/_FlagsInternalProxy.kt index dd813c7bd5..36c9e587fb 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/_FlagsInternalProxy.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/_FlagsInternalProxy.kt @@ -81,4 +81,14 @@ class _FlagsInternalProxy(private val client: FlagsClient) { client.trackFlagEvaluationEvent(flagKey, flag, context, errorCode) } } + + /** + * Records only a RUM flag annotation, respecting the client's evaluation telemetry opt-out settings. + * Does not fetch configuration, record an exposure, or record an evaluation event. + */ + fun trackFlagRumEvaluation(flagKey: String, variantKey: String, context: EvaluationContext) { + if (client is DatadogFlagsClient) { + client.trackFlagRumEvaluation(flagKey, variantKey, context) + } + } } diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/DatadogFlagsClient.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/DatadogFlagsClient.kt index ddcda0fa09..0b94f89b4b 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/DatadogFlagsClient.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/DatadogFlagsClient.kt @@ -655,6 +655,14 @@ internal class DatadogFlagsClient( ) } + internal fun trackFlagRumEvaluation(flagKey: String, variantKey: String, context: EvaluationContext) { + if (flagsConfiguration.rumIntegrationEnabled && variantKey.isNotBlank() && + !isEvaluationTelemetryOptedOut(context) + ) { + logEvaluation(flagKey, variantKey) + } + } + // endregion private companion object { diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/FlagsInternalProxyTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/FlagsInternalProxyTest.kt index 08903a6a0c..f0258b4276 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/FlagsInternalProxyTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/FlagsInternalProxyTest.kt @@ -166,6 +166,34 @@ internal class FlagsInternalProxyTest { } } + @ParameterizedTest + @ValueSource(booleans = [false, true]) + fun `M respect RUM setting W trackFlagRumEvaluation()`(enabled: Boolean) { + // Given + testedProxy = newProxy(fakeConfiguration.copy(rumIntegrationEnabled = enabled)) + + // When + testedProxy.trackFlagRumEvaluation(fakeFlagKey, fakeFlag.variationKey, fakeContext) + + // Then + if (enabled) { + verify(mockRumEvaluationLogger).logEvaluation(fakeFlagKey, fakeFlag.variationKey) + } else { + verifyNoInteractions(mockRumEvaluationLogger) + } + verifyNoInteractions(mockExposureProcessor, mockEvaluationsFeature) + } + + @ParameterizedTest + @ValueSource(strings = ["", " "]) + fun `M skip RUM W trackFlagRumEvaluation() {blank variant}`(variant: String) { + // When + testedProxy.trackFlagRumEvaluation(fakeFlagKey, variant, fakeContext) + + // Then + verifyNoInteractions(mockRumEvaluationLogger, mockExposureProcessor, mockEvaluationsFeature) + } + @Test fun `M retain combined tracking W trackFlagSnapshotEvaluation()`() { // When @@ -248,7 +276,7 @@ internal class FlagsInternalProxyTest { } @Test - fun `M do nothing W trackFlagExposure() + trackFlagEvaluationEvent() {fallback client}`() { + fun `M no-op on fallback W trackFlagExposure() + trackFlagEvaluationEvent() + trackFlagRumEvaluation()`() { // Given testedProxy = _FlagsInternalProxy(NoOpFlagsClient("test", "not initialized") { _, _ -> }) @@ -257,6 +285,7 @@ internal class FlagsInternalProxyTest { testedProxy.trackFlagExposure(fakeFlagKey, fakeFlag, fakeContext) testedProxy.trackFlagEvaluationEvent(fakeFlagKey, fakeFlag, fakeContext, null) testedProxy.trackFlagEvaluationEvent(fakeFlagKey, null, fakeContext, "PROVIDER_NOT_READY") + testedProxy.trackFlagRumEvaluation(fakeFlagKey, fakeFlag.variationKey, fakeContext) } } diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt index fcf03c9171..47b15a9521 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt @@ -280,7 +280,7 @@ internal class DatadogFlagsClientTest { "true, true, false", "true, true, true" ) - fun `M respect opt-out W trackFlagExposure() + trackFlagEvaluationEvent() {global or subject policy}`( + fun `M respect opt-out W trackFlagExposure() + trackFlagEvaluationEvent() + trackFlagRumEvaluation()`( enabled: Boolean, telemetryOptedOut: Boolean, perSubject: Boolean, @@ -309,13 +309,14 @@ internal class DatadogFlagsClientTest { testedProxy.trackFlagEvaluationEvent(fakeKey, fakeFlag, fakeContext, null) testedProxy.trackFlagEvaluationEvent(fakeKey, null, fakeContext, null) testedProxy.trackFlagEvaluationEvent(fakeKey, null, fakeContext, "FLAG_NOT_FOUND") + testedProxy.trackFlagRumEvaluation(fakeKey, fakeFlag.variationKey, fakeContext) // Then verify(mockProcessor).processEvent(fakeKey, fakeContext, fakeFlag) - verifyNoInteractions(mockRumEvaluationLogger) if (enabled && telemetryOptedOut) { - verifyNoInteractions(mockEvaluationsFeature) + verifyNoInteractions(mockEvaluationsFeature, mockRumEvaluationLogger) } else { + verify(mockRumEvaluationLogger).logEvaluation(fakeKey, fakeFlag.variationKey) verify(mockEvaluationsFeature).processEvaluation( fakeKey, fakeContext, @@ -334,12 +335,12 @@ internal class DatadogFlagsClientTest { "FLAG_NOT_FOUND" ) } - verifyNoMoreInteractions(mockProcessor, mockEvaluationsFeature) + verifyNoMoreInteractions(mockProcessor, mockEvaluationsFeature, mockRumEvaluationLogger) } @ParameterizedTest @ValueSource(booleans = [false, true]) - fun `M respect opt-out W trackFlagSnapshotEvaluation() + trackFlagEvaluationEvent() {subject switch}`( + fun `M opt out W trackFlagSnapshotEvaluation() + trackFlagEvaluationEvent() + trackFlagRumEvaluation()`( requestedSubjectOptedOut: Boolean, forge: Forge ) { @@ -363,6 +364,7 @@ internal class DatadogFlagsClientTest { // When testedProxy.trackFlagSnapshotEvaluation(fakeKey, fakeFlag, fakeContext) + testedProxy.trackFlagRumEvaluation(fakeKey, fakeFlag.variationKey, fakeContext) testedProxy.trackFlagEvaluationEvent(fakeKey, fakeFlag, fakeContext, null) testedProxy.trackFlagEvaluationEvent(fakeKey, null, fakeContext, "FLAG_NOT_FOUND")