Skip to content

Conversation

@typotter
Copy link
Contributor

@typotter typotter commented Nov 25, 2025

What does this PR do?

Implements state management for FlagsClient in the dd-sdk-android-flags module, enabling applications and OpenFeature providers to observe client lifecycle states.

Key Changes:

  • Adds FlagsClientState enum with states: NOT_READY, READY, RECONCILING, ERROR
  • Adds FlagsStateListener interface for receiving state change notifications
  • Extends FlagsClient interface with state management methods: getCurrentState(), addStateListener(), removeStateListener()
  • Implements FlagsStateChannel wrapper over DDCoreSubscription with semantic notification methods
  • Wires state transitions through EvaluationsManager to DatadogFlagsClient
  • Provides comprehensive unit test coverage for all state management components

Motivation

This change is a prerequisite for implementing the OpenFeature provider's observe() method. The OpenFeature specification requires providers to emit events (PROVIDER_READY, PROVIDER_RECONCILING, PROVIDER_ERROR) when their state changes.

Without state tracking in the underlying FlagsClient, the OpenFeature provider has no mechanism to know when flags are loaded, when context changes begin/complete, or when errors occur.

Benefits:

  • Enables OpenFeature provider compliance with the observe() specification
  • Provides visibility into flag loading lifecycle for debugging and monitoring
  • Allows applications to react to flag availability changes
  • Fully backwards compatible - existing clients continue to work unchanged

Additional Notes

Design Decisions:

  1. Left out STALE state: The OpenFeature spec includes a STALE state for "cached flags may be outdated", but we removed it because there's no current mechanism to determine staleness. Added a note in FlagsClientState docs that this may be added in a future release.

  2. DDCoreSubscription with synchronization: Used the existing DDCoreSubscription utility for listener management, wrapped with synchronized blocks to guarantee ordered delivery of state changes (critical for NOT_READY → RECONCILING → READY flows).

  3. FlagsStateChannel abstraction: Created a wrapper class with semantic methods (notifyReady(), notifyReconciling(), etc.) to abstract DDCoreSubscription internals and improve code readability.

State Transition Flow:

setEvaluationContext()
    ↓
EvaluationsManager.notifyReconciling()
    ↓
[Background fetch...]
    ↓
Success: EvaluationsManager.notifyReady()
Failure: EvaluationsManager.notifyError()
    ↓
FlagsStateChannel → All registered listeners notified

Thread Safety:

  • getCurrentState(): Lock-free atomic read
  • addStateListener()/removeStateListener(): Thread-safe via DDCoreSubscription
  • State notifications: Synchronized to guarantee ordered delivery

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)

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 title Typo/ffl 1442 android sdk of wrapper implement observe method feat: State change notification for flags client Nov 25, 2025
@typotter typotter marked this pull request as ready for review November 25, 2025 06:50
@typotter typotter requested review from a team as code owners November 25, 2025 06:50
@datadog-datadog-prod-us1
Copy link

datadog-datadog-prod-us1 bot commented Nov 25, 2025

🎯 Code Coverage
Patch Coverage: 100.00%
Total Coverage: 71.37% (+0.05%)

View detailed report

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

@typotter typotter force-pushed the typo/FFL-1442-android-sdk-of-wrapper-implement-observe-method branch from 8cfaa65 to be53823 Compare November 27, 2025 18:01
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. I've removed coroutines and updated deps, etc. ptal

Comment on lines 1086 to 1087
- "kotlinx.coroutines.flow.MutableStateFlow(com.datadog.android.flags.model.FlagsClientState)"
- "kotlinx.coroutines.flow.MutableStateFlow.asStateFlow()"
Copy link
Member

Choose a reason for hiding this comment

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

since coroutines are now removed from this module, this is not relevant

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

Comment on lines 153 to 167
/**
* Observable interface for tracking client state changes.
*
* Provides three ways to observe state:
* - Synchronous: [StateObservable.getCurrentState] for immediate queries (Java-friendly)
* - Reactive: [StateObservable.flow] for coroutine-based updates (Kotlin)
* - Callback: [StateObservable.addListener] for traditional observers (Java-friendly)
*
* Example:
* ```kotlin
* // Synchronous
* val current = client.state.getCurrentState()
*
* // Reactive Flow
* client.state.flow.collect { state -> /* ... */ }
Copy link
Member

Choose a reason for hiding this comment

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

this comment is outdated, there is no Flow option

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

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

Comment on lines 40 to 41
override fun addListener(listener: FlagsStateListener) { /* no-op */ }
override fun removeListener(listener: FlagsStateListener) { /* no-op */ }
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
override fun addListener(listener: FlagsStateListener) { /* no-op */ }
override fun removeListener(listener: FlagsStateListener) { /* no-op */ }
override fun addListener(listener: FlagsStateListener) = Unit
override fun removeListener(listener: FlagsStateListener) = Unit

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

Comment on lines 190 to 199
@Test
fun `M return expected value W hasFlags() { for various states }`(forge: Forge) {
data class TestCase(val given: () -> Unit, val then: Boolean)

val testCases = listOf(
TestCase(
given = { /* no state set */ },
then = false
),
TestCase(
Copy link
Member

Choose a reason for hiding this comment

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

it is better to split this into different standalone tests

@Test
fun `M not block W hasFlags() { persistence still loading }`() {
// Given
val startTime = System.currentTimeMillis()
Copy link
Member

Choose a reason for hiding this comment

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

probably it is not a right place to start counting time, it is better to move it right before slowRepository.hasFlags() call

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

fun `set up`() {
whenever(mockSdkCore.internalLogger) doReturn mockInternalLogger
whenever(mockSdkCore.createSingleThreadExecutorService(FLAGS_CLIENT_EXECUTOR_NAME)) doReturn
whenever(mockSdkCore.createSingleThreadExecutorService(org.mockito.kotlin.any())) doReturn
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
whenever(mockSdkCore.createSingleThreadExecutorService(org.mockito.kotlin.any())) doReturn
whenever(mockSdkCore.createSingleThreadExecutorService(any())) doReturn

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

@Test
fun `M delegate to state manager W state_addListener()`() {
// Given
val mockListener = mock(FlagsStateListener::class.java)
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 mockListener = mock(FlagsStateListener::class.java)
val mockListener = mock<FlagsStateListener>()

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

@Test
fun `M delegate to state manager W state_removeListener()`() {
// Given
val mockListener = mock(FlagsStateListener::class.java)
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 mockListener = mock(FlagsStateListener::class.java)
val mockListener = mock<FlagsStateListener>()

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 addListener / removeListener

@Test
fun `M notify listener W addListener() and notify`() {
Copy link
Member

Choose a reason for hiding this comment

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

this test is already included by the parametrized test above

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

testedManager.updateState(FlagsClientState.Ready)

// Then - no further notifications after removal
org.mockito.kotlin.verifyNoMoreInteractions(mockListener)
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
org.mockito.kotlin.verifyNoMoreInteractions(mockListener)
verifyNoMoreInteractions(mockListener)

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

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

testedManager.updateState(FlagsClientState.Ready)

// Then - no further notifications after removal
org.mockito.kotlin.verifyNoMoreInteractions(mockListener)
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

@Test
fun `M delegate to state manager W state_removeListener()`() {
// Given
val mockListener = mock(FlagsStateListener::class.java)
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

@Test
fun `M delegate to state manager W state_addListener()`() {
// Given
val mockListener = mock(FlagsStateListener::class.java)
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

Comment on lines 40 to 41
override fun addListener(listener: FlagsStateListener) { /* no-op */ }
override fun removeListener(listener: FlagsStateListener) { /* no-op */ }
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

Comment on lines 153 to 167
/**
* Observable interface for tracking client state changes.
*
* Provides three ways to observe state:
* - Synchronous: [StateObservable.getCurrentState] for immediate queries (Java-friendly)
* - Reactive: [StateObservable.flow] for coroutine-based updates (Kotlin)
* - Callback: [StateObservable.addListener] for traditional observers (Java-friendly)
*
* Example:
* ```kotlin
* // Synchronous
* val current = client.state.getCurrentState()
*
* // Reactive Flow
* client.state.flow.collect { 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.

fixed

fun `set up`() {
whenever(mockSdkCore.internalLogger) doReturn mockInternalLogger
whenever(mockSdkCore.createSingleThreadExecutorService(FLAGS_CLIENT_EXECUTOR_NAME)) doReturn
whenever(mockSdkCore.createSingleThreadExecutorService(org.mockito.kotlin.any())) doReturn
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

@Test
fun `M not block W hasFlags() { persistence still loading }`() {
// Given
val startTime = System.currentTimeMillis()
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. good catch

Comment on lines 1086 to 1087
- "kotlinx.coroutines.flow.MutableStateFlow(com.datadog.android.flags.model.FlagsClientState)"
- "kotlinx.coroutines.flow.MutableStateFlow.asStateFlow()"
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

Comment on lines 153 to 167
/**
* Observable interface for tracking client state changes.
*
* Provides three ways to observe state:
* - Synchronous: [StateObservable.getCurrentState] for immediate queries (Java-friendly)
* - Reactive: [StateObservable.flow] for coroutine-based updates (Kotlin)
* - Callback: [StateObservable.addListener] for traditional observers (Java-friendly)
*
* Example:
* ```kotlin
* // Synchronous
* val current = client.state.getCurrentState()
*
* // Reactive Flow
* client.state.flow.collect { 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.

fixed

// region addListener / removeListener

@Test
fun `M notify listener W addListener() and notify`() {
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

@typotter typotter requested a review from 0xnm December 1, 2025 16:23
0xnm
0xnm previously approved these changes Dec 1, 2025
@typotter
Copy link
Contributor Author

typotter commented Dec 1, 2025

/merge

@typotter typotter requested a review from 0xnm December 1, 2025 16:47
@dd-devflow-routing-codex
Copy link

dd-devflow-routing-codex bot commented Dec 1, 2025

View all feedbacks in Devflow UI.

2025-12-01 16:47:44 UTC ℹ️ Start processing command /merge


2025-12-01 16:47:50 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.
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.


2025-12-01 18:07:30 UTC ⚠️ MergeQueue: This merge request was unqueued

tyler.potter@datadoghq.com unqueued this merge request

@typotter
Copy link
Contributor Author

typotter commented Dec 1, 2025

Thanks. Just need one more stamp for the final change

@typotter
Copy link
Contributor Author

typotter commented Dec 1, 2025

/remove

@dd-devflow-routing-codex
Copy link

dd-devflow-routing-codex bot commented Dec 1, 2025

View all feedbacks in Devflow UI.

2025-12-01 18:07:25 UTC ℹ️ Start processing command /remove


2025-12-01 18:07:28 UTC ℹ️ Devflow: /remove

@typotter typotter dismissed aleksandr-gringauz’s stale review December 1, 2025 18:46

Requested changes have been applied; reviewer is on PTO.

@typotter
Copy link
Contributor Author

typotter commented Dec 1, 2025

/merge

@dd-devflow-routing-codex
Copy link

dd-devflow-routing-codex bot commented Dec 1, 2025

View all feedbacks in Devflow UI.

2025-12-01 20:12:44 UTC ℹ️ Start processing command /merge


2025-12-01 20:12:48 UTC ℹ️ MergeQueue: pull request added to the queue

The expected merge time in develop is approximately 1h (p90).


2025-12-01 20:29:08 UTCMergeQueue: The checks failed on this merge request

Tests failed on this commit 1909853:

What to do next?

  • Investigate the failures and when ready, re-add your pull request to the queue!
  • If your PR checks are green, try to rebase/merge. It might be because the CI run is a bit old.
  • Any question, go check the FAQ.

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.

6 participants