From 9bf7d3e29fc9a809a49a9245b96192b53852e0a3 Mon Sep 17 00:00:00 2001 From: Danyal Khan Date: Tue, 3 Nov 2026 15:48:02 -0500 Subject: [PATCH] EX-4005: Route split features and share exposure decisions Parse per-flag features from precomputed assignments, route them to flag metadata, exposure events and evaluation counts, and add an OpenFeature ExposureHook that shares the SDK's exposure decision. trackExposures(false) now stops only the send to Datadog; the exposure cache keeps recording. --- detekt_custom_safe_calls_third_party.yml | 6 + .../README.md | 50 +++ .../api/apiSurface | 9 + .../api/dd-sdk-android-flags-openfeature.api | 25 ++ .../flags/openfeature/DatadogFlagsProvider.kt | 42 ++- .../flags/openfeature/ExposureCallback.kt | 20 ++ .../flags/openfeature/ExposureEvaluation.kt | 46 +++ .../android/flags/openfeature/ExposureHook.kt | 118 +++++++ .../internal/adapters/Converters.kt | 15 + .../exposure/ExposureAdmissionHook.kt | 21 ++ .../internal/exposure/ExposureDecisions.kt | 60 ++++ .../internal/exposure/ExposureMetadata.kt | 22 ++ .../openfeature/DatadogFlagsProviderTest.kt | 5 +- .../flags/openfeature/ExposureHookTest.kt | 297 +++++++++++++++++ .../internal/adapters/ConvertersTest.kt | 53 +++ .../exposure/ExposureDecisionsTest.kt | 152 +++++++++ features/dd-sdk-android-flags/README.md | 6 + features/dd-sdk-android-flags/api/apiSurface | 3 + .../api/dd-sdk-android-flags.api | 7 + .../json/flags/exposure-event-schema.json | 5 + .../json/flags/flag-evaluation-schema.json | 5 + .../android/flags/_FlagsInternalProxy.kt | 28 +- .../flags/internal/DatadogFlagsClient.kt | 129 ++++++-- .../internal/EvaluationEventsProcessor.kt | 6 +- .../flags/internal/EvaluationsFeature.kt | 6 +- .../android/flags/internal/EventsProcessor.kt | 9 +- .../flags/internal/ExposureEventsProcessor.kt | 41 ++- .../android/flags/internal/FlagsFeature.kt | 3 +- .../aggregation/EvaluationAggregationKey.kt | 4 +- .../aggregation/EvaluationAggregationStats.kt | 2 + .../aggregation/EvaluationAggregator.kt | 6 +- .../flags/internal/model/PrecomputedFlag.kt | 3 +- .../flags/internal/model/SplitFeature.kt | 38 +++ .../persistence/FlagsStateSerializer.kt | 2 +- .../repository/net/PrecomputeMapper.kt | 37 ++- .../flags/internal/serialization/FlagsJson.kt | 12 +- .../serialization/SplitFeaturesJson.kt | 72 ++++ .../android/flags/FlagsInternalProxyTest.kt | 8 +- .../DatadogFlagsClientExposureTest.kt | 307 ++++++++++++++++++ .../flags/internal/DatadogFlagsClientTest.kt | 22 +- .../internal/ExposureEventsProcessorTest.kt | 120 +++++++ .../flags/internal/FlagsFeatureTest.kt | 27 ++ .../EvaluationAggregationStatsTest.kt | 22 ++ .../aggregation/EvaluationAggregatorTest.kt | 25 +- .../persistence/FlagsStateSerializerTest.kt | 29 ++ .../repository/net/PrecomputeMapperTest.kt | 46 +++ .../internal/serialization/FlagsJsonTest.kt | 124 +++++++ .../integration/EvaluationTelemetryTest.kt | 29 +- .../flags/integration/ExposureHookTest.kt | 222 +++++++++++++ .../tests/utils/FlagsTelemetryTestFixture.kt | 8 +- .../tests/utils/OpenFeatureEvaluations.kt | 31 ++ .../tests/utils/PrecomputedAssignments.kt | 5 +- 52 files changed, 2281 insertions(+), 109 deletions(-) create mode 100644 features/dd-sdk-android-flags-openfeature/src/main/kotlin/com/datadog/android/flags/openfeature/ExposureCallback.kt create mode 100644 features/dd-sdk-android-flags-openfeature/src/main/kotlin/com/datadog/android/flags/openfeature/ExposureEvaluation.kt create mode 100644 features/dd-sdk-android-flags-openfeature/src/main/kotlin/com/datadog/android/flags/openfeature/ExposureHook.kt create mode 100644 features/dd-sdk-android-flags-openfeature/src/main/kotlin/com/datadog/android/flags/openfeature/internal/exposure/ExposureAdmissionHook.kt create mode 100644 features/dd-sdk-android-flags-openfeature/src/main/kotlin/com/datadog/android/flags/openfeature/internal/exposure/ExposureDecisions.kt create mode 100644 features/dd-sdk-android-flags-openfeature/src/main/kotlin/com/datadog/android/flags/openfeature/internal/exposure/ExposureMetadata.kt create mode 100644 features/dd-sdk-android-flags-openfeature/src/test/kotlin/com/datadog/android/flags/openfeature/ExposureHookTest.kt create mode 100644 features/dd-sdk-android-flags-openfeature/src/test/kotlin/com/datadog/android/flags/openfeature/internal/exposure/ExposureDecisionsTest.kt create mode 100644 features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/model/SplitFeature.kt create mode 100644 features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/serialization/SplitFeaturesJson.kt create mode 100644 features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientExposureTest.kt create mode 100644 reliability/single-fit/flags/src/test/kotlin/com/datadog/android/flags/integration/ExposureHookTest.kt create mode 100644 reliability/single-fit/flags/src/test/kotlin/com/datadog/android/flags/integration/tests/utils/OpenFeatureEvaluations.kt 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()