Skip to content

Conversation

@typotter
Copy link
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
  • Type Converters: Bidirectional conversion between OpenFeature Values and JSON types, preserving type information
  • Factory Method: Single create() method with @jvmoverloads for flexible provider instantiation
  • 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

Dependencies

This PR depends on PR #3025 (State change notification for flags client).

  • PR feat: State change notification for flags client #3025 provides the StateObservable API (getCurrentState(), addListener(), removeListener())
  • This API enables blocking behavior and event observation without adding coroutines to the core flags module
  • Blocking is implemented in OpenFeature module only using coroutines (which OpenFeature SDK already requires)

Additional Notes

Architecture Decisions

Static-Context Paradigm

  • This provider implements the static-context paradigm per OpenFeature spec
  • Global context is set via OpenFeatureAPI.setEvaluationContext()
  • Per-evaluation (invocation) contexts are not supported - OpenFeature spec does not support invocation contexts in static-paradigm providers
  • Warnings are logged if invocation contexts are provided

** 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()

Implementation: DatadogFlagsProvider.kt:325-345

Type Conversion Strategy

  • JSON → OpenFeature Value: Intelligent type promotion (e.g., Long within Int range becomes Value.Integer)
  • OpenFeature Value → JSON: Preserves type information (Integer→Int, Boolean→Boolean)
  • Null Handling: JSONObject.NULL sentinel values are converted to Value.Null
  • Special Values: Handles NaN, Infinity, nested structures, large collections

Error Handling

  • Specific exception catching (JSONException, ClassCastException, IllegalStateException)
  • Appropriate OpenFeature error codes (PARSE_ERROR, TYPE_MISMATCH, GENERAL, PROVIDER_NOT_READY)
  • Graceful degradation - returns default values on errors
  • Comprehensive logging via InternalLogger for debugging

Thread Safety

  • Provider methods are thread-safe, delegating coordination to FlagsClient
  • No synchronization locks (upstream FlagsClient handles concurrency)
  • State notifications use dedicated single-threaded executor (from FlagsClient)

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:

  • dev.openfeature:kotlin-sdk-android:0.6.2 (118 Kb)
  • kotlinx-coroutines-core-jvm:1.7.3 (1514 Kb) - For blocking suspend functions
  • Total transitive dependencies: 3 Mb

Test Coverage:

  • 71 unit tests covering all provider methods and edge cases
  • Error code mapping, type conversion, context management
  • Blocking behavior verification
  • Uses Elmyr forgeries for better test data generation

CI/CD Integration:

  • Added to detekt pipeline (line 115 in ci/pipelines/default-pipeline.yml)
  • Added to local_ci.sh features block
  • Publishing configured
  • License compliance updated (Apache-2.0 for OpenFeature SDK)

Detekt Safe Calls:

  • Added OpenFeature SDK calls to detekt_custom_safe_calls.yml
  • Added coroutines flow API calls (callbackFlow, awaitClose, trySend)
  • All third-party calls verified as safe

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)

  • 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

codecov-commenter commented Nov 10, 2025

Codecov Report

❌ Patch coverage is 47.23247% with 143 lines in your changes missing coverage. Please review.
✅ Project coverage is 70.86%. Comparing base (6c2f8ab) to head (ef411d2).

Files with missing lines Patch % Lines
.../android/flags/openfeature/DatadogFlagsProvider.kt 23.08% 81 Missing and 9 partials ⚠️
...s/openfeature/internal/adapters/ValueConverters.kt 68.52% 12 Missing and 5 partials ⚠️
.../flags/openfeature/internal/adapters/Converters.kt 57.14% 14 Missing and 1 partial ⚠️
...in/kotlin/com/datadog/android/flags/FlagsClient.kt 0.00% 13 Missing ⚠️
...atadog/android/flags/internal/FlagsStateManager.kt 86.36% 3 Missing ⚠️
.../datadog/android/flags/internal/NoOpFlagsClient.kt 50.00% 2 Missing ⚠️
...id/flags/internal/evaluation/EvaluationsManager.kt 93.33% 1 Missing ⚠️
...lags/internal/repository/DefaultFlagsRepository.kt 0.00% 0 Missing and 1 partial ⚠️
...om/datadog/android/flags/model/FlagsClientState.kt 83.33% 1 Missing ⚠️
Additional details and impacted files
@@                   Coverage Diff                   @@
##           feature/flags-ofeat    #2998      +/-   ##
=======================================================
- Coverage                71.10%   70.86%   -0.24%     
=======================================================
  Files                      860      865       +5     
  Lines                    31311    31565     +254     
  Branches                  5275     5334      +59     
=======================================================
+ Hits                     22263    22368     +105     
- Misses                    7556     7673     +117     
- Partials                  1492     1524      +32     
Files with missing lines Coverage Δ
...atadog/android/flags/openfeature/FlagsClientExt.kt 100.00% <100.00%> (ø)
...tadog/android/flags/internal/DatadogFlagsClient.kt 88.41% <100.00%> (-1.30%) ⬇️
...id/flags/internal/evaluation/EvaluationsManager.kt 91.18% <93.33%> (+1.89%) ⬆️
...lags/internal/repository/DefaultFlagsRepository.kt 64.00% <0.00%> (-1.31%) ⬇️
...om/datadog/android/flags/model/FlagsClientState.kt 83.33% <83.33%> (ø)
.../datadog/android/flags/internal/NoOpFlagsClient.kt 93.10% <50.00%> (-6.90%) ⬇️
...atadog/android/flags/internal/FlagsStateManager.kt 86.36% <86.36%> (ø)
...in/kotlin/com/datadog/android/flags/FlagsClient.kt 35.59% <0.00%> (-2.59%) ⬇️
.../flags/openfeature/internal/adapters/Converters.kt 57.14% <57.14%> (ø)
...s/openfeature/internal/adapters/ValueConverters.kt 68.52% <68.52%> (ø)
... and 1 more

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

This comment has been minimized.

@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
paths:
- features/dd-sdk-android-flags/verification-metadata.xml

publish:release-flags-openfeature:
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 that :features:dd-sdk-android-flags-openfeature is missing in the custom Detekt rules execution job above, at least at this revision.

Copy link
Contributor Author

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/
Copy link
Member

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

Copy link
Contributor Author

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)
Copy link
Member

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

done

Copy link
Contributor Author

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) {
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
} catch (e: org.json.JSONException) {
} catch (e: JSONException) {

Copy link
Contributor Author

Choose a reason for hiding this comment

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

done

Copy link
Contributor Author

Choose a reason for hiding this comment

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

done

val targetingKey = this.getTargetingKey()

val stringAttributes = this.asMap()
.filterKeys { it != "targetingKey" }
Copy link
Contributor

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?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

we do not

Copy link
Contributor Author

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?) {
Copy link
Contributor

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.

Copy link
Contributor Author

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.

Copy link
Contributor Author

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

Copy link
Contributor

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.

Copy link
Contributor

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?

@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
paths:
- features/dd-sdk-android-flags/verification-metadata.xml

publish:release-flags-openfeature:
Copy link
Contributor Author

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?) {
Copy link
Contributor Author

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/
Copy link
Contributor Author

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)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

done

// region String Evaluation

@Test
fun `M return string value W getStringEvaluation() {successful resolution}`(
Copy link
Contributor Author

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" }
Copy link
Contributor Author

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) {
Copy link
Contributor Author

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)
Copy link
Contributor Author

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")
Copy link
Member

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

Copy link
Contributor Author

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

Copy link
Member

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

Copy link
Contributor Author

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

Copy link
Member

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.

Copy link
Contributor Author

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) :
Copy link
Member

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.

Copy link
Contributor Author

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

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 no change there as well

* @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> {
Copy link
Member

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) {
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
internal fun convertToValue(value: Any?, internalLogger: InternalLogger? = null): Value = when (value) {
internal fun convertToValue(value: Any?, internalLogger: InternalLogger?): Value = when (value) {

Copy link
Member

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")
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 newContext = dev.openfeature.kotlin.sdk.ImmutableContext(targetingKey = "user-456")
val newContext = ImmutableContext(targetingKey = "user-456")

Copy link
Contributor Author

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()
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 already guaranteed by the type system that this value is not null

Copy link
Contributor Author

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()
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
assertThatCode { provider.shutdown() }.doesNotThrowAnyException()
assertDoesNotThrow { provider.shutdown() }

Copy link
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 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
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?

Comment on lines 196 to 206
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
Copy link
Member

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

Copy link
Contributor Author

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

Base automatically changed from typo/openfeature-flags-client-state to feature/flags-ofeat December 9, 2025 15:16
Copy link
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


override val hooks: List<Hook<*>> = emptyList()

override suspend fun initialize(initialContext: OpenFeatureEvaluationContext?) {
Copy link
Contributor Author

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

Comment on lines 196 to 206
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
Copy link
Contributor Author

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 {
Copy link
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 71
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
Contributor Author

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}`(
Copy link
Contributor Author

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")
Copy link
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
Contributor Author

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")
Copy link
Contributor Author

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()
Copy link
Contributor Author

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()
Copy link
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
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
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
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
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
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
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.
@typotter typotter force-pushed the typo/openfeature-module-impl branch from 3e80743 to 1e5bd40 Compare December 18, 2025 04:46
}

override fun onFailure(error: Throwable) {
continuation.resumeWithException(error)
Copy link
Contributor

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(
Copy link
Contributor

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)
Copy link
Contributor

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)
}
Copy link
Contributor

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?) {
Copy link
Contributor

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?

t resolution tests to cover errors, verify all fields
- 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
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