feat: Openfeature Provider implementation powered by DD Flags#2998
feat: Openfeature Provider implementation powered by DD Flags#2998dd-mergequeue[bot] merged 39 commits intofeature/flags-ofeatfrom
Conversation
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## feature/flags-ofeat #2998 +/- ##
=======================================================
+ Coverage 70.70% 70.86% +0.16%
=======================================================
Files 895 899 +4
Lines 33030 33137 +107
Branches 5562 5592 +30
=======================================================
+ Hits 23352 23481 +129
+ Misses 8111 8100 -11
+ Partials 1567 1556 -11
🚀 New features to boost your workflow:
|
|
🎯 Code Coverage 🔗 Commit SHA: d8e9f68 | Docs | Datadog PR Page | Was this helpful? Give us feedback! |
16c7902 to
7692d7f
Compare
ef411d2 to
688b08c
Compare
688b08c to
2f3ed72
Compare
778e82a to
3e80743
Compare
| is FlagsClientState.Ready -> { | ||
| flagsClient.state.removeListener(listener) | ||
| continuation.resume(Unit) | ||
| } | ||
| is FlagsClientState.Error -> { | ||
| flagsClient.state.removeListener(listener) | ||
| continuation.resumeWithException( | ||
| currentState.error ?: Exception("Initialization failed") | ||
| ) | ||
| } | ||
| else -> {} // Wait for state change notification |
There was a problem hiding this comment.
minor: it seems that this block is exactly the same as above, maybe it is worth extracting it?
| * @return Value.List containing the converted elements | ||
| */ | ||
| @Suppress("UnsafeThirdPartyFunctionCall") | ||
| internal fun convertArrayToValue(jsonArray: JSONArray, internalLogger: InternalLogger? = null): Value { |
There was a problem hiding this comment.
made not optional.
| Mockito.lenient().`when`(mockFlagsClient.state).thenReturn(mockStateObservable) | ||
| Mockito.lenient().`when`(mockStateObservable.addListener(any())).thenAnswer { | ||
| capturedStateListener = it.getArgument(0) | ||
| Unit | ||
| } | ||
| Mockito.lenient().`when`(mockStateObservable.getCurrentState()).thenReturn(FlagsClientState.NotReady) |
| @Test | ||
| fun `M call setEvaluationContext W initialize() {valid context}`() = runTest { | ||
| // Given | ||
| val context = dev.openfeature.kotlin.sdk.ImmutableContext(targetingKey = "user-123") |
| val result = provider.helloWorld() | ||
| fun `M return immediately W initialize() {already ready state}`() = runTest { | ||
| // Given | ||
| val context = dev.openfeature.kotlin.sdk.ImmutableContext(targetingKey = "user-123") |
| } | ||
| is FlagsClientState.Error -> { | ||
| flagsClient.state.removeListener(this) | ||
| continuation.resumeWithException( |
There was a problem hiding this comment.
This will make initialize function throw an exception that isn't specified in its signature here https://github.com/open-feature/kotlin-sdk/blob/da47e54909bc1cfc77e2295b3d0ceb217d4193b9/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/FeatureProvider.kt#L18. Should we create OpenFeatureError here?
| when (val currentState = flagsClient.state.getCurrentState()) { | ||
| is FlagsClientState.Ready -> { | ||
| flagsClient.state.removeListener(listener) | ||
| continuation.resume(Unit) |
There was a problem hiding this comment.
We should be extra careful here. You can't call resume methods of Continuation multiple times https://kotlinlang.org/api/core/kotlin-stdlib/kotlin.coroutines/suspend-coroutine.html. It is theoretically possible that it will be called in addListener right away. I suggest that we add the listener only if we aren't resuming here. Or skip this "fast path" entirely.
There was a problem hiding this comment.
understood. With the setContext callback now, I think we should be OK here, as we're either calling resume from the callback, or from the other branch of the if; not both. Not listening to state change either
| setContext(newContext) | ||
|
|
||
| // Then wait for Ready, Stale, or Error state | ||
| suspendCoroutine<Unit> { continuation -> |
There was a problem hiding this comment.
I think you can create a helper function like this
private suspend fun StateObservable.waitFor(predicate: (FlagsClientState) -> Boolean): FlagsClientState {
val currentState = getCurrentState()
if (predicate(currentState)) {
return currentState
}
return suspendCancellableCoroutine { cont ->
val listener = object : FlagsStateListener {
override fun onStateChanged(newState: FlagsClientState) {
if (predicate(newState)) {
removeListener(this)
cont.resume(newState)
}
}
}
addListener(listener)
cont.invokeOnCancellation {
removeListener(listener)
}
}
}and use it here and in initialize function.
Also it is better to use suspendCancellableCoroutine.
There was a problem hiding this comment.
Thanks for the details. We have a callback now, so much cleaner.
Implements OpenFeature Provider for Datadog Feature Flags SDK, enabling standardized feature flag management with vendor-neutral API. Key components: - DatadogFlagsProvider: Spec-compliant provider with blocking lifecycle - FlagsClient.asOpenFeatureProvider(): Extension function for wrapping clients - Type converters: Bidirectional JSON ↔ OpenFeature Value conversion - Event observation: Filtered state change events via Kotlin Flow OpenFeature spec compliance: - Blocking initialize() and onContextSet() (spec 1.7) - Proper event filtering (NotReady/Reconciling handled by SDK) - Static-context paradigm implementation - Error code mapping NotReady state clarifications: - Enhanced FlagsClientState.NotReady documentation - NoOpFlagsClient returns NotReady (not Error) as appropriate state - Aligns with OpenFeature NOT_READY semantics Dependencies: - dev.openfeature:kotlin-sdk-android:0.6.2 (118 Kb) - kotlinx-coroutines-core-jvm:1.7.3 (1514 Kb) - kotlinx-coroutines-test:1.7.3 (test only) - Total transitive dependencies: 3 Mb Test coverage: - 147 unit tests covering all provider methods - Type conversion edge cases - Blocking behavior verification - Event emission and filtering
- Add resolveStructureValue(Map) overload that returns pure Kotlin collections - Implement JsonExtensions with bidirectional Map<->JSONObject conversion - Add comprehensive test coverage for Map API - Update FlagValueConverter to support all Map implementations - Add detekt safe calls configuration for JSON methods The Map API provides better Kotlin integration by returning nested Maps and Lists instead of JSONObject/JSONArray types. Both APIs coexist for compatibility.
| } catch (e: OpenFeatureError.ProviderFatalError) { | ||
| throw e |
There was a problem hiding this comment.
yes, we can actually skip the error promoting altogether.
| * @param initialContext The initial evaluation context to set (optional) | ||
| * @throws OpenFeatureError.ProviderFatalError if initialization fails | ||
| */ | ||
| @Suppress("SwallowedException") // Message is propagated but detekt still considers it a swallowed exception. |
There was a problem hiding this comment.
This is still a valid concern from Detekt, because stacktrace is lost.
Maybe it is worth to avoid catching OpenFeatureError? Is there any added value in re-packaging it?
There was a problem hiding this comment.
If we consider setting the initial context to not be a fatal error mode (this would come from not being able to pull a flag configuration wen setEvaluationContextSuspend is called), then we can avoid repackaging and catch/throwing below.
I suppose that, even though there might not have been a configuration successfully loaded, the SDK is still able to function returning the coded default at the least.
Actually, looking further into it, the OpenFeature SDK itself will shortcircuit the the Provider if it is in either the fatal or error states after initialize so it doesn't actually matter what which OpenFeatureError we throw here.
| // Safe: trySend returns ChannelResult, never throws exceptions | ||
| @Suppress("UnsafeThirdPartyFunctionCall") |
There was a problem hiding this comment.
trySend is already declared as a safe call, is this annotation necessary?
| /** | ||
| * Sentinel default value used to avoid unnecessary Value-to-Map conversion. | ||
| * | ||
| * When resolving structured flags, we pass this sentinel to the FlagsClient instead of | ||
| * converting the user's OpenFeature Value. If the FlagsClient returns this sentinel | ||
| * (via reference equality), we know no flag was found and can return the user's original | ||
| * default without any conversion overhead. | ||
| */ |
There was a problem hiding this comment.
| /** | |
| * Sentinel default value used to avoid unnecessary Value-to-Map conversion. | |
| * | |
| * When resolving structured flags, we pass this sentinel to the FlagsClient instead of | |
| * converting the user's OpenFeature Value. If the FlagsClient returns this sentinel | |
| * (via reference equality), we know no flag was found and can return the user's original | |
| * default without any conversion overhead. | |
| */ | |
| /** | |
| * Sentinel default value used to avoid unnecessary Value-to-Map conversion. | |
| * | |
| * When resolving structured flags, we pass this sentinel to the [FlagsClient] instead of | |
| * converting the user's OpenFeature [Value]. If the [FlagsClient] returns this sentinel | |
| * (via reference equality), we know no flag was found and can return the user's original | |
| * default without any conversion overhead. | |
| */ |
There was a problem hiding this comment.
There is another FlagsClientExt file with the same function inside.
There was a problem hiding this comment.
removed from the public package. This extension is only needed internally
| @Test | ||
| fun `M return greeting W helloWorld()`() { | ||
| val result = provider.helloWorld() | ||
| fun `M return immediately W initialize() {already ready state}`() = runTest { |
There was a problem hiding this comment.
the body of this test is exactly the same as above
|
|
||
| assertThat(result).isEqualTo("Hello from DatadogFlagsProvider!") | ||
| // Then - triggers context setting via suspend function | ||
| verify(mockFlagsClient).setEvaluationContext(any(), any()) |
There was a problem hiding this comment.
worth to assert first argument instead of using any()
| delay(100) // Let flow set up | ||
| capturedStateListener?.onStateChanged(FlagsClientState.Ready) | ||
| delay(100) // Let event propagate |
There was a problem hiding this comment.
minor: Using delay with some value makes tests flaky. In fact for such tests we can just block execution until capturedStateListener is captured. Or use some other deterministic marker.
There was a problem hiding this comment.
Thanks. I used testScheduler.runCurrent to run out any current coroutines instead of the delay. I think I have used it properly, though liberally
| // When/Then - should complete without throwing | ||
| mockFlagsClient.setEvaluationContextSuspend(context) | ||
|
|
||
| // Verify the correct context was passed |
There was a problem hiding this comment.
contrary to this comment, there is no context verification
| // Then - verify the correct context was passed | ||
| verify(mockFlagsClient).setEvaluationContext(contextCaptor.capture(), any()) | ||
| assertThat(contextCaptor.firstValue).isEqualTo(context) | ||
| assertThat(contextCaptor.firstValue.targetingKey).isEqualTo(targetingKey) |
| assertThat(contextCaptor.firstValue.targetingKey).isEqualTo(targetingKey) | ||
| assertThat(contextCaptor.firstValue.attributes).isEqualTo(attributes) |
…datadog/android/flags/openfeature/internal/FlagsClientExt.kt Co-authored-by: Nikita Ogorodnikov <4046447+0xnm@users.noreply.github.com>
|
Thanks @0xnm PTAL for the last small changes |
|
/merge |
|
View all feedbacks in Devflow UI.
This pull 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.
The expected merge time in
|
a45cc1a
into
feature/flags-ofeat
What does this PR do?
This PR adds a new OpenFeature provider module (dd-sdk-android-flags-openfeature) that integrates the Datadog Feature Flags SDK with the
OpenFeature API, enabling developers to use standardized feature flag management while leveraging Datadog's observability platform.
Key components:
Motivation
Why OpenFeature Integration?
Additional Notes
** Provider Lifecycle & Blocking Behavior (OpenFeature Spec 1.7)**
Per the OpenFeature specification, provider lifecycle methods must block until ready:
initialize(initialContext)
onContextSet(oldContext, newContext)
This blocking design ensures:
Event Emission via observe() (OpenFeature Spec Section 5)
Per the OpenFeature Events specification, providers emit only certain events while the SDK handles others:
Provider emits (via observe() Flow):
SDK emits (not from provider):
Filtered (not emitted by provider):
Review checklist (to be filled by reviewers)