diff --git a/android/api/android.api b/android/api/android.api index 7b66a24..a2f478b 100644 --- a/android/api/android.api +++ b/android/api/android.api @@ -39,6 +39,21 @@ public abstract interface class dev/openfeature/sdk/EvaluationContext : dev/open public abstract fun withTargetingKey (Ljava/lang/String;)Ldev/openfeature/sdk/EvaluationContext; } +public final class dev/openfeature/sdk/EvaluationEvent { + public fun (Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/util/Map; + public final fun component3 ()Ljava/util/Map; + public final fun copy (Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;)Ldev/openfeature/sdk/EvaluationEvent; + public static synthetic fun copy$default (Ldev/openfeature/sdk/EvaluationEvent;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)Ldev/openfeature/sdk/EvaluationEvent; + public fun equals (Ljava/lang/Object;)Z + public final fun getAttributes ()Ljava/util/Map; + public final fun getBody ()Ljava/util/Map; + public final fun getName ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class dev/openfeature/sdk/EvaluationMetadata { public static final field Companion Ldev/openfeature/sdk/EvaluationMetadata$Companion; public fun equals (Ljava/lang/Object;)Z @@ -398,6 +413,24 @@ public abstract interface class dev/openfeature/sdk/Structure { public abstract fun keySet ()Ljava/util/Set; } +public final class dev/openfeature/sdk/TelemetryKt { + public static final field FLAG_EVALUATION_EVENT_NAME Ljava/lang/String; + public static final field TELEMETRY_BODY Ljava/lang/String; + public static final field TELEMETRY_CONTEXT_ID Ljava/lang/String; + public static final field TELEMETRY_ERROR_CODE Ljava/lang/String; + public static final field TELEMETRY_ERROR_MSG Ljava/lang/String; + public static final field TELEMETRY_FLAG_META_CONTEXT_ID Ljava/lang/String; + public static final field TELEMETRY_FLAG_META_FLAG_SET_ID Ljava/lang/String; + public static final field TELEMETRY_FLAG_META_VERSION Ljava/lang/String; + public static final field TELEMETRY_FLAG_SET_ID Ljava/lang/String; + public static final field TELEMETRY_KEY Ljava/lang/String; + public static final field TELEMETRY_PROVIDER Ljava/lang/String; + public static final field TELEMETRY_REASON Ljava/lang/String; + public static final field TELEMETRY_VARIANT Ljava/lang/String; + public static final field TELEMETRY_VERSION Ljava/lang/String; + public static final fun createEvaluationEvent (Ldev/openfeature/sdk/HookContext;Ldev/openfeature/sdk/FlagEvaluationDetails;)Ldev/openfeature/sdk/EvaluationEvent; +} + public abstract interface class dev/openfeature/sdk/Tracking { public abstract fun track (Ljava/lang/String;Ldev/openfeature/sdk/TrackingEventDetails;)V } diff --git a/android/src/main/java/dev/openfeature/sdk/EvaluationEvent.kt b/android/src/main/java/dev/openfeature/sdk/EvaluationEvent.kt new file mode 100644 index 0000000..e3fd3ec --- /dev/null +++ b/android/src/main/java/dev/openfeature/sdk/EvaluationEvent.kt @@ -0,0 +1,27 @@ +package dev.openfeature.sdk + +/** + * OpenTelemetry compatible telemetry signal for flag evaluations. Can be created by calling [createEvaluationEvent]. + * + * See + * [TELEMETRY_KEY], + * [TELEMETRY_ERROR_CODE], + * [TELEMETRY_VARIANT], + * [TELEMETRY_CONTEXT_ID], + * [TELEMETRY_ERROR_MSG], + * [TELEMETRY_REASON], + * [TELEMETRY_PROVIDER], + * [TELEMETRY_FLAG_SET_ID], + * [TELEMETRY_VERSION], + * [TELEMETRY_FLAG_META_CONTEXT_ID], + * [TELEMETRY_FLAG_META_FLAG_SET_ID], + * [TELEMETRY_FLAG_META_VERSION], + * [TELEMETRY_BODY] and + * [FLAG_EVALUATION_EVENT_NAME] + * for attribute and body keys. + */ +data class EvaluationEvent( + val name: String, + val attributes: Map, + val body: Map +) \ No newline at end of file diff --git a/android/src/main/java/dev/openfeature/sdk/Telemetry.kt b/android/src/main/java/dev/openfeature/sdk/Telemetry.kt new file mode 100644 index 0000000..3eae071 --- /dev/null +++ b/android/src/main/java/dev/openfeature/sdk/Telemetry.kt @@ -0,0 +1,64 @@ +package dev.openfeature.sdk + +import dev.openfeature.sdk.exceptions.ErrorCode + +const val TELEMETRY_KEY = "feature_flag.key" +const val TELEMETRY_ERROR_CODE = "error.type" +const val TELEMETRY_VARIANT = "feature_flag.variant" +const val TELEMETRY_CONTEXT_ID = "feature_flag.context.id" +const val TELEMETRY_ERROR_MSG = "feature_flag.evaluation.error.message" +const val TELEMETRY_REASON = "feature_flag.evaluation.reason" +const val TELEMETRY_PROVIDER = "feature_flag.provider_name" +const val TELEMETRY_FLAG_SET_ID = "feature_flag.set.id" +const val TELEMETRY_VERSION = "feature_flag.version" + +// Well-known flag metadata attributes for telemetry events. +// Specification: https://openfeature.dev/specification/appendix-d#flag-metadata +const val TELEMETRY_FLAG_META_CONTEXT_ID = "contextId" +const val TELEMETRY_FLAG_META_FLAG_SET_ID = "flagSetId" +const val TELEMETRY_FLAG_META_VERSION = "version" + +// OpenTelemetry event body. +// Specification: https://opentelemetry.io/docs/specs/semconv/feature-flags/feature-flags-logs/ +const val TELEMETRY_BODY = "value" + +const val FLAG_EVALUATION_EVENT_NAME = "feature_flag.evaluation" + +/** + * Creates an [EvaluationEvent] from a flag evaluation. To be used inside a finally hook to provide an OpenTelemetry + * compatible telemetry signal. + */ +fun createEvaluationEvent( + hookContext: HookContext, + flagEvaluationDetails: FlagEvaluationDetails +): EvaluationEvent { + val attributes = mutableMapOf() + val body = mutableMapOf() + attributes[TELEMETRY_KEY] = hookContext.flagKey + attributes[TELEMETRY_PROVIDER] = hookContext.providerMetadata.name ?: "" + attributes[TELEMETRY_REASON] = flagEvaluationDetails.reason?.lowercase() ?: Reason.UNKNOWN.name.lowercase() + attributes[TELEMETRY_CONTEXT_ID] = + flagEvaluationDetails.metadata.getString(TELEMETRY_FLAG_META_CONTEXT_ID) ?: hookContext.ctx?.getTargetingKey() + flagEvaluationDetails.metadata.getString(TELEMETRY_FLAG_META_FLAG_SET_ID)?.let { + attributes[TELEMETRY_FLAG_SET_ID] = it + } + flagEvaluationDetails.metadata.getString(TELEMETRY_FLAG_META_VERSION)?.let { attributes[TELEMETRY_VERSION] = it } + + val variant = flagEvaluationDetails.variant + if (variant == null) { + body[TELEMETRY_BODY] = flagEvaluationDetails.value + } else { + attributes[TELEMETRY_VARIANT] = variant + } + + if (flagEvaluationDetails.reason == Reason.ERROR.name) { + attributes[TELEMETRY_ERROR_CODE] = flagEvaluationDetails.errorCode ?: ErrorCode.GENERAL + flagEvaluationDetails.errorMessage?.let { attributes[TELEMETRY_ERROR_MSG] = it } + } + + return EvaluationEvent( + FLAG_EVALUATION_EVENT_NAME, + attributes, + body + ) +} \ No newline at end of file diff --git a/android/src/test/java/dev/openfeature/sdk/TelemetryTest.kt b/android/src/test/java/dev/openfeature/sdk/TelemetryTest.kt new file mode 100644 index 0000000..e640cc6 --- /dev/null +++ b/android/src/test/java/dev/openfeature/sdk/TelemetryTest.kt @@ -0,0 +1,440 @@ +package dev.openfeature.sdk + +import dev.openfeature.sdk.exceptions.ErrorCode +import org.junit.Assert +import org.junit.Test +import org.junit.experimental.runners.Enclosed +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import dev.openfeature.sdk.Reason as EvaluationReason + +private val providerMetadata = object : ProviderMetadata { + override val name: String + get() = "Provider name" +} + +@RunWith(Enclosed::class) +class TelemetryTest { + @Test + fun `flagKey is set correctly`() { + val flagKey = "flag key" + val ctx = ImmutableContext("targeting key") + val hookContext = HookContext( + flagKey, + FlagValueType.STRING, + "default", + ctx, + mock(), + providerMetadata + ) + val flagEvaluationDetails = FlagEvaluationDetails.from(ProviderEvaluation("value"), flagKey) + val evaluationEvent = createEvaluationEvent(hookContext, flagEvaluationDetails) + + Assert.assertEquals(flagKey, evaluationEvent.attributes[TELEMETRY_KEY]) + } + + class ProviderName { + @Test + fun `provider name is set correctly`() { + val flagKey = "flag key" + val ctx = ImmutableContext("targeting key") + val hookContext = HookContext( + flagKey, + FlagValueType.STRING, + "default", + ctx, + mock(), + providerMetadata + ) + val flagEvaluationDetails = FlagEvaluationDetails.from(ProviderEvaluation("value"), flagKey) + val evaluationEvent = createEvaluationEvent(hookContext, flagEvaluationDetails) + + Assert.assertEquals(providerMetadata.name, evaluationEvent.attributes[TELEMETRY_PROVIDER]) + } + + @Test + fun `provider name is set to empty string when not available`() { + val flagKey = "flag key" + val ctx = ImmutableContext("targeting key") + val hookContext = HookContext( + flagKey, + FlagValueType.STRING, + "default", + ctx, + mock(), + object : ProviderMetadata { + override val name: String? + get() = null + } + ) + val flagEvaluationDetails = FlagEvaluationDetails.from(ProviderEvaluation("value"), flagKey) + val evaluationEvent = createEvaluationEvent(hookContext, flagEvaluationDetails) + + Assert.assertEquals("", evaluationEvent.attributes[TELEMETRY_PROVIDER]) + } + } + + class Reason { + @Test + fun `create EvaluationEvent with UNKNOWN reason if reason is null`() { + val flagKey = "flag key" + val ctx = ImmutableContext("targeting key") + val hookContext = HookContext( + flagKey, + FlagValueType.STRING, + "default", + ctx, + mock(), + providerMetadata + ) + val flagEvaluationDetails = FlagEvaluationDetails.from( + ProviderEvaluation( + "value", + reason = null + ), + flagKey + ) + val evaluationEvent = createEvaluationEvent(hookContext, flagEvaluationDetails) + + Assert.assertEquals(EvaluationReason.UNKNOWN.name.lowercase(), evaluationEvent.attributes[TELEMETRY_REASON]) + } + + @Test + fun `create EvaluationEvent with correct reason if reason is set`() { + val flagKey = "flag key" + val ctx = ImmutableContext("targeting key") + val hookContext = HookContext( + flagKey, + FlagValueType.STRING, + "default", + ctx, + mock(), + providerMetadata + ) + val flagEvaluationDetails = FlagEvaluationDetails.from( + ProviderEvaluation( + "value", + reason = EvaluationReason.TARGETING_MATCH.name.lowercase() + ), + flagKey + ) + val evaluationEvent = createEvaluationEvent(hookContext, flagEvaluationDetails) + + Assert.assertEquals(EvaluationReason.TARGETING_MATCH.name.lowercase(), evaluationEvent.attributes[TELEMETRY_REASON]) + } + } + + class ContextId { + @Test + fun `contextId is taken from evaluation metadata when available`() { + val flagKey = "flag key" + val targetingKey = "targeting key" + val ctx = ImmutableContext(targetingKey) + val hookContext = HookContext( + flagKey, + FlagValueType.STRING, + "default", + ctx, + mock(), + providerMetadata + ) + val contextId = "contextId metadata" + val evaluationMetadata = EvaluationMetadata(mapOf(Pair("contextId", contextId))) + val flagEvaluationDetails = FlagEvaluationDetails.from( + ProviderEvaluation( + "value", + metadata = evaluationMetadata + ), + flagKey + ) + val evaluationEvent = createEvaluationEvent(hookContext, flagEvaluationDetails) + + Assert.assertEquals(contextId, evaluationEvent.attributes[TELEMETRY_CONTEXT_ID]) + Assert.assertNotEquals(targetingKey, evaluationEvent.attributes[TELEMETRY_CONTEXT_ID]) + } + + @Test + fun `contextId is taken from ctx when evaluation metadata is not available`() { + val flagKey = "flag key" + val targetingKey = "targeting key" + val ctx = ImmutableContext(targetingKey) + val hookContext = HookContext( + flagKey, + FlagValueType.STRING, + "default", + ctx, + mock(), + providerMetadata + ) + val flagEvaluationDetails = FlagEvaluationDetails.from(ProviderEvaluation("value"), flagKey) + val evaluationEvent = createEvaluationEvent(hookContext, flagEvaluationDetails) + + Assert.assertEquals(targetingKey, evaluationEvent.attributes[TELEMETRY_CONTEXT_ID]) + } + } + + class FlagSetId { + @Test + fun `flagSetId is set correctly when available`() { + val flagKey = "flag key" + val ctx = ImmutableContext("targeting key") + val hookContext = HookContext( + flagKey, + FlagValueType.STRING, + "default", + ctx, + mock(), + providerMetadata + ) + val flagSetId = "flag set id" + val evaluationMetadata = EvaluationMetadata(mapOf(Pair("flagSetId", flagSetId))) + val flagEvaluationDetails = FlagEvaluationDetails.from( + ProviderEvaluation( + "value", + metadata = evaluationMetadata + ), + flagKey + ) + val evaluationEvent = createEvaluationEvent(hookContext, flagEvaluationDetails) + + Assert.assertEquals(flagSetId, evaluationEvent.attributes[TELEMETRY_FLAG_SET_ID]) + } + + @Test + fun `flagSetId is not set when not available`() { + val flagKey = "flag key" + val ctx = ImmutableContext("targeting key") + val hookContext = HookContext( + flagKey, + FlagValueType.STRING, + "default", + ctx, + mock(), + providerMetadata + ) + val flagEvaluationDetails = FlagEvaluationDetails.from(ProviderEvaluation("value"), flagKey) + val evaluationEvent = createEvaluationEvent(hookContext, flagEvaluationDetails) + + Assert.assertNull(evaluationEvent.attributes[TELEMETRY_FLAG_SET_ID]) + } + } + + class Version { + @Test + fun `version is set correctly when available`() { + val flagKey = "flag key" + val ctx = ImmutableContext("targeting key") + val hookContext = HookContext( + flagKey, + FlagValueType.STRING, + "default", + ctx, + mock(), + providerMetadata + ) + val version = "flag set id" + val evaluationMetadata = EvaluationMetadata(mapOf(Pair("version", version))) + val flagEvaluationDetails = FlagEvaluationDetails.from( + ProviderEvaluation( + "value", + metadata = evaluationMetadata + ), + flagKey + ) + val evaluationEvent = createEvaluationEvent(hookContext, flagEvaluationDetails) + + Assert.assertEquals(version, evaluationEvent.attributes[TELEMETRY_VERSION]) + } + + @Test + fun `version is not set when not available`() { + val flagKey = "flag key" + val ctx = ImmutableContext("targeting key") + val hookContext = HookContext( + flagKey, + FlagValueType.STRING, + "default", + ctx, + mock(), + providerMetadata + ) + val flagEvaluationDetails = FlagEvaluationDetails.from(ProviderEvaluation("value"), flagKey) + val evaluationEvent = createEvaluationEvent(hookContext, flagEvaluationDetails) + + Assert.assertNull(evaluationEvent.attributes[TELEMETRY_VERSION]) + } + } + + class Variant { + @Test + fun `variant is taken from provider evaluation when available`() { + val flagKey = "flag key" + val targetingKey = "targeting key" + val ctx = ImmutableContext(targetingKey) + val hookContext = HookContext( + flagKey, + FlagValueType.STRING, + "default", + ctx, + mock(), + providerMetadata + ) + val variant = "variant" + val value = "value" + val flagEvaluationDetails = FlagEvaluationDetails.from( + ProviderEvaluation( + value, + variant + ), + flagKey + ) + val evaluationEvent = createEvaluationEvent(hookContext, flagEvaluationDetails) + + Assert.assertEquals(variant, evaluationEvent.attributes[TELEMETRY_VARIANT]) + Assert.assertNotEquals(value, evaluationEvent.attributes[TELEMETRY_VARIANT]) + } + + @Test + fun `variant is not set when not available`() { + val flagKey = "flag key" + val targetingKey = "targeting key" + val ctx = ImmutableContext(targetingKey) + val hookContext = HookContext( + flagKey, + FlagValueType.STRING, + "default", + ctx, + mock(), + providerMetadata + ) + val variant = null + val value = "value" + val flagEvaluationDetails = FlagEvaluationDetails.from( + ProviderEvaluation( + value, + variant + ), + flagKey + ) + val evaluationEvent = createEvaluationEvent(hookContext, flagEvaluationDetails) + + Assert.assertNull(evaluationEvent.attributes[TELEMETRY_VARIANT]) + } + + @Test + fun `telemetry body is set when variant is not available`() { + val flagKey = "flag key" + val targetingKey = "targeting key" + val ctx = ImmutableContext(targetingKey) + val hookContext = HookContext( + flagKey, + FlagValueType.STRING, + "default", + ctx, + mock(), + providerMetadata + ) + val variant = null + val value = "value" + val flagEvaluationDetails = FlagEvaluationDetails.from( + ProviderEvaluation( + value, + variant + ), + flagKey + ) + val evaluationEvent = createEvaluationEvent(hookContext, flagEvaluationDetails) + + Assert.assertEquals(value, evaluationEvent.body[TELEMETRY_BODY]) + } + } + + class Error { + @Test + fun `error code and message are taken from provider evaluation when available`() { + val flagKey = "flag key" + val targetingKey = "targeting key" + val ctx = ImmutableContext(targetingKey) + val hookContext = HookContext( + flagKey, + FlagValueType.STRING, + "default", + ctx, + mock(), + providerMetadata + ) + val errorCode = ErrorCode.PARSE_ERROR + val errorMessage = "error message" + val flagEvaluationDetails = FlagEvaluationDetails.from( + ProviderEvaluation( + "value", + reason = EvaluationReason.ERROR.name, + errorCode = errorCode, + errorMessage = errorMessage + ), + flagKey + ) + val evaluationEvent = createEvaluationEvent(hookContext, flagEvaluationDetails) + + Assert.assertEquals(errorCode, evaluationEvent.attributes[TELEMETRY_ERROR_CODE]) + Assert.assertEquals(errorMessage, evaluationEvent.attributes[TELEMETRY_ERROR_MSG]) + } + + @Test + fun `error code and message use defaults when reason is error, but no details are available`() { + val flagKey = "flag key" + val targetingKey = "targeting key" + val ctx = ImmutableContext(targetingKey) + val hookContext = HookContext( + flagKey, + FlagValueType.STRING, + "default", + ctx, + mock(), + providerMetadata + ) + val flagEvaluationDetails = FlagEvaluationDetails.from( + ProviderEvaluation( + "value", + reason = EvaluationReason.ERROR.name, + errorCode = null, + errorMessage = null + ), + flagKey + ) + val evaluationEvent = createEvaluationEvent(hookContext, flagEvaluationDetails) + + Assert.assertEquals(ErrorCode.GENERAL, evaluationEvent.attributes[TELEMETRY_ERROR_CODE]) + Assert.assertNull(evaluationEvent.attributes[TELEMETRY_ERROR_MSG]) + } + + @Test + fun `error code and message are not set when no error is present`() { + val flagKey = "flag key" + val targetingKey = "targeting key" + val ctx = ImmutableContext(targetingKey) + val hookContext = HookContext( + flagKey, + FlagValueType.STRING, + "default", + ctx, + mock(), + providerMetadata + ) + val flagEvaluationDetails = FlagEvaluationDetails.from( + ProviderEvaluation( + "value", + "variant", + reason = EvaluationReason.UNKNOWN.name, + errorCode = null, + errorMessage = null + ), + flagKey + ) + val evaluationEvent = createEvaluationEvent(hookContext, flagEvaluationDetails) + + Assert.assertNull(evaluationEvent.attributes[TELEMETRY_ERROR_CODE]) + Assert.assertNull(evaluationEvent.attributes[TELEMETRY_ERROR_MSG]) + } + } +} \ No newline at end of file