diff --git a/features/dd-sdk-android-flags/api/apiSurface b/features/dd-sdk-android-flags/api/apiSurface index 8b95feb419..6e87e9ddcd 100644 --- a/features/dd-sdk-android-flags/api/apiSurface +++ b/features/dd-sdk-android-flags/api/apiSurface @@ -51,6 +51,9 @@ class com.datadog.android.flags._FlagsInternalProxy fun isEvaluationTelemetryOptOutParameterEnabled(): Boolean 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?) + 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 0c62e195f7..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 @@ -98,6 +98,9 @@ public final class com/datadog/android/flags/_FlagsInternalProxy { public final fun getFlagAssignmentsSnapshot ()Ljava/util/Map; public final fun isEvaluationTelemetryOptOutParameterEnabled ()Z 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 7de70d93cb..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 @@ -45,9 +45,50 @@ 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. + * 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) + } + } + + /** + * 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, + flag: UnparsedFlag?, + context: EvaluationContext, + errorCode: String? + ) { + if (client is DatadogFlagsClient) { + 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 8a709f0cd3..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 @@ -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. * @@ -559,12 +563,7 @@ internal class DatadogFlagsClient( context: EvaluationContext, telemetryOptedOut: Boolean ) { - // Exposure logging (only when doLog=true) - if (flag.doLog) { - if (flagsConfiguration.trackExposures) { - writeExposureEvent(flagKey, flag, context) - } - } + trackFlagExposure(flagKey, flag, context) if (telemetryOptedOut) { return @@ -575,7 +574,6 @@ internal class DatadogFlagsClient( logEvaluation(flagKey, flag.variationKey) } - // Evaluation logging for all evaluations (when enabled) if (flagsConfiguration.trackEvaluations) { writeEvaluationEvent( flagKey = flagKey, @@ -622,9 +620,61 @@ 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) { + if (flagsConfiguration.trackExposures && flag.doLog) { + writeExposureEvent(flagKey, flag, context) + } + } + + internal fun trackFlagEvaluationEvent( + flagKey: String, + flag: UnparsedFlag?, + context: EvaluationContext, + errorCode: String? + ) { + if (!flagsConfiguration.trackEvaluations || isEvaluationTelemetryOptedOut(context)) { + return + } + + // Hook errors can contain arbitrary customer data. Only forward standard OpenFeature codes. + val sanitizedErrorCode = when (errorCode) { + null -> null + in ALLOWED_EVALUATION_ERROR_CODES -> errorCode + else -> "GENERAL" + } + writeEvaluationEvent( + flagKey = flagKey, + context = context, + variantKey = flag?.variationKey.takeIf { sanitizedErrorCode == null }, + allocationKey = flag?.allocationKey.takeIf { sanitizedErrorCode == null }, + errorCode = sanitizedErrorCode, + errorMessage = sanitizedErrorCode + ) + } + + internal fun trackFlagRumEvaluation(flagKey: String, variantKey: String, context: EvaluationContext) { + if (flagsConfiguration.rumIntegrationEnabled && variantKey.isNotBlank() && + !isEvaluationTelemetryOptedOut(context) + ) { + logEvaluation(flagKey, variantKey) + } } // 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/FlagsInternalProxyTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/FlagsInternalProxyTest.kt new file mode 100644 index 0000000000..f0258b4276 --- /dev/null +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/FlagsInternalProxyTest.kt @@ -0,0 +1,308 @@ +/* + * 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 + +import com.datadog.android.api.feature.FeatureSdkCore +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 +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.assertDoesNotThrow +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 +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 FlagsInternalProxyTest { + + @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 + ) + } + + @ParameterizedTest + @CsvSource("false, false", "false, true", "true, false", "true, true") + fun `M respect each tracking setting W trackFlagExposure() + trackFlagEvaluationEvent()`( + 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 + 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) + } + } + + @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 + 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) + + // Then + verifyNoInteractions(mockExposureProcessor, mockEvaluationsFeature, mockRumEvaluationLogger) + } + + @Test + fun `M skip evaluation W trackFlagEvaluationEvent() {feature unavailable}`() { + // Given + testedProxy = newProxy(evaluationsFeature = null) + + // When + testedProxy.trackFlagEvaluationEvent(fakeFlagKey, fakeFlag, fakeContext, null) + + // Then + verifyNoInteractions(mockExposureProcessor, mockEvaluationsFeature, mockRumEvaluationLogger) + } + + @Test + fun `M no-op on fallback W trackFlagExposure() + trackFlagEvaluationEvent() + trackFlagRumEvaluation()`() { + // Given + testedProxy = _FlagsInternalProxy(NoOpFlagsClient("test", "not initialized") { _, _ -> }) + + // When / Then + assertDoesNotThrow { + 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) + } + } + + 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, + 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..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 @@ -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,111 @@ 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() + trackFlagRumEvaluation()`( + 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") + testedProxy.trackFlagRumEvaluation(fakeKey, fakeFlag.variationKey, fakeContext) + + // Then + verify(mockProcessor).processEvent(fakeKey, fakeContext, fakeFlag) + if (enabled && telemetryOptedOut) { + verifyNoInteractions(mockEvaluationsFeature, mockRumEvaluationLogger) + } else { + verify(mockRumEvaluationLogger).logEvaluation(fakeKey, fakeFlag.variationKey) + 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, mockRumEvaluationLogger) + } + + @ParameterizedTest + @ValueSource(booleans = [false, true]) + fun `M opt out W trackFlagSnapshotEvaluation() + trackFlagEvaluationEvent() + trackFlagRumEvaluation()`( + 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.trackFlagRumEvaluation(fakeKey, fakeFlag.variationKey, 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