diff --git a/detekt_custom_safe_calls.yml b/detekt_custom_safe_calls.yml index b1b5d973e3..bc06f693a5 100644 --- a/detekt_custom_safe_calls.yml +++ b/detekt_custom_safe_calls.yml @@ -1401,6 +1401,12 @@ datadog: - "org.json.JSONObject.toJsonObject()" # endregion # region OpenFeature + - "dev.openfeature.kotlin.sdk.Builder.build()" + - "dev.openfeature.kotlin.sdk.Builder.constructor()" + - "dev.openfeature.kotlin.sdk.Builder.putBoolean(kotlin.String, kotlin.Boolean)" + - "dev.openfeature.kotlin.sdk.Builder.putDouble(kotlin.String, kotlin.Double)" + - "dev.openfeature.kotlin.sdk.Builder.putInt(kotlin.String, kotlin.Int)" + - "dev.openfeature.kotlin.sdk.Builder.putString(kotlin.String, kotlin.String)" - "dev.openfeature.kotlin.sdk.EvaluationContext.asMap()" - "dev.openfeature.kotlin.sdk.EvaluationContext.getTargetingKey()" - "dev.openfeature.kotlin.sdk.events.OpenFeatureProviderEvents.ProviderError.constructor(dev.openfeature.kotlin.sdk.exceptions.OpenFeatureError)" 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 b455984c6a..1faab3cb8c 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 @@ -9,6 +9,8 @@ package com.datadog.android.flags.openfeature.internal.adapters import com.datadog.android.flags.model.ErrorCode import com.datadog.android.flags.model.EvaluationContext import com.datadog.android.flags.model.ResolutionDetails +import dev.openfeature.kotlin.sdk.Builder +import dev.openfeature.kotlin.sdk.EvaluationMetadata import dev.openfeature.kotlin.sdk.ProviderEvaluation import dev.openfeature.kotlin.sdk.EvaluationContext as OpenFeatureEvaluationContext import dev.openfeature.kotlin.sdk.exceptions.ErrorCode as OpenFeatureErrorCode @@ -38,19 +40,37 @@ internal fun ResolutionDetails.toProviderEvaluation(): ProviderEval variant = this.variant, reason = this.reason?.name, errorCode = this.errorCode?.toOpenFeatureErrorCode(), - errorMessage = this.errorMessage + errorMessage = this.errorMessage, + metadata = this.flagMetadata.toEvaluationMetadata() ) +private fun Map.toEvaluationMetadata(): EvaluationMetadata { + val builder = Builder() + forEach { (key, value) -> + when (value) { + is String -> builder.putString(key, value) + is Boolean -> builder.putBoolean(key, value) + is Int -> builder.putInt(key, value) + is Double -> builder.putDouble(key, value) + else -> builder.putString(key, value.toString()) + } + } + return builder.build() +} + /** * Converts a Datadog [ErrorCode] to an OpenFeature [ErrorCode]. */ internal fun ErrorCode.toOpenFeatureErrorCode(): OpenFeatureErrorCode = when (this) { ErrorCode.PROVIDER_NOT_READY -> OpenFeatureErrorCode.PROVIDER_NOT_READY + ErrorCode.FLAG_NOT_FOUND -> OpenFeatureErrorCode.FLAG_NOT_FOUND + ErrorCode.PARSE_ERROR -> OpenFeatureErrorCode.PARSE_ERROR + ErrorCode.TYPE_MISMATCH -> OpenFeatureErrorCode.TYPE_MISMATCH } 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 8b21a889b9..68f86d6af9 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 @@ -135,6 +135,44 @@ internal class ConvertersTest { assertThat(result.reason).isEqualTo("ERROR") } + @Test + fun `M surface allocationKey in metadata W toProviderEvaluation() {flagMetadata contains allocationKey}`( + @BoolForgery fakeValue: Boolean, + @StringForgery fakeAllocationKey: String + ) { + // Given + val resolution = ResolutionDetails( + value = fakeValue, + reason = ResolutionReason.TARGETING_MATCH, + flagMetadata = mapOf("allocationKey" to fakeAllocationKey) + ) + + // When + val result = resolution.toProviderEvaluation() + + // Then + assertThat(result.metadata.getString("allocationKey")).isEqualTo(fakeAllocationKey) + } + + @Test + fun `M serialize Long to string W toProviderEvaluation() {flagMetadata contains Long value}`( + @BoolForgery fakeValue: Boolean + ) { + // Given — Builder has no putLong (only putInt/putDouble/putString/putBoolean); + // Long values fall through to the else branch and are stored as their toString() representation. + val resolution = ResolutionDetails( + value = fakeValue, + reason = ResolutionReason.TARGETING_MATCH, + flagMetadata = mapOf("count" to 42L) + ) + + // When + val result = resolution.toProviderEvaluation() + + // Then — Long stored as string via putString fallback + assertThat(result.metadata.getString("count")).isEqualTo("42") + } + // endregion // region toOpenFeatureErrorCode 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 5a10c8add6..a394131d39 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 @@ -167,6 +167,7 @@ internal class DatadogFlagsClient( trackResolution(resolution) createSuccessResolution(resolution.flag, resolution.value) } + is InternalResolution.Error -> { trackErrorResolution(resolution) createErrorResolution( @@ -321,6 +322,7 @@ internal class DatadogFlagsClient( errorCode = ErrorCode.TYPE_MISMATCH errorMessage = exception.message ?: "Type mismatch" } + else -> { errorCode = ErrorCode.PARSE_ERROR val typeName = FlagValueConverter.getTypeName(defaultValue::class) @@ -366,6 +368,7 @@ internal class DatadogFlagsClient( trackResolution(resolution) resolution.value } + is InternalResolution.Error -> { // Only log type mismatches as warnings to help developers identify configuration issues. // Other errors (FLAG_NOT_FOUND, PARSE_ERROR) are expected in normal operation. @@ -395,9 +398,23 @@ internal class DatadogFlagsClient( reason = parseReason(precomputedFlag.reason), errorCode = null, errorMessage = null, - flagMetadata = extractMetadata(precomputedFlag.extraLogging) + flagMetadata = buildMetadata(precomputedFlag) ) + private fun buildMetadata(precomputedFlag: PrecomputedFlag): Map { + val metadata = mutableMapOf() + precomputedFlag.extraLogging.keys().forEach { key -> + val value = precomputedFlag.extraLogging.opt(key) + when (value) { + is String, is Number, is Boolean -> metadata[key] = value + } + } + if (precomputedFlag.allocationKey.isNotBlank()) { + metadata["allocationKey"] = precomputedFlag.allocationKey + } + return metadata + } + private fun createErrorResolution( flagKey: String, defaultValue: T, @@ -429,22 +446,6 @@ internal class DatadogFlagsClient( } } - private fun extractMetadata(extraLogging: JSONObject): Map { - if (extraLogging.length() == 0) { - return emptyMap() - } - - val metadata = mutableMapOf() - extraLogging.keys().forEach { key -> - val value = extraLogging.opt(key) - when (value) { - is String, is Number, is Boolean -> metadata[key] = value - } - } - - return metadata - } - private fun trackResolution(resolution: InternalResolution.Success) { trackResolution(resolution.flagKey, resolution.flag, resolution.context) } 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 962259a59b..d1f0e888cb 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 @@ -249,7 +249,7 @@ internal class DatadogFlagsClientTest { targetingKey = forge.anAlphabeticalString(), attributes = emptyMap() ) - whenever(mockFlagsRepository.getPrecomputedFlag(fakeFlagKey)) doReturn null + whenever(mockFlagsRepository.getPrecomputedFlagWithContext(fakeFlagKey)) doReturn null whenever(mockFlagsRepository.getEvaluationContext()) doReturn fakeEvaluationContext // When @@ -273,8 +273,8 @@ internal class DatadogFlagsClientTest { targetingKey = forge.anAlphabeticalString(), attributes = emptyMap() ) - whenever(mockFlagsRepository.getPrecomputedFlag(fakeFlagKey)) doReturn fakeFlag - whenever(mockFlagsRepository.getEvaluationContext()) doReturn fakeEvaluationContext + whenever(mockFlagsRepository.getPrecomputedFlagWithContext(fakeFlagKey)) doReturn + (fakeFlag to fakeEvaluationContext) // When val result = testedClient.resolveBooleanValue(fakeFlagKey, fakeDefaultValue) @@ -803,6 +803,7 @@ internal class DatadogFlagsClientTest { val fakeDefaultValue = forge.aBool() val fakeFlagValue = !fakeDefaultValue val fakeVariationKey = forge.anAlphabeticalString() + val fakeAllocationKey = forge.anAlphabeticalString() val fakeReason = forge.anElementFrom("STATIC", "TARGETING_MATCH", "RULE_MATCH", "DEFAULT") val fakeExtraLogging = JSONObject().apply { put("version", forge.anAlphabeticalString()) @@ -813,7 +814,8 @@ internal class DatadogFlagsClientTest { variationValue = fakeFlagValue.toString(), variationKey = fakeVariationKey, reason = fakeReason, - extraLogging = fakeExtraLogging + extraLogging = fakeExtraLogging, + allocationKey = fakeAllocationKey ) val fakeContext = EvaluationContext( targetingKey = forge.anAlphabeticalString(), @@ -832,6 +834,119 @@ internal class DatadogFlagsClientTest { assertThat(result.errorMessage).isNull() assertThat(result.flagMetadata).isNotNull 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) { + // Given + val fakeFlagKey = forge.anAlphabeticalString() + val fakeDefaultValue = forge.aBool() + val fakeFlagValue = !fakeDefaultValue + val fakeFlag = forge.getForgery().copy( + variationType = VariationType.BOOLEAN.value, + variationValue = fakeFlagValue.toString(), + allocationKey = "", + extraLogging = JSONObject() + ) + val fakeContext = EvaluationContext( + targetingKey = forge.anAlphabeticalString(), + attributes = emptyMap() + ) + whenever(mockFlagsRepository.getPrecomputedFlagWithContext(fakeFlagKey)) doReturn (fakeFlag to fakeContext) + + // When + val result = testedClient.resolve(fakeFlagKey, fakeDefaultValue) + + // Then + assertThat(result.value).isEqualTo(fakeFlagValue) + assertThat(result.flagMetadata).doesNotContainKey("allocationKey") + } + + @Test + fun `M allocationKey excluded from metadata W resolve() { whitespace allocationKey }`(forge: Forge) { + // Given + val fakeFlagKey = forge.anAlphabeticalString() + val fakeDefaultValue = forge.aBool() + val fakeFlagValue = !fakeDefaultValue + val fakeFlag = forge.getForgery().copy( + variationType = VariationType.BOOLEAN.value, + variationValue = fakeFlagValue.toString(), + allocationKey = " ", + extraLogging = JSONObject() + ) + val fakeContext = EvaluationContext( + targetingKey = forge.anAlphabeticalString(), + attributes = emptyMap() + ) + whenever(mockFlagsRepository.getPrecomputedFlagWithContext(fakeFlagKey)) doReturn (fakeFlag to fakeContext) + + // When + val result = testedClient.resolve(fakeFlagKey, fakeDefaultValue) + + // Then + assertThat(result.value).isEqualTo(fakeFlagValue) + assertThat(result.flagMetadata).doesNotContainKey("allocationKey") + } + + @Test + fun `M null extraLogging value excluded from metadata W resolve() { extraLogging has null value }`( + forge: Forge + ) { + // Given + val fakeFlagKey = forge.anAlphabeticalString() + val fakeDefaultValue = forge.aBool() + val fakeFlagValue = !fakeDefaultValue + val fakeValidValue = forge.anAlphabeticalString() + val fakeFlag = forge.getForgery().copy( + variationType = VariationType.BOOLEAN.value, + variationValue = fakeFlagValue.toString(), + extraLogging = JSONObject().apply { + put("nullKey", JSONObject.NULL) // JSONObject.NULL is not String/Number/Boolean + put("validKey", fakeValidValue) + } + ) + val fakeContext = EvaluationContext( + targetingKey = forge.anAlphabeticalString(), + attributes = emptyMap() + ) + whenever(mockFlagsRepository.getPrecomputedFlagWithContext(fakeFlagKey)) doReturn (fakeFlag to fakeContext) + + // When + val result = testedClient.resolve(fakeFlagKey, fakeDefaultValue) + + // Then - JSONObject.NULL is dropped; only primitive values pass through + assertThat(result.flagMetadata).doesNotContainKey("nullKey") + assertThat(result.flagMetadata["validKey"]).isEqualTo(fakeValidValue) } @Test @@ -1250,8 +1365,6 @@ internal class DatadogFlagsClientTest { attributes = emptyMap() ) - whenever(mockFlagsRepository.getPrecomputedFlag(fakeFlagKey)) doReturn fakeFlag - whenever(mockFlagsRepository.getEvaluationContext()) doReturn fakeEvaluationContext whenever(mockFeatureSdkCore.getFeature(any())) doReturn null whenever(mockFlagsRepository.getPrecomputedFlagWithContext(fakeFlagKey)) doReturn (fakeFlag to fakeEvaluationContext) @@ -1359,9 +1472,6 @@ internal class DatadogFlagsClientTest { attributes = emptyMap() ) - whenever(mockFlagsRepository.getPrecomputedFlag(fakeFlagKey)) doReturn fakeFlag - whenever(mockFlagsRepository.getEvaluationContext()) doReturn fakeEvaluationContext - testedClient = DatadogFlagsClient( featureSdkCore = mockFeatureSdkCore, evaluationsManager = mockEvaluationsManager,