Skip to content

feat: Openfeature Provider implementation powered by DD Flags#2998

Merged
dd-mergequeue[bot] merged 39 commits intofeature/flags-ofeatfrom
typo/openfeature-module-impl
Jan 13, 2026
Merged

feat: Openfeature Provider implementation powered by DD Flags#2998
dd-mergequeue[bot] merged 39 commits intofeature/flags-ofeatfrom
typo/openfeature-module-impl

Conversation

@typotter
Copy link
Copy Markdown
Contributor

@typotter typotter commented Nov 10, 2025

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:

  • DatadogFlagsProvider: OpenFeature FeatureProvider implementation that adapts the OpenFeature API to Datadog's FlagsClient
  • Blocking Lifecycle: Implements spec-compliant blocking for initialize() and onContextSet() methods
  • State Observation: observe() method emitting provider state change events via Kotlin Flow
  • Extension Function: FlagsClient.asOpenFeatureProvider() for convenient wrapping of existing clients
  • Static-Context Paradigm: Implements OpenFeature's static-context model with global context management

Motivation

Why OpenFeature Integration?

  1. Standardization: OpenFeature provides a vendor-neutral, community-driven specification for feature flagging
  2. Ecosystem: Enables integration with OpenFeature-compatible tools and libraries
  3. Migration Path: Allows customers to adopt OpenFeature API while using Datadog as the backend
  4. Multi-Vendor: Customers can switch between feature flag providers without code changes

Additional Notes

** Provider Lifecycle & Blocking Behavior (OpenFeature Spec 1.7)**

Per the OpenFeature specification, provider lifecycle methods must block until ready:

initialize(initialContext)

  • Blocks until provider reaches Ready or Error state
  • Uses suspendCoroutine + StateObservable.addListener() to wait for terminal state
  • Throws exception if initialization fails (Error state)
  • Implementation: DatadogFlagsProvider.kt:99-124

onContextSet(oldContext, newContext)

  • Blocks during context reconciliation until Ready, Stale, or Error state
  • Enables OpenFeature SDK to emit PROVIDER_RECONCILING events while method executes
  • Returns normally for Ready/Stale, throws for Error
  • Implementation: DatadogFlagsProvider.kt:143-169

This blocking design ensures:

  • SDK knows when reconciliation is happening → emits PROVIDER_RECONCILING automatically
  • SDK emits PROVIDER_CONTEXT_CHANGED when onContextSet() completes
  • Proper state visibility for OpenFeature clients

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):

  • PROVIDER_READY: When FlagsClient reaches Ready state
  • PROVIDER_STALE: When flags are stale but cached data available
  • PROVIDER_ERROR: When FlagsClient reaches Error state (unrecoverable)

SDK emits (not from provider):

  • PROVIDER_RECONCILING: SDK emits while onContextSet() is executing
  • PROVIDER_CONTEXT_CHANGED: SDK emits when onContextSet() completes

Filtered (not emitted by provider):

  • FlagsClientState.NotReady: Pre-initialization state, handled via blocking initialize()
  • FlagsClientState.Reconciling: Context reconciliation, SDK handles via blocking onContextSet()

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/flags-ofeat November 10, 2025 18:22
@codecov-commenter
Copy link
Copy Markdown

codecov-commenter commented Nov 10, 2025

Codecov Report

❌ Patch coverage is 88.46154% with 6 lines in your changes missing coverage. Please review.
✅ Project coverage is 70.86%. Comparing base (3099ab5) to head (d8e9f68).
⚠️ Report is 13 commits behind head on feature/flags-ofeat.

Files with missing lines Patch % Lines
.../android/flags/openfeature/DatadogFlagsProvider.kt 88.64% 0 Missing and 5 partials ⚠️
...droid/flags/openfeature/internal/FlagsClientExt.kt 85.71% 0 Missing and 1 partial ⚠️
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     
Files with missing lines Coverage Δ
...atadog/android/flags/openfeature/FlagsClientExt.kt 100.00% <100.00%> (ø)
...droid/flags/openfeature/internal/FlagsClientExt.kt 85.71% <85.71%> (ø)
.../android/flags/openfeature/DatadogFlagsProvider.kt 88.64% <88.64%> (-11.36%) ⬇️

... and 37 files with indirect coverage changes

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

@datadog-datadog-prod-us1
Copy link
Copy Markdown

datadog-datadog-prod-us1 Bot commented Nov 10, 2025

🎯 Code Coverage
Patch Coverage: 100.00%
Overall Coverage: 65.96% (+1.32%)

View detailed report

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

@typotter typotter marked this pull request as ready for review November 24, 2025 16:51
@typotter typotter requested review from a team as code owners November 24, 2025 16:51
Comment thread ci/pipelines/default-pipeline.yml
Comment thread local_ci.sh Outdated
@typotter typotter force-pushed the typo/openfeature-module-impl branch from 16c7902 to 7692d7f Compare December 2, 2025 04:41
@typotter typotter changed the base branch from feature/flags-ofeat to typo/openfeature-flags-client-state December 8, 2025 16:44
@typotter typotter force-pushed the typo/openfeature-module-impl branch from ef411d2 to 688b08c Compare December 8, 2025 17:02
@typotter typotter changed the base branch from typo/openfeature-flags-client-state to typo/flags-not-ready-clarifications December 8, 2025 17:36
@typotter typotter force-pushed the typo/openfeature-module-impl branch from 688b08c to 2f3ed72 Compare December 8, 2025 17:36
@typotter typotter changed the base branch from typo/flags-not-ready-clarifications to typo/openfeature-flags-client-state December 8, 2025 17:45
@typotter typotter force-pushed the typo/openfeature-module-impl branch 4 times, most recently from 778e82a to 3e80743 Compare December 8, 2025 20:39
Comment thread ci/pipelines/default-pipeline.yml
Comment thread features/dd-sdk-android-flags-openfeature/build.gradle.kts
Comment thread local_ci.sh Outdated
Comment thread features/dd-sdk-android-flags-openfeature/build.gradle.kts Outdated
Comment on lines +133 to +143
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
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 seems that this block is exactly the same as above, maybe it is worth extracting it?

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.

refactored

Base automatically changed from typo/openfeature-flags-client-state to feature/flags-ofeat December 9, 2025 15:16
Copy link
Copy Markdown
Contributor Author

@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.

Thanks. Still need to sort out some more changes. Will ping when ready for review

* @return Value.List containing the converted elements
*/
@Suppress("UnsafeThirdPartyFunctionCall")
internal fun convertArrayToValue(jsonArray: JSONArray, internalLogger: InternalLogger? = null): Value {
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.

made not optional.

Comment on lines +68 to +73
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)
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.

applied

@Test
fun `M call setEvaluationContext W initialize() {valid context}`() = runTest {
// Given
val context = dev.openfeature.kotlin.sdk.ImmutableContext(targetingKey = "user-123")
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

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")
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

}
is FlagsClientState.Error -> {
flagsClient.state.removeListener(this)
continuation.resumeWithException(
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.

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?

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

when (val currentState = flagsClient.state.getCurrentState()) {
is FlagsClientState.Ready -> {
flagsClient.state.removeListener(listener)
continuation.resume(Unit)
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.

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.

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.

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 ->
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 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.

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 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.
Comment on lines +108 to +109
} catch (e: OpenFeatureError.ProviderFatalError) {
throw e
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.

this can be removed

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, 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.
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.

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?

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.

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.

Comment on lines +234 to +235
// Safe: trySend returns ChannelResult, never throws exceptions
@Suppress("UnsafeThirdPartyFunctionCall")
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.

trySend is already declared as a safe call, is this annotation necessary?

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.

fixed

Comment on lines +251 to +258
/**
* 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.
*/
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
/**
* 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.
*/

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.

fixed

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.

There is another FlagsClientExt file with the same function inside.

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 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 {
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.

the body of this test is exactly the same as above

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.

fixed


assertThat(result).isEqualTo("Hello from DatadogFlagsProvider!")
// Then - triggers context setting via suspend function
verify(mockFlagsClient).setEvaluationContext(any(), 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.

worth to assert first argument instead of using 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.

verified

Comment on lines +509 to +511
delay(100) // Let flow set up
capturedStateListener?.onStateChanged(FlagsClientState.Ready)
delay(100) // Let event propagate
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: 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.

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. 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
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.

contrary to this comment, there is no context verification

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.

fixed

@typotter typotter requested a review from 0xnm January 13, 2026 05:24
0xnm
0xnm previously approved these changes Jan 13, 2026
// Then - verify the correct context was passed
verify(mockFlagsClient).setEvaluationContext(contextCaptor.capture(), any())
assertThat(contextCaptor.firstValue).isEqualTo(context)
assertThat(contextCaptor.firstValue.targetingKey).isEqualTo(targetingKey)
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.

the line above already covers it

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

Comment on lines +213 to +214
assertThat(contextCaptor.firstValue.targetingKey).isEqualTo(targetingKey)
assertThat(contextCaptor.firstValue.attributes).isEqualTo(attributes)
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.

line above covers it as well

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

…datadog/android/flags/openfeature/internal/FlagsClientExt.kt

Co-authored-by: Nikita Ogorodnikov <4046447+0xnm@users.noreply.github.com>
@typotter
Copy link
Copy Markdown
Contributor Author

Thanks @0xnm PTAL for the last small changes

@typotter typotter requested a review from 0xnm January 13, 2026 16:06
@typotter
Copy link
Copy Markdown
Contributor Author

/merge

@gh-worker-devflow-routing-ef8351
Copy link
Copy Markdown

gh-worker-devflow-routing-ef8351 Bot commented Jan 13, 2026

View all feedbacks in Devflow UI.

2026-01-13 16:19:38 UTC ℹ️ Start processing command /merge


2026-01-13 16:20:10 UTC ℹ️ MergeQueue: waiting for PR to be ready

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.
It will be added to the queue as soon as checks pass and/or get approvals. View in MergeQueue UI.
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.


2026-01-13 17:10:49 UTC ℹ️ MergeQueue: merge request added to the queue

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


2026-01-13 18:16:11 UTC ℹ️ MergeQueue: This merge request was merged

@dd-mergequeue dd-mergequeue Bot merged commit a45cc1a into feature/flags-ofeat Jan 13, 2026
26 of 27 checks passed
@dd-mergequeue dd-mergequeue Bot deleted the typo/openfeature-module-impl branch January 13, 2026 18:16
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.

4 participants