Skip to content

Commit 5ea87a9

Browse files
committed
feat: telemetry utils
Signed-off-by: Marcin Stepien <marcin.stepien@fluxon.com>
1 parent da9d2c8 commit 5ea87a9

6 files changed

Lines changed: 372 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 == Reason.ERROR.name) {
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
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+
}
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
package dev.openfeature.kotlin.sdk.telemetry
2+
3+
import dev.openfeature.kotlin.sdk.ClientMetadata
4+
import dev.openfeature.kotlin.sdk.EvaluationMetadata
5+
import dev.openfeature.kotlin.sdk.FlagEvaluationDetails
6+
import dev.openfeature.kotlin.sdk.FlagValueType
7+
import dev.openfeature.kotlin.sdk.HookContext
8+
import dev.openfeature.kotlin.sdk.ImmutableContext
9+
import dev.openfeature.kotlin.sdk.ProviderMetadata
10+
import dev.openfeature.kotlin.sdk.Reason
11+
import dev.openfeature.kotlin.sdk.Value
12+
import dev.openfeature.kotlin.sdk.exceptions.ErrorCode
13+
import kotlin.test.Test
14+
import kotlin.test.assertEquals
15+
import kotlin.test.assertNotNull
16+
import kotlin.test.assertNull
17+
18+
class TelemetryTest {
19+
20+
private val providerMetadata = object : ProviderMetadata {
21+
override val name: String = "TestProvider"
22+
}
23+
24+
private val clientMetadata = object : ClientMetadata {
25+
override val name: String? = "TestClient"
26+
}
27+
28+
@Test
29+
fun `test telemetry event constructs accurately on SUCCESS evaluations`() {
30+
val hookContext = HookContext(
31+
flagKey = "my-flag",
32+
type = FlagValueType.BOOLEAN,
33+
defaultValue = false,
34+
ctx = ImmutableContext(targetingKey = "user-123"),
35+
clientMetadata = clientMetadata,
36+
providerMetadata = providerMetadata
37+
)
38+
39+
val metadataBuilder = EvaluationMetadata.builder()
40+
.putString(Telemetry.TELEMETRY_FLAG_META_CONTEXT_ID, "override-context")
41+
.putString(Telemetry.TELEMETRY_FLAG_META_FLAG_SET_ID, "set-1")
42+
.putString(Telemetry.TELEMETRY_FLAG_META_VERSION, "v1.0")
43+
.build()
44+
45+
val details = FlagEvaluationDetails(
46+
flagKey = "my-flag",
47+
value = true,
48+
variant = "on",
49+
reason = Reason.TARGETING_MATCH.name,
50+
metadata = metadataBuilder
51+
)
52+
53+
val event = Telemetry.createEvaluationEvent(hookContext, details)
54+
55+
assertEquals(Telemetry.FLAG_EVALUATION_EVENT_NAME, event.name)
56+
val attrs = event.attributes
57+
58+
assertEquals("my-flag", attrs[Telemetry.TELEMETRY_KEY])
59+
assertEquals("TestProvider", attrs[Telemetry.TELEMETRY_PROVIDER])
60+
assertEquals("targeting_match", attrs[Telemetry.TELEMETRY_REASON])
61+
assertEquals("on", attrs[Telemetry.TELEMETRY_VARIANT])
62+
assertNull(attrs[Telemetry.TELEMETRY_VALUE]) // Value is skipped because variant is present
63+
assertEquals("override-context", attrs[Telemetry.TELEMETRY_CONTEXT_ID])
64+
assertEquals("set-1", attrs[Telemetry.TELEMETRY_FLAG_SET_ID])
65+
assertEquals("v1.0", attrs[Telemetry.TELEMETRY_VERSION])
66+
assertNull(attrs[Telemetry.TELEMETRY_ERROR_CODE])
67+
assertNull(attrs[Telemetry.TELEMETRY_ERROR_MSG])
68+
}
69+
70+
@Test
71+
fun `test telemetry event constructs accurately on ERROR evaluations`() {
72+
val hookContext = HookContext(
73+
flagKey = "error-flag",
74+
type = FlagValueType.STRING,
75+
defaultValue = "fallback",
76+
ctx = ImmutableContext(),
77+
clientMetadata = clientMetadata,
78+
providerMetadata = providerMetadata
79+
)
80+
81+
val details = FlagEvaluationDetails(
82+
flagKey = "error-flag",
83+
value = "fallback",
84+
reason = Reason.ERROR.name,
85+
errorCode = ErrorCode.PROVIDER_FATAL,
86+
errorMessage = "Provider crashed unexpectedly"
87+
)
88+
89+
val event = Telemetry.createEvaluationEvent(hookContext, details)
90+
91+
val attrs = event.attributes
92+
assertEquals("error", attrs[Telemetry.TELEMETRY_REASON])
93+
assertEquals("PROVIDER_FATAL", attrs[Telemetry.TELEMETRY_ERROR_CODE])
94+
assertEquals("Provider crashed unexpectedly", attrs[Telemetry.TELEMETRY_ERROR_MSG])
95+
assertEquals("fallback", attrs[Telemetry.TELEMETRY_VALUE])
96+
}
97+
98+
@Test
99+
fun `test telemetry correctly unwraps Value structures`() {
100+
val hookContext = HookContext<Value>(
101+
flagKey = "obj-flag",
102+
type = FlagValueType.OBJECT,
103+
defaultValue = Value.String("none"),
104+
ctx = null,
105+
clientMetadata = null,
106+
providerMetadata = providerMetadata
107+
)
108+
109+
val structure = Value.Structure(
110+
mapOf(
111+
"key1" to Value.String("val1"),
112+
"key2" to Value.List(listOf(Value.Integer(42)))
113+
)
114+
)
115+
116+
val details = FlagEvaluationDetails<Value>(
117+
flagKey = "obj-flag",
118+
value = structure,
119+
reason = Reason.DEFAULT.name
120+
)
121+
122+
val event = Telemetry.createEvaluationEvent(hookContext, details)
123+
124+
val unwrappedValue = event.attributes[Telemetry.TELEMETRY_VALUE]
125+
assertNotNull(unwrappedValue)
126+
127+
@Suppress("UNCHECKED_CAST")
128+
val map = unwrappedValue as Map<String, Any?>
129+
assertEquals("val1", map["key1"])
130+
131+
@Suppress("UNCHECKED_CAST")
132+
val list = map["key2"] as List<Any?>
133+
assertEquals(42, list[0])
134+
}
135+
}

0 commit comments

Comments
 (0)