Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions detekt_custom_safe_calls.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -38,19 +40,37 @@ internal fun <T : Any> ResolutionDetails<T>.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<String, Any>.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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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
}
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,25 @@ 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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Fails CI due to metadata is non-null

e: warnings found and -Werror specified
w: file:///go/src/github.com/DataDog/dd-sdk-android/features/dd-sdk-android-flags-openfeature/src/test/kotlin/com/datadog/android/flags/openfeature/internal/adapters/ConvertersTest.kt:154:35 Unnecessary safe call on a non-null receiver of type EvaluationMetadata

Suggested change
assertThat(result.metadata?.getString("allocationKey")).isEqualTo(fakeAllocationKey)
assertThat(result.metadata.getString("allocationKey")).isEqualTo(fakeAllocationKey)

}

// endregion

// region toOpenFeatureErrorCode
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ internal class DatadogFlagsClient(
trackResolution(resolution)
createSuccessResolution(resolution.flag, resolution.value)
}

is InternalResolution.Error -> {
trackErrorResolution(resolution)
createErrorResolution(
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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<String, Any> {
val metadata = mutableMapOf<String, Any>()
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 <T : Any> createErrorResolution(
flagKey: String,
defaultValue: T,
Expand Down Expand Up @@ -429,22 +446,6 @@ internal class DatadogFlagsClient(
}
}

private fun extractMetadata(extraLogging: JSONObject): Map<String, Any> {
if (extraLogging.length() == 0) {
return emptyMap()
}

val metadata = mutableMapOf<String, Any>()
extraLogging.keys().forEach { key ->
val value = extraLogging.opt(key)
when (value) {
is String, is Number, is Boolean -> metadata[key] = value
}
}

return metadata
}

private fun <T : Any> trackResolution(resolution: InternalResolution.Success<T>) {
trackResolution(resolution.flagKey, resolution.flag, resolution.context)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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())
Expand All @@ -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(),
Expand All @@ -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<PrecomputedFlag>().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<PrecomputedFlag>().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<PrecomputedFlag>().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<PrecomputedFlag>().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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
Loading