Repository navigation
FFL-3459: Support flagSelection on Flags precompute requests - #4031
Conversation
Add FlagsConfiguration.Builder.flagSelection(tags, keyPatterns). The selection is canonicalized and validated like Compute and ffe-service, sent as flag_selection on the precompute POST, and stored with the persisted state so a cache fetched with another selection is ignored. Report DD-Flag-Selection-Ignored, and treat a valid empty configuration as loaded so a later failed fetch reports Stale. Ref: FFL-3459
Ref: FFL-3459
🎉 All green!🧪 All tests passed 🎯 Code Coverage (details) 🔗 Commit SHA: 198c684 | Docs | View more details | Give us feedback! |
Persist the flag selection through the generated FlagsState model and re-apply selection filtering, empty-config handling and the ignored header on top of develop's obfuscation changes. Ref: FFL-3459
The downloader reports DD-Flag-Selection-Ignored only for requests that carried flag_selection, and passes the value through verbatim. Ref: FFL-3459
Builder.flagSelection takes List<String>? for tags and key patterns. A null list is treated as absent, matching the browser SDK; null elements inside a list remain malformed_selection. Ref: FFL-3459
State that limits count values after dedupe, that flags outside the selection return the default with FLAG_NOT_FOUND, and when the server returns the full configuration instead. Ref: FFL-3459
Add hand-written canonicalizer cases for the selection vectors with sdk expectations that the existing tests did not cover. Ref: FFL-3459
|
🤖 Comment from Claude working with Aaron Silverman (aarsilv): Heads up: I ran the pending device checks for this branch on an emulator: the harness cases below and both instrumented suites. Every harness case behaved as described, and the instrumented suites pass except one test that failed once and passed in two reruns. No change requested. Head Results — 32 selection cases and 3 cache cases pass
The flag requests go to the SDK's HTTPS endpoint, so this run checked evaluation results and logs, not the request body or the full response. The cache cases use airplane mode and a fixed boot subject, and judge the first Ready, Stale or Error after launch. Instrumented suites — 81/81 and 74/75; the one failure passed in two reruns
The failure was API names across SDKs — the description lists iOS parity as open
The names match: Large offline caches — why the cache cases use one-flag selectionsWith the whole test environment cached, an offline cold start reported Error instead of Stale, with or without a selection. With 1 or 22 flags cached it reported Stale. |
Treat an empty DD-Flag-Selection-Ignored value as absent, as the browser SDK does, so it no longer logs a server ignore with an empty reason. Clarify in the flagSelection KDoc that the byte limit applies after trimming, that unpaired UTF-16 surrogates are malformed, and that FLAG_NOT_FOUND applies when the server applies the selection. Ref: FFL-3459
Verify that FlagsClient passes the configured selection to the repository, so a persisted state saved under that selection is served. Verify that a persisted state saved without a selection is discarded once a selection is configured. Extend the trim test to every Unicode White_Space character. Ref: FFL-3459
Dedupe selection values before sorting, compute each UTF-8 sort key once, and skip sorting a list that is already over the value limit. Pass the response mapper by reference. Drop positional null arguments in tests that only restated the default obfuscation value. Ref: FFL-3459
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2d05334269
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
There was a problem hiding this comment.
More details
Selection validation, request encoding, and persisted-cache matching are consistent with the intended behavior, including treating a zero-match configuration as loaded. The completed static review identified no actionable defects.
🤖 Bits Code Review · Commit 2d05334
Send the canonical selection through the generated request model that develop introduced.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b754a44011
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
mark-caii
left a comment
There was a problem hiding this comment.
Review of b754a44 (this branch with develop merged at ba1ae26). Scope: correctness, performance, tests, operations, design, and a search for missed callers. I ran mutation checks against the JVM unit tests of :features:dd-sdk-android-flags.
Three inline findings follow: two test gaps (P2) and one comment wording nit (P3).
Heads up: the description needs two updates. They do not need a code change.
- It says that iOS parity of the API is not verified. The names match iOS (
Flags.FlagSelection(tags:keyPatterns:)) and the browser SDK (flagSelection: { tags, keyPatterns }). - The local and staging results are for
2d05334and6766ce9. Thedevelopmerge moved the request body to the generatedPrecomputedAssignmentsRequestmodel, so the end-to-end run must be repeated at the final head.
Checked with no finding
- The merge:
flag_selectionis now a property of the generated request model, with both arrays required. A mutant that drops it fails 3 tests inPrecomputedAssignmentsRequestFactoryTest. - Canonicalization and validation run once, when
FlagsFeatureis created. They are not on the evaluation path. - The ignored header is read only when the request carried a selection and the response was successful. An empty value gives no reason.
- The persisted state is used only when its stored selection equals the configured one. A state saved before this change (no field) is not used once a selection is configured.
hasFlags()andhasLoadedFlagsForContext()keep the obfuscation readability check for an empty configuration.- Telemetry events carry only the reason. Selection values are not logged or sent in telemetry.
- Public API: one new builder method, and
copy()keeps its JVM signature.checkApiSurfaceChanges,ktlintCheckanddetektpass.
Each existing thread gets a separate reply.
The reason is reported before the body is parsed, so it is logged even when the configuration is not installed.
There was a problem hiding this comment.
More details
Selection handling across requests, cache matching, and empty configurations is consistent with the stated requirements. The completed static review produced no reportable findings, and human PR discussion did not change that conclusion.
🤖 Bits Code Review · Commit 1cc6771
List all 38 cases that have an sdk expectation in the shared vectors, in file order, so each one maps to one test row.
hasFlags() and hasLoadedFlagsForContext() report whether a configuration is installed, including a valid one with zero flags. Name them hasConfiguration() and hasConfigurationForContext() so call sites read as intended.
Log the DD-Flag-Selection-Ignored reason and send its telemetry only after the response maps to a configuration and passes the obfuscation filter, just before it is installed. A body that fails to parse, or an encoded configuration this source cannot use, no longer reports a reason for a configuration that was never loaded.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 28e82cdfd6
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
Resolve the flag selection and experimentation opt-out conflicts: - Each opt-out mode gets a selection variant of its cache key (flags-selection-state, flags-selection-immediate-v1-state, flags-selection-future-v1-state). SDK versions without flag selection read only the unselected key of their mode, so they never load selected state as a full configuration, and creating an instance removes the sibling key so one persisted state remains per instance. - The per-client request factory used for opt-out clients now carries the flag selection, so a request sends both flag_selection and evaluation_options when both apply. - Keep the hasConfiguration() names, read the ignored reason from the accepted response, and map the response body.
The dropped and ignored selection telemetry events now use onlyOnce, so frequent fetches cannot flood telemetry. The USER error log is unchanged and still reports every ignored response.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 00787acfd7
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
With the experimentation opt-out, a newer context can supersede an accepted response, which is then not installed. Log the ignored reason after installFlags confirms the install, so the message never claims a configuration that was not loaded.
Resolve the DefaultFlagsRepository constructor conflict: keep develop's change that makes instanceName and dataStore plain constructor parameters, and treat the branch's flagSelection the same way, since it is only read while building the persistence manager.
Say that the flag selection is what every FlagsClient shares, and use a function reference for the empty ignored-reason header check.
What does this PR do?
Adds
FlagsConfiguration.Builder.flagSelection(tags, keyPatterns)so an app loads only the flags that match the given tags or key patterns. Spec: FFL-3459. It ports the browser SDK reference implementation, DataDog/openfeature-js-client#426, to Android's precompute-only flow. The spec numbers its items and acceptance criteria like the browser spec and lists each intentional difference as P1–P16.Public API
FlagsConfiguration.copy()keeps the selection, and its public signature is unchanged.*is special; any tag or any key pattern), the limits, the fallback to the full configuration,FLAG_NOT_FOUNDfor flags outside an applied selection, that older servers or disabled gates return the full configuration without a warning, and that the selection applies to everyFlagsClientand flags cached under a different selection are not loaded at startup. The module README has a matching "Load only selected flags" section.Flags.FlagSelection(tags:keyPatterns:), [FFL-3458] Flags: support flagSelection (tags + key patterns) dd-sdk-ios#3344) and the browser SDK (flagSelection: { tags, keyPatterns }).Behavior
internal/selection/FlagSelection.kt, one canonicalizer for the request and the cache comparison): trim the Unicode White_Space set (an explicit set, not Kotlintrim()), drop empty values, dedupe, and sort by unsigned UTF-8 bytes (not UTF-16compareTo). If both lists are empty afterwards, there is no selection.malformed_selection(a null element, a non-string element from a raw Java list, or an unpaired UTF-16 surrogate),too_many_values(more than 31 per list after dedupe),invalid_key_patternandinvalid_tag(more than 200 UTF-8 bytes after trimming).ERROR/USERlog,flagSelection was not sent (<reason>: <limit>). Loading the full flag configuration., and oneTELEMETRYdebug event,Flag selection droppedwith onlyreason, are emitted once perFlags.enable(P3). After that, a dropped selection behaves like no selection.data.attributes.flag_selection: { tags, key_patterns }through the generatedPrecomputedAssignmentsRequestmodel. Both arrays are always present and in canonical order, including withuseCustomFlagEndpoint. Without a selection, the body is unchanged.DD-Flag-Selection-Ignoredis read only when the request carried a selection and the response was successful, before the body is parsed. A non-empty value is reported once per accepted response, after the configuration is installed: oneERROR/USERlog,flagSelection was ignored by the server (<reason>). Loaded the full flag configuration., and oneFlag selection ignoredtelemetry debug event withreason. The configuration is installed as returned. A response is accepted when its body maps to a configuration and passes the obfuscation filter; a body that fails to parse or is rejected, or a response that a newer context supersedes before it is installed (experimentation opt-out), reports nothing (P16). The browser currently reports the reason before parsing. An empty value is treated as absent. A proxy behinduseCustomFlagEndpointmust forward the header (P2).malformed_selection,too_many_values,invalid_key_pattern,invalid_tagortag_load_failed. Any other non-empty value is reported asunknown, so the raw header value never reaches the USER log or telemetry. iOS uses the same rule; the browser SDK currently reports the value verbatim.flags-state.jsonhas an optionalflagSelection: { tags, keyPatterns }, persisted through the generatedFlagsStatemodel.DefaultFlagsRepositoryuses persisted state only when its selection equals the configured one, and saves the configured selection with each install. Selected state is stored under a selection variant of the experimentation opt-out mode's key:flags-selection-state-<instance>,flags-selection-immediate-v1-state-<instance>orflags-selection-future-v1-state-<instance>. SDK versions without flag selection read only the unselected key of their mode (flags-state-,flags-immediate-v1-state-orflags-future-v1-state-<instance>, with any DataStore version, ignoring unknown fields), so a rollback cannot serve a selected subset as the full configuration. Unselected state keeps its existing key, so existing caches stay valid and older SDKs can still read it. Creating an instance removes the sibling key of the same mode, so one persisted state remains per instance and mode.FlagsClient.Builder.experimentationOptOutMode. When the mode is enabled and the context carries the opt-out attribute, the precompute POST carries bothflag_selectionandevaluation_options; the request factory that opt-out clients build carries the configured selection.hasConfiguration()andhasConfigurationForContext()count a valid configuration with zero flags as loaded. A selection that matches nothing givesReady, evaluations return the default withFLAG_NOT_FOUND, and a later failed fetch or initialization timeout for the same context reportsStale.Motivation
Large orgs can configure many flags. Mobile apps should be able to fetch only the flags they use.
Additional Notes
Telemetry volume. The
Flag selection droppedandFlag selection ignoredtelemetry events useonlyOnce, so each is sent at most once per process, and only the first reason in a process is reported. The USER error log is not deduplicated and still reports every ignored response (AC8).Out of scope (as in the spec)
flag-selection/vectors directly (P10). This repo has no submodules or vendored cross-repo fixtures. Every vector case that the Kotlin API can express has a hand-written equivalent (see below).reliability/single-fitend-to-end test, React Native / Flutter / KMP wrappers, changing the selection at runtime, public docs, initial flags, backend changes, andCHANGELOG.md(updated at release time, P12).Local verification
At
8db220a97(after merging develop with the experimentation opt-out), 2026-10-30.Unit and static checks
./gradlew :features:dd-sdk-android-flags:testDebugUnitTest: 920 tests, 0 failures.ktlintFormat(no changes),detektandcheckApiSurfaceChangespass at the head, andapiDumpproduces no diff.DefaultFlagsRepositoryTestchecks the read key, the written key and the removed sibling key for every opt-out mode with and without a selection, and that selected state is never written to the unselected key.PrecomputedAssignmentsRequestFactoryTestchecks that the body carries bothflag_selectionandevaluation_options.testReleaseUnitTest(722 tests),checkCompilerMetadataChanges,checkAarMetadataInfoChangesandcheckTransitiveDependenciesListpass atdb50cef. CI runs the full set on the head.DefaultFlagsRepositoryTestcase failed beforef68612f, because selected state was written toflags-state-<instance>, the key older SDKs read. It passes now.FlagsFeaturepassing a null selection to the request factory, all 692 tests passed atb754a44. That mutant now fails. A non-string element from a raw Java list threwClassCastExceptionfromFlags.enablebeforedb50cef; it is nowmalformed_selection.Shared vectors (DataDog/ffe-system-test-data@e8777633,
flag-selection/selection-vectors.json):FlagSelectionCanonicalizerTest.M match the shared selection vectorhas one row for each of the 38 cases with ansdkexpectation, in file order and under the same name. I checked every row's input and expected result against the file with a temporary loader test (not committed): 38 of 38 identical, and the canonicalizer returns the expected result for all 38. An expected empty selection is no selection on Android. The other 6 cases need a raw query string (undecodable values, repeated or percent-encoded parameter names, and malformed-first precedence through the query), so they are N/A (P1, P4). Malformed-first precedence is covered by themalformed before ...cases instead.Android flag-selection e2e against staging (
DD_SITE=datad0g.com,DD_ENV=staging), generated byscripts/e2e-android-local.shin ddoghq/ffe-dogfooding8db220a97b39d25f797fe840014c6ef6f04fd515, published to a local Maven repository as3.17.0-SNAPSHOT0f17d2340d37f059b8dc05361dc10dcf0a71730f96e202e33d0736e3eb79600bfa7acdc9e59f9b0564e76908d3512d99d83f1418;/healthreporteddd-sdk-android-flags 3.17.0-SNAPSHOTffe_api34), fresh app data for every case, subjectffl3459-e2e-subjectGET /api/v2/feature-flagsduring this run (8872 flags), intersected with this run's E1 baseline (828 of 828 loaded keys decodable)flags-state-default, no selectionteam:feature-flaggingflags-selection-state-default, canonicalteam:feature-flags,team:feature-flagging-backendflags-selection-state-default, canonicalabin-*flags-selection-state-default, canonical*-v2flags-selection-state-default, canonical*flag*flags-selection-state-default, canonicalteam:feature-flagging+abin-*flags-selection-state-default, canonicalffe-e2e-no-such-tag)ReadyPROVIDER_READY; a flag evaluates to the default withFLAG_NOT_FOUNDflags-selection-state-default, canonicalflags-state-default, no selectiontoo_many_valuesdrop errorflags-state-default, no selectioninvalid_tagdrop error"\u3000team:feature-flagging ", a duplicate and""flags-selection-state-default,{"tags":["team:feature-flagging"],"keyPatterns":[]}flags-selection-state-default, canonicalignored by the server (too_many_values)errorResult: PASS (12 of 12 cases passed)
In E11, staging returned
DD-Flag-Selection-Ignored: too_many_valueswith a valid body, so the response was accepted: the SDK installed and cached the full configuration under the selection and logged exactly one ignored error. No other case returned the header. Telemetry events do not appear in logcat, so the unit tests assert them. Staging data drifts between runs, so counts compare only within one run. The harness runs with the experimentation opt-out disabled, so the e2e keys are theDISABLEDones; unit tests cover the other modes.Cache cases at
f68612f(flags AAR sha25609e6376c…cc4d47bb, same harness setup;A= tagteam:feature-flagging)PROVIDER_READY, 831 flagsflags-state-defaultonly (no selection)A), fresh dataPROVIDER_READY, 22 flags, same keys as expectedflags-selection-state-defaultonly, canonicalAPROVIDER_READY, 22 flagsflags-selection-state-defaultonly, canonicalAPROVIDER_READY, 831 flagsflags-state-defaultonly (selected state removed)APROVIDER_READY, 22 flagsflags-selection-state-defaultonly (unselected state removed)APROVIDER_STALE(cache forAused)team:feature-flagsPROVIDER_ERROR(cache forAnot used)PROVIDER_ERROR(no unselected state)Aonline, then install the harness pinned to the released 3.14.1 over it and start it offlinePROVIDER_ERROR: it reads no state and does not serve the 22-flag subsetflags-selection-state-defaultonlyLogcat for E9, E10 and E11
Spec acceptance criteria and evidence
PrecomputedAssignmentsRequestFactoryTestbody tests;FlagsFeatureTestasserts the selection reaches the factoryM not add flag_selection W create();FlagsFeatureTestno values after canonicalization(empty lists, whitespace-only values, null lists); E1FlagSelectionCanonicalizerTest(White_Space set, U+0085 and U+3000 trimmed, interior whitespace kept for all 25 White_Space code points, U+FEFF, U+200B and U+001C kept, UTF-8 order); 38 of 38 vector rows; E2cmalformed before too many values / invalid key pattern / invalid tag,too many values before invalid key pattern / invalid tag,invalid key pattern before invalid tagFlags.enableFlagsFeatureTestdrop test (one log and one telemetry event atonInitialize); the fetch path logs no selection message (EvaluationsManagerTest)EvaluationsManagerTestignored-reason tests, includingignored reason, invalid body,ignored reason, unsupported encodingandignored reason, superseded request; E11 against stagingtag_load_failedreported; other values asunknown(P15); empty value ignoredFlagSelectionIgnoredReasonTest(each allowed value kept; unknown value, case mismatch, over 32 characters, control and non-ASCII characters giveunknown); downloader tests for allowed values, unknown values andempty ignored header; header name looked up without case sensitivity; E11 passes a realtoo_many_valuesthroughno selection sent, ignored headerverifyLogwithadditionalProperties = mapOf("reason" to ...)andonlyOnce = truefor drop and ignore; the USER log is verified withonlyOnce = false; the ignored reason is sanitized before loggingReady,FLAG_NOT_FOUND, thenStaleDefaultFlagsRepositoryTestempty-configuration tests;failed fetch after an empty configurationgivesStale; E8[" checkout", "checkout"]recovering["checkout"];FlagsClientTestwiringM send flag_selection and evaluation_options W create() { selection and opt-out };M use the opt-out mode key for the selection W init and setFlagsAndContext()(6 mode × selection cases)6766ce9(API 36): 32 selection cases and 3 offline cache cases pass. The cache cases above repeat the offline checks on the head.Open questions for reviewers
flagSelection(tags, keyPatterns). Both lists are required, so a Kotlin caller with tags only writesflagSelection(listOf("x"), null). The repo guidance avoids unneeded default arguments; iOS uses defaults. Adding= nulldefaults later is not a breaking change.PERSISTENCE_LOAD_TIMEOUT_MS(100 ms) to load on cold start, which reportsErrorbefore the cache is available.Release notes
Staleinstead ofError, for all apps, including through the OpenFeature provider (ProviderStaleinstead ofProviderError).Review checklist (to be filled by reviewers)