Skip to content

[flags] Generic resolve method#2923

Merged
typotter merged 22 commits into
feature/feature-flaggingfrom
typo/FFL-1184-resolve-value-method
Oct 22, 2025
Merged

[flags] Generic resolve method#2923
typotter merged 22 commits into
feature/feature-flaggingfrom
typo/FFL-1184-resolve-value-method

Conversation

@typotter

@typotter typotter commented Oct 9, 2025

Copy link
Copy Markdown
Contributor

PR #2923: [flags] Generic resolve method

What does this PR do?

This PR implements the generic resolve<T>() method providing OpenFeature-compatible flag resolution with detailed error information, metadata, and variant tracking.

Key additions:
public API

  • Generic resolve<T>(flagKey, defaultValue) method that returns ResolutionDetails<T>
  • ResolutionDetails<T> data class containing value, variant, reason, error code, error message, and flag metadata
  • ErrorCode enum with OpenFeature-spec error codes (FLAG_NOT_FOUND, TYPE_MISMATCH, PARSE_ERROR, etc.)

internal

  • FlagValueConverter object for centralized, side-effect-free type conversion
  • Enhanced error handling and type mismatch detection
  • Improved internal architecture with InternalResolution sealed class

Motivation

OpenFeature Specification Alignment

The OpenFeature specification defines a resolve() method as the core evaluation API, providing rich resolution details beyond just the flag value. This enables:

  • Error handling: Applications can detect and handle flag resolution failures (missing flags, type mismatches, parse errors)
  • Variant tracking: Track which variant of a flag was returned for experimentation
  • Metadata support: Access additional flag metadata for analytics and debugging
  • Debugging: Detailed resolution reasons help troubleshoot flag behavior

Additional Notes

Anything else we should know when reviewing?

Review checklist (to be filled by reviewers)

  • Feature or bugfix MUST have appropriate tests (unit, integration, e2e)
  • Make sure you discussed the feature or bugfix with the maintaining team in an Issue
  • Make sure each commit and the PR mention the Issue number (cf the CONTRIBUTING doc)

@typotter
typotter changed the base branch from develop to feature/feature-flagging October 9, 2025 08:03
@typotter
typotter changed the base branch from feature/feature-flagging to typo/FFL-1112-flagging-proxy-custom-precomputed-assignments-endpoint October 9, 2025 08:03
@codecov-commenter

codecov-commenter commented Oct 9, 2025

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 83.53659% with 27 lines in your changes missing coverage. Please review.
✅ Project coverage is 70.88%. Comparing base (969ad5d) to head (1163ff5).
⚠️ Report is 37 commits behind head on feature/feature-flagging.

Files with missing lines Patch % Lines
.../flags/featureflags/internal/DatadogFlagsClient.kt 86.84% 9 Missing and 6 partials ⚠️
.../flags/featureflags/internal/FlagValueConverter.kt 66.67% 3 Missing and 7 partials ⚠️
...oid/flags/featureflags/internal/NoOpFlagsClient.kt 0.00% 2 Missing ⚠️
Additional details and impacted files
@@                     Coverage Diff                      @@
##           feature/feature-flagging    #2923      +/-   ##
============================================================
+ Coverage                     70.81%   70.88%   +0.06%     
============================================================
  Files                           837      841       +4     
  Lines                         30446    30574     +128     
  Branches                       5136     5167      +31     
============================================================
+ Hits                          21560    21670     +110     
- Misses                         7432     7447      +15     
- Partials                       1454     1457       +3     
Files with missing lines Coverage Δ
.../datadog/android/flags/featureflags/FlagsClient.kt 35.58% <ø> (ø)
...flags/featureflags/internal/RumEvaluationLogger.kt 100.00% <100.00%> (ø)
...reflags/internal/model/PrecomputedFlagConstants.kt 76.92% <100.00%> (-23.08%) ⬇️
...otlin/com/datadog/android/flags/model/ErrorCode.kt 100.00% <100.00%> (ø)
...m/datadog/android/flags/model/ResolutionDetails.kt 100.00% <100.00%> (ø)
...om/datadog/android/flags/model/ResolutionReason.kt 100.00% <100.00%> (ø)
...oid/flags/featureflags/internal/NoOpFlagsClient.kt 76.19% <0.00%> (-8.02%) ⬇️
.../flags/featureflags/internal/FlagValueConverter.kt 66.67% <66.67%> (ø)
.../flags/featureflags/internal/DatadogFlagsClient.kt 88.81% <86.84%> (-7.35%) ⬇️

... and 46 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@typotter
typotter force-pushed the typo/FFL-1112-flagging-proxy-custom-precomputed-assignments-endpoint branch from 2b5bcf6 to 211e111 Compare October 14, 2025 20:37
@typotter
typotter force-pushed the typo/FFL-1184-resolve-value-method branch from 77e34af to 287fa8a Compare October 15, 2025 07:05
Base automatically changed from typo/FFL-1112-flagging-proxy-custom-precomputed-assignments-endpoint to feature/feature-flagging October 16, 2025 09:50
@typotter
typotter force-pushed the typo/FFL-1184-resolve-value-method branch from be2f0e8 to 626593b Compare October 20, 2025 04:13
@typotter
typotter marked this pull request as ready for review October 20, 2025 06:10
@typotter
typotter requested review from a team as code owners October 20, 2025 06:10
@typotter
typotter requested a review from jonathanmos October 20, 2025 06:10
fun resolveDoubleValue(String, Double): Double
fun resolveIntValue(String, Int): Int
fun resolveStructureValue(String, org.json.JSONObject): org.json.JSONObject
fun <T> resolve(String, T): com.datadog.android.flags.model.ResolutionDetails<T>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Specification says that even for concrete methods above we need to return ResolutionDetails https://openfeature.dev/specification/sections/providers, but we are returning just the objects. Is it fine to not use this structure there?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are not implementing the OpenFeature specification, rather we're exposing enough to allow a shim package to implement the OpenFeature Provider spec. This is the resolve method.
The concrete methods above mirror the OpenFeature Client interface, which the dev uses in their app. This is to make this SDK still be functional and useful, even without OpenFeature layered on top.

* @param defaultValue The value to return if the flag cannot be retrieved or parsed.
* @return [ResolutionDetails] containing the value, variant, reason, error info, and metadata.
*/
fun <T> resolve(flagKey: String, defaultValue: T): ResolutionDetails<T>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we support nullable types (values) here? If it is not the case, type should be T : Any

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

non null all the way.

* @param flagKey The name of the flag to query. Cannot be null.
* @param defaultValue The value to return if the flag cannot be retrieved.
* @return The string value of the flag, or the default value if unavailable.
* @param defaultValue The value to return if the flag cannot be found resolved for any reason. Cannot be null.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* @param defaultValue The value to return if the flag cannot be found resolved for any reason. Cannot be null.
* @param defaultValue The value to return if the flag cannot be found or resolved for any reason. Cannot be null.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reworded

Comment on lines +148 to +149
private fun <T> resolveValue(flagKey: String, defaultValue: T): T =
resolveTracked(resolveInternal(flagKey, defaultValue))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is confusing: here we have 3 resolveXXX methods, and the fact that there is resolveInternal with a result passed to resolveTracked doesn't add clarity. Should resolveInternal be renamed, maybe?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, I agree.
I renamed to readAndParseAssignment

* @param defaultValue The value to return if the flag cannot be retrieved or parsed.
* @return [ResolutionDetails] with either the parsed value and metadata, or an error.
*/
override fun <T> resolve(flagKey: String, defaultValue: T): ResolutionDetails<T> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minor: it is better to move this up, so that we have interface methods grouped first and only after them we have private methods.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

* @param extraLogging The JSONObject containing additional flag metadata.
* @return An immutable map of metadata, or null if the JSONObject is empty.
*/
private fun extractMetadata(extraLogging: JSONObject): Map<String, Any>? {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need to explicitly make return type nullable? Cannot we return empty collection if JSONObject is empty?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good idea. there is no reason to return null

) {
// Catch all conversion exceptions (including JSONException) and return null
// The caller is responsible for logging errors as appropriate
null

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe it is worth to log it with MAINTAINER target?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was on the fence about this one b/c I wanted to keep the singleton. It would be useful to get a handle on any exception that happens here. I just discovered Kotlin's Result, so using that instead

fun <T> isTypeCompatible(variationType: String, defaultValue: T): Boolean = when (defaultValue) {
is Boolean -> variationType == VariationType.BOOLEAN.value
is String -> variationType == VariationType.STRING.value
is Int -> variationType == VariationType.INTEGER.value

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we also check for VariationType.NUMBER here as below?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, you're right. The number type that the backend is sending will evolve into sending specifically a float or an integer variant type so it should be usable as either now.

@Suppress("UNCHECKED_CAST")
private fun <T> getConverterForType(defaultValue: T): (String) -> T? = when (defaultValue) {
is Boolean -> { s: String -> s.lowercase(Locale.US).toBooleanStrictOrNull() as? T }
is String -> { s: String -> s as? T }

@0xnm 0xnm Oct 20, 2025

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this one can be just { s: String -> s }: input and output types are the same, so cast is not needed

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

Comment on lines -33 to +34
DOUBLE("double"),
JSON("json")
NUMBER("number"),
FLOAT("float"),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we still have the usage of double term in API, should we keep it here? Or is it the change per spec?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are the types that are sent over the wire. We actually need only one numeric resolution function - the number type is what is required for open feature. We'll treat number, float, and integer as satisfying the "resolveDouble" method. integer type and numeric type will satisfy resolveInteger

* @property flagMetadata Optional map of arbitrary metadata associated with the flag (string keys, primitive values).
*/
data class ResolutionDetails<T>(
val value: T,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can value be null? If not, it is better to do data class ResolutionDetails<T: Any>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks for the tip.

if (flagsConfiguration.rumIntegrationEnabled && resolution.flag.doLog) {
logEvaluation(
key = resolution.flagKey,
value = resolution.value as Any

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks again

/**
* Error codes for flag resolution failures, aligned with the OpenFeature specification.
*/
enum class ErrorCode {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I only see that FLAG_NOT_FOUND, PARSE_ERROR and TYPE_MISMATCH are used in this PR. Will others be used in other PRs or are they not needed?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pruned!

* @param T The type of the resolved flag value (Boolean, String, Int, Double, JSONObject).
* @property value The resolved flag value. This is always present, either from flag evaluation or the default value.
* @property variant Optional string identifier for the resolved variant (e.g., "control", "treatment").
* @property reason Optional string explaining why this value was resolved (e.g., "TARGETING_MATCH", "DEFAULT", "ERROR").

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

e.g., "TARGETING_MATCH", "DEFAULT", "ERROR"

Is it possible to make this a enum instead of a String?

* @param defaultValue The default value (used to infer the target type)
* @return The converted value, or null if types are incompatible or conversion fails
*/
@Suppress("UNCHECKED_CAST")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Suppress("UNCHECKED_CAST")

this is not needed here

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removed

* @return The converted value, or null if types are incompatible or conversion fails
*/
@Suppress("UNCHECKED_CAST")
fun <T> convert(variationValue: String, variationType: String, defaultValue: T): T? {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit

You don't actually need a defaultValue here. You can pass the class itself using KClass.

internal object FlagValueConverter {

    fun <T : Any> convert(variationValue: String, variationType: String, resultType: KClass<T>): T? {
        if (!isTypeCompatible(variationType, resultType)) {
            return null
        }
        return try {
            doConvert(variationValue, resultType)
        } catch (
            @Suppress("TooGenericExceptionCaught", "SwallowedException")
            e: Exception
        ) {
            null
        }
    }

    fun isTypeCompatible(variationType: String, resultType: KClass<*>): Boolean = when (resultType) {
        Boolean::class -> variationType == VariationType.BOOLEAN.value
        String::class -> variationType == VariationType.STRING.value
        Int::class -> variationType == VariationType.INTEGER.value
        Double::class -> variationType == VariationType.NUMBER.value || variationType == VariationType.FLOAT.value
        JSONObject::class -> variationType == VariationType.OBJECT.value
        else -> false
    }
    
    @Suppress("UNCHECKED_CAST")
    private fun <T : Any> doConvert(s: String, resultType: KClass<T>): T? = when (resultType) {
        Boolean::class -> s.lowercase(Locale.US).toBooleanStrictOrNull() as? T
        String::class -> s as? T
        Int::class -> s.toIntOrNull() as? T
        Double::class -> s.toDoubleOrNull() as? T
        JSONObject::class -> JSONObject(s) as? T
        else -> null
    }

    fun getTypeName(type: KClass<*>): String = when (type) {
        Boolean::class -> "Boolean"
        String::class -> "String"
        Int::class -> "Int"
        Double::class -> "Double"
        JSONObject::class -> "JSONObject"
        else -> type.simpleName ?: "Unknown"
    }
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is great, thank you

@typotter
typotter force-pushed the typo/FFL-1184-resolve-value-method branch 3 times, most recently from 35c1888 to 9c296a3 Compare October 21, 2025 07:30
@typotter
typotter force-pushed the typo/FFL-1184-resolve-value-method branch from 9c296a3 to a73b2bd Compare October 21, 2025 07:32
@typotter
typotter dismissed stale reviews from aleksandr-gringauz and 0xnm via 6e6806a October 21, 2025 22:04
@typotter
typotter requested a review from jonathanmos October 21, 2025 22:11
0xnm
0xnm previously approved these changes Oct 22, 2025
jonathanmos
jonathanmos previously approved these changes Oct 22, 2025

@jonathanmos jonathanmos left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm - see minor comments

Co-authored-by: Nikita Ogorodnikov <[email protected]>
@typotter

Copy link
Copy Markdown
Contributor Author

Thanks. Just need one more stamp to merge please and thanks

@typotter

Copy link
Copy Markdown
Contributor Author

/merge

@dd-devflow-routing-codex

dd-devflow-routing-codex Bot commented Oct 22, 2025

Copy link
Copy Markdown

View all feedbacks in Devflow UI.

2025-10-22 14:58:26 UTC ℹ️ Start processing command /merge


2025-10-22 14:58:32 UTC ℹ️ MergeQueue: waiting for PR to be ready

This merge request is not mergeable according to GitHub. Common reasons include pending required checks, missing approvals, or merge conflicts — but it could also be blocked by other repository rules or settings.
It will be added to the queue as soon as checks pass and/or get approvals.
Note: if you pushed new commits since the last approval, you may need additional approval.
You can remove it from the waiting list with /remove command.


2025-10-22 15:24:41 UTC ℹ️ MergeQueue: merge request added to the queue

The expected merge time in feature/feature-flagging is approximately 1h (p90).


2025-10-22 16:31:34 UTCMergeQueue: The checks failed on this merge request

Tests failed on this commit d076ddd:

What to do next?

  • Investigate the failures and when ready, re-add your pull request to the queue!
  • If your PR checks are green, try to rebase/merge. It might be because the CI run is a bit old.
  • Any question, go check the FAQ.

@typotter
typotter merged commit d1a3f4d into feature/feature-flagging Oct 22, 2025
25 of 26 checks passed
@typotter
typotter deleted the typo/FFL-1184-resolve-value-method branch October 22, 2025 17:15
This was referenced Nov 4, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants