-
Notifications
You must be signed in to change notification settings - Fork 75
feat: Openfeature Provider implementation powered by DD Flags #2998
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: feature/flags-ofeat
Are you sure you want to change the base?
Conversation
This comment has been minimized.
This comment has been minimized.
| paths: | ||
| - features/dd-sdk-android-flags/verification-metadata.xml | ||
|
|
||
| publish:release-flags-openfeature: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
it seems that :features:dd-sdk-android-flags-openfeature is missing in the custom Detekt rules execution job above, at least at this revision.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Was added in the first module scaffolding merge to the feature branch ~line 115. L106 now that I've reordered
local_ci.sh
Outdated
| rm -rf integrations/dd-sdk-android-sqldelight/build/ | ||
| rm -rf integrations/dd-sdk-android-timber/build/ | ||
| rm -rf integrations/dd-sdk-android-tv/build/ | ||
| rm -rf features/dd-sdk-android-flags-openfeature/build/ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: it is better to move it up, to the features block
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
moved. also added the flags feature module
| context: OpenFeatureEvaluationContext? | ||
| ): ProviderEvaluation<Boolean> { | ||
| context?.let { | ||
| Log.w(LOG_TAG, INVOCATION_CONTEXT_NOT_SUPPORTED_MESSAGE) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We shouldn't use android.util.Log, but InternalLogger. We must get it from the SdkCore instance which was used to create this provider.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
done
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
done
| errorCode = null, | ||
| errorMessage = null | ||
| ) | ||
| } catch (e: org.json.JSONException) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| } catch (e: org.json.JSONException) { | |
| } catch (e: JSONException) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
done
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
done
...gs-openfeature/src/main/kotlin/com/datadog/android/flags/openfeature/DatadogFlagsProvider.kt
Outdated
Show resolved
Hide resolved
...c/test/kotlin/com/datadog/android/flags/openfeature/internal/adapters/ValueConvertersTest.kt
Outdated
Show resolved
Hide resolved
...c/test/kotlin/com/datadog/android/flags/openfeature/internal/adapters/ValueConvertersTest.kt
Outdated
Show resolved
Hide resolved
...c/test/kotlin/com/datadog/android/flags/openfeature/internal/adapters/ValueConvertersTest.kt
Outdated
Show resolved
Hide resolved
...c/test/kotlin/com/datadog/android/flags/openfeature/internal/adapters/ValueConvertersTest.kt
Outdated
Show resolved
Hide resolved
...c/test/kotlin/com/datadog/android/flags/openfeature/internal/adapters/ValueConvertersTest.kt
Outdated
Show resolved
Hide resolved
| val targetingKey = this.getTargetingKey() | ||
|
|
||
| val stringAttributes = this.asMap() | ||
| .filterKeys { it != "targetingKey" } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Are you sure we need to filter this "targetingKey"? Is it put inside by dev.openfeature.kotlin.sdk?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
we do not
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
we do not
|
|
||
| override val hooks: List<Hook<*>> = emptyList() | ||
|
|
||
| override suspend fun initialize(initialContext: OpenFeatureEvaluationContext?) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see that it is suspend in openfeature sdk https://github.com/open-feature/kotlin-sdk/blob/da47e54909bc1cfc77e2295b3d0ceb217d4193b9/kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/FeatureProvider.kt#L19
It says in the docs: "This function should block until ready". What does "ready" mean here? Does it mean that when this function is finished it is expected that we've loaded the current state of the flags (from disk, from network, ...)?
I'm asking because IIUC the current implementation doesn't provide such guarantees.
Same question for override suspend fun onContextSet.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've added lifecycle state events to the base flags module which we can consume/re-emit here. The OpenFeature API handles emitting the READY, RECONCILING and ERROR/FATAL states depending on how initialize resolves. Thus, we don't need to re-emit those events in our openfeature layer, just watch for them when initializing and block the method until the underlying flags client enters either the READY or the ERROR state.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
for setEvaluationContext I have added a direct callback in #3056
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I looked at this pr.
Hope I understood your intentions correctly.
Here you notify the new callback after the call to flagStateManager.updateState. But flagStateManager.updateState changes the state asynchronously. So when the callback is called, there is no guarantee that the state has changed. So you'll still have to wait for the state like you do now.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@typotter WDYT about the comment above? Does the state in FlagStateManager need to be Ready when initialize returns?
16c7902 to
7692d7f
Compare
ef411d2 to
688b08c
Compare
688b08c to
2f3ed72
Compare
778e82a to
3e80743
Compare
| paths: | ||
| - features/dd-sdk-android-flags/verification-metadata.xml | ||
|
|
||
| publish:release-flags-openfeature: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Was added in the first module scaffolding merge to the feature branch ~line 115. L106 now that I've reordered
|
|
||
| override val hooks: List<Hook<*>> = emptyList() | ||
|
|
||
| override suspend fun initialize(initialContext: OpenFeatureEvaluationContext?) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've added lifecycle state events to the base flags module which we can consume/re-emit here. The OpenFeature API handles emitting the READY, RECONCILING and ERROR/FATAL states depending on how initialize resolves. Thus, we don't need to re-emit those events in our openfeature layer, just watch for them when initializing and block the method until the underlying flags client enters either the READY or the ERROR state.
local_ci.sh
Outdated
| rm -rf integrations/dd-sdk-android-sqldelight/build/ | ||
| rm -rf integrations/dd-sdk-android-timber/build/ | ||
| rm -rf integrations/dd-sdk-android-tv/build/ | ||
| rm -rf features/dd-sdk-android-flags-openfeature/build/ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
moved. also added the flags feature module
| context: OpenFeatureEvaluationContext? | ||
| ): ProviderEvaluation<Boolean> { | ||
| context?.let { | ||
| Log.w(LOG_TAG, INVOCATION_CONTEXT_NOT_SUPPORTED_MESSAGE) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
done
...penfeature/src/test/kotlin/com/datadog/android/flags/openfeature/DatadogFlagsProviderTest.kt
Show resolved
Hide resolved
| // region String Evaluation | ||
|
|
||
| @Test | ||
| fun `M return string value W getStringEvaluation() {successful resolution}`( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
added
| val targetingKey = this.getTargetingKey() | ||
|
|
||
| val stringAttributes = this.asMap() | ||
| .filterKeys { it != "targetingKey" } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
we do not
| errorCode = null, | ||
| errorMessage = null | ||
| ) | ||
| } catch (e: org.json.JSONException) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
done
| context: OpenFeatureEvaluationContext? | ||
| ): ProviderEvaluation<Boolean> { | ||
| context?.let { | ||
| Log.w(LOG_TAG, INVOCATION_CONTEXT_NOT_SUPPORTED_MESSAGE) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
done
| } | ||
| testImplementation(libs.bundles.jUnit5) | ||
| testImplementation(libs.bundles.testTools) | ||
| testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
should go to version catalog
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
done and aligned with core version
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
there is no change here, probably it wasn't committed/pushed
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
:( lost them in when rebasing. pushed now
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks like some other review-related changes are still missing.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yes, still working through these between meetings this morning. Didn't realize my push would dismiss your review/re-request.
| * The underlying [FlagsClient] handles thread coordination. | ||
| */ | ||
| class DatadogFlagsProvider { | ||
| class DatadogFlagsProvider private constructor(private val flagsClient: FlagsClient, sdkCore: SdkCore) : |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
apparently SdkCore is not needed here, but only InternalLogger. So it can be DatadogFlagsProvider(flagsClient, sdkCore as FeatureSdkCore) at the call side.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
moved the cast to the wrap method
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
it seems there is no change there as well
...eature/src/main/kotlin/com/datadog/android/flags/openfeature/internal/adapters/Converters.kt
Outdated
Show resolved
Hide resolved
| * @return A map containing the JSONObject's key-value pairs, or an empty map if an error occurs | ||
| */ | ||
| @Suppress("UnsafeThirdPartyFunctionCall") | ||
| internal fun JSONObject.toMap(internalLogger: InternalLogger? = null): Map<String, Any> { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this means that we will only convert top-level keys, leaving nested values as of JsonElement / JsonObject / JsonArray type?
For example, this:
{
"foo": "bar",
"qwerty": { "a" : "b" }
}
will be converted to:
mapOf<String, Any>("foo" to JsonElement("bar"), "qwerty" to JsonObject(...))
is it expected?
| * @param internalLogger Logger for diagnostic messages (optional, for unexpected type warnings) | ||
| * @return The converted OpenFeature Value | ||
| */ | ||
| internal fun convertToValue(value: Any?, internalLogger: InternalLogger? = null): Value = when (value) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| internal fun convertToValue(value: Any?, internalLogger: InternalLogger? = null): Value = when (value) { | |
| internal fun convertToValue(value: Any?, internalLogger: InternalLogger?): Value = when (value) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The suggestion is not applied despite the resolved comment. I guess the changes are not pushed?
| @Test | ||
| fun `M call setEvaluationContext W onContextSet() {context change}`() = runTest { | ||
| // Given | ||
| val newContext = dev.openfeature.kotlin.sdk.ImmutableContext(targetingKey = "user-456") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| val newContext = dev.openfeature.kotlin.sdk.ImmutableContext(targetingKey = "user-456") | |
| val newContext = ImmutableContext(targetingKey = "user-456") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
done
| val flow = provider.observe() | ||
|
|
||
| // Then - returns a Flow (basic smoke test) | ||
| assertThat(flow).isNotNull() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
it is already guaranteed by the type system that this value is not null
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
fixed
|
|
||
| @Test | ||
| fun `M not throw exception W shutdown()`() { | ||
| assertThatCode { provider.shutdown() }.doesNotThrowAnyException() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| assertThatCode { provider.shutdown() }.doesNotThrowAnyException() | |
| assertDoesNotThrow { provider.shutdown() } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
done
| 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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
minor: it seems that this block is exactly the same as above, maybe it is worth extracting it?
| FlagsClientState.Ready, FlagsClientState.Stale -> { | ||
| flagsClient.state.removeListener(listener) | ||
| continuation.resume(Unit) | ||
| } | ||
| is FlagsClientState.Error -> { | ||
| flagsClient.state.removeListener(listener) | ||
| continuation.resumeWithException( | ||
| currentState.error ?: Exception("Context reconciliation failed") | ||
| ) | ||
| } | ||
| else -> {} // Wait for state change notification |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
similar to the comment above: a bit of duplication
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
using callback has cleared this up tremendously
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks. Still need to sort out some more changes. Will ping when ready for review
|
|
||
| override val hooks: List<Hook<*>> = emptyList() | ||
|
|
||
| override suspend fun initialize(initialContext: OpenFeatureEvaluationContext?) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
for setEvaluationContext I have added a direct callback in #3056
| FlagsClientState.Ready, FlagsClientState.Stale -> { | ||
| flagsClient.state.removeListener(listener) | ||
| continuation.resume(Unit) | ||
| } | ||
| is FlagsClientState.Error -> { | ||
| flagsClient.state.removeListener(listener) | ||
| continuation.resumeWithException( | ||
| currentState.error ?: Exception("Context reconciliation failed") | ||
| ) | ||
| } | ||
| else -> {} // Wait for state change notification |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
using callback has cleared this up tremendously
| * @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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
applied
| // region String Evaluation | ||
|
|
||
| @Test | ||
| fun `M return string value W getStringEvaluation() {successful resolution}`( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
done
| @Test | ||
| fun `M call setEvaluationContext W initialize() {valid context}`() = runTest { | ||
| // Given | ||
| val context = dev.openfeature.kotlin.sdk.ImmutableContext(targetingKey = "user-123") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
done
| 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") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
done
| @Test | ||
| fun `M call setEvaluationContext W onContextSet() {context change}`() = runTest { | ||
| // Given | ||
| val newContext = dev.openfeature.kotlin.sdk.ImmutableContext(targetingKey = "user-456") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
done
| val flow = provider.observe() | ||
|
|
||
| // Then - returns a Flow (basic smoke test) | ||
| assertThat(flow).isNotNull() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
fixed
|
|
||
| @Test | ||
| fun `M not throw exception W shutdown()`() { | ||
| assertThatCode { provider.shutdown() }.doesNotThrowAnyException() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
done
| } | ||
| is FlagsClientState.Error -> { | ||
| flagsClient.state.removeListener(this) | ||
| continuation.resumeWithException( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done
| when (val currentState = flagsClient.state.getCurrentState()) { | ||
| is FlagsClientState.Ready -> { | ||
| flagsClient.state.removeListener(listener) | ||
| continuation.resume(Unit) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
3e80743 to
1e5bd40
Compare
| } | ||
|
|
||
| override fun onFailure(error: Throwable) { | ||
| continuation.resumeWithException(error) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Here we throw an exception that isn't specified in the function signature.
| else -> { | ||
| // Not ready yet - need to wait, but no context to set | ||
| // This shouldn't happen in normal flow | ||
| continuation.resumeWithException( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Here we throw an exception that isn't specified in the function signature.
| } | ||
|
|
||
| override fun onFailure(error: Throwable) { | ||
| continuation.resumeWithException(error) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Here we throw an exception that isn't specified in the function signature.
| } | ||
|
|
||
| flagsClient.setEvaluationContext(newContext.toDatadogEvaluationContext(), callback) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This can be extracted into a helper function and reused in initialize function.
override suspend fun initialize(initialContext: OpenFeatureEvaluationContext?) {
val datadogContext = initialContext?.toDatadogEvaluationContext()
if (datadogContext != null) {
flagsClient.setEvaluationContextSuspend(datadogContext)
} else {
// No context provided - check if already ready
when (flagsClient.state.getCurrentState()) {
is FlagsClientState.Ready -> return
is FlagsClientState.Error -> throw OpenFeatureError.ProviderFatalError("Provider not ready")
else -> throw OpenFeatureError.ProviderFatalError("Provider initialization requires a context")
}
}
}
override suspend fun onContextSet(
oldContext: OpenFeatureEvaluationContext?,
newContext: OpenFeatureEvaluationContext
) {
flagsClient.setEvaluationContextSuspend(newContext.toDatadogEvaluationContext())
}
private suspend fun FlagsClient.setEvaluationContextSuspend(context: EvaluationContext) {
suspendCoroutine { continuation ->
val callback = object : EvaluationContextCallback {
override fun onSuccess() {
continuation.resume(Unit)
}
override fun onFailure(error: Throwable) {
continuation.resumeWithException(OpenFeatureError.ProviderFatalError(error.message ?: ""))
}
}
flagsClient.setEvaluationContext(context, callback)
}
}|
|
||
| override val hooks: List<Hook<*>> = emptyList() | ||
|
|
||
| override suspend fun initialize(initialContext: OpenFeatureEvaluationContext?) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@typotter WDYT about the comment above? Does the state in FlagStateManager need to be Ready when initialize returns?
- Add comprehensive tests for state mapping (NotReady, Reconciling, Ready, Stale, Error) - Add test for listener lifecycle (removal on flow cancellation) - Add publish job for dd-sdk-android-flags-openfeature module - Include openfeature module in merge-verification-metadata dependencies - Fix imports for ImmutableContext and OpenFeatureProviderEvents
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?
Dependencies
This PR depends on PR #3025 (State change notification for flags client).
Additional Notes
Architecture Decisions
Static-Context Paradigm
** 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):
Implementation: DatadogFlagsProvider.kt:325-345
Type Conversion Strategy
Error Handling
Thread Safety
Implementation Details
Module Structure:
dd-sdk-android-flags-openfeature/
├── DatadogFlagsProvider.kt # Main provider implementation
├── FlagsClientExt.kt # Extension function
└── internal/adapters/
├── Converters.kt # Context & error code converters
└── ValueConverters.kt # JSON ↔ OpenFeature Value converters
Dependencies:
Test Coverage:
CI/CD Integration:
Detekt Safe Calls:
Usage Example
import com.datadog.android.flags.openfeature.DatadogFlagsProvider
import dev.openfeature.kotlin.sdk.OpenFeatureAPI
// Create and set provider (blocks until ready)
val provider = DatadogFlagsProvider.create()
OpenFeatureAPI.setProviderAndWait(provider)
// Use OpenFeature API
val client = OpenFeatureAPI.getClient()
val isEnabled = client.getBooleanValue("my-feature", false)
// Observe provider state changes
lifecycleScope.launch {
provider.observe().collect { event ->
when (event) {
is OpenFeatureProviderEvents.ProviderReady -> { /* ready / }
is OpenFeatureProviderEvents.ProviderError -> { / handle error / }
is OpenFeatureProviderEvents.ProviderStale -> { / stale data */ }
}
}
}
Review checklist (to be filled by reviewers)