Skip to content

feat: context management#2886

Merged
typotter merged 1 commit into
feature/feature-flaggingfrom
typo/ff-context
Sep 22, 2025
Merged

feat: context management#2886
typotter merged 1 commit into
feature/feature-flaggingfrom
typo/ff-context

Conversation

@typotter

Copy link
Copy Markdown
Contributor

Feature Flags Evaluat Context Implementation

What does this PR do?

Implements synchronous evaluation context management for feature flag evaluation, inclusion in precompute-assignments request, for exposure logging via in-memory storage and enrichment from the SDK core.

This establishes the foundation for feature flag targeting by allowing developers to set evaluation context that gets automatically enhanced with device, app, and user data from the Datadog SDK core.

Context, Targeting Data, Attributes; these are all words that mean the set of properties the flag evaluation system can use to determine the effective value of a flag for a particular user or subject (or context). The targetingKey is the pillar of the evaluation context, impacting both targeting rules and the selection of traffic for Experiments (such as an A/B test). An inconsistent targetingKey for a user leads to inconsistent bucketing.

Motivation

The feature flags implementation needs context management to:

  • Enable proper flag targeting based on user/device attributes
  • Pull context data from existing Datadog SDK infrastructure
  • Provide a developer-friendly API following Kotlin best practices
  • Include context information in network requests for precomputed flag evaluation
  • Prepare the foundation for future atomic configuration management

How to navigate this PR

How to Navigate This PR

🎯 Start Here - Core API Changes

  1. FlagsProvider.kt - See the new setContext(targetingKey, attributes) API
  2. FlagsConfiguration.kt - New useDatadogContextForEvaluations option
  3. EvaluationContext.kt - New Kotlin DSL for context creation

🏗️ Architecture Overview

  1. FlagsContextManager.kt - Central context management with SDK core integration
  2. DefaultFlagsRepository.kt - Context storage and network coordination
  3. DefaultFlagsNetworkManager.kt - Context inclusion in network requests

🧪 Understanding Through Tests

  1. EvaluationContextTest.kt - See the new DSL syntax examples
  2. FlagsContextIntegrationTest.kt - End-to-end context flow
  3. FlagsContextManagerTest.kt - SDK core integration examples

💡 Understanding the Flow

  1. Start at: DatadogFlagsProvider.setContext() (line 37)
  2. Follow to: DefaultFlagsRepository.updateContext() (line 53)
  3. See enhancement: FlagsContextManager.enhanceWithCoreContext() (line 100+)
  4. Network inclusion: DefaultFlagsNetworkManager.buildRequestBody() (line 178+)

Usage Examples

Basic Context Setting

val provider = FlagsClient.get()

provider.setContext("user123", mapOf(
        "region" to "us-east",
        "tier" to "gold",
        "beta" to true
    ))

val enabled = provider.resolveBooleanValue("new-feature", false)

SDK Core Integration

// Automatically enhanced with:
// - device.type, device.brand, device.model, device.architecture
// - app.version, app.build, env, service, version
// - user.id, user.name, user.email, user.* (additional properties)

Comment thread features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/Flags.kt Outdated
}

internal fun build(
site: String,

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.

Users will get these parameters from here or from datadogContext?

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.

ddcontext

updatedAttributes[key] = value
setContext(currentContext.targetingKey, updatedAttributes)
} else {
throw IllegalStateException(

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.

Do we want to throw an exception here?

Comment on lines +162 to +179
context.targetingAttributes.forEach { (key, value) ->
when (value) {
is String -> attributesJson.put(key, value)
is Number -> attributesJson.put(key, value)
is Boolean -> attributesJson.put(key, value)
is Collection<*> -> {
// Handle arrays/lists by converting to JSON array
val jsonArray = org.json.JSONArray(value)
attributesJson.put(key, jsonArray)
}
is Map<*, *> -> {
// Handle nested objects
val jsonObject = JSONObject(value as Map<String, Any>)
attributesJson.put(key, jsonObject)
}
else -> attributesJson.put(key, value.toString())
}
}

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.

question/ It looks like we allow multiple data types in targeting_attributes, but the endpoint spec defines them as Map<String, String>:

targeting_attributes:
   type: object
   description: Key/value attributes for subject targeting.
   additionalProperties:
      type: string
   example:
      attr1: value1
      companyId: "1"

The js-client stringifies targeting_attributes, and @sameerank did the same in the Swift client here.

To better support this in a typed language like Swift, we decided to require a [String: String] map in the EvaluationContext API. This could be relaxed later (by stringifying various types internally), but the plan is to handle that in future iterations.

Just double-checking here—this implementation doesn’t seem consistent with the spec. Is that intentional?

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.

not intentional - got carried away a bit; have fixed!

@codecov-commenter

codecov-commenter commented Sep 21, 2025

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 41.33858% with 149 lines in your changes missing coverage. Please review.
✅ Project coverage is 70.50%. Comparing base (ccf1684) to head (d8c2c35).
⚠️ Report is 322 commits behind head on feature/feature-flagging.

Files with missing lines Patch % Lines
...ernal/repository/net/DefaultFlagsNetworkManager.kt 0.00% 49 Missing ⚠️
...ureflags/internal/evaluation/EvaluationsManager.kt 23.26% 33 Missing ⚠️
...eflags/internal/repository/net/PrecomputeMapper.kt 3.12% 31 Missing ⚠️
...lags/internal/repository/DefaultFlagsRepository.kt 12.00% 22 Missing ⚠️
...reflags/internal/repository/net/EndpointsHelper.kt 79.31% 5 Missing and 1 partial ⚠️
.../datadog/android/flags/featureflags/FlagsClient.kt 68.75% 2 Missing and 3 partials ⚠️
...d/flags/featureflags/internal/NoOpFlagsProvider.kt 83.33% 1 Missing ⚠️
...droid/flags/featureflags/model/FeatureFlagsUser.kt 0.00% 1 Missing ⚠️
...ndroid/flags/featureflags/model/ProviderContext.kt 0.00% 1 Missing ⚠️
Additional details and impacted files
@@                     Coverage Diff                      @@
##           feature/feature-flagging    #2886      +/-   ##
============================================================
- Coverage                     70.60%   70.50%   -0.09%     
============================================================
  Files                           827      828       +1     
  Lines                         30069    30123      +54     
  Branches                       5007     5012       +5     
============================================================
+ Hits                          21228    21238      +10     
- Misses                         7470     7505      +35     
- Partials                       1371     1380       +9     
Files with missing lines Coverage Δ
...src/main/kotlin/com/datadog/android/flags/Flags.kt 86.49% <100.00%> (-0.36%) ⬇️
...in/com/datadog/android/flags/FlagsConfiguration.kt 100.00% <100.00%> (ø)
...lags/featureflags/internal/DatadogFlagsProvider.kt 89.80% <100.00%> (+9.15%) ⬆️
.../flags/featureflags/internal/model/FlagsContext.kt 100.00% <100.00%> (ø)
...roid/flags/featureflags/model/EvaluationContext.kt 100.00% <100.00%> (ø)
...com/datadog/android/flags/internal/FlagsFeature.kt 42.86% <100.00%> (-7.14%) ⬇️
...d/flags/featureflags/internal/NoOpFlagsProvider.kt 85.71% <83.33%> (ø)
...droid/flags/featureflags/model/FeatureFlagsUser.kt 0.00% <0.00%> (-100.00%) ⬇️
...ndroid/flags/featureflags/model/ProviderContext.kt 0.00% <0.00%> (-100.00%) ⬇️
.../datadog/android/flags/featureflags/FlagsClient.kt 74.19% <68.75%> (+4.50%) ⬆️
... and 5 more

... and 34 files with indirect coverage changes

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

@typotter
typotter marked this pull request as ready for review September 22, 2025 17:04
@typotter
typotter requested review from a team as code owners September 22, 2025 17:04
@typotter
typotter merged commit 853fd91 into feature/feature-flagging Sep 22, 2025
25 checks passed
@typotter
typotter deleted the typo/ff-context branch September 22, 2025 17:04
Comment on lines +18 to +29
data class com.datadog.android.flags.featureflags.model.EvaluationContext
constructor(String, Map<String, Any> = emptyMap())
class Builder
constructor(String, com.datadog.android.api.InternalLogger)
fun addAttribute(String, String): Builder
fun addAttribute(String, Number): Builder
fun addAttribute(String, Boolean): Builder
fun addAttribute(String, Any): Builder
fun addAll(Map<String, Any>): Builder
fun build(): EvaluationContext
companion object
fun builder(String, com.datadog.android.api.InternalLogger)

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.

We shouldn't expose InternalLogger in the public API, it is not supposed to be used by the customer.

Is this class EvaluationContext should be internal and made public by mistake?

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.

The EvaluationContext is going to be an external model, so I've split it out now, with an internal model where we can do validation/filtering.

@@ -9,12 +9,24 @@ object com.datadog.android.flags.featureflags.FlagsClient
fun isRegistered(com.datadog.android.api.SdkCore = Datadog.getInstance()): Boolean
fun get(com.datadog.android.api.SdkCore = Datadog.getInstance()): FlagsProvider
interface com.datadog.android.flags.featureflags.FlagsProvider

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.

What is the reason to have flags.featureflags in the package name? Shouldn't it be only flags or featureflags? Using both seems redundant.

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.

I do agree we need to settle on a single name here. I think flags is sufficient. Because we're two working on a lot of the same files right now, we'll make this rename in this feature branch in an independent pr

/**
* Set the context for the provider.
* @param newContext The new context to use for the provider.
* @param targetingKey The targeting key to use for flag evaluation.

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.

We probably need better documentation for that, it is not clear what is targetingKey by looking just on the javadoc.

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.

Have filled out the docs on the EvaluationContext for targetingKey and have updated the signature here.

* @param attributes The attributes to use for targeting.
*/
fun setContext(newContext: ProviderContext)
fun setContext(targetingKey: String, attributes: Map<String, 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.

Usually the type of attributes (at least in RUM and Logs) is Map<String, Any?> (allowing nullable value). Is there a reason for using non-nullable value type?

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.

We don't have targeting for null attributes at this time, but it's possible we will in the future. I've refactored the EvaluationContext to an internal and external model where the former will carry only the attributes supported by the flagging system and the external model is more freeform and doesn't use the internal logger

)
}

// Create orchestrator with network manager and dependencies

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.

nit: no need to have comments like that, they don't add any 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.

agree

Comment on lines +462 to +469
verify(mockInternalLogger, times(3)).log(
eq(InternalLogger.Level.WARN),
eq(InternalLogger.Target.USER),
any(),
eq(null),
eq(false),
eq(null)
)

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.

We are checking only logging and we don't check that we have proper attributes as a result of filtering.

If underlying code will filter out valid attributes and keep invalid, this test will still pass.

You need to assert that only validAttributes are kept.

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.

this test is moved to DatadogEvaluationContextTest


// Then
// Should complete without errors and not log any warnings
verify(mockInternalLogger, times(0)).log(

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.

same: we check only logging here, but not checking that attributes didn't make 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.

moved

Comment on lines +42 to +57
@Test
fun `M create context W constructor() { targeting key and attributes }`(forge: Forge) {
// Given
val fakeTargetingKey = forge.anAlphabeticalString()
val fakeAttributes = mapOf(
"plan" to forge.anAlphabeticalString(),
"user_id" to forge.anInt()
)

// When
val context = EvaluationContext(fakeTargetingKey, fakeAttributes)

// Then
assertThat(context.targetingKey).isEqualTo(fakeTargetingKey)
assertThat(context.attributes).isEqualTo(fakeAttributes)
}

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.

is this test useful?

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.

not particularly, I suppose

Comment on lines +35 to +38
@BeforeEach
fun `set up`(forge: Forge) {
ForgeConfigurator.configure(forge)
}

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 replace by @ForgeConfiguration(ForgeConfigurator::class) annotation on the class

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. much cleaner!

Comment on lines +90 to +97
verify(mockInternalLogger, times(0)).log(
any<InternalLogger.Level>(),
any<InternalLogger.Target>(),
any<() -> String>(),
any(),
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.

verifyNoInteractions(mockInternalLogger), same for similar blocks below

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.

@typotter typotter left a comment

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.

Thank you for the thoughtful review, @0xnm
I've a WIP drat PR #2896 to address these. It's too late in my day to quite get #2896 ready for review, but it is most of the way.

Comment on lines +35 to +38
@BeforeEach
fun `set up`(forge: Forge) {
ForgeConfigurator.configure(forge)
}

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. much cleaner!

Comment on lines +18 to +29
data class com.datadog.android.flags.featureflags.model.EvaluationContext
constructor(String, Map<String, Any> = emptyMap())
class Builder
constructor(String, com.datadog.android.api.InternalLogger)
fun addAttribute(String, String): Builder
fun addAttribute(String, Number): Builder
fun addAttribute(String, Boolean): Builder
fun addAttribute(String, Any): Builder
fun addAll(Map<String, Any>): Builder
fun build(): EvaluationContext
companion object
fun builder(String, com.datadog.android.api.InternalLogger)

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.

The EvaluationContext is going to be an external model, so I've split it out now, with an internal model where we can do validation/filtering.

/**
* Set the context for the provider.
* @param newContext The new context to use for the provider.
* @param targetingKey The targeting key to use for flag evaluation.

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.

Have filled out the docs on the EvaluationContext for targetingKey and have updated the signature here.

Comment on lines +90 to +97
verify(mockInternalLogger, times(0)).log(
any<InternalLogger.Level>(),
any<InternalLogger.Target>(),
any<() -> String>(),
any(),
any(),
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.

Comment on lines +42 to +57
@Test
fun `M create context W constructor() { targeting key and attributes }`(forge: Forge) {
// Given
val fakeTargetingKey = forge.anAlphabeticalString()
val fakeAttributes = mapOf(
"plan" to forge.anAlphabeticalString(),
"user_id" to forge.anInt()
)

// When
val context = EvaluationContext(fakeTargetingKey, fakeAttributes)

// Then
assertThat(context.targetingKey).isEqualTo(fakeTargetingKey)
assertThat(context.attributes).isEqualTo(fakeAttributes)
}

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.

not particularly, I suppose


// Then
// Should complete without errors and not log any warnings
verify(mockInternalLogger, times(0)).log(

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.

moved

@@ -9,12 +9,24 @@ object com.datadog.android.flags.featureflags.FlagsClient
fun isRegistered(com.datadog.android.api.SdkCore = Datadog.getInstance()): Boolean
fun get(com.datadog.android.api.SdkCore = Datadog.getInstance()): FlagsProvider
interface com.datadog.android.flags.featureflags.FlagsProvider

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.

I do agree we need to settle on a single name here. I think flags is sufficient. Because we're two working on a lot of the same files right now, we'll make this rename in this feature branch in an independent pr

)
}

// Create orchestrator with network manager and dependencies

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.

agree


// Pass to orchestrator to handle network request and atomic storage
flagsOrchestrator.updateEvaluationsForContext(evaluationContext)
} catch (e: IllegalArgumentException) {

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.

I've shifted around the validation here to avoid the exception, thank you

Comment on lines +436 to +437
// Since orchestrator runs async, we can't directly verify the EvaluationContext
// But we can verify that the method completes without throwing

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.

hoisted the evaluations manager so we can mock it; makes this a lot less awkward

This was referenced Nov 4, 2025
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