Skip to content

Commit 5f29ccc

Browse files
authored
feat: Add LoggingHook for evaluation logging (#219)
Signed-off-by: Tyler Potter <tyler.john.potter@gmail.com>
1 parent a529a89 commit 5f29ccc

7 files changed

Lines changed: 899 additions & 3 deletions

File tree

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -808,6 +808,32 @@ public final class dev/openfeature/kotlin/sdk/exceptions/OpenFeatureError$TypeMi
808808
public fun getMessage ()Ljava/lang/String;
809809
}
810810

811+
public final class dev/openfeature/kotlin/sdk/hooks/LoggingHook : dev/openfeature/kotlin/sdk/Hook {
812+
public static final field Companion Ldev/openfeature/kotlin/sdk/hooks/LoggingHook$Companion;
813+
public static final field HINT_LOG_EVALUATION_CONTEXT Ljava/lang/String;
814+
public fun <init> ()V
815+
public fun <init> (Ldev/openfeature/kotlin/sdk/logging/Logger;ZLdev/openfeature/kotlin/sdk/logging/LogLevel;Ldev/openfeature/kotlin/sdk/logging/LogLevel;Ldev/openfeature/kotlin/sdk/logging/LogLevel;Ldev/openfeature/kotlin/sdk/logging/LogLevel;)V
816+
public synthetic fun <init> (Ldev/openfeature/kotlin/sdk/logging/Logger;ZLdev/openfeature/kotlin/sdk/logging/LogLevel;Ldev/openfeature/kotlin/sdk/logging/LogLevel;Ldev/openfeature/kotlin/sdk/logging/LogLevel;Ldev/openfeature/kotlin/sdk/logging/LogLevel;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
817+
public fun after (Ldev/openfeature/kotlin/sdk/HookContext;Ldev/openfeature/kotlin/sdk/FlagEvaluationDetails;Ljava/util/Map;)V
818+
public fun before (Ldev/openfeature/kotlin/sdk/HookContext;Ljava/util/Map;)V
819+
public fun error (Ldev/openfeature/kotlin/sdk/HookContext;Ljava/lang/Exception;Ljava/util/Map;)V
820+
public fun finallyAfter (Ldev/openfeature/kotlin/sdk/HookContext;Ldev/openfeature/kotlin/sdk/FlagEvaluationDetails;Ljava/util/Map;)V
821+
public fun supportsFlagValueType (Ldev/openfeature/kotlin/sdk/FlagValueType;)Z
822+
}
823+
824+
public final class dev/openfeature/kotlin/sdk/hooks/LoggingHook$Companion {
825+
}
826+
827+
public final class dev/openfeature/kotlin/sdk/logging/LogLevel : java/lang/Enum {
828+
public static final field DEBUG Ldev/openfeature/kotlin/sdk/logging/LogLevel;
829+
public static final field ERROR Ldev/openfeature/kotlin/sdk/logging/LogLevel;
830+
public static final field INFO Ldev/openfeature/kotlin/sdk/logging/LogLevel;
831+
public static final field WARN Ldev/openfeature/kotlin/sdk/logging/LogLevel;
832+
public static fun getEntries ()Lkotlin/enums/EnumEntries;
833+
public static fun valueOf (Ljava/lang/String;)Ldev/openfeature/kotlin/sdk/logging/LogLevel;
834+
public static fun values ()[Ldev/openfeature/kotlin/sdk/logging/LogLevel;
835+
}
836+
811837
public abstract interface class dev/openfeature/kotlin/sdk/logging/Logger {
812838
public abstract fun debug (Ljava/lang/Throwable;Lkotlin/jvm/functions/Function0;)V
813839
public static synthetic fun debug$default (Ldev/openfeature/kotlin/sdk/logging/Logger;Ljava/lang/Throwable;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)V

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -808,6 +808,32 @@ public final class dev/openfeature/kotlin/sdk/exceptions/OpenFeatureError$TypeMi
808808
public fun getMessage ()Ljava/lang/String;
809809
}
810810

811+
public final class dev/openfeature/kotlin/sdk/hooks/LoggingHook : dev/openfeature/kotlin/sdk/Hook {
812+
public static final field Companion Ldev/openfeature/kotlin/sdk/hooks/LoggingHook$Companion;
813+
public static final field HINT_LOG_EVALUATION_CONTEXT Ljava/lang/String;
814+
public fun <init> ()V
815+
public fun <init> (Ldev/openfeature/kotlin/sdk/logging/Logger;ZLdev/openfeature/kotlin/sdk/logging/LogLevel;Ldev/openfeature/kotlin/sdk/logging/LogLevel;Ldev/openfeature/kotlin/sdk/logging/LogLevel;Ldev/openfeature/kotlin/sdk/logging/LogLevel;)V
816+
public synthetic fun <init> (Ldev/openfeature/kotlin/sdk/logging/Logger;ZLdev/openfeature/kotlin/sdk/logging/LogLevel;Ldev/openfeature/kotlin/sdk/logging/LogLevel;Ldev/openfeature/kotlin/sdk/logging/LogLevel;Ldev/openfeature/kotlin/sdk/logging/LogLevel;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
817+
public fun after (Ldev/openfeature/kotlin/sdk/HookContext;Ldev/openfeature/kotlin/sdk/FlagEvaluationDetails;Ljava/util/Map;)V
818+
public fun before (Ldev/openfeature/kotlin/sdk/HookContext;Ljava/util/Map;)V
819+
public fun error (Ldev/openfeature/kotlin/sdk/HookContext;Ljava/lang/Exception;Ljava/util/Map;)V
820+
public fun finallyAfter (Ldev/openfeature/kotlin/sdk/HookContext;Ldev/openfeature/kotlin/sdk/FlagEvaluationDetails;Ljava/util/Map;)V
821+
public fun supportsFlagValueType (Ldev/openfeature/kotlin/sdk/FlagValueType;)Z
822+
}
823+
824+
public final class dev/openfeature/kotlin/sdk/hooks/LoggingHook$Companion {
825+
}
826+
827+
public final class dev/openfeature/kotlin/sdk/logging/LogLevel : java/lang/Enum {
828+
public static final field DEBUG Ldev/openfeature/kotlin/sdk/logging/LogLevel;
829+
public static final field ERROR Ldev/openfeature/kotlin/sdk/logging/LogLevel;
830+
public static final field INFO Ldev/openfeature/kotlin/sdk/logging/LogLevel;
831+
public static final field WARN Ldev/openfeature/kotlin/sdk/logging/LogLevel;
832+
public static fun getEntries ()Lkotlin/enums/EnumEntries;
833+
public static fun valueOf (Ljava/lang/String;)Ldev/openfeature/kotlin/sdk/logging/LogLevel;
834+
public static fun values ()[Ldev/openfeature/kotlin/sdk/logging/LogLevel;
835+
}
836+
811837
public abstract interface class dev/openfeature/kotlin/sdk/logging/Logger {
812838
public abstract fun debug (Ljava/lang/Throwable;Lkotlin/jvm/functions/Function0;)V
813839
public static synthetic fun debug$default (Ldev/openfeature/kotlin/sdk/logging/Logger;Ljava/lang/Throwable;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)V
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
package dev.openfeature.kotlin.sdk.hooks
2+
3+
import dev.openfeature.kotlin.sdk.EvaluationContext
4+
import dev.openfeature.kotlin.sdk.FlagEvaluationDetails
5+
import dev.openfeature.kotlin.sdk.Hook
6+
import dev.openfeature.kotlin.sdk.HookContext
7+
import dev.openfeature.kotlin.sdk.Value
8+
import dev.openfeature.kotlin.sdk.logging.LogLevel
9+
import dev.openfeature.kotlin.sdk.logging.Logger
10+
import dev.openfeature.kotlin.sdk.logging.NoOpLogger
11+
import kotlin.time.ExperimentalTime
12+
13+
/**
14+
* A hook that logs detailed information during flag evaluation lifecycle.
15+
*
16+
* Logs at different stages:
17+
* - Before: Flag evaluation request
18+
* - After: Successful evaluation with result
19+
* - Error: Errors during evaluation
20+
* - Finally: Completion status
21+
*
22+
* @param logger The logger to use. Defaults to NoOpLogger.
23+
* @param logEvaluationContext If true, includes evaluation context in logs (default: false for privacy)
24+
* @param beforeLogLevel Log level for the before stage (default: DEBUG)
25+
* @param afterLogLevel Log level for the after stage (default: DEBUG)
26+
* @param errorLogLevel Log level for the error stage (default: ERROR)
27+
* @param finallyLogLevel Log level for the finallyAfter stage (default: DEBUG)
28+
*/
29+
class LoggingHook(
30+
private val logger: Logger = NoOpLogger(),
31+
private val logEvaluationContext: Boolean = false,
32+
private val beforeLogLevel: LogLevel = LogLevel.DEBUG,
33+
private val afterLogLevel: LogLevel = LogLevel.DEBUG,
34+
private val errorLogLevel: LogLevel = LogLevel.ERROR,
35+
private val finallyLogLevel: LogLevel = LogLevel.DEBUG
36+
) : Hook<Any> {
37+
38+
companion object {
39+
/**
40+
* Hook hint key to enable/disable context logging for a specific evaluation.
41+
* Pass this key in hookHints with a Boolean value to override the hook's default behavior.
42+
*/
43+
const val HINT_LOG_EVALUATION_CONTEXT = "logEvaluationContext"
44+
}
45+
46+
override fun before(ctx: HookContext<Any>, hints: Map<String, Any>) {
47+
val shouldLogContext = hints[HINT_LOG_EVALUATION_CONTEXT] as? Boolean ?: logEvaluationContext
48+
49+
val message = buildString {
50+
append("Flag evaluation starting: ")
51+
append("flag='${ctx.flagKey}', ")
52+
append("type=${ctx.type}, ")
53+
append("defaultValue=${formatAnyValue(ctx.defaultValue)}")
54+
if (shouldLogContext && ctx.ctx != null) {
55+
append(", ")
56+
append(formatContext(ctx.ctx))
57+
}
58+
append(", provider='${ctx.providerMetadata.name}'")
59+
if (ctx.clientMetadata?.name != null) {
60+
append(", client='${ctx.clientMetadata.name}'")
61+
}
62+
}
63+
64+
logAtLevel(beforeLogLevel) { message }
65+
}
66+
67+
override fun after(ctx: HookContext<Any>, details: FlagEvaluationDetails<Any>, hints: Map<String, Any>) {
68+
val shouldLogContext = hints[HINT_LOG_EVALUATION_CONTEXT] as? Boolean ?: logEvaluationContext
69+
70+
val message = buildString {
71+
append("Flag evaluation completed: ")
72+
append("flag='${details.flagKey}', ")
73+
append("value=${formatAnyValue(details.value)}")
74+
if (details.variant != null) {
75+
append(", variant='${details.variant}'")
76+
}
77+
if (details.reason != null) {
78+
append(", reason='${details.reason}'")
79+
}
80+
if (shouldLogContext && ctx.ctx != null) {
81+
append(", ")
82+
append(formatContext(ctx.ctx))
83+
}
84+
append(", provider='${ctx.providerMetadata.name}'")
85+
}
86+
87+
logAtLevel(afterLogLevel) { message }
88+
}
89+
90+
override fun error(ctx: HookContext<Any>, error: Exception, hints: Map<String, Any>) {
91+
val shouldLogContext = hints[HINT_LOG_EVALUATION_CONTEXT] as? Boolean ?: logEvaluationContext
92+
93+
val message = buildString {
94+
append("Flag evaluation error: ")
95+
append("flag='${ctx.flagKey}', ")
96+
append("type=${ctx.type}, ")
97+
append("defaultValue=${formatAnyValue(ctx.defaultValue)}")
98+
if (shouldLogContext && ctx.ctx != null) {
99+
append(", ")
100+
append(formatContext(ctx.ctx))
101+
}
102+
append(", provider='${ctx.providerMetadata.name}', ")
103+
append("error='${error.message?.replace("'", "''")}'")
104+
}
105+
106+
logAtLevel(errorLogLevel, error) { message }
107+
}
108+
109+
override fun finallyAfter(ctx: HookContext<Any>, details: FlagEvaluationDetails<Any>, hints: Map<String, Any>) {
110+
val message = buildString {
111+
append("Flag evaluation finalized: ")
112+
append("flag='${ctx.flagKey}'")
113+
if (details.errorCode != null) {
114+
append(", errorCode=${details.errorCode}")
115+
}
116+
if (details.errorMessage != null) {
117+
append(", errorMessage='${details.errorMessage.replace("'", "''")}'")
118+
}
119+
}
120+
121+
logAtLevel(finallyLogLevel) { message }
122+
}
123+
124+
private fun logAtLevel(level: LogLevel, throwable: Throwable? = null, message: () -> String) {
125+
when (level) {
126+
LogLevel.DEBUG -> logger.debug(throwable, message)
127+
LogLevel.INFO -> logger.info(throwable, message)
128+
LogLevel.WARN -> logger.warn(throwable, message)
129+
LogLevel.ERROR -> logger.error(throwable, message)
130+
}
131+
}
132+
133+
private fun formatContext(context: EvaluationContext): String {
134+
return buildString {
135+
append("context={")
136+
append("targetingKey='${context.getTargetingKey()}'")
137+
val attributes = context.asMap()
138+
if (attributes.isNotEmpty()) {
139+
append(", attributes={")
140+
append(attributes.entries.joinToString(", ") { "${it.key}=${formatValue(it.value)}" })
141+
append("}")
142+
}
143+
append("}")
144+
}
145+
}
146+
147+
@OptIn(ExperimentalTime::class)
148+
private fun formatValue(value: Value): String {
149+
return when (value) {
150+
is Value.String -> "'${value.string.replace("'", "''")}'"
151+
is Value.Integer -> value.integer.toString()
152+
is Value.Double -> value.double.toString()
153+
is Value.Boolean -> value.boolean.toString()
154+
is Value.Instant -> value.instant.toString()
155+
is Value.List -> "[${value.list.joinToString(", ") { formatValue(it) }}]"
156+
is Value.Structure ->
157+
"{${value.structure.entries.joinToString(", ") { "${it.key}=${formatValue(it.value)}" }}}"
158+
is Value.Null -> "null"
159+
}
160+
}
161+
162+
private fun formatAnyValue(value: Any?): String {
163+
return when (value) {
164+
null -> "null"
165+
is String -> "'${value.replace("'", "''")}'"
166+
is Number, is Boolean -> value.toString()
167+
else -> "'${value.toString().replace("'", "''")}'"
168+
}
169+
}
170+
}

kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/logging/Logger.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
package dev.openfeature.kotlin.sdk.logging
22

3+
/**
4+
* Log levels supported by the [Logger] interface.
5+
*/
6+
enum class LogLevel { DEBUG, INFO, WARN, ERROR }
7+
38
/**
49
* Logger interface for OpenFeature SDK logging.
510
* Defines a minimal logging contract that can be implemented by platform-specific loggers

0 commit comments

Comments
 (0)