diff --git a/features/dd-sdk-android-flags-openfeature/src/main/kotlin/com/datadog/android/flags/openfeature/internal/adapters/Converters.kt b/features/dd-sdk-android-flags-openfeature/src/main/kotlin/com/datadog/android/flags/openfeature/internal/adapters/Converters.kt index 1faab3cb8c..81bc37d0f3 100644 --- a/features/dd-sdk-android-flags-openfeature/src/main/kotlin/com/datadog/android/flags/openfeature/internal/adapters/Converters.kt +++ b/features/dd-sdk-android-flags-openfeature/src/main/kotlin/com/datadog/android/flags/openfeature/internal/adapters/Converters.kt @@ -41,10 +41,10 @@ internal fun ResolutionDetails.toProviderEvaluation(): ProviderEval reason = this.reason?.name, errorCode = this.errorCode?.toOpenFeatureErrorCode(), errorMessage = this.errorMessage, - metadata = this.flagMetadata.toEvaluationMetadata() + metadata = flagMetadata.toEvaluationMetadata(allocationKey) ) -private fun Map.toEvaluationMetadata(): EvaluationMetadata { +private fun Map.toEvaluationMetadata(allocationKey: String? = null): EvaluationMetadata { val builder = Builder() forEach { (key, value) -> when (value) { @@ -55,6 +55,7 @@ private fun Map.toEvaluationMetadata(): EvaluationMetadata { else -> builder.putString(key, value.toString()) } } + allocationKey?.let { builder.putString("allocationKey", it) } return builder.build() } diff --git a/features/dd-sdk-android-flags-openfeature/src/test/kotlin/com/datadog/android/flags/openfeature/internal/adapters/ConvertersTest.kt b/features/dd-sdk-android-flags-openfeature/src/test/kotlin/com/datadog/android/flags/openfeature/internal/adapters/ConvertersTest.kt index 68f86d6af9..96adb2430a 100644 --- a/features/dd-sdk-android-flags-openfeature/src/test/kotlin/com/datadog/android/flags/openfeature/internal/adapters/ConvertersTest.kt +++ b/features/dd-sdk-android-flags-openfeature/src/test/kotlin/com/datadog/android/flags/openfeature/internal/adapters/ConvertersTest.kt @@ -136,15 +136,15 @@ internal class ConvertersTest { } @Test - fun `M surface allocationKey in metadata W toProviderEvaluation() {flagMetadata contains allocationKey}`( - @BoolForgery fakeValue: Boolean, - @StringForgery fakeAllocationKey: String + fun `M surface allocationKey in metadata W toProviderEvaluation() {resolution with allocationKey}`( + @StringForgery fakeAllocationKey: String, + @BoolForgery fakeValue: Boolean ) { // Given val resolution = ResolutionDetails( value = fakeValue, reason = ResolutionReason.TARGETING_MATCH, - flagMetadata = mapOf("allocationKey" to fakeAllocationKey) + allocationKey = fakeAllocationKey ) // When @@ -173,6 +173,66 @@ internal class ConvertersTest { assertThat(result.metadata.getString("count")).isEqualTo("42") } + @Test + fun `M not surface allocationKey in metadata W toProviderEvaluation() {resolution with null allocationKey}`( + @BoolForgery fakeValue: Boolean + ) { + // Given + val resolution = ResolutionDetails( + value = fakeValue, + reason = ResolutionReason.DEFAULT, + allocationKey = null + ) + + // When + val result = resolution.toProviderEvaluation() + + // Then + assertThat(result.metadata.getString("allocationKey")).isNull() + } + + @Test + fun `M surface allocationKey and flagMetadata W toProviderEvaluation() {both present}`( + @StringForgery fakeAllocationKey: String, + @BoolForgery fakeValue: Boolean + ) { + // Given + val resolution = ResolutionDetails( + value = fakeValue, + reason = ResolutionReason.TARGETING_MATCH, + allocationKey = fakeAllocationKey, + flagMetadata = mapOf("env" to "prod") + ) + + // When + val result = resolution.toProviderEvaluation() + + // Then — allocationKey and flagMetadata entries both surfaced + assertThat(result.metadata.getString("allocationKey")).isEqualTo(fakeAllocationKey) + assertThat(result.metadata.getString("env")).isEqualTo("prod") + } + + @Test + fun `M typed allocationKey wins W toProviderEvaluation() {flagMetadata also contains allocationKey}`( + @StringForgery fakeAllocationKey: String, + @StringForgery fakeMetadataAllocationKey: String, + @BoolForgery fakeValue: Boolean + ) { + // Given — flagMetadata has an "allocationKey" entry that should be overridden by the typed field + val resolution = ResolutionDetails( + value = fakeValue, + reason = ResolutionReason.TARGETING_MATCH, + allocationKey = fakeAllocationKey, + flagMetadata = mapOf("allocationKey" to fakeMetadataAllocationKey) + ) + + // When + val result = resolution.toProviderEvaluation() + + // Then — typed allocationKey field wins over flagMetadata entry + assertThat(result.metadata.getString("allocationKey")).isEqualTo(fakeAllocationKey) + } + // endregion // region toOpenFeatureErrorCode diff --git a/features/dd-sdk-android-flags/api/apiSurface b/features/dd-sdk-android-flags/api/apiSurface index 8f084a7096..c154602339 100644 --- a/features/dd-sdk-android-flags/api/apiSurface +++ b/features/dd-sdk-android-flags/api/apiSurface @@ -56,7 +56,7 @@ sealed class com.datadog.android.flags.model.FlagsClientState data class Error : FlagsClientState constructor(Throwable? = null) data class com.datadog.android.flags.model.ResolutionDetails - constructor(T, String? = null, ResolutionReason? = null, ErrorCode? = null, String? = null, Map = emptyMap()) + constructor(T, String? = null, ResolutionReason? = null, ErrorCode? = null, String? = null, String? = null, Map = emptyMap()) enum com.datadog.android.flags.model.ResolutionReason - STATIC - DEFAULT diff --git a/features/dd-sdk-android-flags/api/dd-sdk-android-flags.api b/features/dd-sdk-android-flags/api/dd-sdk-android-flags.api index d73cd91762..6d36dee787 100644 --- a/features/dd-sdk-android-flags/api/dd-sdk-android-flags.api +++ b/features/dd-sdk-android-flags/api/dd-sdk-android-flags.api @@ -457,17 +457,19 @@ public final class com/datadog/android/flags/model/FlagsClientState$Stale : com/ } public final class com/datadog/android/flags/model/ResolutionDetails { - public fun (Ljava/lang/Object;Ljava/lang/String;Lcom/datadog/android/flags/model/ResolutionReason;Lcom/datadog/android/flags/model/ErrorCode;Ljava/lang/String;Ljava/util/Map;)V - public synthetic fun (Ljava/lang/Object;Ljava/lang/String;Lcom/datadog/android/flags/model/ResolutionReason;Lcom/datadog/android/flags/model/ErrorCode;Ljava/lang/String;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/lang/Object;Ljava/lang/String;Lcom/datadog/android/flags/model/ResolutionReason;Lcom/datadog/android/flags/model/ErrorCode;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;)V + public synthetic fun (Ljava/lang/Object;Ljava/lang/String;Lcom/datadog/android/flags/model/ResolutionReason;Lcom/datadog/android/flags/model/ErrorCode;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/Object; public final fun component2 ()Ljava/lang/String; public final fun component3 ()Lcom/datadog/android/flags/model/ResolutionReason; public final fun component4 ()Lcom/datadog/android/flags/model/ErrorCode; public final fun component5 ()Ljava/lang/String; - public final fun component6 ()Ljava/util/Map; - public final fun copy (Ljava/lang/Object;Ljava/lang/String;Lcom/datadog/android/flags/model/ResolutionReason;Lcom/datadog/android/flags/model/ErrorCode;Ljava/lang/String;Ljava/util/Map;)Lcom/datadog/android/flags/model/ResolutionDetails; - public static synthetic fun copy$default (Lcom/datadog/android/flags/model/ResolutionDetails;Ljava/lang/Object;Ljava/lang/String;Lcom/datadog/android/flags/model/ResolutionReason;Lcom/datadog/android/flags/model/ErrorCode;Ljava/lang/String;Ljava/util/Map;ILjava/lang/Object;)Lcom/datadog/android/flags/model/ResolutionDetails; + public final fun component6 ()Ljava/lang/String; + public final fun component7 ()Ljava/util/Map; + public final fun copy (Ljava/lang/Object;Ljava/lang/String;Lcom/datadog/android/flags/model/ResolutionReason;Lcom/datadog/android/flags/model/ErrorCode;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;)Lcom/datadog/android/flags/model/ResolutionDetails; + public static synthetic fun copy$default (Lcom/datadog/android/flags/model/ResolutionDetails;Ljava/lang/Object;Ljava/lang/String;Lcom/datadog/android/flags/model/ResolutionReason;Lcom/datadog/android/flags/model/ErrorCode;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;ILjava/lang/Object;)Lcom/datadog/android/flags/model/ResolutionDetails; public fun equals (Ljava/lang/Object;)Z + public final fun getAllocationKey ()Ljava/lang/String; public final fun getErrorCode ()Lcom/datadog/android/flags/model/ErrorCode; public final fun getErrorMessage ()Ljava/lang/String; public final fun getFlagMetadata ()Ljava/util/Map; diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/DatadogFlagsClient.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/DatadogFlagsClient.kt index a394131d39..ae47c41203 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/DatadogFlagsClient.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/DatadogFlagsClient.kt @@ -398,6 +398,7 @@ internal class DatadogFlagsClient( reason = parseReason(precomputedFlag.reason), errorCode = null, errorMessage = null, + allocationKey = precomputedFlag.allocationKey.takeIf { it.isNotBlank() }, flagMetadata = buildMetadata(precomputedFlag) ) @@ -409,9 +410,6 @@ internal class DatadogFlagsClient( is String, is Number, is Boolean -> metadata[key] = value } } - if (precomputedFlag.allocationKey.isNotBlank()) { - metadata["allocationKey"] = precomputedFlag.allocationKey - } return metadata } diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/model/ResolutionDetails.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/model/ResolutionDetails.kt index 68660db1ad..40f2bb8461 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/model/ResolutionDetails.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/model/ResolutionDetails.kt @@ -18,6 +18,7 @@ package com.datadog.android.flags.model * @property reason Optional reason code explaining why this value was resolved. * @property errorCode Optional error code if the resolution failed. Null indicates successful resolution. * @property errorMessage Optional human-readable error message providing additional context about failures. + * @property allocationKey The allocation key from the flag assignment. Null if not available. * @property flagMetadata Map of arbitrary metadata associated with the flag (string keys, primitive values). Empty if no metadata. */ data class ResolutionDetails( @@ -26,5 +27,6 @@ data class ResolutionDetails( val reason: ResolutionReason? = null, val errorCode: ErrorCode? = null, val errorMessage: String? = null, + val allocationKey: String? = null, val flagMetadata: Map = emptyMap() ) diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt index d1f0e888cb..bffd4498e3 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt @@ -832,42 +832,12 @@ internal class DatadogFlagsClientTest { assertThat(result.reason).isEqualTo(ResolutionReason.valueOf(fakeReason)) assertThat(result.errorCode).isNull() assertThat(result.errorMessage).isNull() - assertThat(result.flagMetadata).isNotNull + assertThat(result.allocationKey).isEqualTo(fakeAllocationKey) assertThat(result.flagMetadata).containsKeys("version", "environment") - assertThat(result.flagMetadata["allocationKey"]).isEqualTo(fakeAllocationKey) } @Test - fun `M typed allocationKey wins W resolve() { extraLogging also contains allocationKey }`(forge: Forge) { - // Given - val fakeFlagKey = forge.anAlphabeticalString() - val fakeDefaultValue = forge.aBool() - val fakeFlagValue = !fakeDefaultValue - val fakeAllocationKey = forge.anAlphabeticalString() - val fakeExtraLoggingAllocationKey = forge.anAlphabeticalString() - val fakeFlag = forge.getForgery().copy( - variationType = VariationType.BOOLEAN.value, - variationValue = fakeFlagValue.toString(), - allocationKey = fakeAllocationKey, - extraLogging = JSONObject().apply { - put("allocationKey", fakeExtraLoggingAllocationKey) - } - ) - val fakeContext = EvaluationContext( - targetingKey = forge.anAlphabeticalString(), - attributes = emptyMap() - ) - whenever(mockFlagsRepository.getPrecomputedFlagWithContext(fakeFlagKey)) doReturn (fakeFlag to fakeContext) - - // When - val result = testedClient.resolve(fakeFlagKey, fakeDefaultValue) - - // Then - typed allocationKey wins over any "allocationKey" entry from extraLogging - assertThat(result.flagMetadata["allocationKey"]).isEqualTo(fakeAllocationKey) - } - - @Test - fun `M allocationKey excluded from metadata W resolve() { empty allocationKey }`(forge: Forge) { + fun `M allocationKey null W resolve() { empty allocationKey }`(forge: Forge) { // Given val fakeFlagKey = forge.anAlphabeticalString() val fakeDefaultValue = forge.aBool() @@ -889,11 +859,11 @@ internal class DatadogFlagsClientTest { // Then assertThat(result.value).isEqualTo(fakeFlagValue) - assertThat(result.flagMetadata).doesNotContainKey("allocationKey") + assertThat(result.allocationKey).isNull() } @Test - fun `M allocationKey excluded from metadata W resolve() { whitespace allocationKey }`(forge: Forge) { + fun `M allocationKey null W resolve() { whitespace allocationKey }`(forge: Forge) { // Given val fakeFlagKey = forge.anAlphabeticalString() val fakeDefaultValue = forge.aBool() @@ -915,7 +885,7 @@ internal class DatadogFlagsClientTest { // Then assertThat(result.value).isEqualTo(fakeFlagValue) - assertThat(result.flagMetadata).doesNotContainKey("allocationKey") + assertThat(result.allocationKey).isNull() } @Test @@ -1004,6 +974,7 @@ internal class DatadogFlagsClientTest { assertThat(result.errorCode).isEqualTo(ErrorCode.TYPE_MISMATCH) assertThat(result.errorMessage).contains("Flag '$fakeFlagKey'") assertThat(result.errorMessage).contains("has type 'string' but Boolean was requested") + assertThat(result.allocationKey).isNull() assertThat(result.flagMetadata).isEmpty() // Verify no exposure tracked for type mismatch @@ -1027,6 +998,7 @@ internal class DatadogFlagsClientTest { assertThat(result.errorCode).isEqualTo(ErrorCode.FLAG_NOT_FOUND) assertThat(result.errorMessage).contains("Flag '$fakeFlagKey'") assertThat(result.errorMessage).contains("Flag not found") + assertThat(result.allocationKey).isNull() assertThat(result.flagMetadata).isEmpty() // Verify no exposure tracked when flag not found @@ -1050,6 +1022,7 @@ internal class DatadogFlagsClientTest { assertThat(result.errorCode).isEqualTo(ErrorCode.PROVIDER_NOT_READY) assertThat(result.errorMessage).contains("Flag '$fakeFlagKey'") assertThat(result.errorMessage).contains("Provider not ready") + assertThat(result.allocationKey).isNull() assertThat(result.flagMetadata).isEmpty() // Verify no exposure tracked when provider not ready @@ -1084,6 +1057,7 @@ internal class DatadogFlagsClientTest { assertThat(result.errorCode).isEqualTo(ErrorCode.PARSE_ERROR) assertThat(result.errorMessage).contains("Flag '$fakeFlagKey'") assertThat(result.errorMessage).contains("Failed to parse value") + assertThat(result.allocationKey).isNull() assertThat(result.flagMetadata).isEmpty() // Verify no exposure tracked for parse error