diff --git a/detekt_custom_safe_calls_third_party.yml b/detekt_custom_safe_calls_third_party.yml index 31b95eb122..ac893b48f3 100644 --- a/detekt_custom_safe_calls_third_party.yml +++ b/detekt_custom_safe_calls_third_party.yml @@ -470,9 +470,11 @@ datadog: - "kotlin.ByteArray.joinToString(kotlin.CharSequence, kotlin.CharSequence, kotlin.CharSequence, kotlin.Int, kotlin.CharSequence, kotlin.Function1?)" - "kotlin.arrayOf(kotlin.Array)" - "kotlin.collections.arrayListOf()" + - "kotlin.collections.ArrayDeque.addLast(com.datadog.android.flags.openfeature.internal.exposure.ExposureDecisions.Decision)" - "kotlin.collections.ArrayDeque.addLast(java.lang.Runnable)" - "kotlin.collections.ArrayDeque.addLast(kotlin.Function0)" - "kotlin.collections.ArrayDeque.constructor()" + - "kotlin.collections.ArrayDeque.lastOrNull(kotlin.Function1)" - "kotlin.collections.ArrayDeque.removeFirstOrNull()" - "kotlin.collections.ArrayList()" - "kotlin.collections.ArrayList(kotlin.Int)" @@ -794,6 +796,7 @@ datadog: - "kotlin.collections.listOf(com.datadog.android.profiling.internal.perfetto.ProfileType)" - "kotlin.collections.listOf(com.datadog.android.rum.internal.vitals.FPSVitalListener)" - "kotlin.collections.listOf(com.datadog.android.flags.model.BatchedFlagEvaluations.FlagEvaluation)" + - "kotlin.collections.listOf(com.datadog.android.flags.openfeature.internal.exposure.ExposureAdmissionHook)" - "kotlin.collections.listOf(com.datadog.android.rum.model.ActionEvent.Interface)" - "kotlin.collections.listOf(com.datadog.android.rum.model.ErrorEvent.Interface)" - "kotlin.collections.listOf(com.datadog.android.rum.model.LongTaskEvent.Interface)" @@ -1198,10 +1201,13 @@ datadog: - "dev.openfeature.kotlin.sdk.Builder.putString(kotlin.String, kotlin.String)" - "dev.openfeature.kotlin.sdk.EvaluationContext.asMap()" - "dev.openfeature.kotlin.sdk.EvaluationContext.getTargetingKey()" + - "dev.openfeature.kotlin.sdk.EvaluationMetadata.getBoolean(kotlin.String)" + - "dev.openfeature.kotlin.sdk.EvaluationMetadata.getString(kotlin.String)" - "dev.openfeature.kotlin.sdk.events.OpenFeatureProviderEvents.ProviderError.constructor(dev.openfeature.kotlin.sdk.exceptions.OpenFeatureError)" - "dev.openfeature.kotlin.sdk.exceptions.OpenFeatureError.GeneralError.constructor(kotlin.String)" - "dev.openfeature.kotlin.sdk.exceptions.OpenFeatureError.ProviderFatalError.constructor()" - "dev.openfeature.kotlin.sdk.exceptions.OpenFeatureError.ProviderFatalError.constructor(kotlin.String)" + - "dev.openfeature.kotlin.sdk.ImmutableContext.constructor(kotlin.String, kotlin.collections.Map)" - "dev.openfeature.kotlin.sdk.ProviderEvaluation.constructor(kotlin.Any, kotlin.String?, kotlin.String?, dev.openfeature.kotlin.sdk.exceptions.ErrorCode?, kotlin.String?)" - "dev.openfeature.kotlin.sdk.ProviderEvaluation.constructor(kotlin.Any, kotlin.String?, kotlin.String?, dev.openfeature.kotlin.sdk.exceptions.ErrorCode?, kotlin.String?, dev.openfeature.kotlin.sdk.EvaluationMetadata)" - "dev.openfeature.kotlin.sdk.ProviderEvaluation.constructor(dev.openfeature.kotlin.sdk.Value, kotlin.String?, kotlin.String?, dev.openfeature.kotlin.sdk.exceptions.ErrorCode?, kotlin.String?, dev.openfeature.kotlin.sdk.EvaluationMetadata)" diff --git a/features/dd-sdk-android-flags-openfeature/README.md b/features/dd-sdk-android-flags-openfeature/README.md index c9ddcf844b..0cd78c8c74 100644 --- a/features/dd-sdk-android-flags-openfeature/README.md +++ b/features/dd-sdk-android-flags-openfeature/README.md @@ -204,6 +204,56 @@ structured results, including error/default results. It does not emit a second e or hash targeting keys itself. Use the provider and Flags artifacts from the same SDK release; see the [native privacy behavior](../dd-sdk-android-flags/README.md#evaluation-telemetry-privacy). +## Exposures in your own data store + +To record exposures in your own data store, register an `ExposureHook`. Its callback receives every completed evaluation with the Datadog exposure decision: + +```kotlin +import com.datadog.android.flags.openfeature.ExposureHook + +OpenFeatureAPI.addHooks( + listOf( + ExposureHook { evaluation -> + if (evaluation.shouldSend()) { + exposureQueue.add( + MyExposure( + flagKey = evaluation.flagKey, + variant = evaluation.variant, + allocationKey = evaluation.allocationKey, + subject = evaluation.targetingKey, + holdoutKey = evaluation.features["holdout.key"] as? String + ) + ) + } + } + ) +) +``` + +`ExposureEvaluation` gives: + +| Member | Meaning | +|---|---| +| `isExposure()` | The evaluation succeeded and selected an allocation that logs exposures. Repeats are included. | +| `isCacheHit()` | The subject was already exposed to the same allocation, variant and split. | +| `shouldSend()` | `isExposure()` and not a repeat. These are exactly the exposures that the SDK sends to Datadog. | +| `features` | The features of the selected split for application hooks, for example `holdout.key`, `holdout.assignment_group` and `holdout.should_include_in_holdout_analysis`. A value is a `String`, `Double` or `Boolean`. | +| `flagKey`, `variant`, `allocationKey`, `targetingKey` | The evaluated flag and the selected assignment. | +| `evaluationContext` | A copy of the evaluation context. | + +The same features are in the flag metadata as `__dd_feature.`, so other hooks can read them too. Other `__dd_` metadata entries are internal. + +To stop sending exposures to Datadog and keep only your own, use `FlagsConfiguration.Builder().trackExposures(false)`. The hook still reports the same decisions. + +Keep in mind: + +- The SDK records an exposure only when the whole evaluation succeeds, in the final hook stage. If another hook fails, the evaluation is not an exposure. +- The provider records exposures in its own hook. Evaluate flags through an OpenFeature client: a direct call to a provider `get...Evaluation` method records no exposure. +- The callback runs on the evaluating thread. Do not do I/O in it: hand the data to a background queue. +- An exception from the callback does not affect the evaluation. The SDK logs the first one as a warning, and later ones at debug level. +- Exposures need the `DatadogFlagsProvider` to be the OpenFeature provider. A composed provider such as `MultiProvider` does not run provider hooks, so the SDK records no exposures and the callback is not called. +- Repeats are detected for recent subjects and flags in the current process only. After a restart, the first evaluation of each flag is a new exposure. + ## Integration with RUM When RUM is enabled in your application and RUM integration is enabled in the Flags configuration (default), flag evaluations are automatically: diff --git a/features/dd-sdk-android-flags-openfeature/api/apiSurface b/features/dd-sdk-android-flags-openfeature/api/apiSurface index 963b1ac446..2b45f898e8 100644 --- a/features/dd-sdk-android-flags-openfeature/api/apiSurface +++ b/features/dd-sdk-android-flags-openfeature/api/apiSurface @@ -11,4 +11,13 @@ class com.datadog.android.flags.openfeature.DatadogFlagsProvider : dev.openfeatu override fun shutdown() override fun observe(): kotlinx.coroutines.flow.Flow companion object +interface com.datadog.android.flags.openfeature.ExposureCallback + fun onEvaluation(ExposureEvaluation) +class com.datadog.android.flags.openfeature.ExposureEvaluation + fun isExposure(): Boolean + fun isCacheHit(): Boolean + fun shouldSend(): Boolean +class com.datadog.android.flags.openfeature.ExposureHook : dev.openfeature.kotlin.sdk.Hook + constructor(ExposureCallback) + override fun finallyAfter(dev.openfeature.kotlin.sdk.HookContext, dev.openfeature.kotlin.sdk.FlagEvaluationDetails, Map) fun com.datadog.android.flags.FlagsClient.asOpenFeatureProvider(): dev.openfeature.kotlin.sdk.FeatureProvider diff --git a/features/dd-sdk-android-flags-openfeature/api/dd-sdk-android-flags-openfeature.api b/features/dd-sdk-android-flags-openfeature/api/dd-sdk-android-flags-openfeature.api index 9efb6cec0f..8481091660 100644 --- a/features/dd-sdk-android-flags-openfeature/api/dd-sdk-android-flags-openfeature.api +++ b/features/dd-sdk-android-flags-openfeature/api/dd-sdk-android-flags-openfeature.api @@ -18,6 +18,31 @@ public final class com/datadog/android/flags/openfeature/DatadogFlagsProvider : public final class com/datadog/android/flags/openfeature/DatadogFlagsProvider$Companion { } +public abstract interface class com/datadog/android/flags/openfeature/ExposureCallback { + public abstract fun onEvaluation (Lcom/datadog/android/flags/openfeature/ExposureEvaluation;)V +} + +public final class com/datadog/android/flags/openfeature/ExposureEvaluation { + public final fun getAllocationKey ()Ljava/lang/String; + public final fun getEvaluationContext ()Ldev/openfeature/kotlin/sdk/EvaluationContext; + public final fun getFeatures ()Ljava/util/Map; + public final fun getFlagKey ()Ljava/lang/String; + public final fun getTargetingKey ()Ljava/lang/String; + public final fun getVariant ()Ljava/lang/String; + public final fun isCacheHit ()Z + public final fun isExposure ()Z + public final fun shouldSend ()Z +} + +public final class com/datadog/android/flags/openfeature/ExposureHook : dev/openfeature/kotlin/sdk/Hook { + public fun (Lcom/datadog/android/flags/openfeature/ExposureCallback;)V + public fun after (Ldev/openfeature/kotlin/sdk/HookContext;Ldev/openfeature/kotlin/sdk/FlagEvaluationDetails;Ljava/util/Map;)V + public fun before (Ldev/openfeature/kotlin/sdk/HookContext;Ljava/util/Map;)V + public fun error (Ldev/openfeature/kotlin/sdk/HookContext;Ljava/lang/Exception;Ljava/util/Map;)V + public fun finallyAfter (Ldev/openfeature/kotlin/sdk/HookContext;Ldev/openfeature/kotlin/sdk/FlagEvaluationDetails;Ljava/util/Map;)V + public fun supportsFlagValueType (Ldev/openfeature/kotlin/sdk/FlagValueType;)Z +} + public final class com/datadog/android/flags/openfeature/FlagsClientExtKt { public static final fun asOpenFeatureProvider (Lcom/datadog/android/flags/FlagsClient;)Ldev/openfeature/kotlin/sdk/FeatureProvider; } diff --git a/features/dd-sdk-android-flags-openfeature/src/main/kotlin/com/datadog/android/flags/openfeature/DatadogFlagsProvider.kt b/features/dd-sdk-android-flags-openfeature/src/main/kotlin/com/datadog/android/flags/openfeature/DatadogFlagsProvider.kt index 446caca966..f770f40da0 100644 --- a/features/dd-sdk-android-flags-openfeature/src/main/kotlin/com/datadog/android/flags/openfeature/DatadogFlagsProvider.kt +++ b/features/dd-sdk-android-flags-openfeature/src/main/kotlin/com/datadog/android/flags/openfeature/DatadogFlagsProvider.kt @@ -14,11 +14,16 @@ import com.datadog.android.flags.FlagsInvalidContextException import com.datadog.android.flags.FlagsStateListener import com.datadog.android.flags._FlagsInternalProxy import com.datadog.android.flags.model.FlagsClientState +import com.datadog.android.flags.model.ResolutionDetails import com.datadog.android.flags.openfeature.internal.adapters.convertToValue +import com.datadog.android.flags.openfeature.internal.adapters.hookFeatures import com.datadog.android.flags.openfeature.internal.adapters.toDatadogEvaluationContext import com.datadog.android.flags.openfeature.internal.adapters.toEvaluationMetadata import com.datadog.android.flags.openfeature.internal.adapters.toOpenFeatureErrorCode import com.datadog.android.flags.openfeature.internal.adapters.toProviderEvaluation +import com.datadog.android.flags.openfeature.internal.exposure.DatadogProviderMetadata +import com.datadog.android.flags.openfeature.internal.exposure.ExposureAdmissionHook +import com.datadog.android.flags.openfeature.internal.exposure.ExposureDecisions import com.datadog.android.flags.openfeature.internal.setEvaluationContextSuspend import dev.openfeature.kotlin.sdk.FeatureProvider import dev.openfeature.kotlin.sdk.Hook @@ -86,11 +91,11 @@ class DatadogFlagsProvider private constructor(private val flagsClient: FlagsCli private val evaluationTelemetryOptOutParameterEnabled = _FlagsInternalProxy(flagsClient).isEvaluationTelemetryOptOutParameterEnabled() - override val metadata: ProviderMetadata = object : ProviderMetadata { - override val name: String = PROVIDER_NAME - } + private val exposureDecisions = ExposureDecisions() + + override val metadata: ProviderMetadata = DatadogProviderMetadata(PROVIDER_NAME, exposureDecisions) - override val hooks: List> = emptyList() + override val hooks: List> = listOf(ExposureAdmissionHook(exposureDecisions)) /** * Initializes the provider with the given evaluation context. @@ -139,25 +144,25 @@ class DatadogFlagsProvider private constructor(private val flagsClient: FlagsCli key: String, defaultValue: Boolean, @Suppress("UNUSED_PARAMETER") context: OpenFeatureEvaluationContext? - ): ProviderEvaluation = flagsClient.resolve(key, defaultValue).toProviderEvaluation() + ): ProviderEvaluation = evaluate(key, defaultValue) override fun getStringEvaluation( key: String, defaultValue: String, @Suppress("UNUSED_PARAMETER") context: OpenFeatureEvaluationContext? - ): ProviderEvaluation = flagsClient.resolve(key, defaultValue).toProviderEvaluation() + ): ProviderEvaluation = evaluate(key, defaultValue) override fun getIntegerEvaluation( key: String, defaultValue: Int, @Suppress("UNUSED_PARAMETER") context: OpenFeatureEvaluationContext? - ): ProviderEvaluation = flagsClient.resolve(key, defaultValue).toProviderEvaluation() + ): ProviderEvaluation = evaluate(key, defaultValue) override fun getDoubleEvaluation( key: String, defaultValue: Double, @Suppress("UNUSED_PARAMETER") context: OpenFeatureEvaluationContext? - ): ProviderEvaluation = flagsClient.resolve(key, defaultValue).toProviderEvaluation() + ): ProviderEvaluation = evaluate(key, defaultValue) override fun getObjectEvaluation( key: String, @@ -165,7 +170,8 @@ class DatadogFlagsProvider private constructor(private val flagsClient: FlagsCli @Suppress("UNUSED_PARAMETER") context: OpenFeatureEvaluationContext? ): ProviderEvaluation { // Use sentinel to avoid unnecessary conversion of user's default value - val resolutionDetails = flagsClient.resolve(key, SENTINEL_DEFAULT_MAP) + val resolution = _FlagsInternalProxy(flagsClient).resolveDeferringExposure(key, SENTINEL_DEFAULT_MAP) + val resolutionDetails = resolution.details // If we got the sentinel back (via reference equality), return the user's original default // This happens when: flag not found, provider not ready, or any error occurs @@ -180,7 +186,7 @@ class DatadogFlagsProvider private constructor(private val flagsClient: FlagsCli ) } - val resultValue = Value.Structure( + val resultValue: Value = Value.Structure( resolutionDetails.value.mapValues { (_, v) -> convertToValue(v, internalLogger) } ) @@ -191,7 +197,7 @@ class DatadogFlagsProvider private constructor(private val flagsClient: FlagsCli errorCode = null, errorMessage = null, metadata = resolutionDetails.flagMetadata.toEvaluationMetadata() - ) + ).also { register(it, resolutionDetails, resolution.recordExposure) } } override fun shutdown() { @@ -253,6 +259,20 @@ class DatadogFlagsProvider private constructor(private val flagsClient: FlagsCli } } + private fun evaluate(key: String, defaultValue: T): ProviderEvaluation { + val resolution = _FlagsInternalProxy(flagsClient).resolveDeferringExposure(key, defaultValue) + return resolution.details.toProviderEvaluation() + .also { register(it, resolution.details, resolution.recordExposure) } + } + + private fun register( + evaluation: ProviderEvaluation<*>, + details: ResolutionDetails<*>, + recordExposure: (() -> Boolean)? + ) { + exposureDecisions.register(evaluation.metadata, details.hookFeatures(), recordExposure) + } + private suspend fun setEvaluationContext(context: OpenFeatureEvaluationContext?) { val datadogContext = targetingContext(context) val telemetryOptedOut = if (evaluationTelemetryOptOutParameterEnabled) { diff --git a/features/dd-sdk-android-flags-openfeature/src/main/kotlin/com/datadog/android/flags/openfeature/ExposureCallback.kt b/features/dd-sdk-android-flags-openfeature/src/main/kotlin/com/datadog/android/flags/openfeature/ExposureCallback.kt new file mode 100644 index 0000000000..709395fc8e --- /dev/null +++ b/features/dd-sdk-android-flags-openfeature/src/main/kotlin/com/datadog/android/flags/openfeature/ExposureCallback.kt @@ -0,0 +1,20 @@ +/* + * 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.openfeature + +/** + * Receives each completed flag evaluation from an [ExposureHook]. + */ +fun interface ExposureCallback { + /** + * Called on the evaluating thread, for every evaluation, including errors and repeats. + * Do not do I/O here: hand the data to a background queue. + * + * @param evaluation the evaluation and its Datadog exposure decision. + */ + fun onEvaluation(evaluation: ExposureEvaluation) +} diff --git a/features/dd-sdk-android-flags-openfeature/src/main/kotlin/com/datadog/android/flags/openfeature/ExposureEvaluation.kt b/features/dd-sdk-android-flags-openfeature/src/main/kotlin/com/datadog/android/flags/openfeature/ExposureEvaluation.kt new file mode 100644 index 0000000000..9c762a8d5a --- /dev/null +++ b/features/dd-sdk-android-flags-openfeature/src/main/kotlin/com/datadog/android/flags/openfeature/ExposureEvaluation.kt @@ -0,0 +1,46 @@ +/* + * 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.openfeature + +import dev.openfeature.kotlin.sdk.EvaluationContext + +/** + * A completed flag evaluation and its Datadog exposure decision, passed to an [ExposureCallback]. + * + * @property flagKey the flag key. + * @property variant the selected variant key, or null when the default value was returned. + * @property allocationKey the key of the selected allocation, or null when none was selected. + * @property targetingKey the targeting key of the evaluation context, or null if it has none. + * @property evaluationContext a copy of the evaluation context, or null if none was set. + * @property features the `HOOK` features of the selected split, keyed by feature key, for example + * `holdout.key`. Each value is a [String], [Double] or [Boolean]. Empty when the split has none. + */ +class ExposureEvaluation internal constructor( + val flagKey: String, + val variant: String?, + val allocationKey: String?, + val targetingKey: String?, + val evaluationContext: EvaluationContext?, + val features: Map, + private val exposure: Boolean, + private val cacheHit: Boolean? +) { + /** + * True when the evaluation succeeded and selected an allocation that logs exposures, + * whether or not this subject was already exposed. + */ + fun isExposure(): Boolean = exposure + + /** + * True when this exposure repeats one already recorded for this subject: + * same allocation, variant and split. False for a new or changed exposure, and when [isExposure] is false. + */ + fun isCacheHit(): Boolean = exposure && cacheHit == true + + /** True for a new or changed exposure: exactly the exposures the SDK sends to Datadog. */ + fun shouldSend(): Boolean = exposure && cacheHit == false +} diff --git a/features/dd-sdk-android-flags-openfeature/src/main/kotlin/com/datadog/android/flags/openfeature/ExposureHook.kt b/features/dd-sdk-android-flags-openfeature/src/main/kotlin/com/datadog/android/flags/openfeature/ExposureHook.kt new file mode 100644 index 0000000000..89ec61c4b7 --- /dev/null +++ b/features/dd-sdk-android-flags-openfeature/src/main/kotlin/com/datadog/android/flags/openfeature/ExposureHook.kt @@ -0,0 +1,118 @@ +/* + * 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.openfeature + +import com.datadog.android.Datadog +import com.datadog.android.api.InternalLogger +import com.datadog.android.api.feature.FeatureSdkCore +import com.datadog.android.flags.openfeature.internal.exposure.DatadogProviderMetadata +import com.datadog.android.flags.openfeature.internal.exposure.ExposureMetadata +import dev.openfeature.kotlin.sdk.FlagEvaluationDetails +import dev.openfeature.kotlin.sdk.Hook +import dev.openfeature.kotlin.sdk.HookContext +import dev.openfeature.kotlin.sdk.ImmutableContext +import dev.openfeature.kotlin.sdk.Reason +import java.util.concurrent.atomic.AtomicBoolean + +/** + * An OpenFeature hook that passes each completed flag evaluation, with its Datadog exposure decision, + * to [callback], so that an application can record exposures in its own data store. + * + * Register it with `OpenFeatureAPI.addHooks(listOf(ExposureHook { evaluation -> ... }))`, or on a client. + * Use [ExposureEvaluation.shouldSend] to keep new or changed exposures. They are exactly the exposures the + * SDK sends to Datadog, also when [com.datadog.android.flags.FlagsConfiguration.Builder.trackExposures] is + * off and nothing is sent. + * + * The callback runs in the final hook stage, so it sees errors from other hooks. It runs only for + * evaluations of a [DatadogFlagsProvider] set directly as the OpenFeature provider: composed providers + * such as `MultiProvider` do not run provider hooks, so the SDK records no exposures under them. + * + * An exception from the callback does not affect the evaluation. The first one is logged as a warning, + * later ones at debug level. + * + * @param callback receives each evaluation, on the evaluating thread. It must not do I/O. + * @param internalLogger provides the logger for callback failures. + */ +class ExposureHook internal constructor( + private val callback: ExposureCallback, + private val internalLogger: () -> InternalLogger +) : Hook { + + constructor(callback: ExposureCallback) : this( + callback, + { (Datadog.getInstance() as? FeatureSdkCore)?.internalLogger ?: InternalLogger.UNBOUND } + ) + + private val callbackFailed = AtomicBoolean(false) + private val warnedUnsupportedProvider = AtomicBoolean(false) + + override fun finallyAfter(ctx: HookContext, details: FlagEvaluationDetails, hints: Map) { + val providerMetadata = ctx.providerMetadata as? DatadogProviderMetadata + if (providerMetadata == null) { + if (!warnedUnsupportedProvider.getAndSet(true)) { + log(InternalLogger.Level.WARN) { UNSUPPORTED_PROVIDER_MESSAGE } + } + return + } + @Suppress("TooGenericExceptionCaught") // The callback is application code. + try { + callback.onEvaluation(createEvaluation(ctx, details, providerMetadata)) + } catch (e: Throwable) { + val level = if (callbackFailed.getAndSet(true)) InternalLogger.Level.DEBUG else InternalLogger.Level.WARN + log(level, e) { CALLBACK_FAILED_MESSAGE } + } + } + + private fun createEvaluation( + ctx: HookContext, + details: FlagEvaluationDetails, + providerMetadata: DatadogProviderMetadata + ): ExposureEvaluation { + val decisions = providerMetadata.exposureDecisions + val decision = decisions.find(details) + val allocationKey = details.metadata.getString(ExposureMetadata.ALLOCATION_KEY) + val isExposure = details.errorCode == null && + details.reason != Reason.ERROR.name && + details.variant != null && + allocationKey != null && + details.metadata.getBoolean(ExposureMetadata.DO_LOG) == true + val cacheHit = if (isExposure) { + decisions.complete(details) ?: details.metadata.getBoolean(ExposureMetadata.EXPOSURE_CACHE_HIT) + } else { + null + } + val targetingKey = ctx.ctx?.getTargetingKey() + val context = ctx.ctx?.let { ImmutableContext(it.getTargetingKey(), it.asMap()) } + return ExposureEvaluation( + flagKey = details.flagKey, + variant = details.variant, + allocationKey = allocationKey, + targetingKey = targetingKey?.takeIf { it.isNotEmpty() }, + evaluationContext = context, + features = decision?.hookFeatures ?: emptyMap(), + exposure = isExposure, + cacheHit = cacheHit + ) + } + + private fun log(level: InternalLogger.Level, throwable: Throwable? = null, message: () -> String) { + @Suppress("TooGenericExceptionCaught") // Logging must not fail the evaluation either. + try { + internalLogger().log(level, InternalLogger.Target.USER, message, throwable) + } catch (ignored: Throwable) { + // Nothing else can report it. + } + } + + private companion object { + private const val CALLBACK_FAILED_MESSAGE = + "Exposure callback failed; later failures are logged at debug level." + private const val UNSUPPORTED_PROVIDER_MESSAGE = + "ExposureHook only reports evaluations of a DatadogFlagsProvider set directly as the OpenFeature " + + "provider. Other evaluations are ignored." + } +} diff --git a/features/dd-sdk-android-flags-openfeature/src/main/kotlin/com/datadog/android/flags/openfeature/internal/adapters/Converters.kt b/features/dd-sdk-android-flags-openfeature/src/main/kotlin/com/datadog/android/flags/openfeature/internal/adapters/Converters.kt index 57e0ad63fc..0202d4520f 100644 --- a/features/dd-sdk-android-flags-openfeature/src/main/kotlin/com/datadog/android/flags/openfeature/internal/adapters/Converters.kt +++ b/features/dd-sdk-android-flags-openfeature/src/main/kotlin/com/datadog/android/flags/openfeature/internal/adapters/Converters.kt @@ -9,6 +9,7 @@ package com.datadog.android.flags.openfeature.internal.adapters import com.datadog.android.flags.model.ErrorCode import com.datadog.android.flags.model.EvaluationContext import com.datadog.android.flags.model.ResolutionDetails +import com.datadog.android.flags.openfeature.internal.exposure.ExposureMetadata import dev.openfeature.kotlin.sdk.Builder import dev.openfeature.kotlin.sdk.EvaluationMetadata import dev.openfeature.kotlin.sdk.ProviderEvaluation @@ -72,6 +73,20 @@ internal fun Map.toEvaluationMetadata(): EvaluationMetadata { return builder.build() } +/** + * Returns the `HOOK` features in [ResolutionDetails.flagMetadata], keyed by feature key. + */ +internal fun ResolutionDetails<*>.hookFeatures(): Map { + if (flagMetadata.keys.none { it.startsWith(ExposureMetadata.FEATURE_PREFIX) }) return emptyMap() + val features = LinkedHashMap() + flagMetadata.forEach { (key, value) -> + if (key.startsWith(ExposureMetadata.FEATURE_PREFIX)) { + features[key.removePrefix(ExposureMetadata.FEATURE_PREFIX)] = value + } + } + return features +} + /** * Converts a Datadog [ErrorCode] to an OpenFeature [ErrorCode]. */ diff --git a/features/dd-sdk-android-flags-openfeature/src/main/kotlin/com/datadog/android/flags/openfeature/internal/exposure/ExposureAdmissionHook.kt b/features/dd-sdk-android-flags-openfeature/src/main/kotlin/com/datadog/android/flags/openfeature/internal/exposure/ExposureAdmissionHook.kt new file mode 100644 index 0000000000..66e0328e08 --- /dev/null +++ b/features/dd-sdk-android-flags-openfeature/src/main/kotlin/com/datadog/android/flags/openfeature/internal/exposure/ExposureAdmissionHook.kt @@ -0,0 +1,21 @@ +/* + * 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.openfeature.internal.exposure + +import dev.openfeature.kotlin.sdk.FlagEvaluationDetails +import dev.openfeature.kotlin.sdk.Hook +import dev.openfeature.kotlin.sdk.HookContext + +/** + * Records and sends the exposure of a successful evaluation. As a provider hook it runs first in the + * final stage, after every after hook, so a failing hook prevents the exposure. + */ +internal class ExposureAdmissionHook(private val decisions: ExposureDecisions) : Hook { + override fun finallyAfter(ctx: HookContext, details: FlagEvaluationDetails, hints: Map) { + decisions.complete(details) + } +} diff --git a/features/dd-sdk-android-flags-openfeature/src/main/kotlin/com/datadog/android/flags/openfeature/internal/exposure/ExposureDecisions.kt b/features/dd-sdk-android-flags-openfeature/src/main/kotlin/com/datadog/android/flags/openfeature/internal/exposure/ExposureDecisions.kt new file mode 100644 index 0000000000..45e1364df5 --- /dev/null +++ b/features/dd-sdk-android-flags-openfeature/src/main/kotlin/com/datadog/android/flags/openfeature/internal/exposure/ExposureDecisions.kt @@ -0,0 +1,60 @@ +/* + * 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.openfeature.internal.exposure + +import dev.openfeature.kotlin.sdk.EvaluationMetadata +import dev.openfeature.kotlin.sdk.FlagEvaluationDetails +import dev.openfeature.kotlin.sdk.Reason + +/** + * Exposure decisions of the evaluations a provider resolved on the current thread. + * + * OpenFeature passes the provider's metadata instance unchanged to every hook of the same evaluation, + * so it identifies the evaluation. Hooks run on the evaluating thread, after the provider returns. + */ +internal class ExposureDecisions { + + internal class Decision( + val metadata: EvaluationMetadata, + val hookFeatures: Map, + private val recordExposure: (() -> Boolean)? + ) { + private var isRecorded: Boolean? = null + + /** Records the exposure on the first call; returns whether it was already recorded, or null. */ + fun complete(): Boolean? { + val record = recordExposure ?: return null + return isRecorded ?: record().also { isRecorded = it } + } + } + + private val decisions = object : ThreadLocal>() { + override fun initialValue(): ArrayDeque = ArrayDeque() + } + + fun register(metadata: EvaluationMetadata, hookFeatures: Map, recordExposure: (() -> Boolean)?) { + if (hookFeatures.isEmpty() && recordExposure == null) return + val queue = decisions.get() ?: return + if (queue.size == MAX_DECISIONS_PER_THREAD) queue.removeFirstOrNull() + queue.addLast(Decision(metadata, hookFeatures, recordExposure)) + } + + fun find(details: FlagEvaluationDetails<*>): Decision? = + decisions.get()?.lastOrNull { it.metadata === details.metadata } + + /** + * Makes the final exposure decision of a successful evaluation. + * @return whether the exposure was already recorded, or null when [details] is not a Datadog exposure. + */ + fun complete(details: FlagEvaluationDetails<*>): Boolean? = + if (details.errorCode != null || details.reason == Reason.ERROR.name) null else find(details)?.complete() + + private companion object { + // Covers evaluations nested in hooks; a decision is used by the hooks of its own evaluation only. + const val MAX_DECISIONS_PER_THREAD = 16 + } +} diff --git a/features/dd-sdk-android-flags-openfeature/src/main/kotlin/com/datadog/android/flags/openfeature/internal/exposure/ExposureMetadata.kt b/features/dd-sdk-android-flags-openfeature/src/main/kotlin/com/datadog/android/flags/openfeature/internal/exposure/ExposureMetadata.kt new file mode 100644 index 0000000000..baa26bb3e1 --- /dev/null +++ b/features/dd-sdk-android-flags-openfeature/src/main/kotlin/com/datadog/android/flags/openfeature/internal/exposure/ExposureMetadata.kt @@ -0,0 +1,22 @@ +/* + * 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.openfeature.internal.exposure + +import dev.openfeature.kotlin.sdk.ProviderMetadata + +/** Identifies evaluations of a [com.datadog.android.flags.openfeature.DatadogFlagsProvider] in hooks. */ +internal class DatadogProviderMetadata( + override val name: String, + val exposureDecisions: ExposureDecisions +) : ProviderMetadata + +internal object ExposureMetadata { + const val FEATURE_PREFIX = "__dd_feature." + const val DO_LOG = "__dd_do_log" + const val EXPOSURE_CACHE_HIT = "__dd_exposure_cache_hit" + const val ALLOCATION_KEY = "allocationKey" +} diff --git a/features/dd-sdk-android-flags-openfeature/src/test/kotlin/com/datadog/android/flags/openfeature/DatadogFlagsProviderTest.kt b/features/dd-sdk-android-flags-openfeature/src/test/kotlin/com/datadog/android/flags/openfeature/DatadogFlagsProviderTest.kt index 3f54161596..1410c6f7db 100644 --- a/features/dd-sdk-android-flags-openfeature/src/test/kotlin/com/datadog/android/flags/openfeature/DatadogFlagsProviderTest.kt +++ b/features/dd-sdk-android-flags-openfeature/src/test/kotlin/com/datadog/android/flags/openfeature/DatadogFlagsProviderTest.kt @@ -22,6 +22,7 @@ import com.datadog.android.flags.model.EvaluationContext import com.datadog.android.flags.model.FlagsClientState import com.datadog.android.flags.model.ResolutionDetails import com.datadog.android.flags.model.ResolutionReason +import com.datadog.android.flags.openfeature.internal.exposure.ExposureAdmissionHook import com.datadog.tools.unit.forge.BaseConfigurator import dev.openfeature.kotlin.sdk.ImmutableContext import dev.openfeature.kotlin.sdk.OpenFeatureAPI @@ -189,12 +190,12 @@ internal class DatadogFlagsProviderTest { } @Test - fun `M return empty hooks list W hooks()`() { + fun `M return the exposure admission hook W hooks()`() { // When val hooks = provider.hooks // Then - assertThat(hooks).isEmpty() + assertThat(hooks).singleElement().isInstanceOf(ExposureAdmissionHook::class.java) } // region Primitive Type Evaluations diff --git a/features/dd-sdk-android-flags-openfeature/src/test/kotlin/com/datadog/android/flags/openfeature/ExposureHookTest.kt b/features/dd-sdk-android-flags-openfeature/src/test/kotlin/com/datadog/android/flags/openfeature/ExposureHookTest.kt new file mode 100644 index 0000000000..67619881cb --- /dev/null +++ b/features/dd-sdk-android-flags-openfeature/src/test/kotlin/com/datadog/android/flags/openfeature/ExposureHookTest.kt @@ -0,0 +1,297 @@ +/* + * 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.openfeature + +import com.datadog.android.api.InternalLogger +import com.datadog.android.flags.openfeature.internal.exposure.DatadogProviderMetadata +import com.datadog.android.flags.openfeature.internal.exposure.ExposureDecisions +import com.datadog.tools.unit.forge.BaseConfigurator +import dev.openfeature.kotlin.sdk.EvaluationContext +import dev.openfeature.kotlin.sdk.EvaluationMetadata +import dev.openfeature.kotlin.sdk.FlagEvaluationDetails +import dev.openfeature.kotlin.sdk.FlagValueType +import dev.openfeature.kotlin.sdk.HookContext +import dev.openfeature.kotlin.sdk.ImmutableContext +import dev.openfeature.kotlin.sdk.ProviderMetadata +import dev.openfeature.kotlin.sdk.Value +import dev.openfeature.kotlin.sdk.exceptions.ErrorCode +import fr.xgouchet.elmyr.annotation.StringForgery +import fr.xgouchet.elmyr.junit5.ForgeConfiguration +import fr.xgouchet.elmyr.junit5.ForgeExtension +import org.assertj.core.api.Assertions.assertThat +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.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.doThrow +import org.mockito.kotlin.eq +import org.mockito.kotlin.inOrder +import org.mockito.kotlin.isA +import org.mockito.kotlin.isNull +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness + +@Extensions( + ExtendWith(MockitoExtension::class), + ExtendWith(ForgeExtension::class) +) +@MockitoSettings(strictness = Strictness.LENIENT) +@ForgeConfiguration(BaseConfigurator::class) +internal class ExposureHookTest { + + @Mock + lateinit var mockInternalLogger: InternalLogger + + @StringForgery + lateinit var fakeFlagKey: String + + @StringForgery + lateinit var fakeTargetingKey: String + + private val evaluations = mutableListOf() + private val decisions = ExposureDecisions() + private val providerMetadata = DatadogProviderMetadata("Datadog", decisions) + private lateinit var fakeContext: LiveContext + private lateinit var testedHook: ExposureHook + + @BeforeEach + fun `set up`() { + fakeContext = LiveContext(fakeTargetingKey, mutableMapOf("plan" to Value.String("pro"))) + testedHook = ExposureHook({ evaluations.add(it) }, { mockInternalLogger }) + } + + @Test + fun `M report a new exposure W finallyAfter() {exposure not recorded}`() { + // Given + val details = exposureDetails(isRecorded = false, features = mapOf("holdout.key" to "holdout-1")) + + // When + testedHook.finallyAfter(hookContext(), details, emptyMap()) + + // Then + val evaluation = evaluations.single() + assertThat(evaluation.isExposure()).isTrue() + assertThat(evaluation.shouldSend()).isTrue() + assertThat(evaluation.isCacheHit()).isFalse() + assertThat(evaluation.flagKey).isEqualTo(fakeFlagKey) + assertThat(evaluation.variant).isEqualTo("on") + assertThat(evaluation.allocationKey).isEqualTo("allocation") + assertThat(evaluation.targetingKey).isEqualTo(fakeTargetingKey) + assertThat(evaluation.features).containsExactlyEntriesOf(mapOf("holdout.key" to "holdout-1")) + } + + @Test + fun `M report a repeat W finallyAfter() {exposure already recorded}`() { + // Given + val details = exposureDetails(isRecorded = true) + + // When + testedHook.finallyAfter(hookContext(), details, emptyMap()) + + // Then + val evaluation = evaluations.single() + assertThat(evaluation.isExposure()).isTrue() + assertThat(evaluation.isCacheHit()).isTrue() + assertThat(evaluation.shouldSend()).isFalse() + } + + @Test + fun `M use the final verdict W finallyAfter() {provisional verdict outdated}`() { + // Given + val details = exposureDetails(isRecorded = true, provisionalIsRecorded = false) + + // When + testedHook.finallyAfter(hookContext(), details, emptyMap()) + + // Then + assertThat(evaluations.single().isCacheHit()).isTrue() + } + + @Test + fun `M report no exposure W finallyAfter() {failed evaluation}`() { + // Given + var recorded = false + val metadata = exposureMetadata(provisionalIsRecorded = false) + decisions.register(metadata, emptyMap()) { + recorded = true + false + } + val details = + FlagEvaluationDetails(fakeFlagKey, true, "on", errorCode = ErrorCode.GENERAL, metadata = metadata) + + // When + testedHook.finallyAfter(hookContext(), details, emptyMap()) + + // Then + val evaluation = evaluations.single() + assertThat(evaluation.isExposure()).isFalse() + assertThat(evaluation.shouldSend()).isFalse() + assertThat(evaluation.isCacheHit()).isFalse() + assertThat(recorded).isFalse() + } + + @Test + fun `M report no exposure W finallyAfter() {allocation does not log}`() { + // Given + val details = FlagEvaluationDetails( + fakeFlagKey, + true, + "on", + metadata = EvaluationMetadata.builder().putString("allocationKey", "allocation").build() + ) + + // When + testedHook.finallyAfter(hookContext(), details, emptyMap()) + + // Then + assertThat(evaluations.single().isExposure()).isFalse() + assertThat(evaluations.single().features).isEmpty() + } + + @Test + fun `M pass a copy of the context W finallyAfter()`() { + // Given + val details = exposureDetails(isRecorded = false) + testedHook.finallyAfter(hookContext(), details, emptyMap()) + + // When + fakeContext.attributes["plan"] = Value.String("free") + + // Then + assertThat(checkNotNull(evaluations.single().evaluationContext).getValue("plan")) + .isEqualTo(Value.String("pro")) + } + + @Test + fun `M ignore the evaluation W finallyAfter() {not a Datadog provider}`() { + // Given + val otherProvider = object : ProviderMetadata { + override val name: String = "other" + } + + // When + testedHook.finallyAfter(hookContext(otherProvider), exposureDetails(isRecorded = false), emptyMap()) + testedHook.finallyAfter(hookContext(otherProvider), exposureDetails(isRecorded = false), emptyMap()) + + // Then + assertThat(evaluations).isEmpty() + val messageCaptor = argumentCaptor<() -> String>() + verify(mockInternalLogger).log( + eq(InternalLogger.Level.WARN), + eq(InternalLogger.Target.USER), + messageCaptor.capture(), + isNull(), + eq(false), + isNull() + ) + assertThat(messageCaptor.firstValue()).isEqualTo( + "ExposureHook only reports evaluations of a DatadogFlagsProvider set directly as the OpenFeature " + + "provider. Other evaluations are ignored." + ) + } + + @Test + fun `M log the first failure as a warning and later ones at debug level W finallyAfter() {callback throws}`() { + // Given + val failure = IllegalStateException("callback failed") + testedHook = ExposureHook({ throw failure }, { mockInternalLogger }) + + // When + assertDoesNotThrow { + testedHook.finallyAfter(hookContext(), exposureDetails(isRecorded = false), emptyMap()) + testedHook.finallyAfter(hookContext(), exposureDetails(isRecorded = false), emptyMap()) + } + + // Then + val messageCaptor = argumentCaptor<() -> String>() + inOrder(mockInternalLogger) { + verify(mockInternalLogger).log( + eq(InternalLogger.Level.WARN), + eq(InternalLogger.Target.USER), + messageCaptor.capture(), + eq(failure), + eq(false), + isNull() + ) + verify(mockInternalLogger).log( + eq(InternalLogger.Level.DEBUG), + eq(InternalLogger.Target.USER), + messageCaptor.capture(), + eq(failure), + eq(false), + isNull() + ) + } + assertThat(messageCaptor.allValues.map { it() }) + .containsOnly("Exposure callback failed; later failures are logged at debug level.") + } + + @Test + fun `M not throw W finallyAfter() {callback and logger throw}`() { + // Given + whenever( + mockInternalLogger.log(any(), isA(), any(), anyOrNull(), any(), anyOrNull()) + ) doThrow IllegalStateException("logger failed") + testedHook = ExposureHook({ throw IllegalStateException("callback failed") }, { mockInternalLogger }) + + // When / Then + assertDoesNotThrow { + testedHook.finallyAfter(hookContext(), exposureDetails(isRecorded = false), emptyMap()) + } + } + + @Test + fun `M not log W finallyAfter() {callback succeeds}`() { + // When + testedHook.finallyAfter(hookContext(), exposureDetails(isRecorded = false), emptyMap()) + + // Then + verifyNoInteractions(mockInternalLogger) + } + + private fun exposureDetails( + isRecorded: Boolean, + provisionalIsRecorded: Boolean = isRecorded, + features: Map = emptyMap() + ): FlagEvaluationDetails { + val metadata = exposureMetadata(provisionalIsRecorded) + decisions.register(metadata, features) { isRecorded } + return FlagEvaluationDetails(fakeFlagKey, true, "on", metadata = metadata) + } + + private fun exposureMetadata(provisionalIsRecorded: Boolean) = EvaluationMetadata.builder() + .putString("allocationKey", "allocation") + .putBoolean("__dd_do_log", true) + .putBoolean("__dd_exposure_cache_hit", provisionalIsRecorded) + .build() + + private fun hookContext(metadata: ProviderMetadata = providerMetadata) = + HookContext(fakeFlagKey, FlagValueType.BOOLEAN, false, fakeContext, null, metadata) + + class LiveContext( + private val targetingKey: String, + val attributes: MutableMap + ) : EvaluationContext { + override fun getTargetingKey(): String = targetingKey + override fun withTargetingKey(targetingKey: String): EvaluationContext = LiveContext(targetingKey, attributes) + override fun keySet(): Set = attributes.keys + override fun getValue(key: String): Value? = attributes[key] + override fun asMap(): Map = attributes + override fun asObjectMap(): Map = ImmutableContext(targetingKey, attributes).asObjectMap() + override fun hashCode(): Int = attributes.hashCode() + override fun equals(other: Any?): Boolean = other is LiveContext && other.attributes == attributes + } +} diff --git a/features/dd-sdk-android-flags-openfeature/src/test/kotlin/com/datadog/android/flags/openfeature/internal/adapters/ConvertersTest.kt b/features/dd-sdk-android-flags-openfeature/src/test/kotlin/com/datadog/android/flags/openfeature/internal/adapters/ConvertersTest.kt index 567c4bbe64..f186231a37 100644 --- a/features/dd-sdk-android-flags-openfeature/src/test/kotlin/com/datadog/android/flags/openfeature/internal/adapters/ConvertersTest.kt +++ b/features/dd-sdk-android-flags-openfeature/src/test/kotlin/com/datadog/android/flags/openfeature/internal/adapters/ConvertersTest.kt @@ -29,6 +29,59 @@ import dev.openfeature.kotlin.sdk.exceptions.ErrorCode as OpenFeatureErrorCode @ForgeConfiguration(BaseConfigurator::class) internal class ConvertersTest { + @Test + fun `M return only HOOK features W hookFeatures()`() { + // Given + val details = ResolutionDetails( + value = true, + flagMetadata = mapOf( + "allocationKey" to "allocation", + "__dd_feature.holdout.key" to "holdout-1", + "__dd_feature.ratio" to 0.5, + "__dd_exposure_feature.exposure" to "e", + "__dd_evaluation_feature.evaluation" to "v" + ) + ) + + // When + val features = details.hookFeatures() + + // Then + assertThat(features).containsExactlyEntriesOf(mapOf("holdout.key" to "holdout-1", "ratio" to 0.5)) + } + + @Test + fun `M return no features W hookFeatures() {no HOOK features}`() { + // When + val features = ResolutionDetails(value = true, flagMetadata = mapOf("allocationKey" to "a")).hookFeatures() + + // Then + assertThat(features).isEmpty() + } + + @Test + fun `M keep feature value types W toProviderEvaluation() {feature metadata}`() { + // Given + val details = ResolutionDetails( + value = true, + flagMetadata = mapOf( + "__dd_feature.text" to "holdout-1", + "__dd_feature.number" to 3.0, + "__dd_feature.flag" to false, + "__dd_split_serial_id" to 42 + ) + ) + + // When + val metadata = details.toProviderEvaluation().metadata + + // Then + assertThat(metadata.getString("__dd_feature.text")).isEqualTo("holdout-1") + assertThat(metadata.getDouble("__dd_feature.number")).isEqualTo(3.0) + assertThat(metadata.getBoolean("__dd_feature.flag")).isFalse() + assertThat(metadata.getInt("__dd_split_serial_id")).isEqualTo(42) + } + @Test fun `M preserve invalid typed opt out W toDatadogEvaluationContext()`() { // Given diff --git a/features/dd-sdk-android-flags-openfeature/src/test/kotlin/com/datadog/android/flags/openfeature/internal/exposure/ExposureDecisionsTest.kt b/features/dd-sdk-android-flags-openfeature/src/test/kotlin/com/datadog/android/flags/openfeature/internal/exposure/ExposureDecisionsTest.kt new file mode 100644 index 0000000000..505de6632b --- /dev/null +++ b/features/dd-sdk-android-flags-openfeature/src/test/kotlin/com/datadog/android/flags/openfeature/internal/exposure/ExposureDecisionsTest.kt @@ -0,0 +1,152 @@ +/* + * 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.openfeature.internal.exposure + +import com.datadog.tools.unit.forge.BaseConfigurator +import dev.openfeature.kotlin.sdk.EvaluationMetadata +import dev.openfeature.kotlin.sdk.FlagEvaluationDetails +import dev.openfeature.kotlin.sdk.Reason +import dev.openfeature.kotlin.sdk.exceptions.ErrorCode +import fr.xgouchet.elmyr.annotation.BoolForgery +import fr.xgouchet.elmyr.annotation.StringForgery +import fr.xgouchet.elmyr.junit5.ForgeConfiguration +import fr.xgouchet.elmyr.junit5.ForgeExtension +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +@ExtendWith(ForgeExtension::class) +@ForgeConfiguration(BaseConfigurator::class) +internal class ExposureDecisionsTest { + + @StringForgery + lateinit var fakeFlagKey: String + + private var recordCount = 0 + private lateinit var testedDecisions: ExposureDecisions + + @BeforeEach + fun `set up`() { + testedDecisions = ExposureDecisions() + } + + @Test + fun `M record once W complete() twice`(@BoolForgery fakeIsRecorded: Boolean) { + // Given + val metadata = newMetadata() + testedDecisions.register(metadata, emptyMap(), record(fakeIsRecorded)) + + // When + val first = testedDecisions.complete(details(metadata)) + val second = testedDecisions.complete(details(metadata)) + + // Then + assertThat(first).isEqualTo(fakeIsRecorded) + assertThat(second).isEqualTo(fakeIsRecorded) + assertThat(recordCount).isEqualTo(1) + } + + @Test + fun `M record nothing W complete() {failed evaluation}`() { + // Given + val metadata = newMetadata() + testedDecisions.register(metadata, emptyMap(), record(false)) + + // When + val withErrorCode = testedDecisions.complete(details(metadata).copy(errorCode = ErrorCode.GENERAL)) + val withErrorReason = testedDecisions.complete(details(metadata).copy(reason = Reason.ERROR.name)) + + // Then + assertThat(withErrorCode).isNull() + assertThat(withErrorReason).isNull() + assertThat(recordCount).isZero() + } + + @Test + fun `M match by identity W complete() {equal metadata of another evaluation}`() { + // Given + testedDecisions.register(newMetadata(), emptyMap(), record(false)) + + // When + val result = testedDecisions.complete(details(newMetadata())) + + // Then + assertThat(result).isNull() + assertThat(recordCount).isZero() + } + + @Test + fun `M keep the decision on its thread W complete() {other thread}`() { + // Given + val metadata = newMetadata() + testedDecisions.register(metadata, emptyMap(), record(false)) + val executor = Executors.newSingleThreadExecutor() + + // When + val result = AtomicReference(true) + executor.submit { result.set(testedDecisions.complete(details(metadata))) }.get(5, TimeUnit.SECONDS) + executor.shutdown() + + // Then + assertThat(result.get()).isNull() + assertThat(recordCount).isZero() + } + + @Test + fun `M drop the oldest decision W register() {more than the per-thread capacity}`() { + // Given + val metadata = List(17) { newMetadata() } + + // When + metadata.forEach { testedDecisions.register(it, emptyMap(), record(false)) } + + // Then + assertThat(testedDecisions.find(details(metadata.first()))).isNull() + assertThat(testedDecisions.find(details(metadata[1]))).isNotNull() + assertThat(testedDecisions.find(details(metadata.last()))).isNotNull() + } + + @Test + fun `M keep hook features W register() + find() {no exposure}`() { + // Given + val metadata = newMetadata() + val features = mapOf("holdout.key" to "holdout-1") + + // When + testedDecisions.register(metadata, features, null) + + // Then + assertThat(checkNotNull(testedDecisions.find(details(metadata))).hookFeatures).isEqualTo(features) + assertThat(testedDecisions.complete(details(metadata))).isNull() + } + + @Test + fun `M keep nothing W register() {no features and no exposure}`() { + // Given + val metadata = newMetadata() + + // When + testedDecisions.register(metadata, emptyMap(), null) + + // Then + assertThat(testedDecisions.find(details(metadata))).isNull() + } + + private fun record(isRecorded: Boolean): () -> Boolean = { + recordCount++ + isRecorded + } + + private fun newMetadata(): EvaluationMetadata = EvaluationMetadata.builder().putBoolean("__dd_do_log", true).build() + + private fun details(metadata: EvaluationMetadata) = + FlagEvaluationDetails(fakeFlagKey, true, variant = "on", metadata = metadata) +} diff --git a/features/dd-sdk-android-flags/README.md b/features/dd-sdk-android-flags/README.md index acf64792de..b65de7f358 100644 --- a/features/dd-sdk-android-flags/README.md +++ b/features/dd-sdk-android-flags/README.md @@ -82,6 +82,8 @@ val flagsConfig = FlagsConfiguration.Builder() .build() ``` +With `trackExposures(false)`, the SDK sends no exposure events to Datadog. It still records which exposures it has seen, so that the OpenFeature `ExposureHook` reports correct exposure decisions. See [Exposures in your own data store](../dd-sdk-android-flags-openfeature/README.md#exposures-in-your-own-data-store). + #### Configure custom endpoints For testing or proxy purposes, you can configure custom endpoints: @@ -344,6 +346,10 @@ if (result.errorCode != null) { - `errorMessage: String?` - Optional human-readable error message - `flagMetadata: Map?` - Optional metadata associated with the flag +##### Split features + +When the selected split has features for application hooks, `flagMetadata` contains one entry for each of them, with the key `__dd_feature.`. For example, a holdout gives `__dd_feature.holdout.key`, `__dd_feature.holdout.assignment_group` and `__dd_feature.holdout.should_include_in_holdout_analysis`. A value is a `String`, `Double` or `Boolean`. Other `__dd_` entries are internal and can change without notice. + ##### Error codes - `FLAG_NOT_FOUND` - The flag could not be found - `PARSE_ERROR` - Error parsing the flag value diff --git a/features/dd-sdk-android-flags/api/apiSurface b/features/dd-sdk-android-flags/api/apiSurface index 6c7776a54a..6650fae245 100644 --- a/features/dd-sdk-android-flags/api/apiSurface +++ b/features/dd-sdk-android-flags/api/apiSurface @@ -66,6 +66,9 @@ class com.datadog.android.flags._FlagsInternalProxy fun trackFlagEvaluationEvent(String, com.datadog.android.flags.model.UnparsedFlag?, com.datadog.android.flags.model.EvaluationContext, String?) fun trackFlagEvaluationEvent(String, com.datadog.android.flags.model.UnparsedFlag?, com.datadog.android.flags.model.EvaluationContext, String?, Boolean, String?) fun trackFlagRumEvaluation(String, String, com.datadog.android.flags.model.EvaluationContext) + fun resolveDeferringExposure(String, T): DeferredExposureResolution + class DeferredExposureResolution + constructor(com.datadog.android.flags.model.ResolutionDetails, () -> Boolean?) 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 09dbd2f1b3..678e8e944c 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 @@ -116,6 +116,7 @@ public final class com/datadog/android/flags/_FlagsInternalProxy { public final fun getFlagAssignmentsSnapshot (Lcom/datadog/android/flags/model/EvaluationContext;)Ljava/util/Map; public final fun isEvaluationTelemetryOptOutParameterEnabled ()Z public final fun isExperimentationOptOutEnabled ()Z + public final fun resolveDeferringExposure (Ljava/lang/String;Ljava/lang/Object;)Lcom/datadog/android/flags/_FlagsInternalProxy$DeferredExposureResolution; 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 trackFlagEvaluationEvent (Ljava/lang/String;Lcom/datadog/android/flags/model/UnparsedFlag;Lcom/datadog/android/flags/model/EvaluationContext;Ljava/lang/String;ZLjava/lang/String;)V @@ -125,6 +126,12 @@ public final class com/datadog/android/flags/_FlagsInternalProxy { public final fun trackFlagSnapshotEvaluation (Ljava/lang/String;Lcom/datadog/android/flags/model/UnparsedFlag;Lcom/datadog/android/flags/model/EvaluationContext;Ljava/lang/String;)V } +public final class com/datadog/android/flags/_FlagsInternalProxy$DeferredExposureResolution { + public fun (Lcom/datadog/android/flags/model/ResolutionDetails;Lkotlin/jvm/functions/Function0;)V + public final fun getDetails ()Lcom/datadog/android/flags/model/ResolutionDetails; + public final fun getRecordExposure ()Lkotlin/jvm/functions/Function0; +} + public final class com/datadog/android/flags/model/ErrorCode : java/lang/Enum { public static final field FLAG_NOT_FOUND Lcom/datadog/android/flags/model/ErrorCode; public static final field INVALID_CONTEXT Lcom/datadog/android/flags/model/ErrorCode; diff --git a/features/dd-sdk-android-flags/src/main/json/flags/exposure-event-schema.json b/features/dd-sdk-android-flags/src/main/json/flags/exposure-event-schema.json index d7cb29239e..082ce3fd5e 100644 --- a/features/dd-sdk-android-flags/src/main/json/flags/exposure-event-schema.json +++ b/features/dd-sdk-android-flags/src/main/json/flags/exposure-event-schema.json @@ -55,6 +55,11 @@ "serial_id": { "type": "integer", "description": "Serial identifier of the split that the subject was assigned to" + }, + "features": { + "type": "object", + "description": "Features of the selected split routed to exposure events, keyed by feature key", + "additionalProperties": true } }, "required": ["timestamp", "allocation", "flag", "variant", "subject"] diff --git a/features/dd-sdk-android-flags/src/main/json/flags/flag-evaluation-schema.json b/features/dd-sdk-android-flags/src/main/json/flags/flag-evaluation-schema.json index 345fe52d93..1ea5af5ab5 100644 --- a/features/dd-sdk-android-flags/src/main/json/flags/flag-evaluation-schema.json +++ b/features/dd-sdk-android-flags/src/main/json/flags/flag-evaluation-schema.json @@ -85,6 +85,11 @@ "targeting_rule": { "$ref": "#/definitions/Identifier" }, + "features": { + "type": "object", + "description": "Features of the selected split routed to evaluation events, keyed by feature key", + "additionalProperties": true + }, "error": { "type": "object", "properties": { 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 1067400b1c..3e810c1d8a 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 @@ -8,6 +8,7 @@ package com.datadog.android.flags import com.datadog.android.flags.internal.DatadogFlagsClient import com.datadog.android.flags.model.EvaluationContext +import com.datadog.android.flags.model.ResolutionDetails import com.datadog.android.flags.model.UnparsedFlag import com.datadog.android.lint.InternalApi @@ -22,7 +23,7 @@ import com.datadog.android.lint.InternalApi * are not considered part of the public interface of the Datadog SDK. */ @InternalApi -@Suppress("ClassName", "ClassNaming", "UndocumentedPublicFunction") +@Suppress("ClassName", "ClassNaming", "UndocumentedPublicFunction", "TooManyFunctions") class _FlagsInternalProxy(private val client: FlagsClient) { fun isExperimentationOptOutEnabled(): Boolean = (client as? DatadogFlagsClient)?.experimentationOptOut?.enabled == true @@ -142,4 +143,29 @@ class _FlagsInternalProxy(private val client: FlagsClient) { client.trackFlagRumEvaluation(flagKey, variantKey, context) } } + + /** + * Resolves like [FlagsClient.resolve], but does not record the exposure. The caller invokes + * [DeferredExposureResolution.recordExposure] once, after the evaluation has succeeded. + * A client that is not a Datadog client resolves normally and returns no exposure. + */ + fun resolveDeferringExposure(flagKey: String, defaultValue: T): DeferredExposureResolution = + if (client is DatadogFlagsClient) { + client.resolveDeferringExposure(flagKey, defaultValue) + } else { + DeferredExposureResolution(client.resolve(flagKey, defaultValue), null) + } + + /** + * A resolution whose exposure is not recorded yet. + * + * @param T the type of the flag value. + * @property details the resolution. Its `__dd_exposure_cache_hit` metadata is provisional. + * @property recordExposure records the exposure, sends it unless exposure tracking is off, and returns + * whether it was already recorded. Null when the resolution is not an exposure. + */ + class DeferredExposureResolution( + val details: ResolutionDetails, + val recordExposure: (() -> Boolean)? + ) } 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 7383b922e0..b4456430cf 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 @@ -14,12 +14,15 @@ import com.datadog.android.flags.FlagsClientEventListener import com.datadog.android.flags.FlagsConfiguration import com.datadog.android.flags.FlagsSubscription import com.datadog.android.flags.StateObservable +import com.datadog.android.flags._FlagsInternalProxy import com.datadog.android.flags.internal.aggregation.EvaluationPrivacy import com.datadog.android.flags.internal.aggregation.EvaluationTargetingKey import com.datadog.android.flags.internal.aggregation.EvaluationTargetingKeyCache import com.datadog.android.flags.internal.evaluation.EvaluationsManager import com.datadog.android.flags.internal.evaluation.ExperimentationOptOut import com.datadog.android.flags.internal.model.PrecomputedFlag +import com.datadog.android.flags.internal.model.SplitFeature +import com.datadog.android.flags.internal.model.forDestination import com.datadog.android.flags.internal.repository.FlagsRepository import com.datadog.android.flags.model.ErrorCode import com.datadog.android.flags.model.EvaluationContext @@ -255,21 +258,65 @@ internal class DatadogFlagsClient( createSuccessResolution(resolution.flag, resolution.value, resolution.observeFullEvaluationData) } - is InternalResolution.Error -> { - trackErrorResolution(resolution) - createErrorResolution( - flagKey = flagKey, - defaultValue = resolution.defaultValue, - errorCode = resolution.errorCode, - errorMessage = resolution.errorMessage, - observeFullEvaluationData = resolution.observeFullEvaluationData - ) - } + is InternalResolution.Error -> errorResolution(resolution) } } // endregion + /** + * Resolves like [resolve], but leaves the exposure to the caller, which records it with + * [_FlagsInternalProxy.DeferredExposureResolution.recordExposure] once the evaluation has succeeded. + * The exposure verdict in the metadata is provisional until then. + */ + internal fun resolveDeferringExposure( + flagKey: String, + defaultValue: T + ): _FlagsInternalProxy.DeferredExposureResolution { + val resolution = when (val result = readAndParseAssignment(flagKey, defaultValue)) { + is InternalResolution.Success -> result + is InternalResolution.Error -> return _FlagsInternalProxy.DeferredExposureResolution( + errorResolution(result), + null + ) + } + val flag = resolution.flag + val context = resolution.context + trackEvaluationTelemetry( + flagKey, + flag, + context, + resolution.telemetryOptedOut, + resolution.observeFullEvaluationData, + context.targetingKey, + resolution.telemetryTargetingKey + ) + val details = createSuccessResolution(flag, resolution.value, resolution.observeFullEvaluationData) + return if (flag.doLog && isCurrentResolutionContext(context)) { + val metadata = details.flagMetadata.toMutableMap() + metadata[METADATA_DO_LOG] = true + flag.serialId?.takeIf { it in Int.MIN_VALUE..Int.MAX_VALUE } + ?.let { metadata[METADATA_SPLIT_SERIAL_ID] = it.toInt() } + metadata[METADATA_EXPOSURE_CACHE_HIT] = exposureProcessor.isRecorded(flagKey, context, flag) + _FlagsInternalProxy.DeferredExposureResolution(details.copy(flagMetadata = metadata)) { + exposureProcessor.processEvent(flagKey, context, flag) + } + } else { + _FlagsInternalProxy.DeferredExposureResolution(details, null) + } + } + + private fun errorResolution(resolution: InternalResolution.Error): ResolutionDetails { + trackErrorResolution(resolution) + return createErrorResolution( + flagKey = resolution.flagKey, + defaultValue = resolution.defaultValue, + errorCode = resolution.errorCode, + errorMessage = resolution.errorMessage, + observeFullEvaluationData = resolution.observeFullEvaluationData + ) + } + // region Private Implementation /** @@ -286,14 +333,6 @@ internal class DatadogFlagsClient( private fun resolveValue(flagKey: String, defaultValue: T): T = resolveTracked(readAndParseAssignment(flagKey, defaultValue)) - private fun writeExposureEvent(name: String, data: UnparsedFlag, context: EvaluationContext) { - exposureProcessor.processEvent( - flagKey = name, - context = context, - data = data - ) - } - private fun writeEvaluationEvent( flagKey: String, context: EvaluationContext?, @@ -303,7 +342,8 @@ internal class DatadogFlagsClient( errorMessage: String?, observeFullEvaluationData: Boolean, telemetryTargetingKey: String? = context?.targetingKey, - preparedTargetingKey: EvaluationTargetingKey? = null + preparedTargetingKey: EvaluationTargetingKey? = null, + flag: UnparsedFlag? = null ) { evaluationsFeature?.processEvaluation( flagKey = flagKey, @@ -317,7 +357,9 @@ internal class DatadogFlagsClient( preparedTargetingKey = preparedTargetingKey ?: replayTargetingKeyCache.get( telemetryTargetingKey, observeFullEvaluationData - ) + ), + features = (flag as? PrecomputedFlag)?.features?.forDestination(SplitFeature.DESTINATION_EVALUATION) + ?: emptyMap() ) } @@ -526,7 +568,7 @@ internal class DatadogFlagsClient( * Typed resolution layer that handles side effects for convenience methods. * * This helper method acts as a middle layer to the public API, handling: - * - Exposure event tracking on successful resolution (via [writeExposureEvent]) + * - Exposure event tracking on successful resolution (via [trackFlagExposure]) * - Type mismatch warning logs for developer feedback (via [InternalLogger]) * - Returning the typed value on success or the default value on error * @@ -589,6 +631,11 @@ internal class DatadogFlagsClient( metadata["allocationKey"] = precomputedFlag.allocationKey } metadata[OBSERVE_FULL_EVALUATION_DATA_METADATA_KEY] = observeFullEvaluationData + precomputedFlag.features.forEach { feature -> + feature.destinations.forEach { destination -> + FEATURE_METADATA_PREFIXES[destination]?.let { metadata[it + feature.key] = feature.value } + } + } return metadata } @@ -646,7 +693,26 @@ internal class DatadogFlagsClient( preparedTargetingKey: EvaluationTargetingKey? = null ) { trackFlagExposure(flagKey, flag, context) + trackEvaluationTelemetry( + flagKey, + flag, + context, + telemetryOptedOut, + observeFullEvaluationData, + telemetryTargetingKey, + preparedTargetingKey + ) + } + private fun trackEvaluationTelemetry( + flagKey: String, + flag: UnparsedFlag, + context: EvaluationContext, + telemetryOptedOut: Boolean, + observeFullEvaluationData: Boolean, + telemetryTargetingKey: String?, + preparedTargetingKey: EvaluationTargetingKey? + ) { if (telemetryOptedOut) { return } @@ -666,7 +732,8 @@ internal class DatadogFlagsClient( errorMessage = null, observeFullEvaluationData = observeFullEvaluationData, telemetryTargetingKey = telemetryTargetingKey, - preparedTargetingKey = preparedTargetingKey + preparedTargetingKey = preparedTargetingKey, + flag = flag ) } } @@ -753,11 +820,10 @@ internal class DatadogFlagsClient( ) } + /** Records the exposure, and sends it unless exposure tracking is off. */ internal fun trackFlagExposure(flagKey: String, flag: UnparsedFlag, context: EvaluationContext) { if (!isCurrentResolutionContext(context)) return - if (flagsConfiguration.trackExposures && flag.doLog) { - writeExposureEvent(flagKey, flag, context) - } + if (flag.doLog) exposureProcessor.processEvent(flagKey, context, flag) } internal fun trackFlagEvaluationEvent( @@ -785,7 +851,8 @@ internal class DatadogFlagsClient( errorCode = sanitizedErrorCode, errorMessage = sanitizedErrorCode, observeFullEvaluationData = observeFullEvaluationData, - telemetryTargetingKey = telemetryTargetingKey + telemetryTargetingKey = telemetryTargetingKey, + flag = flag.takeIf { sanitizedErrorCode == null } ) } @@ -806,5 +873,15 @@ internal class DatadogFlagsClient( private companion object { const val OBSERVE_FULL_EVALUATION_DATA_METADATA_KEY = "__dd_observe_full_evaluation_data" + const val METADATA_DO_LOG = "__dd_do_log" + const val METADATA_SPLIT_SERIAL_ID = "__dd_split_serial_id" + const val METADATA_EXPOSURE_CACHE_HIT = "__dd_exposure_cache_hit" + + // Only the HOOK prefix is a public contract. + val FEATURE_METADATA_PREFIXES = mapOf( + SplitFeature.DESTINATION_HOOK to "__dd_feature.", + SplitFeature.DESTINATION_EXPOSURE to "__dd_exposure_feature.", + SplitFeature.DESTINATION_EVALUATION to "__dd_evaluation_feature." + ) } } diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/EvaluationEventsProcessor.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/EvaluationEventsProcessor.kt index 3e86e7a04f..bfad4f2725 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/EvaluationEventsProcessor.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/EvaluationEventsProcessor.kt @@ -52,7 +52,8 @@ internal class EvaluationEventsProcessor( errorMessage: String?, observeFullEvaluationData: Boolean = false, telemetryTargetingKey: String? = context?.targetingKey, - preparedTargetingKey: EvaluationTargetingKey? = null + preparedTargetingKey: EvaluationTargetingKey? = null, + features: Map = emptyMap() ) { val drainedEvents = aggregator.record( timestamp = timeProvider.getDeviceTimestampMillis(), @@ -66,7 +67,8 @@ internal class EvaluationEventsProcessor( errorMessage = errorMessage, observeFullEvaluationData = observeFullEvaluationData, telemetryTargetingKey = telemetryTargetingKey, - preparedTargetingKey = preparedTargetingKey + preparedTargetingKey = preparedTargetingKey, + features = features ) if (drainedEvents.isNotEmpty()) { diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/EvaluationsFeature.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/EvaluationsFeature.kt index ccc2b2df59..d6154a8075 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/EvaluationsFeature.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/EvaluationsFeature.kt @@ -130,7 +130,8 @@ internal class EvaluationsFeature( errorMessage: String?, observeFullEvaluationData: Boolean = false, telemetryTargetingKey: String? = context?.targetingKey, - preparedTargetingKey: EvaluationTargetingKey? = null + preparedTargetingKey: EvaluationTargetingKey? = null, + features: Map = emptyMap() ) { val processor = evaluationProcessor ?: return val currentDdContext = rumContext @@ -146,7 +147,8 @@ internal class EvaluationsFeature( errorMessage = errorMessage, observeFullEvaluationData = observeFullEvaluationData, telemetryTargetingKey = telemetryTargetingKey, - preparedTargetingKey = preparedTargetingKey + preparedTargetingKey = preparedTargetingKey, + features = features ) } diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/EventsProcessor.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/EventsProcessor.kt index 887fbe6cf3..597501cb2f 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/EventsProcessor.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/EventsProcessor.kt @@ -12,5 +12,12 @@ import com.datadog.tools.annotation.NoOpImplementation @NoOpImplementation internal interface EventsProcessor { - fun processEvent(flagKey: String, context: EvaluationContext, data: UnparsedFlag) + /** + * Records the exposure and writes it when it was not recorded yet. + * @return true if the same exposure was already recorded for this subject. + */ + fun processEvent(flagKey: String, context: EvaluationContext, data: UnparsedFlag): Boolean + + /** @return true if the same exposure is recorded for this subject. Records nothing. */ + fun isRecorded(flagKey: String, context: EvaluationContext, data: UnparsedFlag): Boolean } diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/ExposureEventsProcessor.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/ExposureEventsProcessor.kt index 089089cd1c..db34de07cf 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/ExposureEventsProcessor.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/ExposureEventsProcessor.kt @@ -8,6 +8,9 @@ package com.datadog.android.flags.internal import androidx.collection.LruCache import com.datadog.android.flags.internal.model.ExposureEvent +import com.datadog.android.flags.internal.model.PrecomputedFlag +import com.datadog.android.flags.internal.model.SplitFeature +import com.datadog.android.flags.internal.model.forDestination import com.datadog.android.flags.internal.storage.RecordWriter import com.datadog.android.flags.model.EvaluationContext import com.datadog.android.flags.model.UnparsedFlag @@ -30,28 +33,32 @@ internal class ExposureEventsProcessor(private val writer: RecordWriter, private @Suppress("UnsafeThirdPartyFunctionCall") // maxSize > 0 private val exposuresSentCache = LruCache(MAX_CACHE_ENTRIES) - override fun processEvent(flagKey: String, context: EvaluationContext, data: UnparsedFlag) { - val cacheKey = CacheKey( - targetingKey = context.targetingKey, - flagKey = flagKey - ) - val cacheValue = CacheValue( - allocationKey = data.allocationKey, - variationKey = data.variationKey, - serialId = data.serialId - ) + override fun processEvent(flagKey: String, context: EvaluationContext, data: UnparsedFlag): Boolean { + val cacheKey = CacheKey(targetingKey = context.targetingKey, flagKey = flagKey) + val cacheValue = data.toCacheValue() synchronized(exposuresSentCache) { - val lastSentValue = exposuresSentCache[cacheKey] - if (lastSentValue != cacheValue) { - val event = buildExposureEvent(flagKey, context, data) - writeExposureEvent(event) + val isRecorded = exposuresSentCache[cacheKey] == cacheValue + if (!isRecorded) { + writeExposureEvent(buildExposureEvent(flagKey, context, data)) @Suppress("UnsafeThirdPartyFunctionCall") // safe - non-null key and value exposuresSentCache.put(cacheKey, cacheValue) } + return isRecorded } } + override fun isRecorded(flagKey: String, context: EvaluationContext, data: UnparsedFlag): Boolean = + synchronized(exposuresSentCache) { + exposuresSentCache[CacheKey(context.targetingKey, flagKey)] == data.toCacheValue() + } + + private fun UnparsedFlag.toCacheValue() = CacheValue( + allocationKey = allocationKey, + variationKey = variationKey, + serialId = serialId + ) + private fun buildExposureEvent(flagKey: String, context: EvaluationContext, data: UnparsedFlag): ExposureEvent { val now = timeProvider.getDeviceTimestampMillis() return ExposureEvent( @@ -65,7 +72,11 @@ internal class ExposureEventsProcessor(private val writer: RecordWriter, private additionalProperties = context.attributes.toMutableMap() ) ), - serialId = data.serialId + serialId = data.serialId, + features = (data as? PrecomputedFlag)?.features + ?.forDestination(SplitFeature.DESTINATION_EXPOSURE) + ?.takeIf { it.isNotEmpty() } + ?.let { ExposureEvent.Features(it.toMutableMap()) } ) } diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagsFeature.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagsFeature.kt index e129f9bd9c..9a11c2bb31 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagsFeature.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagsFeature.kt @@ -137,8 +137,9 @@ internal class FlagsFeature( sdkCore.internalLogger.logDroppedFlagSelection(it.error) } dataWriter = createDataWriter() + // With exposure tracking off the cache still records, so evaluations keep correct exposure verdicts. processor = ExposureEventsProcessor( - writer = dataWriter, + writer = if (flagsConfiguration.trackExposures) dataWriter else NoOpRecordWriter(), timeProvider = sdkCore.timeProvider ) } diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/aggregation/EvaluationAggregationKey.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/aggregation/EvaluationAggregationKey.kt index a1d493f047..2d40edf4b0 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/aggregation/EvaluationAggregationKey.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/aggregation/EvaluationAggregationKey.kt @@ -17,6 +17,7 @@ package com.datadog.android.flags.internal.aggregation * - RUM view name (or null when RUM is not active or no view available) * - error code (ErrorCode enum name for aggregation, e.g., "FLAG_NOT_FOUND") * - full evaluation data consent mode + * - features of the selected split routed to evaluation events * * Context aggregation is reserved for future use and not implemented. */ @@ -27,5 +28,6 @@ internal data class EvaluationAggregationKey( val targetingKey: String?, val viewName: String?, val errorCode: String?, - val observeFullEvaluationData: Boolean = false + val observeFullEvaluationData: Boolean = false, + val features: Map = emptyMap() ) diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/aggregation/EvaluationAggregationStats.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/aggregation/EvaluationAggregationStats.kt index 7b4a520a18..5d8fc06d68 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/aggregation/EvaluationAggregationStats.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/aggregation/EvaluationAggregationStats.kt @@ -53,6 +53,8 @@ internal data class EvaluationAggregationStats( targetingRule = null, // Not applicable targetingKey = aggregationKey.targetingKey, context = eventContext, + features = aggregationKey.features.takeIf { it.isNotEmpty() } + ?.let { FlagEvaluation.Features(it.toMutableMap()) }, error = aggregationKey.errorCode?.let { FlagEvaluation.Error(message = it) }, evaluationCount = count.toLong(), firstEvaluation = firstEvaluation, diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/aggregation/EvaluationAggregator.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/aggregation/EvaluationAggregator.kt index 89a536709a..6d0f14e50a 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/aggregation/EvaluationAggregator.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/aggregation/EvaluationAggregator.kt @@ -40,7 +40,8 @@ internal class EvaluationAggregator(private val maxAggregations: Int) { errorMessage: String?, observeFullEvaluationData: Boolean = false, telemetryTargetingKey: String? = context?.targetingKey, - preparedTargetingKey: EvaluationTargetingKey? = null + preparedTargetingKey: EvaluationTargetingKey? = null, + features: Map = emptyMap() ): List { val safeErrorCode = EvaluationPrivacy.errorCode(errorCode, errorMessage) val key = EvaluationAggregationKey( @@ -53,7 +54,8 @@ internal class EvaluationAggregator(private val maxAggregations: Int) { ).value, viewName = rumViewName, errorCode = safeErrorCode, - observeFullEvaluationData = observeFullEvaluationData + observeFullEvaluationData = observeFullEvaluationData, + features = features ) val drained = mapLock.withLock { diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/model/PrecomputedFlag.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/model/PrecomputedFlag.kt index 38336bfc40..95b4351964 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/model/PrecomputedFlag.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/model/PrecomputedFlag.kt @@ -18,5 +18,6 @@ internal data class PrecomputedFlag( override val extraLogging: JSONObject, override val reason: String, override val serialId: Long? = null, - override val observeFullEvaluationData: Boolean = false + override val observeFullEvaluationData: Boolean = false, + val features: List = emptyList() ) : UnparsedFlagWithConsent diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/model/SplitFeature.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/model/SplitFeature.kt new file mode 100644 index 0000000000..e6d021c82f --- /dev/null +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/model/SplitFeature.kt @@ -0,0 +1,38 @@ +/* + * 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.model + +/** + * A feature of the split selected for an assignment. + * + * @param key the feature key, unique within the split. + * @param value a [String], [Double] or [Boolean]. + * @param destinations every non-empty destination received, including ones this SDK does not know. + */ +internal data class SplitFeature( + val key: String, + val value: Any, + val destinations: List +) { + internal companion object { + const val DESTINATION_HOOK = "HOOK" + const val DESTINATION_EXPOSURE = "EXPOSURE" + const val DESTINATION_EVALUATION = "EVALUATION" + + val KNOWN_DESTINATIONS = setOf(DESTINATION_HOOK, DESTINATION_EXPOSURE, DESTINATION_EVALUATION) + } +} + +/** Features routed to [destination], keyed by feature key. */ +internal fun List.forDestination(destination: String): Map { + if (isEmpty()) return emptyMap() + val routed = LinkedHashMap() + forEach { feature -> + if (destination in feature.destinations) routed[feature.key] = feature.value + } + return routed +} diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/persistence/FlagsStateSerializer.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/persistence/FlagsStateSerializer.kt index f9994578cf..a3cc6d24f6 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/persistence/FlagsStateSerializer.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/persistence/FlagsStateSerializer.kt @@ -20,7 +20,7 @@ internal class FlagsStateSerializer(private val internalLogger: InternalLogger) override fun serialize(model: FlagsStateEntry): String = try { val flags = FlagsState.Flags( model.flags.mapValues { - FlagsJson.writeAssignment(it.value).toJson() + FlagsJson.writeAssignmentJson(it.value) }.toMutableMap() ) val json = FlagsState( diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/net/PrecomputeMapper.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/net/PrecomputeMapper.kt index 4053c945a8..9420265611 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/net/PrecomputeMapper.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/net/PrecomputeMapper.kt @@ -13,6 +13,7 @@ import com.datadog.android.flags.internal.evaluation.wireValue import com.datadog.android.flags.internal.model.AssignmentsResponse import com.datadog.android.flags.internal.model.PrecomputedAssignments import com.datadog.android.flags.internal.model.PrecomputedFlag +import com.datadog.android.flags.internal.model.SplitFeature import com.datadog.android.flags.internal.serialization.FlagsJson import com.google.gson.JsonElement import com.google.gson.JsonObject @@ -25,6 +26,8 @@ internal class PrecomputeMapper( private val internalLogger: InternalLogger, private val experimentationOptOutMode: ExperimentationOptOutMode = ExperimentationOptOutMode.DISABLED ) { + private val warnedDestinations = mutableSetOf() + internal fun map( rawJson: String, experimentationOptOut: ExperimentationOptOut.Request? = null @@ -41,15 +44,15 @@ internal class PrecomputeMapper( val observeFullEvaluationData = FlagsJson.readConsent(metadata) val flags = attributes.flags.additionalProperties if (obfuscation != null) FlagsJson.validateKeys(flags.keys.iterator()) - PrecomputedAssignments( - flags.mapValues { - FlagsJson.readAssignment(FlagsJson.objectValue(it.value)).copy( - observeFullEvaluationData = observeFullEvaluationData - ) - }, - obfuscation, - observeFullEvaluationData - ) + val assignments = flags.mapValues { + FlagsJson.readAssignment(FlagsJson.objectValue(it.value)).copy( + observeFullEvaluationData = observeFullEvaluationData + ) + } + assignments.values.forEach { flag -> + flag.features.forEach { feature -> feature.destinations.forEach(::warnIfUnknownDestination) } + } + PrecomputedAssignments(assignments, obfuscation, observeFullEvaluationData) } else { internalLogger.log( InternalLogger.Level.WARN, @@ -86,6 +89,20 @@ internal class PrecomputeMapper( null } + private fun warnIfUnknownDestination(destination: String) { + if (destination in SplitFeature.KNOWN_DESTINATIONS) return + val isNew = synchronized(warnedDestinations) { + warnedDestinations.size < MAX_WARNED_DESTINATIONS && warnedDestinations.add(destination) + } + if (isNew) { + internalLogger.log( + InternalLogger.Level.WARN, + listOf(InternalLogger.Target.MAINTAINER, InternalLogger.Target.TELEMETRY), + { "Ignoring unknown feature destination $destination" } + ) + } + } + private fun acknowledgesOptOut(options: JsonObject?, request: ExperimentationOptOut.Request?): Boolean { val mode = options?.get("experimentation_opt_out_mode") val acknowledgedMode = mode?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isString }?.asString @@ -110,6 +127,8 @@ internal class PrecomputeMapper( } private companion object { + // Bounds the warnings a configuration can trigger. + const val MAX_WARNED_DESTINATIONS = 64 const val ERROR_FAILED_TO_PARSE_RESPONSE = "Failed to parse precomputed response" } } diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/serialization/FlagsJson.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/serialization/FlagsJson.kt index 7871a83883..4d3e339049 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/serialization/FlagsJson.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/serialization/FlagsJson.kt @@ -38,6 +38,7 @@ internal object FlagsJson { // Existing readers ignore null and nonnumeric serial IDs. val serialId = json.get("serialId") if (serialId?.isJsonPrimitive != true || !serialId.asJsonPrimitive.isNumber) json.remove("serialId") + val features = SplitFeaturesJson.read(json.get(FEATURES)) val assignment = Assignment.fromJsonObject(json) @@ -51,10 +52,18 @@ internal object FlagsJson { variationKey = assignment.variationKey, extraLogging = extraLogging, reason = assignment.reason, - serialId = assignment.serialId + serialId = assignment.serialId, + features = features ) } + /** Writes [flag] in the response format, so that [readAssignment] reads it back. */ + fun writeAssignmentJson(flag: PrecomputedFlag): JsonElement { + val json = writeAssignment(flag).toJson() + if (flag.features.isNotEmpty() && json is JsonObject) json.add(FEATURES, SplitFeaturesJson.write(flag.features)) + return json + } + fun writeAssignment(flag: PrecomputedFlag): Assignment = Assignment( variationType = flag.variationType, variationValue = flag.variationValue, @@ -131,4 +140,5 @@ internal object FlagsJson { private val STRING_FIELDS = listOf("variationType", "variationValue", "allocationKey", "variationKey", "reason") private const val DIGEST_HEX_LENGTH = 64 private const val OBSERVE_FULL_EVALUATION_DATA = "observeFullEvaluationData" + private const val FEATURES = "features" } diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/serialization/SplitFeaturesJson.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/serialization/SplitFeaturesJson.kt new file mode 100644 index 0000000000..0ba1c71ca6 --- /dev/null +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/serialization/SplitFeaturesJson.kt @@ -0,0 +1,72 @@ +/* + * 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.serialization + +import com.datadog.android.flags.internal.model.SplitFeature +import com.google.gson.JsonArray +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.JsonPrimitive + +/** Reads and writes the `features` of a split, in the precomputed response format. */ +internal object SplitFeaturesJson { + /** + * Features never decide a variation, so a malformed list or entry is dropped instead of failing the + * assignment. An entry needs a non-empty key, a string, number or boolean value and at least one + * non-empty destination. On a repeated key the first valid entry wins. + */ + fun read(json: JsonElement?): List { + if (json !is JsonArray || json.size() == 0) return emptyList() + val keys = mutableSetOf() + return json.map { (it as? JsonObject)?.let(::readFeature) } + .mapNotNull { feature -> feature?.takeIf { keys.add(it.key) } } + } + + private fun readFeature(json: JsonObject): SplitFeature? { + val stringOrNull = { element: JsonElement -> + element.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isString }?.asString + } + val key = json.get(FEATURE_KEY)?.let(stringOrNull) + val value = (json.get(FEATURE_VALUE) as? JsonPrimitive)?.let { + when { + it.isString -> it.asString + it.isBoolean -> it.asBoolean + it.isNumber -> it.asDouble + else -> null + } + } + val destinations = (json.get(FEATURE_DESTINATIONS) as? JsonArray) + ?.map(stringOrNull) + ?.mapNotNull { destination -> destination?.takeIf { it.isNotEmpty() } } + .orEmpty() + return if (key.isNullOrEmpty() || value == null || destinations.isEmpty()) { + null + } else { + SplitFeature(key, value, destinations) + } + } + + fun write(features: List): JsonArray = JsonArray().apply { + features.forEach { feature -> + add( + JsonObject().apply { + addProperty(FEATURE_KEY, feature.key) + when (val value = feature.value) { + is String -> addProperty(FEATURE_VALUE, value) + is Boolean -> addProperty(FEATURE_VALUE, value) + is Number -> addProperty(FEATURE_VALUE, value) + } + add(FEATURE_DESTINATIONS, JsonArray().apply { feature.destinations.forEach { add(it) } }) + } + ) + } + } + + private const val FEATURE_KEY = "key" + private const val FEATURE_VALUE = "value" + private const val FEATURE_DESTINATIONS = "destinations" +} 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 fc0f96e428..d31dc2d820 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 @@ -149,11 +149,7 @@ internal class FlagsInternalProxyTest { testedProxy.trackFlagEvaluationEvent(fakeFlagKey, null, fakeContext, "FLAG_NOT_FOUND") // Then - if (trackExposures) { - verify(mockExposureProcessor).processEvent(fakeFlagKey, fakeContext, fakeFlag) - } else { - verifyNoInteractions(mockExposureProcessor) - } + verify(mockExposureProcessor).processEvent(fakeFlagKey, fakeContext, fakeFlag) if (trackEvaluations) { verify(mockEvaluationsFeature).processEvaluation( fakeFlagKey, @@ -343,7 +339,7 @@ internal class FlagsInternalProxyTest { val identityCaptor = argumentCaptor() verify(mockEvaluationsFeature, times(4)).processEvaluation( eq(fakeFlagKey), eq(fakeContext), isNull(), isNull(), isNull(), isNull(), - eq(false), eq(fakeContext.targetingKey), identityCaptor.capture() + eq(false), eq(fakeContext.targetingKey), identityCaptor.capture(), eq(emptyMap()) ) assertThat(identityCaptor.allValues[0]).isSameAs(identityCaptor.allValues[1]) assertThat(identityCaptor.allValues[2]).isSameAs(identityCaptor.allValues[3]) diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientExposureTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientExposureTest.kt new file mode 100644 index 0000000000..4a981e2502 --- /dev/null +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientExposureTest.kt @@ -0,0 +1,307 @@ +/* + * 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.InternalLogger +import com.datadog.android.api.feature.FeatureSdkCore +import com.datadog.android.flags.FlagsConfiguration +import com.datadog.android.flags.internal.evaluation.EvaluationsManager +import com.datadog.android.flags.internal.evaluation.ExperimentationOptOut +import com.datadog.android.flags.internal.model.FlagEvaluationSnapshot +import com.datadog.android.flags.internal.model.PrecomputedFlag +import com.datadog.android.flags.internal.model.SplitFeature +import com.datadog.android.flags.internal.model.VariationType +import com.datadog.android.flags.internal.repository.FlagsRepository +import com.datadog.android.flags.model.ErrorCode +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.BoolForgery +import fr.xgouchet.elmyr.annotation.StringForgery +import fr.xgouchet.elmyr.junit5.ForgeConfiguration +import fr.xgouchet.elmyr.junit5.ForgeExtension +import org.assertj.core.api.Assertions.assertThat +import org.json.JSONObject +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.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq +import org.mockito.kotlin.isNull +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness + +@Extensions( + ExtendWith(MockitoExtension::class), + ExtendWith(ForgeExtension::class) +) +@MockitoSettings(strictness = Strictness.LENIENT) +@ForgeConfiguration(ForgeConfigurator::class) +internal class DatadogFlagsClientExposureTest { + + @Mock + lateinit var mockFeatureSdkCore: FeatureSdkCore + + @Mock + lateinit var mockInternalLogger: InternalLogger + + @Mock + lateinit var mockFlagsRepository: FlagsRepository + + @Mock + lateinit var mockEvaluationsManager: EvaluationsManager + + @Mock + lateinit var mockProcessor: EventsProcessor + + @Mock + lateinit var mockEvaluationsFeature: EvaluationsFeature + + @Mock + lateinit var mockRumEvaluationLogger: RumEvaluationLogger + + @Mock + lateinit var mockFlagsStateManager: FlagsStateManager + + @StringForgery + lateinit var fakeFlagKey: String + + private lateinit var fakeContext: EvaluationContext + private lateinit var fakeFlag: PrecomputedFlag + private lateinit var testedClient: DatadogFlagsClient + + @BeforeEach + fun `set up`(forge: Forge) { + whenever(mockFeatureSdkCore.internalLogger) doReturn mockInternalLogger + fakeContext = EvaluationContext(forge.anAlphabeticalString()) + fakeFlag = forge.getForgery().copy( + variationType = VariationType.BOOLEAN.value, + variationValue = "true", + allocationKey = forge.anAlphabeticalString(), + variationKey = forge.anAlphabeticalString(), + extraLogging = JSONObject(), + doLog = true, + serialId = forge.aLong(min = 0L, max = Int.MAX_VALUE.toLong()) + ) + testedClient = DatadogFlagsClient( + featureSdkCore = mockFeatureSdkCore, + evaluationsManager = mockEvaluationsManager, + flagsRepository = mockFlagsRepository, + flagsConfiguration = FlagsConfiguration.Builder().trackEvaluations(true).rumIntegrationEnabled(false) + .build(), + rumEvaluationLogger = mockRumEvaluationLogger, + exposureProcessor = mockProcessor, + evaluationsFeature = mockEvaluationsFeature, + flagStateManager = mockFlagsStateManager, + evaluationTelemetryOptOutParameterEnabled = false, + experimentationOptOut = ExperimentationOptOut() + ) + } + + @Test + fun `M write one entry per feature and known destination W resolve() {split features}`() { + // Given + install( + fakeFlag.copy( + features = listOf( + SplitFeature("holdout.key", "holdout-1", listOf("HOOK")), + SplitFeature("holdout.should_include_in_holdout_analysis", false, listOf("HOOK", "FUTURE")), + SplitFeature("exposure", 2.0, listOf("EXPOSURE")), + SplitFeature("evaluation", "e", listOf("EVALUATION", "HOOK")), + SplitFeature("nowhere", "n", listOf("FUTURE")) + ) + ) + ) + + // When + val result = testedClient.resolve(fakeFlagKey, false) + + // Then + assertThat(result.flagMetadata).containsExactlyInAnyOrderEntriesOf( + mapOf( + "allocationKey" to fakeFlag.allocationKey, + "__dd_observe_full_evaluation_data" to false, + "__dd_feature.holdout.key" to "holdout-1", + "__dd_feature.holdout.should_include_in_holdout_analysis" to false, + "__dd_exposure_feature.exposure" to 2.0, + "__dd_evaluation_feature.evaluation" to "e", + "__dd_feature.evaluation" to "e" + ) + ) + } + + @Test + fun `M add no metadata entries W resolve() {no features}`() { + // Given + install(fakeFlag) + + // When + val result = testedClient.resolve(fakeFlagKey, false) + + // Then + assertThat(result.flagMetadata).containsOnlyKeys("allocationKey", "__dd_observe_full_evaluation_data") + } + + @Test + fun `M count EVALUATION features W resolve() {split features}`() { + // Given + install( + fakeFlag.copy( + features = listOf( + SplitFeature("hook", "h", listOf("HOOK")), + SplitFeature("evaluation", 1.0, listOf("EVALUATION")) + ) + ) + ) + + // When + testedClient.resolve(fakeFlagKey, false) + + // Then + verify(mockEvaluationsFeature).processEvaluation( + flagKey = eq(fakeFlagKey), + context = eq(fakeContext), + variantKey = eq(fakeFlag.variationKey), + allocationKey = eq(fakeFlag.allocationKey), + errorCode = isNull(), + errorMessage = isNull(), + observeFullEvaluationData = eq(false), + telemetryTargetingKey = eq(fakeContext.targetingKey), + preparedTargetingKey = anyOrNull(), + features = eq(mapOf("evaluation" to 1.0)) + ) + } + + @Test + fun `M record the exposure W resolve()`() { + // Given + install(fakeFlag) + + // When + testedClient.resolve(fakeFlagKey, false) + + // Then + verify(mockProcessor).processEvent(fakeFlagKey, fakeContext, fakeFlag) + } + + @Test + fun `M stamp a provisional verdict W resolveDeferringExposure()`(@BoolForgery fakeIsRecorded: Boolean) { + // Given + install(fakeFlag) + whenever(mockProcessor.isRecorded(fakeFlagKey, fakeContext, fakeFlag)) doReturn fakeIsRecorded + + // When + val result = testedClient.resolveDeferringExposure(fakeFlagKey, false) + + // Then + assertThat(result.details.value).isTrue() + assertThat(result.details.flagMetadata).containsAllEntriesOf( + mapOf( + "__dd_do_log" to true, + "__dd_split_serial_id" to checkNotNull(fakeFlag.serialId).toInt(), + "__dd_exposure_cache_hit" to fakeIsRecorded + ) + ) + assertThat(result.recordExposure as Any?).isNotNull() + verify(mockProcessor, never()).processEvent(any(), any(), any()) + } + + @Test + fun `M record on request W resolveDeferringExposure() + recordExposure()`(@BoolForgery fakeIsRecorded: Boolean) { + // Given + install(fakeFlag) + whenever(mockProcessor.processEvent(fakeFlagKey, fakeContext, fakeFlag)) doReturn fakeIsRecorded + val resolution = testedClient.resolveDeferringExposure(fakeFlagKey, false) + + // When + val isRecorded = checkNotNull(resolution.recordExposure).invoke() + + // Then + assertThat(isRecorded).isEqualTo(fakeIsRecorded) + verify(mockProcessor).processEvent(fakeFlagKey, fakeContext, fakeFlag) + } + + @Test + fun `M track evaluation telemetry with consent W resolveDeferringExposure()`( + @BoolForgery fakeConsent: Boolean + ) { + // Given + install(fakeFlag.copy(observeFullEvaluationData = fakeConsent)) + + // When + val result = testedClient.resolveDeferringExposure(fakeFlagKey, false) + + // Then + assertThat(result.details.flagMetadata).containsEntry("__dd_observe_full_evaluation_data", fakeConsent) + verify(mockEvaluationsFeature).processEvaluation( + eq(fakeFlagKey), + eq(fakeContext), + eq(fakeFlag.variationKey), + eq(fakeFlag.allocationKey), + isNull(), + isNull(), + eq(fakeConsent), + eq(fakeContext.targetingKey), + anyOrNull(), + eq(emptyMap()) + ) + } + + @Test + fun `M return no exposure W resolveDeferringExposure() {doLog false}`() { + // Given + install(fakeFlag.copy(doLog = false)) + + // When + val result = testedClient.resolveDeferringExposure(fakeFlagKey, false) + + // Then + assertThat(result.recordExposure as Any?).isNull() + assertThat(result.details.flagMetadata).doesNotContainKeys("__dd_do_log", "__dd_exposure_cache_hit") + verify(mockProcessor, never()).isRecorded(any(), any(), any()) + } + + @Test + fun `M return no exposure W resolveDeferringExposure() {type mismatch}`() { + // Given + install(fakeFlag) + + // When + val result = testedClient.resolveDeferringExposure(fakeFlagKey, "default") + + // Then + assertThat(result.details.errorCode).isEqualTo(ErrorCode.TYPE_MISMATCH) + assertThat(result.recordExposure as Any?).isNull() + verify(mockEvaluationsFeature).processEvaluation( + eq(fakeFlagKey), + eq(fakeContext), + isNull(), + isNull(), + eq(ErrorCode.TYPE_MISMATCH.name), + anyOrNull(), + eq(false), + anyOrNull(), + anyOrNull(), + eq(emptyMap()) + ) + verify(mockProcessor, never()).isRecorded(any(), any(), any()) + } + + private fun install(flag: PrecomputedFlag) { + fakeFlag = flag + whenever(mockFlagsRepository.getEvaluationSnapshot(fakeFlagKey)) doReturn + FlagEvaluationSnapshot(flag, fakeContext, flag.observeFullEvaluationData) + } +} 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 0c5733fa1f..3840ff343a 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 @@ -305,7 +305,8 @@ internal class DatadogFlagsClientTest { anyOrNull(), eq(expectedConsent), eq(fakeRequestedContext.targetingKey), - identityCaptor.capture() + identityCaptor.capture(), + eq(emptyMap()) ) assertThat(contextCaptor.allValues).containsExactly(fakeRequestedContext, fakeRequestedContext) assertThat(identityCaptor.firstValue.value).isEqualTo( @@ -410,7 +411,7 @@ internal class DatadogFlagsClientTest { verify(mockEvaluationsFeature, times(4)).processEvaluation( keyCaptor.capture(), eq(fakeContext), variantCaptor.capture(), allocationCaptor.capture(), errorCaptor.capture(), messageCaptor.capture(), eq(fakeConsent), eq(fakeContext.targetingKey), - identityCaptor.capture() + identityCaptor.capture(), eq(emptyMap()) ) assertThat(keyCaptor.allValues).containsExactly("assigned", "assigned", "invalid", "missing") assertThat(variantCaptor.allValues).containsExactly(fakeBooleanFlag.variationKey, null, null, null) @@ -448,13 +449,13 @@ internal class DatadogFlagsClientTest { val bridgeIdentityCaptor = argumentCaptor() verify(mockEvaluationsFeature, times(2)).processEvaluation( eq(fakeFlagKey), eq(fakeContext), eq(fakeFlag.variationKey), eq(fakeFlag.allocationKey), - isNull(), isNull(), eq(false), eq(fakeContext.targetingKey), bridgeIdentityCaptor.capture() + isNull(), isNull(), eq(false), eq(fakeContext.targetingKey), bridgeIdentityCaptor.capture(), eq(emptyMap()) ) val missingIdentityCaptor = argumentCaptor() verify(mockEvaluationsFeature).processEvaluation( eq(fakeFlagKey), isNull(), isNull(), isNull(), eq("PROVIDER_NOT_READY"), eq("Provider not ready - call setEvaluationContext() before resolving flags"), - eq(false), isNull(), missingIdentityCaptor.capture() + eq(false), isNull(), missingIdentityCaptor.capture(), eq(emptyMap()) ) assertThat(missingIdentityCaptor.firstValue.value).isNull() assertThat(bridgeIdentityCaptor.firstValue.value) @@ -1469,7 +1470,8 @@ internal class DatadogFlagsClientTest { errorMessage = eq("Flag not found"), observeFullEvaluationData = eq(false), telemetryTargetingKey = eq(fakeContext.targetingKey), - preparedTargetingKey = eq(EvaluationTargetingKey.create(fakeContext.targetingKey, false)) + preparedTargetingKey = eq(EvaluationTargetingKey.create(fakeContext.targetingKey, false)), + features = eq(emptyMap()) ) } @@ -2146,7 +2148,7 @@ internal class DatadogFlagsClientTest { } @Test - fun `M not track exposure to backend W trackFlagSnapshotEvaluation() { trackExposures is false }`(forge: Forge) { + fun `M still record exposure W trackFlagSnapshotEvaluation() { trackExposures is false }`(forge: Forge) { // Given val fakeFlagKey = forge.anAlphabeticalString() val fakeFlagValue = forge.aBool() @@ -2180,7 +2182,7 @@ internal class DatadogFlagsClientTest { testedClient.trackFlagSnapshotEvaluation(fakeFlagKey, fakeFlag, fakeContext) // Then - verifyNoInteractions(mockProcessor) + verify(mockProcessor).processEvent(fakeFlagKey, fakeContext, fakeFlag) verify(mockRumEvaluationLogger).logEvaluation( flagKey = fakeFlagKey, value = fakeFlag.variationKey @@ -2272,7 +2274,8 @@ internal class DatadogFlagsClientTest { eq("INVALID_CONTEXT"), eq(false), eq(fakeRequested.targetingKey), - eq(EvaluationTargetingKey.create(fakeRequested.targetingKey, false)) + eq(EvaluationTargetingKey.create(fakeRequested.targetingKey, false)), + eq(emptyMap()) ) } @@ -2324,7 +2327,8 @@ internal class DatadogFlagsClientTest { isNull(), eq(fakeFlag.observeFullEvaluationData), eq(fakeContext.targetingKey), - eq(EvaluationTargetingKey.create(fakeContext.targetingKey, fakeFlag.observeFullEvaluationData)) + eq(EvaluationTargetingKey.create(fakeContext.targetingKey, fakeFlag.observeFullEvaluationData)), + eq(emptyMap()) ) verify(mockRumEvaluationLogger).logEvaluation(fakeKey, fakeFlag.variationKey) } diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/ExposureEventsProcessorTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/ExposureEventsProcessorTest.kt index 8b661fb134..c953624c01 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/ExposureEventsProcessorTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/ExposureEventsProcessorTest.kt @@ -8,6 +8,7 @@ package com.datadog.android.flags.internal import com.datadog.android.flags.internal.model.ExposureEvent import com.datadog.android.flags.internal.model.PrecomputedFlag +import com.datadog.android.flags.internal.model.SplitFeature import com.datadog.android.flags.internal.storage.RecordWriter import com.datadog.android.flags.model.EvaluationContext import com.datadog.android.flags.model.ResolutionReason @@ -70,6 +71,125 @@ internal class ExposureEventsProcessorTest { ) } + @Test + fun `M report a new exposure then a repeat W processEvent() twice`() { + // Given + val fakeContext = EvaluationContext(targetingKey = fakeTargetingKey) + + // When + val first = testedProcessor.processEvent(fakeFlagKey, fakeContext, fakeFlag) + val second = testedProcessor.processEvent(fakeFlagKey, fakeContext, fakeFlag) + + // Then + assertThat(first).isFalse() + assertThat(second).isTrue() + verify(mockRecordWriter).write(any()) + } + + @Test + fun `M report a new exposure W processEvent() {variant or serial id changes}`() { + // Given + val fakeContext = EvaluationContext(targetingKey = fakeTargetingKey) + val fakeFlagWithSerial = fakeFlag.copy(serialId = 1L) + testedProcessor.processEvent(fakeFlagKey, fakeContext, fakeFlagWithSerial) + + // When + val variantChanged = testedProcessor.processEvent( + fakeFlagKey, + fakeContext, + fakeFlagWithSerial.copy(variationKey = fakeVariationKey + "-changed") + ) + val serialChanged = testedProcessor.processEvent( + fakeFlagKey, + fakeContext, + fakeFlagWithSerial.copy(variationKey = fakeVariationKey + "-changed", serialId = 2L) + ) + + // Then + assertThat(variantChanged).isFalse() + assertThat(serialChanged).isFalse() + verify(mockRecordWriter, times(3)).write(any()) + } + + @Test + fun `M record nothing W isRecorded()`() { + // Given + val fakeContext = EvaluationContext(targetingKey = fakeTargetingKey) + + // When + val before = testedProcessor.isRecorded(fakeFlagKey, fakeContext, fakeFlag) + val stillBefore = testedProcessor.isRecorded(fakeFlagKey, fakeContext, fakeFlag) + testedProcessor.processEvent(fakeFlagKey, fakeContext, fakeFlag) + val after = testedProcessor.isRecorded(fakeFlagKey, fakeContext, fakeFlag) + + // Then + assertThat(before).isFalse() + assertThat(stillBefore).isFalse() + assertThat(after).isTrue() + verify(mockRecordWriter).write(any()) + } + + @Test + fun `M write only EXPOSURE features W processEvent() {split features}`() { + // Given + val fakeContext = EvaluationContext(targetingKey = fakeTargetingKey) + val flag = fakeFlag.copy( + features = listOf( + SplitFeature("hook", "h", listOf("HOOK")), + SplitFeature("exposure", 2.0, listOf("EXPOSURE", "FUTURE")), + SplitFeature("both", true, listOf("HOOK", "EXPOSURE")), + SplitFeature("evaluation", "e", listOf("EVALUATION")) + ) + ) + + // When + testedProcessor.processEvent(fakeFlagKey, fakeContext, flag) + + // Then + val eventCaptor = argumentCaptor() + verify(mockRecordWriter).write(eventCaptor.capture()) + assertThat(eventCaptor.firstValue.features?.additionalProperties) + .containsExactlyEntriesOf(mapOf("exposure" to 2.0, "both" to true)) + } + + @Test + fun `M write no features W processEvent() {no EXPOSURE features}`() { + // Given + val fakeContext = EvaluationContext(targetingKey = fakeTargetingKey) + val flag = fakeFlag.copy(features = listOf(SplitFeature("hook", "h", listOf("HOOK")))) + + // When + testedProcessor.processEvent(fakeFlagKey, fakeContext, flag) + + // Then + val eventCaptor = argumentCaptor() + verify(mockRecordWriter).write(eventCaptor.capture()) + assertThat(eventCaptor.firstValue.features).isNull() + assertThat(eventCaptor.firstValue.toJson().asJsonObject.has("features")).isFalse() + } + + @Test + fun `M report a repeat W processEvent() {only features change}`() { + // Given + val fakeContext = EvaluationContext(targetingKey = fakeTargetingKey) + testedProcessor.processEvent( + fakeFlagKey, + fakeContext, + fakeFlag.copy(features = listOf(SplitFeature("exposure", "a", listOf("EXPOSURE")))) + ) + + // When + val isRecorded = testedProcessor.processEvent( + fakeFlagKey, + fakeContext, + fakeFlag.copy(features = listOf(SplitFeature("exposure", "b", listOf("EXPOSURE")))) + ) + + // Then + assertThat(isRecorded).isTrue() + verify(mockRecordWriter).write(any()) + } + // region processEvent @Test diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagsFeatureTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagsFeatureTest.kt index 6f16c2a9ce..7b65d7bf85 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagsFeatureTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagsFeatureTest.kt @@ -15,13 +15,16 @@ import com.datadog.android.flags.ExperimentationOptOutMode import com.datadog.android.flags.FlagsClient import com.datadog.android.flags.FlagsConfiguration import com.datadog.android.flags.internal.evaluation.ExperimentationOptOut +import com.datadog.android.flags.internal.model.PrecomputedFlag import com.datadog.android.flags.internal.selection.CanonicalFlagSelection import com.datadog.android.flags.internal.selection.FlagSelectionError import com.datadog.android.flags.internal.storage.ExposureEventRecordWriter import com.datadog.android.flags.internal.storage.NoOpRecordWriter +import com.datadog.android.flags.model.EvaluationContext import com.datadog.android.utils.verifyLog import fr.xgouchet.elmyr.junit5.ForgeExtension import org.assertj.core.api.Assertions.assertThat +import org.json.JSONObject import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertDoesNotThrow @@ -31,6 +34,7 @@ import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.Arguments import org.junit.jupiter.params.provider.EnumSource import org.junit.jupiter.params.provider.MethodSource +import org.junit.jupiter.params.provider.ValueSource import org.mockito.Mock import org.mockito.Mockito.mock import org.mockito.junit.jupiter.MockitoExtension @@ -171,6 +175,29 @@ internal class FlagsFeatureTest { assertThat(testedFeature.dataWriter).isInstanceOf(ExposureEventRecordWriter::class.java) } + @ParameterizedTest + @ValueSource(booleans = [true, false]) + fun `M send exposures only when tracking them W onInitialize() + processEvent()`(trackExposures: Boolean) { + // Given + val feature = FlagsFeature(mockSdkCore, FlagsConfiguration.Builder().trackExposures(trackExposures).build()) + val flag = PrecomputedFlag("boolean", "true", true, "allocation", "on", JSONObject(), "TARGETING_MATCH") + val context = EvaluationContext("subject") + feature.onInitialize(mockContext) + + // When + val first = feature.processor.processEvent("flag", context, flag) + val second = feature.processor.processEvent("flag", context, flag) + + // Then + assertThat(first).isFalse() + assertThat(second).isTrue() + if (trackExposures) { + verify(mockSdkCore).getFeature(FLAGS_FEATURE_NAME) + } else { + verify(mockSdkCore, never()).getFeature(FLAGS_FEATURE_NAME) + } + } + @Test fun `M initialize precomputedRequestFactory W constructor`() { // When diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/aggregation/EvaluationAggregationStatsTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/aggregation/EvaluationAggregationStatsTest.kt index 452dc7a667..9070c48c29 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/aggregation/EvaluationAggregationStatsTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/aggregation/EvaluationAggregationStatsTest.kt @@ -102,6 +102,28 @@ internal class EvaluationAggregationStatsTest { assertThat(event.context?.dd?.rum?.view?.url).isEqualTo(fakeViewName) } + @Test + fun `M write the features W toEvaluationEvent() { evaluation features }`() { + // Given + val stats = createStats(fakeEvaluationAggregationKey.copy(features = mapOf("a" to "x", "b" to false))) + + // When + val event = stats.toEvaluationEvent(fakeDatadogContext) + + // Then + assertThat(event.features?.additionalProperties).containsExactlyEntriesOf(mapOf("a" to "x", "b" to false)) + assertThat(event.toJson().asJsonObject.getAsJsonObject("features").get("b").asBoolean).isFalse() + } + + @Test + fun `M write no features W toEvaluationEvent() { no evaluation features }`() { + // When + val event = createStats().toEvaluationEvent(fakeDatadogContext) + + // Then + assertThat(event.toJson().asJsonObject.has("features")).isFalse() + } + @Test fun `M set runtime default true W toEvaluationEvent() { null variantKey }`() { // Given - null variantKey indicates runtime default was used diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/aggregation/EvaluationAggregatorTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/aggregation/EvaluationAggregatorTest.kt index 33cfdc813b..ba1b0f1881 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/aggregation/EvaluationAggregatorTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/aggregation/EvaluationAggregatorTest.kt @@ -134,6 +134,25 @@ internal class EvaluationAggregatorTest { assertThat(events.first().count).isEqualTo(10) } + @Test + fun `M create separate entries W record() { different evaluation features }`() { + // Given + record(features = mapOf("a" to "x", "b" to 1.0)) + record(features = mapOf("b" to 1.0, "a" to "x")) + record(features = mapOf("a" to "y", "b" to 1.0)) + record() + + // When + val result = testedAggregator.drain() + + // Then + assertThat(result.map { it.aggregationKey.features to it.count }).containsExactlyInAnyOrder( + mapOf("a" to "x", "b" to 1.0) to 2, + mapOf("a" to "y", "b" to 1.0) to 1, + emptyMap() to 1 + ) + } + @Test fun `M create separate entries W record() { different flag keys }`() { repeat(5) { index -> @@ -349,7 +368,8 @@ internal class EvaluationAggregatorTest { context: EvaluationContext = fakeContext, variantKey: String? = fakeVariantKey, errorCode: String? = null, - errorMessage: String? = null + errorMessage: String? = null, + features: Map = emptyMap() ): List = testedAggregator.record( timestamp = timestamp, flagKey = flagKey, @@ -359,7 +379,8 @@ internal class EvaluationAggregatorTest { variantKey = variantKey, allocationKey = null, errorCode = errorCode, - errorMessage = errorMessage + errorMessage = errorMessage, + features = features ) private fun runConcurrently(threadCount: Int, action: (Int) -> Unit) { diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/persistence/FlagsStateSerializerTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/persistence/FlagsStateSerializerTest.kt index 736922ae98..887da22402 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/persistence/FlagsStateSerializerTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/persistence/FlagsStateSerializerTest.kt @@ -10,6 +10,7 @@ import com.datadog.android.api.InternalLogger import com.datadog.android.flags.internal.model.FlagKeyObfuscation import com.datadog.android.flags.internal.model.FlagsStateEntry import com.datadog.android.flags.internal.model.PrecomputedFlag +import com.datadog.android.flags.internal.model.SplitFeature import com.datadog.android.flags.internal.selection.CanonicalFlagSelection import com.datadog.android.flags.model.EvaluationContext import fr.xgouchet.elmyr.Forge @@ -209,6 +210,34 @@ internal class FlagsStateSerializerTest { .hasNoFlagSerialId("flag2") } + @Test + fun `M restore the features W serialize() + deserialize() { flag carries features }`(forge: Forge) { + // Given + val key = forge.anAlphabeticalString() + val features = listOf( + SplitFeature("holdout.key", forge.anAlphabeticalString(), listOf("HOOK")), + SplitFeature("number", forge.aDouble(), listOf("EXPOSURE", "FUTURE")), + SplitFeature("flag", forge.aBool(), listOf("EVALUATION")) + ) + val flag = PrecomputedFlag( + "boolean", + "true", + true, + forge.anAlphabeticalString(), + forge.anAlphabeticalString(), + JSONObject(), + "TARGETING_MATCH", + features = features + ) + val state = FlagsStateEntry(EvaluationContext(forge.anAlphabeticalString()), mapOf(key to flag), fakeTimestamp) + + // When + val restored = FlagsStateDeserializer(mockInternalLogger).deserialize(testedSerializer.serialize(state)) + + // Then + assertThat(checkNotNull(restored).flags[key]?.features).isEqualTo(features) + } + @Test fun `M serialize empty flags state W serialize()`(forge: Forge) { // Given diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/repository/net/PrecomputeMapperTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/repository/net/PrecomputeMapperTest.kt index a2c4ef7fd4..fd6b11fe3d 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/repository/net/PrecomputeMapperTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/repository/net/PrecomputeMapperTest.kt @@ -26,6 +26,7 @@ import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.MethodSource import org.junit.jupiter.params.provider.ValueSource import org.mockito.Mock +import org.mockito.Mockito.mockingDetails import org.mockito.junit.jupiter.MockitoExtension import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor @@ -71,6 +72,51 @@ internal class PrecomputeMapperTest { testedMapper = PrecomputeMapper(mockInternalLogger) } + @Test + fun `M keep features and warn once per unknown destination W map() {split features}`() { + // Given + val features = """[{"key":"a","value":"x","destinations":["HOOK","FUTURE"]},""" + + """{"key":"b","value":1,"destinations":["FUTURE","OTHER"]},{"key":"c","value":{}}]""" + val response = """{"data":{"attributes":{"flags":{"first":${assignmentJson(features)},""" + + """"second":${assignmentJson(features)}}}}}""" + + // When + val result = checkNotNull(testedMapper.map(response)) + + // Then + assertThat(result.flags.values.map { it.features.map { feature -> feature.key } }) + .containsOnly(listOf("a", "b")) + assertThat(loggedMessages()).containsExactlyInAnyOrder( + "Ignoring unknown feature destination FUTURE", + "Ignoring unknown feature destination OTHER" + ) + } + + @Test + fun `M stop warning W map() {more unknown destinations than the warning cap}`() { + // Given + val destinations = (0..64).joinToString(",") { "\"FUTURE_$it\"" } + val response = """{"data":{"attributes":{"flags":{"flag":""" + + """${assignmentJson("""[{"key":"a","value":"x","destinations":[$destinations]}]""")}}}}}""" + + // When + testedMapper.map(response) + + // Then + assertThat(loggedMessages()).hasSize(64) + .contains("Ignoring unknown feature destination FUTURE_63") + .doesNotContain("Ignoring unknown feature destination FUTURE_64") + } + + @Suppress("UNCHECKED_CAST") + private fun loggedMessages(): List = mockingDetails(mockInternalLogger).invocations + .filter { it.arguments[0] == InternalLogger.Level.WARN } + .map { (it.arguments[2] as () -> String)() } + + private fun assignmentJson(features: String) = + """{"variationType":"boolean","variationValue":true,"doLog":true,"allocationKey":"allocation",""" + + """"variationKey":"on","extraLogging":{},"reason":"TARGETING_MATCH","features":$features}""" + // region Valid JSON Parsing @ParameterizedTest diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/serialization/FlagsJsonTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/serialization/FlagsJsonTest.kt index 4782c0e00a..01178b2aac 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/serialization/FlagsJsonTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/serialization/FlagsJsonTest.kt @@ -6,10 +6,12 @@ package com.datadog.android.flags.internal.serialization +import com.datadog.android.flags.internal.model.SplitFeature import com.google.gson.JsonParseException import com.google.gson.JsonParser import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.catchThrowable +import org.junit.jupiter.api.Test import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.MethodSource import org.junit.jupiter.params.provider.ValueSource @@ -67,7 +69,129 @@ internal class FlagsJsonTest { assertThat(exception).isInstanceOf(JsonParseException::class.java) } + @Test + fun `M keep each value type W readAssignment() {mixed features}`() { + // Given + val json = assignment( + """[ + {"key":"text","value":"holdout-1","destinations":["HOOK"]}, + {"key":"number","value":3,"destinations":["EXPOSURE","EVALUATION"]}, + {"key":"flag","value":false,"destinations":["HOOK","FUTURE"]} + ]""" + ) + + // When + val flag = FlagsJson.readAssignment(json) + + // Then + assertThat(flag.features).containsExactly( + SplitFeature("text", "holdout-1", listOf("HOOK")), + SplitFeature("number", 3.0, listOf("EXPOSURE", "EVALUATION")), + SplitFeature("flag", false, listOf("HOOK", "FUTURE")) + ) + } + + @ParameterizedTest + @ValueSource(strings = ["null", "{}", "\"features\"", "42", "[]", "[null, 1, \"text\", []]"]) + fun `M read the assignment without features W readAssignment() {missing or malformed features}`( + features: String + ) { + // Given + val json = assignment(features) + + // When + val flag = FlagsJson.readAssignment(json) + + // Then + assertThat(flag.variationKey).isEqualTo("on") + assertThat(flag.features).isEmpty() + } + + @ParameterizedTest + @MethodSource("invalidFeatures") + fun `M skip the feature W read() {invalid feature}`(feature: String) { + // Given + val json = JsonParser.parseString("[$feature, $VALID_FEATURE]") + + // When + val features = SplitFeaturesJson.read(json) + + // Then + assertThat(features).containsExactly(SplitFeature("valid", "value", listOf("HOOK"))) + } + + @Test + fun `M keep the first valid feature W read() {repeated key}`() { + // Given + val json = JsonParser.parseString( + """[ + {"key":"holdout.key","value":{},"destinations":["HOOK"]}, + {"key":"holdout.key","value":"first","destinations":["EXPOSURE"]}, + {"key":"holdout.key","value":"second","destinations":["HOOK"]} + ]""" + ) + + // When + val features = SplitFeaturesJson.read(json) + + // Then + assertThat(features).containsExactly(SplitFeature("holdout.key", "first", listOf("EXPOSURE"))) + } + + @Test + fun `M read back the features W writeAssignmentJson() + readAssignment()`() { + // Given + val flag = FlagsJson.readAssignment( + assignment( + """[{"key":"a","value":1.5,"destinations":["EVALUATION","FUTURE"]},""" + + """{"key":"b","value":true,"destinations":["HOOK"]}]""" + ) + ) + + // When + val json = FlagsJson.writeAssignmentJson(flag) + + // Then + assertThat(flag.features).hasSize(2) + assertThat(FlagsJson.readAssignment(json.asJsonObject).features).isEqualTo(flag.features) + } + + @Test + fun `M write no features field W writeAssignmentJson() {no features}`() { + // Given + val flag = FlagsJson.readAssignment(assignment("[]")) + + // When + val json = FlagsJson.writeAssignmentJson(flag) + + // Then + assertThat(json.asJsonObject.has("features")).isFalse() + } + + private fun assignment(features: String) = JsonParser.parseString( + """{"variationType":"boolean","variationValue":"true","doLog":true,"allocationKey":"allocation",""" + + """"variationKey":"on","extraLogging":{},"reason":"TARGETING_MATCH","features":$features}""" + ).asJsonObject + companion object { + private const val VALID_FEATURE = """{"key":"valid","value":"value","destinations":["HOOK"]}""" + + @JvmStatic + fun invalidFeatures(): List = listOf( + """{"value":"value","destinations":["HOOK"]}""", + """{"key":"","value":"value","destinations":["HOOK"]}""", + """{"key":1,"value":"value","destinations":["HOOK"]}""", + """{"key":"valid","destinations":["HOOK"]}""", + """{"key":"valid","value":null,"destinations":["HOOK"]}""", + """{"key":"valid","value":{},"destinations":["HOOK"]}""", + """{"key":"valid","value":[],"destinations":["HOOK"]}""", + """{"key":"valid","value":"value"}""", + """{"key":"valid","value":"value","destinations":"HOOK"}""", + """{"key":"valid","value":"value","destinations":[]}""", + """{"key":"valid","value":"value","destinations":["",1,null]}""", + "\"not an object\"" + ) + @JvmStatic fun invalidMetadata(): List { val valid = """{"obfuscated":true,"obfuscation":""" + diff --git a/reliability/single-fit/flags/src/test/kotlin/com/datadog/android/flags/integration/EvaluationTelemetryTest.kt b/reliability/single-fit/flags/src/test/kotlin/com/datadog/android/flags/integration/EvaluationTelemetryTest.kt index e83c455fc4..f0f1d65f7b 100644 --- a/reliability/single-fit/flags/src/test/kotlin/com/datadog/android/flags/integration/EvaluationTelemetryTest.kt +++ b/reliability/single-fit/flags/src/test/kotlin/com/datadog/android/flags/integration/EvaluationTelemetryTest.kt @@ -15,6 +15,7 @@ import com.datadog.android.flags.integration.tests.utils.FlagsTelemetryTestFixtu import com.datadog.android.flags.integration.tests.utils.FlagsTelemetryTestFixture.Companion.CONTROL import com.datadog.android.flags.integration.tests.utils.MainLooperTestConfiguration import com.datadog.android.flags.integration.tests.utils.bodyAsJson +import com.datadog.android.flags.integration.tests.utils.getBooleanEvaluationWithHooks import com.datadog.android.flags.integration.tests.utils.httpResponse import com.datadog.android.flags.model.ErrorCode import com.datadog.android.flags.model.EvaluationContext @@ -138,7 +139,7 @@ internal class EvaluationTelemetryTest { try { // When if (initializing) provider.initialize(interleaved) else provider.onContextSet(original, interleaved) - val result = provider.getBooleanEvaluation("enabled", false, null) + val result = provider.getBooleanEvaluationWithHooks("enabled", false) fixture.flush() // Then @@ -169,11 +170,11 @@ internal class EvaluationTelemetryTest { } // When - val previous = provider.getBooleanEvaluation("enabled", false, null) + val previous = provider.getBooleanEvaluationWithHooks("enabled", false) val rumWhilePending = fixture.stubCore.eventsReceived(Feature.RUM_FEATURE_NAME).size fixture.completeNextAssignment() pending.join() - val updated = provider.getBooleanEvaluation("enabled", false, null) + val updated = provider.getBooleanEvaluationWithHooks("enabled", false) fixture.flush() // Then @@ -196,7 +197,7 @@ internal class EvaluationTelemetryTest { } // When - val result = provider.getBooleanEvaluation("enabled", false, null) + val result = provider.getBooleanEvaluationWithHooks("enabled", false) fixture.flush() // Then @@ -221,7 +222,7 @@ internal class EvaluationTelemetryTest { } // When - val result = provider.getBooleanEvaluation("enabled", false, null) + val result = provider.getBooleanEvaluationWithHooks("enabled", false) fixture.completeNextAssignment() fixture.completeNextAssignment() first.join() @@ -244,7 +245,7 @@ internal class EvaluationTelemetryTest { provider.initialize(context(Value.Boolean(true))) // When - val result = provider.getBooleanEvaluation("enabled", false, null) + val result = provider.getBooleanEvaluationWithHooks("enabled", false) fixture.flush() // Then @@ -268,7 +269,7 @@ internal class EvaluationTelemetryTest { provider.initialize(context(fakeControl)) // When - val result = provider.getBooleanEvaluation("enabled", false, null) + val result = provider.getBooleanEvaluationWithHooks("enabled", false) fixture.flush() // Then @@ -448,7 +449,7 @@ internal class EvaluationTelemetryTest { val refresh = runCatching { provider.onContextSet(original, context(Value.Boolean(optedOut))) } // When - val result = provider.getBooleanEvaluation("enabled", false, null) + val result = provider.getBooleanEvaluationWithHooks("enabled", false) fixture.flush() // Then @@ -474,7 +475,7 @@ internal class EvaluationTelemetryTest { // When val wasPending = !pending.isCompleted - val result = provider.getBooleanEvaluation("enabled", false, null) + val result = provider.getBooleanEvaluationWithHooks("enabled", false) fixture.completeNextAssignment() pending.join() fixture.flush() @@ -501,7 +502,7 @@ internal class EvaluationTelemetryTest { // When val latestWasPending = !newer.isCompleted - val result = provider.getBooleanEvaluation("enabled", false, null) + val result = provider.getBooleanEvaluationWithHooks("enabled", false) fixture.completeNextAssignment() newer.join() fixture.flush() @@ -598,11 +599,11 @@ internal class EvaluationTelemetryTest { val provider = newClient().asOpenFeatureProvider() val original = context(Value.Boolean(false)) provider.initialize(original) - provider.getBooleanEvaluation("enabled", false, null) + provider.getBooleanEvaluationWithHooks("enabled", false) // When provider.onContextSet(original, context(Value.Boolean(true))) - val result = provider.getBooleanEvaluation("enabled", false, null) + val result = provider.getBooleanEvaluationWithHooks("enabled", false) fixture.flush() // Then @@ -619,12 +620,12 @@ internal class EvaluationTelemetryTest { val original = context(Value.Boolean(false)) fixture.assignments(enabled = false) provider.initialize(original) - val previous = provider.getBooleanEvaluation("enabled", true, null) + val previous = provider.getBooleanEvaluationWithHooks("enabled", true) fixture.assignments(enabled = true) // When provider.onContextSet(original, context(Value.Boolean(true))) - val result = provider.getBooleanEvaluation("enabled", false, null) + val result = provider.getBooleanEvaluationWithHooks("enabled", false) fixture.flush() // Then diff --git a/reliability/single-fit/flags/src/test/kotlin/com/datadog/android/flags/integration/ExposureHookTest.kt b/reliability/single-fit/flags/src/test/kotlin/com/datadog/android/flags/integration/ExposureHookTest.kt new file mode 100644 index 0000000000..00e0728aaa --- /dev/null +++ b/reliability/single-fit/flags/src/test/kotlin/com/datadog/android/flags/integration/ExposureHookTest.kt @@ -0,0 +1,222 @@ +/* + * 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.integration + +import com.datadog.android.api.feature.Feature +import com.datadog.android.flags.FlagsClient +import com.datadog.android.flags.integration.tests.elmyr.FlagsIntegrationForgeConfigurator +import com.datadog.android.flags.integration.tests.utils.FakeAssignment +import com.datadog.android.flags.integration.tests.utils.FlagsTelemetryTestFixture +import com.datadog.android.flags.integration.tests.utils.MainLooperTestConfiguration +import com.datadog.android.flags.openfeature.ExposureEvaluation +import com.datadog.android.flags.openfeature.ExposureHook +import com.datadog.android.flags.openfeature.asOpenFeatureProvider +import com.datadog.tools.unit.annotations.TestConfigurationsProvider +import com.datadog.tools.unit.extensions.TestConfigurationExtension +import com.datadog.tools.unit.extensions.config.TestConfiguration +import dev.openfeature.kotlin.sdk.FlagEvaluationDetails +import dev.openfeature.kotlin.sdk.FlagEvaluationOptions +import dev.openfeature.kotlin.sdk.Hook +import dev.openfeature.kotlin.sdk.HookContext +import dev.openfeature.kotlin.sdk.ImmutableContext +import dev.openfeature.kotlin.sdk.OpenFeatureAPI +import dev.openfeature.kotlin.sdk.Value +import dev.openfeature.kotlin.sdk.exceptions.ErrorCode +import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.junit5.ForgeConfiguration +import fr.xgouchet.elmyr.junit5.ForgeExtension +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThat +import org.json.JSONArray +import org.json.JSONObject +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +@ExtendWith(ForgeExtension::class, TestConfigurationExtension::class) +@ForgeConfiguration(FlagsIntegrationForgeConfigurator::class) +internal class ExposureHookTest { + private lateinit var fixture: FlagsTelemetryTestFixture + private val evaluations = mutableListOf() + + @AfterEach + fun `tear down`() { + runBlocking { OpenFeatureAPI.shutdown() } + fixture.close() + } + + @Test + fun `M route each feature to its destination W getBooleanDetails() {split features}`(forge: Forge) { + // Given + start(forge) + + // When + val details = OpenFeatureAPI.getClient().getBooleanDetails(FLAG, false) + fixture.flush() + + // Then + assertThat(details.metadata.getString("__dd_feature.holdout.key")).isEqualTo("holdout-1") + assertThat(details.metadata.getBoolean("__dd_feature.holdout.should_include_in_holdout_analysis")) + .isFalse() + assertThat(details.metadata.getString("__dd_exposure_feature.exposure.key")).isEqualTo("exposed") + assertThat(details.metadata.getDouble("__dd_evaluation_feature.evaluation.key")).isEqualTo(3.0) + assertThat(details.metadata.getString("__dd_feature.exposure.key")).isNull() + assertThat(details.metadata.getString("__dd_feature.unknown.key")).isNull() + assertThat(evaluations.single().features).containsExactlyEntriesOf( + mapOf("holdout.key" to "holdout-1", "holdout.should_include_in_holdout_analysis" to false) + ) + val exposure = fixture.events(Feature.FLAGS_FEATURE_NAME).single() + assertThat(exposure.getAsJsonObject("features").entrySet().associate { it.key to it.value.asString }) + .containsExactlyEntriesOf(mapOf("exposure.key" to "exposed")) + val evaluation = fixture.events(Feature.FLAGS_EVALUATIONS_FEATURE_NAME).single() + assertThat(evaluation.getAsJsonObject("features").entrySet().associate { it.key to it.value.asDouble }) + .containsExactlyEntriesOf(mapOf("evaluation.key" to 3.0)) + } + + @Test + fun `M share one verdict W getBooleanValue() twice`(forge: Forge) { + // Given + start(forge) + + // When + OpenFeatureAPI.getClient().getBooleanValue(FLAG, false) + OpenFeatureAPI.getClient().getBooleanValue(FLAG, false) + fixture.flush() + + // Then + assertThat(evaluations.map { it.isExposure() }).containsExactly(true, true) + assertThat(evaluations.map { it.shouldSend() }).containsExactly(true, false) + assertThat(evaluations.map { it.isCacheHit() }).containsExactly(false, true) + assertThat(evaluations.map { it.targetingKey }).containsOnly(SUBJECT) + assertThat(evaluations.map { it.allocationKey }).containsOnly(ALLOCATION) + assertThat(fixture.events(Feature.FLAGS_FEATURE_NAME)).hasSize(1) + } + + @Test + fun `M record without sending W getBooleanValue() twice {trackExposures false}`(forge: Forge) { + // Given + start(forge, trackExposures = false) + + // When + OpenFeatureAPI.getClient().getBooleanValue(FLAG, false) + OpenFeatureAPI.getClient().getBooleanValue(FLAG, false) + fixture.flush() + + // Then + assertThat(evaluations.map { it.shouldSend() }).containsExactly(true, false) + assertThat(evaluations.map { it.isCacheHit() }).containsExactly(false, true) + assertThat(fixture.events(Feature.FLAGS_FEATURE_NAME)).isEmpty() + } + + @Test + fun `M record nothing W getBooleanDetails() {after hook fails}`(forge: Forge) { + // Given + start(forge) + val failingHook = object : Hook { + override fun after( + ctx: HookContext, + details: FlagEvaluationDetails, + hints: Map + ) { + throw IllegalStateException("after hook failed") + } + } + + // When + val failed = OpenFeatureAPI.getClient().getBooleanDetails(FLAG, false, hookOptions(failingHook)) + OpenFeatureAPI.getClient().getBooleanValue(FLAG, false) + fixture.flush() + + // Then + assertThat(failed.errorCode).isEqualTo(ErrorCode.GENERAL) + assertThat(evaluations.map { it.isExposure() }).containsExactly(false, true) + assertThat(evaluations.map { it.shouldSend() }).containsExactly(false, true) + assertThat(fixture.events(Feature.FLAGS_FEATURE_NAME)).hasSize(1) + } + + @Test + fun `M keep the evaluation W getBooleanDetails() {callback throws}`(forge: Forge) { + // Given + start(forge, callback = { throw IllegalStateException("callback failed") }) + + // When + val details = OpenFeatureAPI.getClient().getBooleanDetails(FLAG, false) + fixture.flush() + + // Then + assertThat(details.value).isTrue() + assertThat(details.errorCode).isNull() + assertThat(fixture.events(Feature.FLAGS_FEATURE_NAME)).hasSize(1) + } + + @Test + fun `M give a copy of the context W getBooleanValue()`(forge: Forge) { + // Given + start(forge) + + // When + OpenFeatureAPI.getClient().getBooleanValue(FLAG, false) + + // Then + val context = checkNotNull(evaluations.single().evaluationContext) + assertThat(context).isNotSameAs(OpenFeatureAPI.getEvaluationContext()) + assertThat(context.getTargetingKey()).isEqualTo(SUBJECT) + assertThat(context.getValue("plan")).isEqualTo(Value.String("pro")) + } + + private fun start( + forge: Forge, + trackExposures: Boolean = true, + callback: (ExposureEvaluation) -> Unit = { evaluations.add(it) } + ) { + fixture = FlagsTelemetryTestFixture(forge, trackExposures) + fixture.assignments( + mapOf( + FLAG to FakeAssignment( + variationType = "boolean", + variationValue = true, + variationKey = "on", + allocationKey = ALLOCATION, + serialId = 42L, + features = JSONArray() + .put(feature("holdout.key", "holdout-1", "HOOK")) + .put(feature("holdout.key", "ignored-duplicate", "HOOK")) + .put(feature("holdout.should_include_in_holdout_analysis", false, "HOOK")) + .put(feature("exposure.key", "exposed", "EXPOSURE")) + .put(feature("evaluation.key", 3, "EVALUATION")) + .put(feature("unknown.key", "nowhere", "FUTURE")) + .put(JSONObject().put("key", "invalid.value").put("value", JSONObject())) + ) + ) + ) + val provider = FlagsClient.Builder("exposure-hook", fixture.core).build().asOpenFeatureProvider() + OpenFeatureAPI.addHooks(listOf(ExposureHook { callback(it) })) + runBlocking { + OpenFeatureAPI.setProviderAndWait( + provider, + ImmutableContext(SUBJECT, mapOf("plan" to Value.String("pro"))), + Dispatchers.Unconfined + ) + } + } + + private fun feature(key: String, value: Any, destination: String): JSONObject = + JSONObject().put("key", key).put("value", value).put("destinations", JSONArray().put(destination)) + + private fun hookOptions(hook: Hook) = FlagEvaluationOptions(hooks = listOf(hook)) + + companion object { + private const val FLAG = "holdout-flag" + private const val SUBJECT = "subject" + private const val ALLOCATION = "holdout-allocation" + + @TestConfigurationsProvider + @JvmStatic + fun getTestConfigurations(): List = listOf(MainLooperTestConfiguration()) + } +} diff --git a/reliability/single-fit/flags/src/test/kotlin/com/datadog/android/flags/integration/tests/utils/FlagsTelemetryTestFixture.kt b/reliability/single-fit/flags/src/test/kotlin/com/datadog/android/flags/integration/tests/utils/FlagsTelemetryTestFixture.kt index a8e889853e..9d1b0d3a48 100644 --- a/reliability/single-fit/flags/src/test/kotlin/com/datadog/android/flags/integration/tests/utils/FlagsTelemetryTestFixture.kt +++ b/reliability/single-fit/flags/src/test/kotlin/com/datadog/android/flags/integration/tests/utils/FlagsTelemetryTestFixture.kt @@ -22,7 +22,7 @@ import org.json.JSONObject import java.util.concurrent.ExecutorService /** Real Flags and RUM features using the shared single-FIT transport, storage, and event fixtures. */ -internal class FlagsTelemetryTestFixture(forge: Forge) : AutoCloseable { +internal class FlagsTelemetryTestFixture(forge: Forge, private val trackExposures: Boolean = true) : AutoCloseable { val stubCore = createStubSdkCore(forge) var pauseAssignments = false private val pendingAssignments = ArrayDeque() @@ -54,6 +54,7 @@ internal class FlagsTelemetryTestFixture(forge: Forge) : AutoCloseable { FlagsConfiguration.Builder() .useCustomFlagEndpoint("https://flags.example.test/assignments") .initializationTimeout(0) + .trackExposures(trackExposures) .build(), core ) @@ -75,6 +76,11 @@ internal class FlagsTelemetryTestFixture(forge: Forge) : AutoCloseable { stubCore.stubNetworkResponses { httpResponse(it, body = body) } } + fun assignments(assignments: Map) { + val body = precomputedAssignmentsBody(assignments) + stubCore.stubNetworkResponses { httpResponse(it, body = body) } + } + fun failAssignments() { stubCore.stubNetworkResponses { httpResponse(it, code = 500) } } diff --git a/reliability/single-fit/flags/src/test/kotlin/com/datadog/android/flags/integration/tests/utils/OpenFeatureEvaluations.kt b/reliability/single-fit/flags/src/test/kotlin/com/datadog/android/flags/integration/tests/utils/OpenFeatureEvaluations.kt new file mode 100644 index 0000000000..e39b0a7605 --- /dev/null +++ b/reliability/single-fit/flags/src/test/kotlin/com/datadog/android/flags/integration/tests/utils/OpenFeatureEvaluations.kt @@ -0,0 +1,31 @@ +/* + * 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.integration.tests.utils + +import dev.openfeature.kotlin.sdk.FeatureProvider +import dev.openfeature.kotlin.sdk.FlagEvaluationDetails +import dev.openfeature.kotlin.sdk.FlagValueType +import dev.openfeature.kotlin.sdk.Hook +import dev.openfeature.kotlin.sdk.HookContext +import dev.openfeature.kotlin.sdk.ProviderEvaluation +import dev.openfeature.kotlin.sdk.from + +/** + * Evaluates with the provider directly, then runs the provider's final hooks as the OpenFeature client does. + * The provider records exposures in its final hook. + */ +@Suppress("UNCHECKED_CAST") +internal fun FeatureProvider.getBooleanEvaluationWithHooks( + key: String, + defaultValue: Boolean +): ProviderEvaluation { + val evaluation = getBooleanEvaluation(key, defaultValue, null) + val hookContext = HookContext(key, FlagValueType.BOOLEAN, defaultValue, null, null, metadata) + val details = FlagEvaluationDetails.from(evaluation, key) + hooks.forEach { (it as Hook).finallyAfter(hookContext, details, emptyMap()) } + return evaluation +} diff --git a/reliability/single-fit/flags/src/test/kotlin/com/datadog/android/flags/integration/tests/utils/PrecomputedAssignments.kt b/reliability/single-fit/flags/src/test/kotlin/com/datadog/android/flags/integration/tests/utils/PrecomputedAssignments.kt index 0fd8e1ceb8..91d722ea2d 100644 --- a/reliability/single-fit/flags/src/test/kotlin/com/datadog/android/flags/integration/tests/utils/PrecomputedAssignments.kt +++ b/reliability/single-fit/flags/src/test/kotlin/com/datadog/android/flags/integration/tests/utils/PrecomputedAssignments.kt @@ -12,6 +12,7 @@ import okhttp3.Request import okhttp3.Response import okhttp3.ResponseBody.Companion.toResponseBody import okio.Buffer +import org.json.JSONArray import org.json.JSONObject /** @@ -25,7 +26,8 @@ data class FakeAssignment( val doLog: Boolean = true, val reason: String = "TARGETING_MATCH", val serialId: Long? = null, - val extraLogging: Map = emptyMap() + val extraLogging: Map = emptyMap(), + val features: JSONArray? = null ) /** @@ -45,6 +47,7 @@ fun precomputedAssignmentsBody(assignments: Map): String .put("reason", assignment.reason) .put("extraLogging", JSONObject(assignment.extraLogging)) .apply { assignment.serialId?.let { put("serialId", it) } } + .apply { assignment.features?.let { put("features", it) } } ) } return JSONObject()