Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
23fa314
FFL-3459: Support flagSelection on Flags precompute requests
mark-caii Oct 22, 2026
845e923
FFL-3459: Tighten flag selection internals
mark-caii Oct 22, 2026
afba396
FFL-3459: Merge develop into flag selection branch
mark-caii Oct 26, 2026
d61bd6e
FFL-3459: Read the ignored header only when a selection was sent
mark-caii Oct 26, 2026
9e78344
FFL-3459: Accept null lists in flagSelection
mark-caii Oct 26, 2026
e5562f4
FFL-3459: Document flagSelection limits and server behavior
mark-caii Oct 26, 2026
6766ce9
FFL-3459: Cover the shared flag selection vectors in unit tests
mark-caii Oct 26, 2026
61f6476
FFL-3459: Ignore an empty flag selection ignored header
mark-caii Oct 27, 2026
e9a6306
FFL-3459: Cover selection wiring, legacy caches and the trim set
mark-caii Oct 27, 2026
2d05334
FFL-3459: Simplify flag selection internals and tests
mark-caii Oct 27, 2026
b754a44
FFL-3459: Merge develop into flag selection branch
mark-caii Oct 28, 2026
ec0039c
FFL-3459: Test the ignored reason with an unparseable body
mark-caii Oct 28, 2026
a8cfcab
FFL-3459: Assert the selection reaches the request factory
mark-caii Oct 28, 2026
fce8625
FFL-3459: Describe flag selection rules without backend names
mark-caii Oct 28, 2026
1cc6771
FFL-3459: Document flagSelection in the module README
mark-caii Oct 28, 2026
d365cbb
FFL-3459: Run every shared selection vector case by name
mark-caii Oct 28, 2026
f80f2c3
FFL-3459: Test the ignored header name without case sensitivity
mark-caii Oct 28, 2026
f79b4d7
FFL-3459: Test equivalent selections and empty configurations in the …
mark-caii Oct 28, 2026
ca5b080
FFL-3459: Test selections with no values after canonicalization
mark-caii Oct 28, 2026
db50cef
FFL-3459: Drop a selection with non-string elements instead of throwing
mark-caii Oct 28, 2026
765454c
FFL-3459: Remove the region markers from the flag selection client test
mark-caii Oct 28, 2026
ff7c444
FFL-3459: Verify the absent selection logs with verifyLog
mark-caii Oct 28, 2026
d29e8e3
FFL-3459: Cover every pair of the selection error order
mark-caii Oct 28, 2026
f68612f
FFL-3459: Keep selected flag state out of the key older SDKs read
mark-caii Oct 28, 2026
f10b90f
FFL-3459: Report only known DD-Flag-Selection-Ignored reasons
mark-caii Oct 28, 2026
344f0f8
FFL-3459: Document unknown ignored reasons and the proxy header
mark-caii Oct 28, 2026
99e58b4
FFL-3459: Rename flag presence checks to configuration checks
mark-caii Oct 29, 2026
e47cbe7
FFL-3459: Share the empty flags response in EvaluationsManagerTest
mark-caii Oct 29, 2026
9c970b1
FFL-3459: Report an ignored selection only for an accepted response
mark-caii Oct 29, 2026
75d6d26
FFL-3459: Test that interior Unicode whitespace is kept
mark-caii Oct 29, 2026
28e82cd
FFL-3459: Document that the selection applies to every client and cache
mark-caii Oct 29, 2026
a916cb2
FFL-3459: Merge develop with the experimentation opt-out
mark-caii Oct 30, 2026
00787ac
FFL-3459: Send flag selection telemetry once per process
mark-caii Oct 30, 2026
161f308
FFL-3459: Report an ignored selection only when its flags are installed
mark-caii Oct 30, 2026
8db220a
FFL-3459: Remove an inline comment from the opt-out key test
mark-caii Oct 30, 2026
e812566
FFL-3459: Merge develop into flag selection
mark-caii Nov 2, 2026
9ce29b1
FFL-3459: Clarify the flag selection scope in the README
mark-caii Nov 2, 2026
198c684
FFL-3459: Clarify install flow logging and model server ignored reasons
mark-caii Nov 3, 2026
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
20 changes: 20 additions & 0 deletions features/dd-sdk-android-flags/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,26 @@ val flagsConfig = FlagsConfiguration.Builder()
.build()
```

#### Load only selected flags

To load only the flags that your app uses, set a flag selection. The SDK sends it with the flag request, and the server returns only the flags that have any of the tags or whose key matches any of the key patterns:

```kotlin
val flagsConfig = FlagsConfiguration.Builder()
.flagSelection(
tags = listOf("mobile"),
keyPatterns = listOf("checkout-*")
)
.build()
```

- Tags match exactly and are case-sensitive. A key pattern matches the whole flag key, and only `*` is a wildcard.
- Each list accepts at most 31 values, and each value is at most 200 UTF-8 bytes. The SDK does not send a selection that is over a limit. It logs an error and loads the full flag configuration.
- When the server applies the selection, evaluating a flag outside it returns the default value with `ErrorCode.FLAG_NOT_FOUND`.
- The flag selection is fixed when the Flags feature is enabled and applies to every `FlagsClient`. Flags cached under a different selection are not loaded at startup; until the next successful fetch, a client starts without cached flags.
- If the server ignores a selection, the SDK loads the full flag configuration and logs an error with the reason. A reason that the SDK does not know is reported as `unknown`.
- With a custom flag endpoint, the proxy must forward the `DD-Flag-Selection-Ignored` response header. Otherwise, the SDK cannot report a selection that the server ignored.

## Use the Feature Flags SDK

### Create a Flags client
Expand Down
1 change: 1 addition & 0 deletions features/dd-sdk-android-flags/api/apiSurface
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ data class com.datadog.android.flags.FlagsConfiguration
fun evaluationFlushInterval(Long): Builder
fun useCustomFlagEndpoint(String): Builder
fun initializationTimeout(Long): Builder
fun flagSelection(List<String>?, List<String>?): Builder
fun rumIntegrationEnabled(Boolean): Builder
fun gracefulModeEnabled(Boolean): Builder
fun build(): FlagsConfiguration
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ public final class com/datadog/android/flags/FlagsConfiguration$Builder {
public fun <init> ()V
public final fun build ()Lcom/datadog/android/flags/FlagsConfiguration;
public final fun evaluationFlushInterval (J)Lcom/datadog/android/flags/FlagsConfiguration$Builder;
public final fun flagSelection (Ljava/util/List;Ljava/util/List;)Lcom/datadog/android/flags/FlagsConfiguration$Builder;
public final fun gracefulModeEnabled (Z)Lcom/datadog/android/flags/FlagsConfiguration$Builder;
public final fun initializationTimeout (J)Lcom/datadog/android/flags/FlagsConfiguration$Builder;
public final fun rumIntegrationEnabled (Z)Lcom/datadog/android/flags/FlagsConfiguration$Builder;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@
"salt": { "type": "string" }
},
"required": ["scheme", "salt"]
},
"flagSelection": {
"type": "object",
"properties": {
"tags": { "type": "array", "items": { "type": "string" } },
"keyPatterns": { "type": "array", "items": { "type": "string" } }
},
"required": ["tags", "keyPatterns"]
}
},
"required": ["evaluationContext", "lastUpdateTimestamp"]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,28 @@
"targeting_key",
"targeting_attributes"
]
},
"flag_selection": {
"description": "Canonical flag selection. Sent only when the application configures a valid selection.",
"type": "object",
"properties": {
"tags": {
"type": "array",
"items": {
"type": "string"
}
},
"key_patterns": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
"tags",
"key_patterns"
]
}
},
"required": [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,7 @@ interface FlagsClient {
featureSdkCore = featureSdkCore,
dataStore = datastore,
instanceName = name,
flagSelection = flagsFeature.flagSelection,
experimentationOptOutMode = experimentationOptOutMode,
deliverFirstFlags = { delivery ->
val worker = featureSdkCore.createSingleThreadExecutorService(FLAGS_FIRST_FLAGS_EXECUTOR_NAME)
Expand All @@ -492,6 +493,7 @@ interface FlagsClient {
PrecomputedAssignmentsRequestFactory(
featureSdkCore.internalLogger,
configuration.customFlagEndpoint,
flagSelection = flagsFeature.flagSelection,
experimentationOptOutMode = experimentationOptOutMode
)
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@

package com.datadog.android.flags

import com.datadog.android.flags.internal.selection.FlagSelection
import com.datadog.android.flags.model.ErrorCode

private const val DEFAULT_INITIALIZATION_TIMEOUT_MS = 5_000L

/**
Expand All @@ -21,10 +24,11 @@ data class FlagsConfiguration internal constructor(
internal val evaluationFlushIntervalMs: Long,
internal val rumIntegrationEnabled: Boolean,
internal val gracefulModeEnabled: Boolean,
internal val initializationTimeoutMs: Long?
internal val initializationTimeoutMs: Long?,
internal val flagSelection: FlagSelection?
) {
/**
* Copies this configuration and preserves the initialization timeout.
* Copies this configuration and preserves the initialization timeout and the flag selection.
*
* This overload keeps the public JVM signature that existed before the initialization timeout was added.
*/
Expand All @@ -46,7 +50,8 @@ data class FlagsConfiguration internal constructor(
evaluationFlushIntervalMs = evaluationFlushIntervalMs,
rumIntegrationEnabled = rumIntegrationEnabled,
gracefulModeEnabled = gracefulModeEnabled,
initializationTimeoutMs = initializationTimeoutMs
initializationTimeoutMs = initializationTimeoutMs,
flagSelection = flagSelection
)

/**
Expand All @@ -62,6 +67,7 @@ data class FlagsConfiguration internal constructor(
private var rumIntegrationEnabled: Boolean = true
private var gracefulModeEnabled: Boolean = true
private var initializationTimeoutMs: Long? = DEFAULT_INITIALIZATION_TIMEOUT_MS
private var flagSelection: FlagSelection? = null

/**
* Sets whether exposures should be logged to the dedicated exposures intake endpoint.
Expand Down Expand Up @@ -172,6 +178,36 @@ data class FlagsConfiguration internal constructor(
initializationTimeoutMs = timeoutMs.takeIf { it > 0 }
}

/**
* Loads only the flags that match the given tags or key patterns.
*
* Tags match exactly and are case-sensitive; a flag matches if it has any of the tags. Key patterns are
* whole-key globs where only `*` is special. Leading and trailing Unicode whitespace is trimmed from every
* value, empty values are dropped, and duplicates are removed. A null list is treated as absent.
*
* Each list accepts at most 31 values after duplicates are removed, and each tag and key pattern is at most
* 200 UTF-8 bytes after trimming. A selection over a limit, or one that contains a null value or an unpaired
* UTF-16 surrogate, is not sent, and the full flag configuration is loaded. The server may also ignore a very
* large selection (`too_many_values`), in which case the full flag configuration is loaded. Servers that do
* not support flag selection, or have it disabled, return the full flag configuration without a warning.
* A server reason that the SDK does not know is reported as `unknown`. With [useCustomFlagEndpoint], the proxy
* must forward the `DD-Flag-Selection-Ignored` response header for an ignored selection to be reported.
*
* When the server applies the selection, flags outside it are not loaded, so evaluating them returns the
* default value with [ErrorCode.FLAG_NOT_FOUND].
*
* The selection is fixed when the feature is enabled and applies to every [FlagsClient]. Flags cached under a
* different selection are not loaded at startup; until the next successful fetch, a client starts without
* cached flags. Calling this method again replaces the previous selection.
*
* @param tags The flag tags to load, or null for none.
* @param keyPatterns The flag key patterns to load, or null for none.
* @return this [Builder] instance for method chaining.
*/
fun flagSelection(tags: List<String>?, keyPatterns: List<String>?): Builder = apply {
Comment thread
mark-caii marked this conversation as resolved.
Resolved
flagSelection = FlagSelection(tags?.toList(), keyPatterns?.toList())
}

/**
* Sets whether RUM evaluation logging is enabled.
* This adds the result of evaluating a feature flag to the view.
Expand Down Expand Up @@ -217,7 +253,8 @@ data class FlagsConfiguration internal constructor(
evaluationFlushIntervalMs = evaluationFlushIntervalMs,
rumIntegrationEnabled = rumIntegrationEnabled,
gracefulModeEnabled = gracefulModeEnabled,
initializationTimeoutMs = initializationTimeoutMs
initializationTimeoutMs = initializationTimeoutMs,
flagSelection = flagSelection
)

internal companion object {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ import com.datadog.android.flags.FlagsConfiguration
import com.datadog.android.flags.internal.evaluation.InitializationTimeoutScheduler
import com.datadog.android.flags.internal.net.ExposuresRequestFactory
import com.datadog.android.flags.internal.net.PrecomputedAssignmentsRequestFactory
import com.datadog.android.flags.internal.selection.CanonicalFlagSelection
import com.datadog.android.flags.internal.selection.FlagSelectionCanonicalizer
import com.datadog.android.flags.internal.selection.FlagSelectionResult
import com.datadog.android.flags.internal.selection.logDroppedFlagSelection
import com.datadog.android.flags.internal.storage.ExposureEventRecordWriter
import com.datadog.android.flags.internal.storage.NoOpRecordWriter
import com.datadog.android.flags.internal.storage.RecordWriter
Expand Down Expand Up @@ -76,10 +80,16 @@ internal class FlagsFeature(

// region Domain Objects

private val flagSelectionResult = FlagSelectionCanonicalizer.canonicalize(flagsConfiguration.flagSelection)

internal val flagSelection: CanonicalFlagSelection? =
(flagSelectionResult as? FlagSelectionResult.Selected)?.selection

internal val precomputedRequestFactory =
PrecomputedAssignmentsRequestFactory(
internalLogger = sdkCore.internalLogger,
customFlagEndpoint = flagsConfiguration.customFlagEndpoint
customFlagEndpoint = flagsConfiguration.customFlagEndpoint,
flagSelection = flagSelection
Comment thread
mark-caii marked this conversation as resolved.
Resolved
)

internal val initializationTimeoutScheduler = InitializationTimeoutScheduler { timeoutMs, action ->
Expand Down Expand Up @@ -123,6 +133,9 @@ internal class FlagsFeature(
}
isDebugBuild = (appContext.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0
isInitialized = true
(flagSelectionResult as? FlagSelectionResult.Dropped)?.let {
sdkCore.internalLogger.logDroppedFlagSelection(it.error)
}
dataWriter = createDataWriter()
processor = ExposureEventsProcessor(
writer = dataWriter,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import com.datadog.android.flags.internal.net.NetworkRequestFailedException
import com.datadog.android.flags.internal.net.PrecomputedAssignmentsReader
import com.datadog.android.flags.internal.repository.FlagsRepository
import com.datadog.android.flags.internal.repository.net.PrecomputeMapper
import com.datadog.android.flags.internal.selection.logIgnoredFlagSelection
import com.datadog.android.flags.model.EvaluationContext
import com.datadog.android.flags.model.FlagsClientState
import java.util.concurrent.Executor
Expand Down Expand Up @@ -95,6 +96,7 @@ private class InitializationCompletion(private var callback: EvaluationContextCa
* @param experimentationOptOut validates contexts and prevents superseded installations
* @param callbackExecutor delivers customer callbacks outside state delivery and network operations
*/
@Suppress("TooManyFunctions") // Each step of the fetch, install and completion lifecycle is a separate function.
internal class EvaluationsManager(
private val sdkCore: FeatureSdkCore,
private val executorService: ExecutorService,
Expand Down Expand Up @@ -200,15 +202,21 @@ internal class EvaluationsManager(
{ "Processing evaluation context: ${context.targetingKey}" }
)
matchingCachedAssignments.set(
flagsRepository.hasFlags() && flagsRepository.getEvaluationContext() == context
flagsRepository.hasConfiguration() && flagsRepository.getEvaluationContext() == context
)
val response = datadogContext?.let { assignmentsReader.readPrecomputedFlags(context, it, diagnostics) }
val assignments = response?.let { rawResponse ->
precomputeMapper.map(rawResponse, request.takeIf { it.optedOut })
precomputeMapper.map(rawResponse.body, request.takeIf { it.optedOut })
}
?.takeIf { it.obfuscation == null || supportsObfuscation }
if (assignments != null) {
installFlags(request, assignments, initializationCompletion, callback)
installFlags(
request,
assignments,
response.flagSelectionIgnoredReason,
initializationCompletion,
callback
)
} else {
handleRequestFailure(
request,
Expand Down Expand Up @@ -266,39 +274,55 @@ internal class EvaluationsManager(
private fun installFlags(
request: ExperimentationOptOut.Request,
assignments: PrecomputedAssignments,
flagSelectionIgnoredReason: String?,
initializationCompletion: InitializationCompletion?,
callback: EvaluationContextCallback?
) {
val context = request.context
synchronized(initializationTerminalLock) {
val installed = experimentationOptOut.ifCurrent(request) {
val installation = flagsRepository.installFlagsAndContext(
context,
assignments.flags,
assignments.obfuscation
)
val result = takeCompletion(initializationCompletion, callback)
currentState = FlagsClientState.Ready
effects.enqueue {
installation.complete {
effects.notifySafely { flagStateManager.updateState(FlagsClientState.Ready) }
complete(result)
}
}
val installed = synchronized(initializationTerminalLock) {
val isCurrent = experimentationOptOut.ifCurrent(request) {
installCurrentFlags(context, assignments, initializationCompletion, callback)
}
if (!installed) {
if (!isCurrent) {
enqueueCompletion(
takeCompletion(initializationCompletion, callback),
FlagsContextSupersededException(SUPERSEDED_MESSAGE)
)
}
isCurrent
}
effects.drain()
internalLogger.log(
InternalLogger.Level.DEBUG,
InternalLogger.Target.MAINTAINER,
{ "Successfully processed context ${context.targetingKey} with ${assignments.flags.size} flags" }
// A superseded response is not installed, so its ignored reason would describe flags that never loaded.
if (installed) {
flagSelectionIgnoredReason?.let { internalLogger.logIgnoredFlagSelection(it) }
internalLogger.log(
InternalLogger.Level.DEBUG,
InternalLogger.Target.MAINTAINER,
{ "Successfully processed context ${context.targetingKey} with ${assignments.flags.size} flags" }
)
}
}

// Callers hold initializationTerminalLock; the Ready notification and callback run later from effects.drain().
private fun installCurrentFlags(
context: EvaluationContext,
assignments: PrecomputedAssignments,
initializationCompletion: InitializationCompletion?,
callback: EvaluationContextCallback?
) {
val installation = flagsRepository.installFlagsAndContext(
context,
assignments.flags,
assignments.obfuscation
)
val result = takeCompletion(initializationCompletion, callback)
currentState = FlagsClientState.Ready
effects.enqueue {
installation.complete {
effects.notifySafely { flagStateManager.updateState(FlagsClientState.Ready) }
complete(result)
}
}
}

private fun startInitializationTimeout(
Expand Down Expand Up @@ -327,7 +351,7 @@ internal class EvaluationsManager(
if (currentState != FlagsClientState.Ready && currentState != FlagsClientState.Stale) {
val hasMatchingCachedAssignments =
(target === request && matchingCachedAssignments.get()) ||
flagsRepository.hasLoadedFlagsForContext(target.context)
flagsRepository.hasConfigurationForContext(target.context)
val timeoutState = if (hasMatchingCachedAssignments) {
FlagsClientState.Stale
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@

package com.datadog.android.flags.internal.model

import com.datadog.android.flags.internal.selection.CanonicalFlagSelection
import com.datadog.android.flags.model.EvaluationContext

internal data class FlagsStateEntry(
val evaluationContext: EvaluationContext,
val flags: Map<String, PrecomputedFlag>,
val lastUpdateTimestamp: Long,
val obfuscation: FlagKeyObfuscation? = null
val obfuscation: FlagKeyObfuscation? = null,
val flagSelection: CanonicalFlagSelection? = null
)
Loading
Loading