Skip to content

Conversation

@yugisu-flux
Copy link

@yugisu-flux yugisu-flux commented Dec 11, 2025

What does this PR do?

This PR introduces new exposed "internal" APIs supporting the upcoming Flags SDK for React Native.

Changes:

  • New "internal" method for retrieving a snapshot of all precomputed flags to FlagsClient
  • New "internal" method for tracking a flag evaluation within a certain EvaluationContext
  • Add a _FlagsInternalProxy class for FlagsClient
  • Add an UnparsedFlag interface to prevent PrecomputedFlag from being exposed to the public

"internal" APIs = exposed APIs that are added purely for consumption within the RN SDK. This implementation follows the example of _RumInternalProxy.

Motivation

The motivation for this work is to allow the upcoming Flags RN SDK to retrieve the complete feature flags state snapshot from the JS side at SDK init time (FFL-1460).

Additional Notes

I've been developing these changes using the example application in the dd-sdk-reactnative repo (by leveraging composite builds)

Sibling PRs in the React Native and iOS repos:

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)

@yugisu-flux yugisu-flux force-pushed the dima/FFL-1460-sync-flag-evaluation-in-rn-sdk branch from f3efb76 to ca508fe Compare December 11, 2025 18:01
@Suppress("UNCHECKED_CAST")
val result: T? = when (targetType) {
Boolean::class -> variationValue.lowercase(Locale.US).toBooleanStrictOrNull() as? T

Copy link
Member

Choose a reason for hiding this comment

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

this is a redundant change, you need to run KtLint version 0.50.0:

ktlint-0.50.0 -F "**/*.kt" "**/*.kts" '!**/build/generated/**' '!**/build/kspCaches/**'

Comment on lines 158 to 160
@Suppress("FunctionName")
@JvmSynthetic
fun _getInternal(): _FlagsInternalProxy?
Copy link
Member

Choose a reason for hiding this comment

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

I would propose to remove this method: it will still show up in the suggestions dropdown in Kotlin.

One can create an instance of _FlagsInternalProxy by themselves, the constructor is public there.

Copy link
Author

@yugisu-flux yugisu-flux Dec 15, 2025

Choose a reason for hiding this comment

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

RUM subpackage does the same in RumMonitor. This seemed like an established pattern in the codebase so I mimicked it in the flags package

Copy link
Member

Choose a reason for hiding this comment

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

This was a first try to expose private APIs, but this still leaks it to the suggestions dropdown in case of Kotlin (not in Java though), so it is better simply to remove this and go through the manual call of _FlagsInternalProxy constructor.

Comment on lines 433 to 435
private val internalProxy = _FlagsInternalProxy(this)

override fun _getInternal(): _FlagsInternalProxy = internalProxy
Copy link
Member

Choose a reason for hiding this comment

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

both can be removed

Copy link
Member

Choose a reason for hiding this comment

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

Seems like moving this class is not necessary? It still stays internal. Can you please rollback this change?

}

private fun <T : Any> trackResolution(
flagKey: String,
Copy link
Contributor

Choose a reason for hiding this comment

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

Flag Value is a member of the precomputed flag.

*
* @return A map of flag key to precomputed flag, or null if no flags are available.
*/
internal fun getFlagAssignmentsSnapshot(): Map<String, PrecomputedFlag>? = flagsRepository.getFlagsSnapshot()
Copy link
Contributor

Choose a reason for hiding this comment

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

Where does the caller get the EvaluationContext from?
What do you think of

interface FlagAssignmentSnapshot {
  Map<String, ResolutionDetails> flagAssignments
  EvaluationContext: context
}

Then, the track method would be

trackResolution(flagKey, flagAssignmentsSnapshot)

The trackResolution method would look up the resolution for that flag from the snapshot and the context for tracking.

Copy link
Author

Choose a reason for hiding this comment

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

The context is stored on JavaScript side alongside with flags cache, and is being sent from there.

* @param reason The evaluation reason.
*/
@InternalApi
data class PrecomputedFlag(
Copy link
Contributor

Choose a reason for hiding this comment

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

Which of these fields are needed for including in the Flag Assignments snapshot? I'd prefer to keep an internal model as this specifically represents when we are sending "over the wire" from the edge assignments server to the SDK.
I think ResolutionDetails should have what you need and it's already public.

Copy link
Author

Choose a reason for hiding this comment

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

ResolutionDetails doesn't have the doLog property from PrecomputedFlag, which is required in the trackResolution method

Copy link
Contributor

Choose a reason for hiding this comment

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

Synced on this and agreed to make an external Interface that we implement with our internal data class. This avoids a lot of unnecessary fieldmapping code

@yugisu-flux yugisu-flux requested review from 0xnm and typotter December 15, 2025 18:48
@yugisu-flux yugisu-flux requested a review from 0xnm December 16, 2025 13:34
*/
@InternalApi
@Suppress("UndocumentedPublicProperty")
interface UnparsedFlag {
Copy link
Author

Choose a reason for hiding this comment

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

TBH I don't really like this naming, open to better name ideas 🙏

Copy link
Contributor

Choose a reason for hiding this comment

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

I think PrecomputedFlag or FlagAssignment would work here. We don't really need to say that it's "unparsed" because the flag assignment itself has been parsed from the server response. It's just that the value has not been typed yet and that's more of an internal concern.

Copy link
Member

@0xnm 0xnm left a comment

Choose a reason for hiding this comment

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

I added some suggestions, nothing blocking, mostly about API/logging. I think it is a final round and we are good to go after.

fun removeListener(FlagsStateListener)
class com.datadog.android.flags._FlagsInternalProxy
constructor(FlagsClient)
fun getFlagAssignmentsSnapshot(): Map<String, com.datadog.android.flags.model.UnparsedFlag>?
Copy link
Member

Choose a reason for hiding this comment

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

Minor: normally returning nullable collection should be avoided, it is better to return empty collection if there is nothing to return. This way there is no null case to handle at the call site.

Copy link
Author

Choose a reason for hiding this comment

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

If the returned collection is null, that means the client initialization has failed at some step. Do you have an alternative to handle the case of the clear failure vs the flags cache is empty so we return an empty collection?

I guess the alternatives would be an pattern Optional (feels better than returning null?) or throwing an exception from this method (personally I don't like this pattern).

Copy link
Member

Choose a reason for hiding this comment

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

I think for the client initialization we now have FlagsClientState and FlagsClient.state.getCurrentState API, so I wouldn't rely on the null value here to make a state judgement. If RN needs to know the state it is better to use that API.

fun getFlagAssignmentsSnapshot(): Map<String, UnparsedFlag>? = if (client is DatadogFlagsClient) {
client.getFlagAssignmentsSnapshot()
} else {
null
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
null
emptyMap()

Comment on lines 459 to 464
val errorMessage = "Failed to parse value '${flag.variationValue}' as JSONObject - ${exception.message}"
featureSdkCore.internalLogger.log(
InternalLogger.Level.WARN,
InternalLogger.Target.MAINTAINER,
{ "Flag '$flagKey': $errorMessage" }
)
Copy link
Member

Choose a reason for hiding this comment

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

it is better to attach exception to the log call

Suggested change
val errorMessage = "Failed to parse value '${flag.variationValue}' as JSONObject - ${exception.message}"
featureSdkCore.internalLogger.log(
InternalLogger.Level.WARN,
InternalLogger.Target.MAINTAINER,
{ "Flag '$flagKey': $errorMessage" }
)
val errorMessage = "Failed to parse value '${flag.variationValue}' as JSONObject"
featureSdkCore.internalLogger.log(
InternalLogger.Level.WARN,
InternalLogger.Target.MAINTAINER,
{ "Flag '$flagKey': $errorMessage" },
exception
)

Copy link
Member

Choose a reason for hiding this comment

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

It seems there is a request to do another round of review, but I don't see a change related to this comment, there is only a merge of develop since last review.

Copy link
Author

Choose a reason for hiding this comment

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

Whops forgot to hit the push button

val errorMessage = "Failed to parse value '${flag.variationValue}' as JSONObject - ${exception.message}"
featureSdkCore.internalLogger.log(
InternalLogger.Level.WARN,
InternalLogger.Target.MAINTAINER,
Copy link
Member

Choose a reason for hiding this comment

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

Should the target be the USER? Since I guess it is about user data parsing

Copy link
Author

Choose a reason for hiding this comment

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

This log is corresponding to the flags data parsing logic in readAndParseAssignment (line 289).

Copy link
Member

Choose a reason for hiding this comment

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

My point here is that MAINTAINER target logs are visible only in the SDK debug builds, they won't be shown in the release build. So if user needs to know about this error, it should be USER target.

Copy link
Author

Choose a reason for hiding this comment

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

@typotter FYI the target has been changed to USER in other related flag value parsing logic as well. Does that work for you?

Comment on lines 471 to 475
featureSdkCore.internalLogger.log(
InternalLogger.Level.WARN,
InternalLogger.Target.MAINTAINER,
{ "Flag '$flagKey': Failed to parse value '${flag.variationValue}' as '${flag.variationType}'" }
)
Copy link
Member

Choose a reason for hiding this comment

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

not sure if it is useful, because we already have a log entry above in case of exception, so if exception happens we will have two log entries about the same

Copy link
Author

Choose a reason for hiding this comment

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

This log triggers for all variation types other than JSONObject. This is made this way to avoid swallowing JSONException details and provider them in the corresponding branch.

InternalLogger.Target.USER,
{ WARN_CONTEXT_NOT_SET }
)
return null
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
return null
return emptyMap()

fun setFlagsAndContext(context: EvaluationContext, flags: Map<String, PrecomputedFlag>)
fun getPrecomputedFlagWithContext(key: String): Pair<PrecomputedFlag, EvaluationContext>?
fun hasFlags(): Boolean
fun getFlagsSnapshot(): Map<String, PrecomputedFlag>?
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
fun getFlagsSnapshot(): Map<String, PrecomputedFlag>?
fun getFlagsSnapshot(): Map<String, PrecomputedFlag>

Comment on lines 1702 to 1706
verify(mockProcessor).processEvent(
flagName = eq(fakeFlagKey),
context = eq(fakeContext),
data = eq(fakeFlag)
)
Copy link
Member

Choose a reason for hiding this comment

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

tip: when all matchers are eq, eq usage can be omitted

Suggested change
verify(mockProcessor).processEvent(
flagName = eq(fakeFlagKey),
context = eq(fakeContext),
data = eq(fakeFlag)
)
verify(mockProcessor).processEvent(
flagName = fakeFlagKey,
context = fakeContext,
data = fakeFlag
)

@datadog-official
Copy link

datadog-official bot commented Dec 16, 2025

🎯 Code Coverage
Patch Coverage: 75.00%
Overall Coverage: 65.77%

View detailed report

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: b566487 | Docs | Datadog PR Page | Was this helpful? Give us feedback!

@codecov-commenter
Copy link

codecov-commenter commented Dec 16, 2025

Codecov Report

❌ Patch coverage is 86.53846% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 71.28%. Comparing base (ed7f4e1) to head (4eed5cb).
⚠️ Report is 4 commits behind head on develop.

Files with missing lines Patch % Lines
...n/com/datadog/android/flags/_FlagsInternalProxy.kt 0.00% 7 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##           develop    #3049      +/-   ##
===========================================
+ Coverage    71.24%   71.28%   +0.04%     
===========================================
  Files          866      867       +1     
  Lines        31730    31773      +43     
  Branches      5360     5370      +10     
===========================================
+ Hits         22604    22648      +44     
+ Misses        7602     7597       -5     
- Partials      1524     1528       +4     
Files with missing lines Coverage Δ
...tadog/android/flags/internal/DatadogFlagsClient.kt 92.68% <100.00%> (+1.38%) ⬆️
.../android/flags/internal/ExposureEventsProcessor.kt 100.00% <ø> (ø)
...og/android/flags/internal/model/PrecomputedFlag.kt 100.00% <100.00%> (ø)
...lags/internal/repository/DefaultFlagsRepository.kt 69.49% <100.00%> (+5.49%) ⬆️
...n/com/datadog/android/flags/_FlagsInternalProxy.kt 0.00% <0.00%> (ø)

... and 45 files with indirect coverage changes

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

@yugisu-flux yugisu-flux requested a review from 0xnm December 16, 2025 17:10
@yugisu-flux yugisu-flux changed the base branch from develop to typo/FFL-1579-sdk-android-expose-callback-for-set-evaluation-context-method-call December 17, 2025 12:15
@yugisu-flux yugisu-flux changed the base branch from typo/FFL-1579-sdk-android-expose-callback-for-set-evaluation-context-method-call to develop December 17, 2025 12:16
0xnm
0xnm previously approved these changes Dec 17, 2025
Copy link
Member

@0xnm 0xnm left a comment

Choose a reason for hiding this comment

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

lgtm, added some final remarks.

And I would still strongly recommend to change fun getFlagAssignmentsSnapshot(): Map<String, com.datadog.android.flags.model.UnparsedFlag>? to fun getFlagAssignmentsSnapshot(): Map<String, com.datadog.android.flags.model.UnparsedFlag>, because having a nullable collection is anti-pattern. Once it is out it will be public ABI-breaking change if type should be changed to non-nullable.

val errorMessage = "Failed to parse value '${flag.variationValue}' as JSONObject - ${exception.message}"
featureSdkCore.internalLogger.log(
InternalLogger.Level.WARN,
InternalLogger.Target.MAINTAINER,
Copy link
Member

Choose a reason for hiding this comment

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

My point here is that MAINTAINER target logs are visible only in the SDK debug builds, they won't be shown in the release build. So if user needs to know about this error, it should be USER target.

VariationType.OBJECT.value -> try {
JSONObject(flag.variationValue)
} catch (exception: JSONException) {
val errorMessage = "Failed to parse value '${flag.variationValue}' as JSONObject - ${exception.message}"
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
val errorMessage = "Failed to parse value '${flag.variationValue}' as JSONObject - ${exception.message}"
val errorMessage = "Failed to parse value '${flag.variationValue}' as JSONObject"

There is no need to add exception message here, it will already be shown due to passing exception object to the log call.

@yugisu-flux
Copy link
Author

Changed the base branch to Tyler's branch back and forth by a mistake; I will need his callback changes in the React Native PR, not this one.

/**
* Retrieves a snapshot of all flag assignments.
*
* Supposed to be used by internal Datadog packages to get flags state snapshot for a given evaluation context.
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
* Supposed to be used by internal Datadog packages to get flags state snapshot for a given evaluation context.
* Explicitly for use by the Datadog React Native SDK to get flags state snapshot for a given evaluation context.

Copy link
Contributor

@typotter typotter left a comment

Choose a reason for hiding this comment

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

LGTM for FFE SDK

trackResolution(flagKey, flag, flagValue, context)
}

private fun parseFlagValueString(flagKey: String, flag: UnparsedFlag): Any {
Copy link
Contributor

Choose a reason for hiding this comment

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

Can you use the FlagValueConverter here?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants