Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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,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.

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.

long value can be converted to int though if it fits

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
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