Skip to content

Commit c63876a

Browse files
committed
feat: telemetry utils (#238)
Signed-off-by: Marcin Stepien <marcin.stepien@fluxon.com>
1 parent da9d2c8 commit c63876a

6 files changed

Lines changed: 399 additions & 0 deletions

File tree

kotlin-sdk/api/android/kotlin-sdk.api

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -861,3 +861,35 @@ public abstract interface class dev/openfeature/kotlin/sdk/multiprovider/MultiPr
861861
public abstract fun evaluate (Ljava/util/List;Ljava/lang/String;Ljava/lang/Object;Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlin/jvm/functions/Function4;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation;
862862
}
863863

864+
public final class dev/openfeature/kotlin/sdk/telemetry/EvaluationEvent {
865+
public fun <init> (Ljava/lang/String;Ljava/util/Map;)V
866+
public final fun component1 ()Ljava/lang/String;
867+
public final fun component2 ()Ljava/util/Map;
868+
public final fun copy (Ljava/lang/String;Ljava/util/Map;)Ldev/openfeature/kotlin/sdk/telemetry/EvaluationEvent;
869+
public static synthetic fun copy$default (Ldev/openfeature/kotlin/sdk/telemetry/EvaluationEvent;Ljava/lang/String;Ljava/util/Map;ILjava/lang/Object;)Ldev/openfeature/kotlin/sdk/telemetry/EvaluationEvent;
870+
public fun equals (Ljava/lang/Object;)Z
871+
public final fun getAttributes ()Ljava/util/Map;
872+
public final fun getName ()Ljava/lang/String;
873+
public fun hashCode ()I
874+
public fun toString ()Ljava/lang/String;
875+
}
876+
877+
public final class dev/openfeature/kotlin/sdk/telemetry/Telemetry {
878+
public static final field FLAG_EVALUATION_EVENT_NAME Ljava/lang/String;
879+
public static final field INSTANCE Ldev/openfeature/kotlin/sdk/telemetry/Telemetry;
880+
public static final field TELEMETRY_CONTEXT_ID Ljava/lang/String;
881+
public static final field TELEMETRY_ERROR_CODE Ljava/lang/String;
882+
public static final field TELEMETRY_ERROR_MSG Ljava/lang/String;
883+
public static final field TELEMETRY_FLAG_META_CONTEXT_ID Ljava/lang/String;
884+
public static final field TELEMETRY_FLAG_META_FLAG_SET_ID Ljava/lang/String;
885+
public static final field TELEMETRY_FLAG_META_VERSION Ljava/lang/String;
886+
public static final field TELEMETRY_FLAG_SET_ID Ljava/lang/String;
887+
public static final field TELEMETRY_KEY Ljava/lang/String;
888+
public static final field TELEMETRY_PROVIDER Ljava/lang/String;
889+
public static final field TELEMETRY_REASON Ljava/lang/String;
890+
public static final field TELEMETRY_VALUE Ljava/lang/String;
891+
public static final field TELEMETRY_VARIANT Ljava/lang/String;
892+
public static final field TELEMETRY_VERSION Ljava/lang/String;
893+
public final fun createEvaluationEvent (Ldev/openfeature/kotlin/sdk/HookContext;Ldev/openfeature/kotlin/sdk/FlagEvaluationDetails;)Ldev/openfeature/kotlin/sdk/telemetry/EvaluationEvent;
894+
}
895+

kotlin-sdk/api/jvm/kotlin-sdk.api

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -861,3 +861,35 @@ public abstract interface class dev/openfeature/kotlin/sdk/multiprovider/MultiPr
861861
public abstract fun evaluate (Ljava/util/List;Ljava/lang/String;Ljava/lang/Object;Ldev/openfeature/kotlin/sdk/EvaluationContext;Lkotlin/jvm/functions/Function4;)Ldev/openfeature/kotlin/sdk/ProviderEvaluation;
862862
}
863863

864+
public final class dev/openfeature/kotlin/sdk/telemetry/EvaluationEvent {
865+
public fun <init> (Ljava/lang/String;Ljava/util/Map;)V
866+
public final fun component1 ()Ljava/lang/String;
867+
public final fun component2 ()Ljava/util/Map;
868+
public final fun copy (Ljava/lang/String;Ljava/util/Map;)Ldev/openfeature/kotlin/sdk/telemetry/EvaluationEvent;
869+
public static synthetic fun copy$default (Ldev/openfeature/kotlin/sdk/telemetry/EvaluationEvent;Ljava/lang/String;Ljava/util/Map;ILjava/lang/Object;)Ldev/openfeature/kotlin/sdk/telemetry/EvaluationEvent;
870+
public fun equals (Ljava/lang/Object;)Z
871+
public final fun getAttributes ()Ljava/util/Map;
872+
public final fun getName ()Ljava/lang/String;
873+
public fun hashCode ()I
874+
public fun toString ()Ljava/lang/String;
875+
}
876+
877+
public final class dev/openfeature/kotlin/sdk/telemetry/Telemetry {
878+
public static final field FLAG_EVALUATION_EVENT_NAME Ljava/lang/String;
879+
public static final field INSTANCE Ldev/openfeature/kotlin/sdk/telemetry/Telemetry;
880+
public static final field TELEMETRY_CONTEXT_ID Ljava/lang/String;
881+
public static final field TELEMETRY_ERROR_CODE Ljava/lang/String;
882+
public static final field TELEMETRY_ERROR_MSG Ljava/lang/String;
883+
public static final field TELEMETRY_FLAG_META_CONTEXT_ID Ljava/lang/String;
884+
public static final field TELEMETRY_FLAG_META_FLAG_SET_ID Ljava/lang/String;
885+
public static final field TELEMETRY_FLAG_META_VERSION Ljava/lang/String;
886+
public static final field TELEMETRY_FLAG_SET_ID Ljava/lang/String;
887+
public static final field TELEMETRY_KEY Ljava/lang/String;
888+
public static final field TELEMETRY_PROVIDER Ljava/lang/String;
889+
public static final field TELEMETRY_REASON Ljava/lang/String;
890+
public static final field TELEMETRY_VALUE Ljava/lang/String;
891+
public static final field TELEMETRY_VARIANT Ljava/lang/String;
892+
public static final field TELEMETRY_VERSION Ljava/lang/String;
893+
public final fun createEvaluationEvent (Ldev/openfeature/kotlin/sdk/HookContext;Ldev/openfeature/kotlin/sdk/FlagEvaluationDetails;)Ldev/openfeature/kotlin/sdk/telemetry/EvaluationEvent;
894+
}
895+
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package dev.openfeature.kotlin.sdk.telemetry
2+
3+
/**
4+
* Represents an evaluation event containing standard OTel flag mapping attributes.
5+
*/
6+
data class EvaluationEvent(
7+
val name: String,
8+
val attributes: Map<String, Any?>
9+
)
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package dev.openfeature.kotlin.sdk.telemetry
2+
3+
import dev.openfeature.kotlin.sdk.FlagEvaluationDetails
4+
import dev.openfeature.kotlin.sdk.HookContext
5+
import dev.openfeature.kotlin.sdk.Reason
6+
import dev.openfeature.kotlin.sdk.Value
7+
import dev.openfeature.kotlin.sdk.exceptions.ErrorCode
8+
9+
/**
10+
* The Telemetry object provides constants and utilities for creating OpenTelemetry compliant
11+
* evaluation events in alignment with OpenFeature Appendix D semantics.
12+
*/
13+
object Telemetry {
14+
15+
// OTEL Semantic Convention keys for feature flag evaluation records
16+
const val TELEMETRY_KEY = "feature_flag.key"
17+
const val TELEMETRY_PROVIDER = "feature_flag.provider.name"
18+
const val TELEMETRY_REASON = "feature_flag.result.reason"
19+
const val TELEMETRY_VARIANT = "feature_flag.result.variant"
20+
const val TELEMETRY_VALUE = "feature_flag.result.value"
21+
const val TELEMETRY_CONTEXT_ID = "feature_flag.context.id"
22+
const val TELEMETRY_FLAG_SET_ID = "feature_flag.set.id"
23+
const val TELEMETRY_VERSION = "feature_flag.version"
24+
const val TELEMETRY_ERROR_CODE = "error.type"
25+
const val TELEMETRY_ERROR_MSG = "feature_flag.evaluation.error.message"
26+
27+
// OpenFeature internal metadata keys matching Spec standard mapping boundaries
28+
const val TELEMETRY_FLAG_META_CONTEXT_ID = "contextId"
29+
const val TELEMETRY_FLAG_META_FLAG_SET_ID = "flagSetId"
30+
const val TELEMETRY_FLAG_META_VERSION = "version"
31+
32+
const val FLAG_EVALUATION_EVENT_NAME = "feature_flag.evaluation"
33+
34+
/**
35+
* Creates an OpenTelemetry compliant EvaluationEvent out of standard Evaluation details.
36+
*/
37+
fun <T> createEvaluationEvent(
38+
hookContext: HookContext<T>,
39+
evaluationDetails: FlagEvaluationDetails<T>
40+
): EvaluationEvent {
41+
val attributes = mutableMapOf<String, Any?>()
42+
43+
// Required telemetry attributes
44+
attributes[TELEMETRY_KEY] = hookContext.flagKey
45+
attributes[TELEMETRY_PROVIDER] = hookContext.providerMetadata.name
46+
47+
// Reason (Conditionally Required / Recommended)
48+
attributes[TELEMETRY_REASON] = evaluationDetails.reason?.lowercase() ?: Reason.UNKNOWN.name.lowercase()
49+
50+
// Variant or fallback to Value
51+
val variant = evaluationDetails.variant
52+
if (variant != null) {
53+
attributes[TELEMETRY_VARIANT] = variant
54+
} else {
55+
attributes[TELEMETRY_VALUE] = unwrapValue(evaluationDetails.value)
56+
}
57+
58+
// Context ID
59+
val contextId = evaluationDetails.metadata.getString(TELEMETRY_FLAG_META_CONTEXT_ID)
60+
?: hookContext.ctx?.getTargetingKey()
61+
if (!contextId.isNullOrEmpty()) {
62+
attributes[TELEMETRY_CONTEXT_ID] = contextId
63+
}
64+
65+
// Flag Set ID
66+
val setId = evaluationDetails.metadata.getString(TELEMETRY_FLAG_META_FLAG_SET_ID)
67+
if (setId != null) {
68+
attributes[TELEMETRY_FLAG_SET_ID] = setId
69+
}
70+
71+
// Version
72+
val version = evaluationDetails.metadata.getString(TELEMETRY_FLAG_META_VERSION)
73+
if (version != null) {
74+
attributes[TELEMETRY_VERSION] = version
75+
}
76+
77+
// Error State mapping
78+
if (evaluationDetails.reason.equals(Reason.ERROR.name, ignoreCase = true)) {
79+
attributes[TELEMETRY_ERROR_CODE] = evaluationDetails.errorCode?.name ?: ErrorCode.GENERAL.name
80+
val errorMessage = evaluationDetails.errorMessage
81+
if (errorMessage != null) {
82+
attributes[TELEMETRY_ERROR_MSG] = errorMessage
83+
}
84+
}
85+
86+
return EvaluationEvent(FLAG_EVALUATION_EVENT_NAME, attributes)
87+
}
88+
89+
/**
90+
* Recursively unwraps structurally-typed OpenFeature [Value] allocations organically down
91+
* strictly representing their primitive equivalents mapping gracefully for OTEL.
92+
*/
93+
@OptIn(kotlin.time.ExperimentalTime::class)
94+
private fun unwrapValue(value: Any?): Any? {
95+
return when (value) {
96+
is Value.Null -> null
97+
is Value.String -> value.string
98+
is Value.Boolean -> value.boolean
99+
is Value.Integer -> value.integer
100+
is Value.Double -> value.double
101+
is Value.Instant -> value.instant.toString() // ISO 8601 string
102+
is Value.List -> value.list.map { unwrapValue(it) }
103+
is Value.Structure -> value.structure.mapValues { (_, v) -> unwrapValue(v) }
104+
else -> value // Already a generic type primitive like standard Boolean/String evaluations
105+
}
106+
}
107+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package dev.openfeature.kotlin.sdk.telemetry
2+
3+
import dev.openfeature.kotlin.sdk.FlagEvaluationDetails
4+
import dev.openfeature.kotlin.sdk.Hook
5+
import dev.openfeature.kotlin.sdk.HookContext
6+
import dev.openfeature.kotlin.sdk.NoOpProvider
7+
import dev.openfeature.kotlin.sdk.OpenFeatureAPI
8+
import dev.openfeature.kotlin.sdk.Reason
9+
import kotlinx.coroutines.ExperimentalCoroutinesApi
10+
import kotlinx.coroutines.test.runTest
11+
import kotlin.test.AfterTest
12+
import kotlin.test.Test
13+
import kotlin.test.assertEquals
14+
import kotlin.test.assertFalse
15+
16+
@OptIn(ExperimentalCoroutinesApi::class)
17+
class TelemetryIntegrationTest {
18+
19+
@AfterTest
20+
fun cleanup() = runTest {
21+
OpenFeatureAPI.clearProvider()
22+
}
23+
24+
@Test
25+
fun `test full flag evaluation successfully publishes OTEL metric semantics to local sink`() = runTest {
26+
val capturedTelemetryEvents = mutableListOf<EvaluationEvent>()
27+
28+
val mockOtelHook = object : Hook<Any> {
29+
override fun finallyAfter(
30+
hookContext: HookContext<Any>,
31+
details: FlagEvaluationDetails<Any>,
32+
hints: Map<String, Any>
33+
) {
34+
val event = Telemetry.createEvaluationEvent(hookContext, details)
35+
capturedTelemetryEvents.add(event)
36+
}
37+
}
38+
39+
val provider = NoOpProvider()
40+
OpenFeatureAPI.setProviderAndWait(provider)
41+
42+
val client = OpenFeatureAPI.getClient()
43+
client.addHooks(listOf(mockOtelHook))
44+
45+
val flagResult = client.getBooleanValue("login-button", false)
46+
assertFalse(flagResult)
47+
48+
assertEquals(1, capturedTelemetryEvents.size)
49+
50+
val metric = capturedTelemetryEvents.first()
51+
assertEquals(Telemetry.FLAG_EVALUATION_EVENT_NAME, metric.name)
52+
assertEquals("login-button", metric.attributes[Telemetry.TELEMETRY_KEY])
53+
// NoOpProvider inherently returns "Passed in default" for variants, skipping telemetry Value propagation explicitly per Appendix D guidelines.
54+
assertEquals("Passed in default", metric.attributes[Telemetry.TELEMETRY_VARIANT])
55+
assertEquals(Reason.DEFAULT.name.lowercase(), metric.attributes[Telemetry.TELEMETRY_REASON])
56+
}
57+
}

0 commit comments

Comments
 (0)