Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions detekt_custom_safe_calls_third_party.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand Down Expand Up @@ -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)"
Expand Down Expand Up @@ -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)"
Expand Down
50 changes: 50 additions & 0 deletions features/dd-sdk-android-flags-openfeature/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<feature key>`, 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:
Expand Down
9 changes: 9 additions & 0 deletions features/dd-sdk-android-flags-openfeature/api/apiSurface
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,13 @@ class com.datadog.android.flags.openfeature.DatadogFlagsProvider : dev.openfeatu
override fun shutdown()
override fun observe(): kotlinx.coroutines.flow.Flow<dev.openfeature.kotlin.sdk.events.OpenFeatureProviderEvents>
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<Any>
constructor(ExposureCallback)
override fun finallyAfter(dev.openfeature.kotlin.sdk.HookContext<Any>, dev.openfeature.kotlin.sdk.FlagEvaluationDetails<Any>, Map<String, Any>)
fun com.datadog.android.flags.FlagsClient.asOpenFeatureProvider(): dev.openfeature.kotlin.sdk.FeatureProvider
Original file line number Diff line number Diff line change
Expand Up @@ -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 <init> (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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Hook<*>> = emptyList()
override val hooks: List<Hook<*>> = listOf(ExposureAdmissionHook(exposureDecisions))

/**
* Initializes the provider with the given evaluation context.
Expand Down Expand Up @@ -139,33 +144,34 @@ class DatadogFlagsProvider private constructor(private val flagsClient: FlagsCli
key: String,
defaultValue: Boolean,
@Suppress("UNUSED_PARAMETER") context: OpenFeatureEvaluationContext?
): ProviderEvaluation<Boolean> = flagsClient.resolve(key, defaultValue).toProviderEvaluation()
): ProviderEvaluation<Boolean> = evaluate(key, defaultValue)

override fun getStringEvaluation(
key: String,
defaultValue: String,
@Suppress("UNUSED_PARAMETER") context: OpenFeatureEvaluationContext?
): ProviderEvaluation<String> = flagsClient.resolve(key, defaultValue).toProviderEvaluation()
): ProviderEvaluation<String> = evaluate(key, defaultValue)

override fun getIntegerEvaluation(
key: String,
defaultValue: Int,
@Suppress("UNUSED_PARAMETER") context: OpenFeatureEvaluationContext?
): ProviderEvaluation<Int> = flagsClient.resolve(key, defaultValue).toProviderEvaluation()
): ProviderEvaluation<Int> = evaluate(key, defaultValue)

override fun getDoubleEvaluation(
key: String,
defaultValue: Double,
@Suppress("UNUSED_PARAMETER") context: OpenFeatureEvaluationContext?
): ProviderEvaluation<Double> = flagsClient.resolve(key, defaultValue).toProviderEvaluation()
): ProviderEvaluation<Double> = evaluate(key, defaultValue)

override fun getObjectEvaluation(
key: String,
defaultValue: Value,
@Suppress("UNUSED_PARAMETER") context: OpenFeatureEvaluationContext?
): ProviderEvaluation<Value> {
// 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
Expand All @@ -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) }
)

Expand All @@ -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() {
Expand Down Expand Up @@ -253,6 +259,20 @@ class DatadogFlagsProvider private constructor(private val flagsClient: FlagsCli
}
}

private fun <T : Any> evaluate(key: String, defaultValue: T): ProviderEvaluation<T> {
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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
@@ -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<String, Any>,
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
}
Loading
Loading