diff --git a/.changeset/unresolved-local-flags.md b/.changeset/unresolved-local-flags.md new file mode 100644 index 000000000..1fe8f3ca3 --- /dev/null +++ b/.changeset/unresolved-local-flags.md @@ -0,0 +1,5 @@ +--- +"posthog-server": minor +--- + +Add `unresolvedFlags` to the `evaluateFlags()` snapshot. It lists the flags that have a local definition but no value, because local evaluation could not resolve them and no `/flags` fallback filled them, for example with `onlyEvaluateLocally`. Each entry has a `PostHogUnresolvedFlagReason`: `EXPERIENCE_CONTINUITY`, `UNSUPPORTED_DEFINITION`, `MISSING_CONTEXT` or `UNRESOLVED_DEPENDENCY`. Reading such a flag now reports `$feature_flag_error: local_evaluation_inconclusive` instead of `flag_missing`, so insights that filter on `flag_missing` count fewer events. The SDK also logs one warning per flag version when loaded definitions include an active flag with experience continuity. diff --git a/posthog-server/api/posthog-server.api b/posthog-server/api/posthog-server.api index 0f4c373ab..42377fe3f 100644 --- a/posthog-server/api/posthog-server.api +++ b/posthog-server/api/posthog-server.api @@ -269,6 +269,7 @@ public final class com/posthog/server/PostHogFeatureFlagEvaluations { public final fun getFlagPayloadAs (Ljava/lang/String;Ljava/lang/Class;)Ljava/lang/Object; public final fun getKeys ()Ljava/util/List; public final fun getRequestId ()Ljava/lang/String; + public final fun getUnresolvedFlags ()Ljava/util/Map; public final fun isEnabled (Ljava/lang/String;)Z public final fun only (Lcom/posthog/server/PostHogFeatureFlagFilter;)Lcom/posthog/server/PostHogFeatureFlagEvaluations; public final fun only (Ljava/util/Collection;)Lcom/posthog/server/PostHogFeatureFlagEvaluations; @@ -503,3 +504,14 @@ public final class com/posthog/server/PostHogRequestContextData { public final fun getSessionId ()Ljava/lang/String; } +public final class com/posthog/server/PostHogUnresolvedFlagReason : java/lang/Enum { + public static final field EXPERIENCE_CONTINUITY Lcom/posthog/server/PostHogUnresolvedFlagReason; + public static final field MISSING_CONTEXT Lcom/posthog/server/PostHogUnresolvedFlagReason; + public static final field UNRESOLVED_DEPENDENCY Lcom/posthog/server/PostHogUnresolvedFlagReason; + public static final field UNSUPPORTED_DEFINITION Lcom/posthog/server/PostHogUnresolvedFlagReason; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public final fun getValue ()Ljava/lang/String; + public static fun valueOf (Ljava/lang/String;)Lcom/posthog/server/PostHogUnresolvedFlagReason; + public static fun values ()[Lcom/posthog/server/PostHogUnresolvedFlagReason; +} + diff --git a/posthog-server/src/main/java/com/posthog/server/PostHog.kt b/posthog-server/src/main/java/com/posthog/server/PostHog.kt index b16c68cc7..a02960a3d 100644 --- a/posthog-server/src/main/java/com/posthog/server/PostHog.kt +++ b/posthog-server/src/main/java/com/posthog/server/PostHog.kt @@ -442,6 +442,7 @@ public class PostHog : PostHogStateless(), PostHogInterface { responseError = result.responseError, host = evaluationsHost, groups = groups, + unresolvedFlags = result.unresolvedFlags, ) } diff --git a/posthog-server/src/main/java/com/posthog/server/PostHogFeatureFlagEvaluations.kt b/posthog-server/src/main/java/com/posthog/server/PostHogFeatureFlagEvaluations.kt index 5165158ea..30b1dfbc7 100644 --- a/posthog-server/src/main/java/com/posthog/server/PostHogFeatureFlagEvaluations.kt +++ b/posthog-server/src/main/java/com/posthog/server/PostHogFeatureFlagEvaluations.kt @@ -16,11 +16,13 @@ import java.util.Collections * - [isEnabled] / [getFlag] fire `$feature_flag_called` (deduped by the same per-distinct-id LRU * used by [PostHogInterface.getFeatureFlag]). Empty/blank distinctId short-circuits the event. * Reads for unknown keys still fire a `$feature_flag_called` event with - * `$feature_flag_error: flag_missing` so dashboards see the lookup attempt. + * `$feature_flag_error: flag_missing` so dashboards see the lookup attempt. Reads for keys in + * [unresolvedFlags] report `local_evaluation_inconclusive` instead. * - [getFlagPayload] does not fire any event. * * Filtered clones from [onlyAccessed] / [only] are independent of the parent — accessing flags on - * the clone does not back-propagate into the parent's "accessed" set. + * the clone does not back-propagate into the parent's "accessed" set. Clones do not carry + * [unresolvedFlags]; inspect them on the original snapshot. * * @property distinctId The distinct ID the snapshot was evaluated for, or null for an empty snapshot. * @property requestId Request ID returned by the `/flags` API, when available. @@ -38,11 +40,22 @@ public class PostHogFeatureFlagEvaluations internal constructor( private val host: EvaluationsHost, initialAccessed: Set = emptySet(), groups: Map? = null, + unresolvedFlags: Map = emptyMap(), ) { private val flagMap: Map = Collections.unmodifiableMap(LinkedHashMap(flagMap)) private val locallyEvaluated: Map = Collections.unmodifiableMap(HashMap(locallyEvaluated)) private val groups: Map? = groups?.let { Collections.unmodifiableMap(HashMap(it)) } + /** + * Flags that have a local definition but no value in this snapshot, because local evaluation + * could not resolve them and no `/flags` fallback filled them. This happens with local-only + * evaluation, or when the fallback fails. The flags stay absent from [keys] and read as missing. + * Reading this map does not fire any event and does not record an access. When several parts of + * a flag are inconclusive, the reason is the first one found during evaluation. + */ + public val unresolvedFlags: Map = + Collections.unmodifiableMap(LinkedHashMap(unresolvedFlags)) + private val accessLock = Any() private val accessed: MutableSet = HashSet(initialAccessed) @@ -246,7 +259,15 @@ public class PostHogFeatureFlagEvaluations internal constructor( val error = buildList { responseError?.let { add(it) } - if (flag == null) add(FeatureFlagError.FLAG_MISSING) + if (flag == null) { + add( + if (key in unresolvedFlags) { + FeatureFlagError.LOCAL_EVALUATION_INCONCLUSIVE + } else { + FeatureFlagError.FLAG_MISSING + }, + ) + } } if (error.isNotEmpty()) { props["\$feature_flag_error"] = error.joinToString(",") diff --git a/posthog-server/src/main/java/com/posthog/server/PostHogUnresolvedFlagReason.kt b/posthog-server/src/main/java/com/posthog/server/PostHogUnresolvedFlagReason.kt new file mode 100644 index 000000000..f3199cc45 --- /dev/null +++ b/posthog-server/src/main/java/com/posthog/server/PostHogUnresolvedFlagReason.kt @@ -0,0 +1,30 @@ +package com.posthog.server + +/** + * Why local evaluation could not resolve a flag that has a loaded definition. Reported by + * [PostHogFeatureFlagEvaluations.unresolvedFlags]. Each reason says what the caller can do about it. + * + * @property value The stable string for this reason, as defined by the SDK spec. + */ +public enum class PostHogUnresolvedFlagReason(public val value: String) { + /** The flag has experience continuity enabled. Local evaluation never resolves it; change the flag. */ + EXPERIENCE_CONTINUITY("experience_continuity"), + + /** + * The flag uses something the local evaluator does not support, such as a static cohort, an + * unrecognized operator or a malformed value. Change the flag or upgrade the SDK. + */ + UNSUPPORTED_DEFINITION("unsupported_definition"), + + /** + * The call did not supply a property, group key or group property the flag's conditions need, + * or supplied it in a form the evaluator cannot use. Pass it. + */ + MISSING_CONTEXT("missing_context"), + + /** + * A flag this flag depends on could not be resolved. Check the dependency's own reason; it appears + * in [PostHogFeatureFlagEvaluations.unresolvedFlags] only when the dependency is in the requested scope. + */ + UNRESOLVED_DEPENDENCY("unresolved_dependency"), +} diff --git a/posthog-server/src/main/java/com/posthog/server/internal/EvaluateFlagsResult.kt b/posthog-server/src/main/java/com/posthog/server/internal/EvaluateFlagsResult.kt index 7fb3ec65e..5923fe8b7 100644 --- a/posthog-server/src/main/java/com/posthog/server/internal/EvaluateFlagsResult.kt +++ b/posthog-server/src/main/java/com/posthog/server/internal/EvaluateFlagsResult.kt @@ -1,6 +1,7 @@ package com.posthog.server.internal import com.posthog.internal.FeatureFlag +import com.posthog.server.PostHogUnresolvedFlagReason /** * The rich envelope returned by [PostHogFeatureFlags.evaluateFlags]. Holds the per-flag results @@ -18,4 +19,6 @@ internal data class EvaluateFlagsResult( * events so they match what the per-flag accessor path emits. */ val responseError: String?, + /** Flags with a local definition that have no value in [flags], with the reason why. */ + val unresolvedFlags: Map, ) diff --git a/posthog-server/src/main/java/com/posthog/server/internal/FeatureFlagError.kt b/posthog-server/src/main/java/com/posthog/server/internal/FeatureFlagError.kt index 318015cfd..686251982 100644 --- a/posthog-server/src/main/java/com/posthog/server/internal/FeatureFlagError.kt +++ b/posthog-server/src/main/java/com/posthog/server/internal/FeatureFlagError.kt @@ -10,6 +10,7 @@ package com.posthog.server.internal * Error values: * ERRORS_WHILE_COMPUTING: Server returned errorsWhileComputingFlags=true * FLAG_MISSING: Requested flag not in API response + * LOCAL_EVALUATION_INCONCLUSIVE: Flag has a local definition, but local evaluation could not resolve it * QUOTA_LIMITED: Rate/quota limit exceeded * TIMEOUT: Request timed out * CONNECTION_ERROR: Network connectivity issue @@ -20,6 +21,7 @@ package com.posthog.server.internal internal object FeatureFlagError { const val ERRORS_WHILE_COMPUTING: String = "errors_while_computing_flags" const val FLAG_MISSING: String = "flag_missing" + const val LOCAL_EVALUATION_INCONCLUSIVE: String = "local_evaluation_inconclusive" const val QUOTA_LIMITED: String = "quota_limited" const val TIMEOUT: String = "timeout" const val CONNECTION_ERROR: String = "connection_error" diff --git a/posthog-server/src/main/java/com/posthog/server/internal/FlagEvaluator.kt b/posthog-server/src/main/java/com/posthog/server/internal/FlagEvaluator.kt index c51c4433c..4acf3618f 100644 --- a/posthog-server/src/main/java/com/posthog/server/internal/FlagEvaluator.kt +++ b/posthog-server/src/main/java/com/posthog/server/internal/FlagEvaluator.kt @@ -11,6 +11,7 @@ import com.posthog.internal.PropertyGroup import com.posthog.internal.PropertyOperator import com.posthog.internal.PropertyType import com.posthog.internal.PropertyValue +import com.posthog.server.PostHogUnresolvedFlagReason import java.security.MessageDigest import java.time.Instant import java.time.ZoneId @@ -135,7 +136,10 @@ internal class FlagEvaluator( // Check if property key exists in values if (!propertyValues.containsKey(key)) { - throw InconclusiveMatchException("Can't match properties without a given property value") + throw InconclusiveMatchException( + "Can't match properties without a given property value", + PostHogUnresolvedFlagReason.MISSING_CONTEXT, + ) } if (propertyOperator == PropertyOperator.IS_SET || propertyOperator == PropertyOperator.IS_NOT_SET) { @@ -157,37 +161,37 @@ internal class FlagEvaluator( PropertyOperator.ICONTAINS -> stringContains( - valueToString(overrideValue), + callerValueToString(overrideValue), valueToString(propertyValue), ) PropertyOperator.NOT_ICONTAINS -> !stringContains( - valueToString(overrideValue), + callerValueToString(overrideValue), valueToString(propertyValue), ) PropertyOperator.STARTS_WITH -> stringStartsWith( - valueToString(overrideValue), + callerValueToString(overrideValue), valueToString(propertyValue), ) PropertyOperator.NOT_STARTS_WITH -> !stringStartsWith( - valueToString(overrideValue), + callerValueToString(overrideValue), valueToString(propertyValue), ) PropertyOperator.ENDS_WITH -> stringEndsWith( - valueToString(overrideValue), + callerValueToString(overrideValue), valueToString(propertyValue), ) PropertyOperator.NOT_ENDS_WITH -> !stringEndsWith( - valueToString(overrideValue), + callerValueToString(overrideValue), valueToString(propertyValue), ) @@ -233,7 +237,10 @@ internal class FlagEvaluator( propertyOperator, ) - else -> throw InconclusiveMatchException("Unknown operator: $propertyOperator") + else -> throw InconclusiveMatchException( + "Unknown operator: $propertyOperator", + PostHogUnresolvedFlagReason.UNSUPPORTED_DEFINITION, + ) } } @@ -249,7 +256,7 @@ internal class FlagEvaluator( return isTruthyPropertyValue(propertyValue) == isTruthyPropertyValue(overrideValue) } - val expectedValue = unicodeLowercase(valueToString(overrideValue)) + val expectedValue = unicodeLowercase(callerValueToString(overrideValue)) return when (propertyValue) { is List<*> -> propertyValue.any { value -> @@ -276,6 +283,14 @@ internal class FlagEvaluator( else -> false } + // A caller value that cannot be stringified is the caller's to fix, not the flag's. + private fun callerValueToString(value: Any?): String = + try { + valueToString(value) + } catch (e: InconclusiveMatchException) { + throw InconclusiveMatchException(e.message.orEmpty(), PostHogUnresolvedFlagReason.MISSING_CONTEXT, e) + } + private fun valueToString(value: Any?): String { if (value is String) { return value @@ -286,8 +301,13 @@ internal class FlagEvaluator( val jsonValue = try { JSON.toJsonTree(value) - } catch (error: IllegalArgumentException) { - throw InconclusiveMatchException("Cannot stringify JSON value locally: ${error.message}") + } catch (error: RuntimeException) { + // Gson throws several runtime exceptions for values it cannot reflect, such as java.time types. + throw InconclusiveMatchException( + "Cannot stringify JSON value locally: ${error.message}", + PostHogUnresolvedFlagReason.UNSUPPORTED_DEFINITION, + error, + ) } return canonicalJson(jsonValue) } @@ -314,7 +334,10 @@ internal class FlagEvaluator( if (rawValue.matches(Regex("-?\\d+"))) { return rawValue } - throw InconclusiveMatchException("Cannot stringify floating-point JSON number locally: $rawValue") + throw InconclusiveMatchException( + "Cannot stringify floating-point JSON number locally: $rawValue", + PostHogUnresolvedFlagReason.UNSUPPORTED_DEFINITION, + ) } private fun compareJsonKeys( @@ -348,14 +371,20 @@ internal class FlagEvaluator( in '\uD800'..'\uDBFF' -> { val next = value.getOrNull(index + 1) if (next == null || next !in '\uDC00'..'\uDFFF') { - throw InconclusiveMatchException("Cannot stringify an unpaired Unicode surrogate") + throw InconclusiveMatchException( + "Cannot stringify an unpaired Unicode surrogate", + PostHogUnresolvedFlagReason.UNSUPPORTED_DEFINITION, + ) } append(character) } in '\uDC00'..'\uDFFF' -> { val previous = value.getOrNull(index - 1) if (previous == null || previous !in '\uD800'..'\uDBFF') { - throw InconclusiveMatchException("Cannot stringify an unpaired Unicode surrogate") + throw InconclusiveMatchException( + "Cannot stringify an unpaired Unicode surrogate", + PostHogUnresolvedFlagReason.UNSUPPORTED_DEFINITION, + ) } append(character) } @@ -468,7 +497,10 @@ internal class FlagEvaluator( try { parseDateValue(propertyValue.toString()) } catch (e: Exception) { - throw InconclusiveMatchException("The date set on the flag is not a valid format") + throw InconclusiveMatchException( + "The date set on the flag is not a valid format", + PostHogUnresolvedFlagReason.UNSUPPORTED_DEFINITION, + ) } val overrideDate = @@ -480,11 +512,17 @@ internal class FlagEvaluator( try { parseOverrideDate(overrideValue) } catch (e: Exception) { - throw InconclusiveMatchException("The date provided is not a valid format") + throw InconclusiveMatchException( + "The date provided is not a valid format", + PostHogUnresolvedFlagReason.MISSING_CONTEXT, + ) } } - else -> throw InconclusiveMatchException("The date provided must be a string or date object") + else -> throw InconclusiveMatchException( + "The date provided must be a string or date object", + PostHogUnresolvedFlagReason.MISSING_CONTEXT, + ) } return when (propertyOperator) { @@ -642,20 +680,32 @@ internal class FlagEvaluator( val match = REGEX_SEMVER.matchEntire(cleaned) - ?: throw InconclusiveMatchException("Invalid semver version: '$version'") + ?: throw InconclusiveMatchException( + "Invalid semver version: '$version'", + PostHogUnresolvedFlagReason.UNSUPPORTED_DEFINITION, + ) val major = parseSemverNumericIdentifier(match.groupValues[1]) - ?: throw InconclusiveMatchException("Invalid semver version: '$version'") + ?: throw InconclusiveMatchException( + "Invalid semver version: '$version'", + PostHogUnresolvedFlagReason.UNSUPPORTED_DEFINITION, + ) val minor = match.groupValues[2].takeIf { it.isNotEmpty() }?.let { parseSemverNumericIdentifier(it) - ?: throw InconclusiveMatchException("Invalid semver version: '$version'") + ?: throw InconclusiveMatchException( + "Invalid semver version: '$version'", + PostHogUnresolvedFlagReason.UNSUPPORTED_DEFINITION, + ) } ?: 0 val patch = match.groupValues[3].takeIf { it.isNotEmpty() }?.let { parseSemverNumericIdentifier(it) - ?: throw InconclusiveMatchException("Invalid semver version: '$version'") + ?: throw InconclusiveMatchException( + "Invalid semver version: '$version'", + PostHogUnresolvedFlagReason.UNSUPPORTED_DEFINITION, + ) } ?: 0 return SemverVersion(major, minor, patch) @@ -673,7 +723,10 @@ internal class FlagEvaluator( return try { parseSemver(propertyValue) } catch (e: InconclusiveMatchException) { - throw InconclusiveMatchException("The flag condition value is not a valid semver: ${e.message}") + throw InconclusiveMatchException( + "The flag condition value is not a valid semver: ${e.message}", + PostHogUnresolvedFlagReason.UNSUPPORTED_DEFINITION, + ) } } @@ -690,7 +743,10 @@ internal class FlagEvaluator( try { parseSemver(overrideValue.toString()) } catch (e: InconclusiveMatchException) { - throw InconclusiveMatchException("The person property value is not a valid semver: ${e.message}") + throw InconclusiveMatchException( + "The person property value is not a valid semver: ${e.message}", + PostHogUnresolvedFlagReason.MISSING_CONTEXT, + ) } val propertyString = propertyValue.toString() @@ -722,12 +778,18 @@ internal class FlagEvaluator( try { computeWildcardBounds(propertyString) } catch (e: InconclusiveMatchException) { - throw InconclusiveMatchException("The flag condition value is not a valid semver: ${e.message}") + throw InconclusiveMatchException( + "The flag condition value is not a valid semver: ${e.message}", + PostHogUnresolvedFlagReason.UNSUPPORTED_DEFINITION, + ) } overrideVersion >= lower && overrideVersion < upper } - else -> throw InconclusiveMatchException("Unknown semver operator: $propertyOperator") + else -> throw InconclusiveMatchException( + "Unknown semver operator: $propertyOperator", + PostHogUnresolvedFlagReason.UNSUPPORTED_DEFINITION, + ) } } @@ -800,7 +862,10 @@ internal class FlagEvaluator( cleaned = cleaned.trimEnd('*', '.') if (cleaned.isEmpty()) { - throw InconclusiveMatchException("Invalid wildcard version: '$propertyValue'") + throw InconclusiveMatchException( + "Invalid wildcard version: '$propertyValue'", + PostHogUnresolvedFlagReason.UNSUPPORTED_DEFINITION, + ) } val parts = cleaned.split(".") @@ -810,12 +875,18 @@ internal class FlagEvaluator( if (part.isEmpty()) continue val num = parseSemverNumericIdentifier(part) - ?: throw InconclusiveMatchException("Invalid wildcard version: '$propertyValue'") + ?: throw InconclusiveMatchException( + "Invalid wildcard version: '$propertyValue'", + PostHogUnresolvedFlagReason.UNSUPPORTED_DEFINITION, + ) components.add(num) } if (components.isEmpty()) { - throw InconclusiveMatchException("Invalid wildcard version: '$propertyValue'") + throw InconclusiveMatchException( + "Invalid wildcard version: '$propertyValue'", + PostHogUnresolvedFlagReason.UNSUPPORTED_DEFINITION, + ) } return when (components.size) { @@ -846,15 +917,19 @@ internal class FlagEvaluator( ): Boolean { val cohortId = property.propertyValue?.toString() - ?: throw InconclusiveMatchException("Cohort property missing value") + ?: throw InconclusiveMatchException("Cohort property missing value", PostHogUnresolvedFlagReason.UNSUPPORTED_DEFINITION) + // Static cohorts are absent from the local cohort map, so they never resolve locally. if (!cohortProperties.containsKey(cohortId)) { - throw InconclusiveMatchException("Can't match cohort without a given cohort property value") + throw InconclusiveMatchException( + "Can't match cohort without a given cohort property value", + PostHogUnresolvedFlagReason.UNSUPPORTED_DEFINITION, + ) } val propertyGroup = cohortProperties[cohortId] - ?: throw InconclusiveMatchException("Cohort definition not found") + ?: throw InconclusiveMatchException("Cohort definition not found", PostHogUnresolvedFlagReason.UNSUPPORTED_DEFINITION) return matchPropertyGroup( propertyGroup, propertyValues, @@ -882,7 +957,7 @@ internal class FlagEvaluator( // Empty properties always match if (properties == null || properties.isEmpty()) return true - var errorMatchingLocally = false + var inconclusiveReason: PostHogUnresolvedFlagReason? = null // Handle based on whether we have nested property groups or flag properties when (properties) { @@ -907,12 +982,12 @@ internal class FlagEvaluator( } } catch (e: InconclusiveMatchException) { config.logger.log("Failed to compute nested property group locally: ${e.message}") - errorMatchingLocally = true + inconclusiveReason = inconclusiveReason ?: e.reason } } - if (errorMatchingLocally) { - throw InconclusiveMatchException("Can't match cohort without a given cohort property value") + inconclusiveReason?.let { + throw InconclusiveMatchException("Can't match cohort without a given cohort property value", it) } // If we get here, all matched in AND case, or none matched in OR case @@ -939,13 +1014,20 @@ internal class FlagEvaluator( evaluateFlagDependency( property, flagsByKey - ?: throw InconclusiveMatchException("Cannot evaluate flag dependencies without flagsByKey"), + ?: throw InconclusiveMatchException( + "Cannot evaluate flag dependencies without flagsByKey", + PostHogUnresolvedFlagReason.UNSUPPORTED_DEFINITION, + ), evaluationCache ?: throw InconclusiveMatchException( "Cannot evaluate flag dependencies without evaluationCache", + PostHogUnresolvedFlagReason.UNSUPPORTED_DEFINITION, ), distinctId - ?: throw InconclusiveMatchException("Cannot evaluate flag dependencies without distinctId"), + ?: throw InconclusiveMatchException( + "Cannot evaluate flag dependencies without distinctId", + PostHogUnresolvedFlagReason.UNSUPPORTED_DEFINITION, + ), propertyValues, cohortProperties, ) @@ -966,12 +1048,12 @@ internal class FlagEvaluator( } } catch (e: InconclusiveMatchException) { config.logger.log("Failed to compute property ${property.key} locally: ${e.message}") - errorMatchingLocally = true + inconclusiveReason = inconclusiveReason ?: e.reason } } - if (errorMatchingLocally) { - throw InconclusiveMatchException("Can't match cohort without a given cohort property value") + inconclusiveReason?.let { + throw InconclusiveMatchException("Can't match cohort without a given cohort property value", it) } // If we get here, all matched in AND case, or none matched in OR case @@ -1014,9 +1096,15 @@ internal class FlagEvaluator( evaluateFlagDependency( prop, flagsByKey - ?: throw InconclusiveMatchException("Cannot evaluate flag dependencies without flagsByKey"), + ?: throw InconclusiveMatchException( + "Cannot evaluate flag dependencies without flagsByKey", + PostHogUnresolvedFlagReason.UNSUPPORTED_DEFINITION, + ), evaluationCache - ?: throw InconclusiveMatchException("Cannot evaluate flag dependencies without evaluationCache"), + ?: throw InconclusiveMatchException( + "Cannot evaluate flag dependencies without evaluationCache", + PostHogUnresolvedFlagReason.UNSUPPORTED_DEFINITION, + ), distinctId, properties, cohortProperties, @@ -1061,11 +1149,14 @@ internal class FlagEvaluator( evaluationCache: MutableMap? = null, ): Any? { if (flag.ensureExperienceContinuity) { - throw InconclusiveMatchException("Flag \"${flag.key}\" has experience continuity enabled") + throw InconclusiveMatchException( + "Flag \"${flag.key}\" has experience continuity enabled", + PostHogUnresolvedFlagReason.EXPERIENCE_CONTINUITY, + ) } val flagConditions = flag.filters.groups ?: emptyList() - var isInconclusive = false + var inconclusiveReason: PostHogUnresolvedFlagReason? = null // Get variant keys for validation val flagVariants = flag.filters.multivariate?.variants ?: emptyList() @@ -1101,12 +1192,12 @@ internal class FlagEvaluator( return variant ?: true } } catch (e: InconclusiveMatchException) { - isInconclusive = true + inconclusiveReason = inconclusiveReason ?: e.reason } } - if (isInconclusive) { - throw InconclusiveMatchException("Can't determine if feature flag is enabled or not with given properties") + inconclusiveReason?.let { + throw InconclusiveMatchException("Can't determine if feature flag is enabled or not with given properties", it) } // We can only return False when either all conditions are False, or no condition was inconclusive @@ -1129,13 +1220,17 @@ internal class FlagEvaluator( if (dependencyChain == null) { throw InconclusiveMatchException( "Flag dependency property for '${property.key}' is missing required 'dependency_chain' field", + PostHogUnresolvedFlagReason.UNSUPPORTED_DEFINITION, ) } // Handle circular dependency (empty chain means circular) if (dependencyChain.isEmpty()) { config.logger.log("Circular dependency detected for flag: ${property.key}") - throw InconclusiveMatchException("Circular dependency detected for flag '${property.key}'") + throw InconclusiveMatchException( + "Circular dependency detected for flag '${property.key}'", + PostHogUnresolvedFlagReason.UNSUPPORTED_DEFINITION, + ) } // Evaluate all dependencies in the chain order @@ -1148,6 +1243,8 @@ internal class FlagEvaluator( evaluationCache[depFlagKey] = null throw InconclusiveMatchException( "Cannot evaluate flag dependency '$depFlagKey' - flag not found in local flags", + // No entry can exist for an unloaded dependency, so the definition itself is the cause. + PostHogUnresolvedFlagReason.UNSUPPORTED_DEFINITION, ) } else { // Check if the flag is active @@ -1169,7 +1266,10 @@ internal class FlagEvaluator( } catch (e: InconclusiveMatchException) { // If we can't evaluate a dependency, store null and propagate the error evaluationCache[depFlagKey] = null - throw InconclusiveMatchException("Cannot evaluate flag dependency '$depFlagKey': ${e.message}") + throw InconclusiveMatchException( + "Cannot evaluate flag dependency '$depFlagKey': ${e.message}", + PostHogUnresolvedFlagReason.UNRESOLVED_DEPENDENCY, + ) } } } @@ -1179,7 +1279,10 @@ internal class FlagEvaluator( val cachedResult = evaluationCache[depFlagKey] if (cachedResult == null) { // Previously inconclusive - raise error again - throw InconclusiveMatchException("Flag dependency '$depFlagKey' was previously inconclusive") + throw InconclusiveMatchException( + "Flag dependency '$depFlagKey' was previously inconclusive", + PostHogUnresolvedFlagReason.UNRESOLVED_DEPENDENCY, + ) } else if (cachedResult == false && depFlagKey != property.key) { // Definitive false result for intermediate dependency - chain failed // Only return false early if this is NOT the final flag we're checking @@ -1199,14 +1302,20 @@ internal class FlagEvaluator( if (actualValue == null) { // Flag wasn't evaluated - this shouldn't happen if dependency chain is correct - throw InconclusiveMatchException("Flag '$flagKey' was not evaluated despite being in dependency chain") + throw InconclusiveMatchException( + "Flag '$flagKey' was not evaluated despite being in dependency chain", + PostHogUnresolvedFlagReason.UNRESOLVED_DEPENDENCY, + ) } // For flag dependencies, we need to compare the actual flag result with expected value if (propertyOperator == PropertyOperator.FLAG_EVALUATES_TO) { return matchesDependencyValue(expectedValue, actualValue) } else { - throw InconclusiveMatchException("Flag dependency property for '${property.key}' has invalid operator '$propertyOperator'") + throw InconclusiveMatchException( + "Flag dependency property for '${property.key}' has invalid operator '$propertyOperator'", + PostHogUnresolvedFlagReason.UNSUPPORTED_DEFINITION, + ) } } diff --git a/posthog-server/src/main/java/com/posthog/server/internal/InconclusiveMatchException.kt b/posthog-server/src/main/java/com/posthog/server/internal/InconclusiveMatchException.kt index 43c0a750b..7e26a2e51 100644 --- a/posthog-server/src/main/java/com/posthog/server/internal/InconclusiveMatchException.kt +++ b/posthog-server/src/main/java/com/posthog/server/internal/InconclusiveMatchException.kt @@ -1,6 +1,12 @@ package com.posthog.server.internal +import com.posthog.server.PostHogUnresolvedFlagReason + /** * Exception thrown when flag evaluation cannot be determined locally */ -internal class InconclusiveMatchException(message: String) : Exception(message) +internal class InconclusiveMatchException( + message: String, + val reason: PostHogUnresolvedFlagReason, + cause: Throwable? = null, +) : Exception(message, cause) diff --git a/posthog-server/src/main/java/com/posthog/server/internal/PostHogFeatureFlags.kt b/posthog-server/src/main/java/com/posthog/server/internal/PostHogFeatureFlags.kt index 2f31d3956..31a23cd8b 100644 --- a/posthog-server/src/main/java/com/posthog/server/internal/PostHogFeatureFlags.kt +++ b/posthog-server/src/main/java/com/posthog/server/internal/PostHogFeatureFlags.kt @@ -12,6 +12,7 @@ import com.posthog.internal.PostHogFeatureFlagsInterface import com.posthog.internal.PostHogFlagsResponse import com.posthog.internal.PropertyGroup import com.posthog.server.PostHogFlagDefinitionCacheProvider +import com.posthog.server.PostHogUnresolvedFlagReason import java.io.IOException import java.io.StringReader import java.io.StringWriter @@ -77,6 +78,9 @@ internal class PostHogFeatureFlags( @Volatile private var definitionsLoadedAt: Long? = null + // Flag key to the definition version already warned about, so each warning fires once per version. + private val warnedUnresolvableFlagVersions = mutableMapOf() + @Volatile private var isLoading = false @@ -251,12 +255,14 @@ internal class PostHogFeatureFlags( /** * The result of one local evaluation pass. [flags] holds every flag that resolved, whether or - * not [needsRemote] is set, so callers can fill only the gaps from `/flags`. + * not [needsRemote] is set, so callers can fill only the gaps from `/flags`. [unresolved] holds + * why each flag with a local definition did not resolve. */ private data class LocalEvaluationOutcome( val flags: Map, val needsRemote: Boolean, val missingDefinitionKeys: Set, + val unresolved: Map, ) private class MissingFlagProbe { @@ -314,6 +320,7 @@ internal class PostHogFeatureFlags( config.logger.log("Attempting local evaluation for distinctId: $distinctId") val localFlags = mutableMapOf() + val unresolved = mutableMapOf() val props = localPersonProperties(distinctId, personProperties) val requestedKeys = flagKeys?.toHashSet() var needsRemote = false @@ -337,9 +344,12 @@ internal class PostHogFeatureFlags( localFlags[key] = buildFeatureFlagFromResult(key, result, flagDef) } catch (e: InconclusiveMatchException) { config.logger.log("Local evaluation inconclusive for flag '$key': ${e.message}") + unresolved[key] = e.reason needsRemote = true } catch (e: Exception) { + // An unexpected failure comes from a definition this evaluator cannot handle. config.logger.log("Local evaluation failed for flag '$key': ${e.message}") + unresolved[key] = PostHogUnresolvedFlagReason.UNSUPPORTED_DEFINITION needsRemote = true } } @@ -358,7 +368,7 @@ internal class PostHogFeatureFlags( } config.logger.log("Local evaluation resolved ${localFlags.size} flags, needsRemote=$needsRemote") - return LocalEvaluationOutcome(localFlags, needsRemote, missingDefinitionKeys) + return LocalEvaluationOutcome(localFlags, needsRemote, missingDefinitionKeys, unresolved) } /** @@ -762,9 +772,11 @@ internal class PostHogFeatureFlags( response: LocalEvaluationResponse, cacheData: Map?, ) { + var newlyUnresolvable = emptyList() val invalidated = synchronized(missingFlagKeysLock) { synchronized(loadLock) { + newlyUnresolvable = recordUnresolvableFlagsLocked(response.flags.orEmpty()) if (cacheData == null || cacheData != definitionSnapshot?.cacheData) { definitionSnapshot = DefinitionSnapshot( @@ -782,6 +794,26 @@ internal class PostHogFeatureFlags( invalidateMissingFlagStateLocked() } invalidated.forEach { it.complete() } + newlyUnresolvable.forEach { key -> + config.logger.log( + "Feature flag '$key' can never be evaluated locally " + + "(reason: ${PostHogUnresolvedFlagReason.EXPERIENCE_CONTINUITY.value}). " + + "It needs a /flags request to resolve.", + ) + } + } + + /** + * Returns the keys of active flags with experience continuity that were not warned about at + * their current version, and remembers them. Inactive flags resolve to false, so they are skipped. + */ + private fun recordUnresolvableFlagsLocked(flags: List): List { + val unresolvable = flags.filter { it.active && it.ensureExperienceContinuity } + val keys = unresolvable.mapTo(HashSet()) { it.key } + warnedUnresolvableFlagVersions.keys.retainAll(keys) + return unresolvable + .filter { warnedUnresolvableFlagVersions.put(it.key, it.version) != it.version } + .map { it.key } } private fun clearKnownMissingFlagKeys() { @@ -920,7 +952,10 @@ internal class PostHogFeatureFlags( if (groupTypeName == null) { config.logger.log("Unknown group type index $aggregationGroupIndex for flag '$key'") - throw InconclusiveMatchException("Flag has unknown group type index") + throw InconclusiveMatchException( + "Flag has unknown group type index", + PostHogUnresolvedFlagReason.UNSUPPORTED_DEFINITION, + ) } val groupKey = groups?.get(groupTypeName) @@ -1082,6 +1117,7 @@ internal class PostHogFeatureFlags( evaluatedAt = entry.evaluatedAt, definitionsLoadedAt = definitionsLoadedAt, responseError = entry.error, + unresolvedFlags = EMPTY_UNRESOLVED_FLAGS, ) } } @@ -1112,6 +1148,7 @@ internal class PostHogFeatureFlags( evaluatedAt = null, definitionsLoadedAt = definitionsLoadedAt, responseError = null, + unresolvedFlags = local.unresolved, ) } @@ -1158,6 +1195,8 @@ internal class PostHogFeatureFlags( evaluatedAt = entry?.evaluatedAt, definitionsLoadedAt = definitionsLoadedAt, responseError = entry?.error, + // A flag stays unresolved only when `/flags` failed or did not return it. + unresolvedFlags = local?.unresolved?.filterKeys { it !in merged }.orEmpty(), ) } @@ -1382,6 +1421,7 @@ internal class PostHogFeatureFlags( private val EMPTY_COHORT_PROPERTIES: Map = emptyMap() private val EMPTY_FLAGS: Map = emptyMap() private val EMPTY_LOCALLY_EVALUATED: Map = emptyMap() + private val EMPTY_UNRESOLVED_FLAGS: Map = emptyMap() private val EMPTY_EVALUATE_FLAGS_RESULT = EvaluateFlagsResult( flags = EMPTY_FLAGS, @@ -1390,6 +1430,7 @@ internal class PostHogFeatureFlags( evaluatedAt = null, definitionsLoadedAt = null, responseError = null, + unresolvedFlags = EMPTY_UNRESOLVED_FLAGS, ) } } diff --git a/posthog-server/src/test/java/com/posthog/server/PostHogEvaluateFlagsTest.kt b/posthog-server/src/test/java/com/posthog/server/PostHogEvaluateFlagsTest.kt index a04488502..581546fd9 100644 --- a/posthog-server/src/test/java/com/posthog/server/PostHogEvaluateFlagsTest.kt +++ b/posthog-server/src/test/java/com/posthog/server/PostHogEvaluateFlagsTest.kt @@ -715,6 +715,21 @@ internal class PostHogEvaluateFlagsTest { } } + @Test + fun `local only evaluation exposes an unresolved flag and reports it as inconclusive`() { + withLocalEvaluation(definitions = conclusiveAndGatedDefinitions()) { postHog, dispatcher, mockServer -> + val snapshot = postHog.evaluateFlags("user-1", onlyEvaluateLocally = true) + + assertEquals(mapOf("gated" to PostHogUnresolvedFlagReason.MISSING_CONTEXT), snapshot.unresolvedFlags) + assertFalse(snapshot.isEnabled("gated")) + postHog.flush() + + assertEquals(0, dispatcher.flagsCalls.get()) + val flagCalled = drainRequests(mockServer).featureFlagCalledEvents().toMap() + assertEquals("local_evaluation_inconclusive", assertNotNull(flagCalled["gated"])["\$feature_flag_error"]) + } + } + @Test fun `a flags outage leaves locally-resolved flags on and is not retried within the cache window`() { withLocalEvaluation( diff --git a/posthog-server/src/test/java/com/posthog/server/PostHogFeatureFlagEvaluationsTest.kt b/posthog-server/src/test/java/com/posthog/server/PostHogFeatureFlagEvaluationsTest.kt index 467ae476c..c74f7b81c 100644 --- a/posthog-server/src/test/java/com/posthog/server/PostHogFeatureFlagEvaluationsTest.kt +++ b/posthog-server/src/test/java/com/posthog/server/PostHogFeatureFlagEvaluationsTest.kt @@ -74,6 +74,7 @@ internal class PostHogFeatureFlagEvaluationsTest { definitionsLoadedAt: Long? = null, responseError: String? = null, groups: Map? = null, + unresolvedFlags: Map = emptyMap(), ) = PostHogFeatureFlagEvaluations( distinctId = distinctId, flagMap = flags, @@ -84,6 +85,7 @@ internal class PostHogFeatureFlagEvaluationsTest { responseError = responseError, host = host, groups = groups, + unresolvedFlags = unresolvedFlags, ) @Test @@ -129,6 +131,42 @@ internal class PostHogFeatureFlagEvaluationsTest { ) } + @Test + fun `reading an unresolved flag reports local_evaluation_inconclusive alongside response errors`() { + val host = FakeHost() + val snapshot = + snapshot( + host = host, + responseError = "errors_while_computing_flags", + unresolvedFlags = mapOf("checkout" to PostHogUnresolvedFlagReason.EXPERIENCE_CONTINUITY), + ) + + assertFalse(snapshot.isEnabled("checkout")) + + assertEquals( + "errors_while_computing_flags,local_evaluation_inconclusive", + host.captures.single().properties["\$feature_flag_error"], + ) + } + + @Test + fun `filtered snapshots do not carry unresolved flags`() { + val host = FakeHost() + val snapshot = + snapshot( + host = host, + flags = mapOf("known" to flag("known")), + unresolvedFlags = mapOf("checkout" to PostHogUnresolvedFlagReason.MISSING_CONTEXT), + ) + + val filtered = snapshot.only("known", "checkout") + filtered.isEnabled("checkout") + + assertTrue(filtered.unresolvedFlags.isEmpty()) + assertEquals(mapOf("checkout" to PostHogUnresolvedFlagReason.MISSING_CONTEXT), snapshot.unresolvedFlags) + assertEquals("flag_missing", host.captures.single().properties["\$feature_flag_error"]) + } + @Test fun `isEnabled propagates groups to the host so dedup can include group context`() { val host = FakeHost() diff --git a/posthog-server/src/test/java/com/posthog/server/internal/FlagEvaluatorTest.kt b/posthog-server/src/test/java/com/posthog/server/internal/FlagEvaluatorTest.kt index 66efe327a..7d8fcc6e0 100644 --- a/posthog-server/src/test/java/com/posthog/server/internal/FlagEvaluatorTest.kt +++ b/posthog-server/src/test/java/com/posthog/server/internal/FlagEvaluatorTest.kt @@ -7,6 +7,7 @@ import com.posthog.internal.FlagProperty import com.posthog.internal.PropertyGroup import com.posthog.internal.PropertyOperator import com.posthog.internal.PropertyType +import com.posthog.server.PostHogUnresolvedFlagReason import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull @@ -3123,4 +3124,74 @@ internal class FlagEvaluatorTest { assertEquals(true, result) assertEquals(false, evaluationCache["base-flag"]) } + + @Test + internal fun testInconclusiveReasonSeparatesDefinitionFromCallerValues() { + data class Row(val name: String, val groups: String, val properties: Map, val reason: PostHogUnresolvedFlagReason) + + fun condition(property: String) = """{"properties": [$property], "rollout_percentage": 100}""" + val rows = + listOf( + Row( + "caller float", + condition("""{"key": "n", "value": "1", "operator": "exact", "type": "person"}"""), + mapOf("n" to 1.5), + PostHogUnresolvedFlagReason.MISSING_CONTEXT, + ), + Row( + "definition float", + condition("""{"key": "n", "value": 1.5, "operator": "exact", "type": "person"}"""), + mapOf("n" to "x"), + PostHogUnresolvedFlagReason.UNSUPPORTED_DEFINITION, + ), + Row( + "caller date", + condition("""{"key": "d", "value": "2024-01-01", "operator": "is_date_before", "type": "person"}"""), + mapOf("d" to "not-a-date"), + PostHogUnresolvedFlagReason.MISSING_CONTEXT, + ), + Row( + "definition date", + condition("""{"key": "d", "value": "not-a-date", "operator": "is_date_before", "type": "person"}"""), + mapOf("d" to "2024-01-01"), + PostHogUnresolvedFlagReason.UNSUPPORTED_DEFINITION, + ), + Row( + "caller semver", + condition("""{"key": "v", "value": "1.2.3", "operator": "semver_gt", "type": "person"}"""), + mapOf("v" to "abc"), + PostHogUnresolvedFlagReason.MISSING_CONTEXT, + ), + Row( + "definition semver", + condition("""{"key": "v", "value": "abc", "operator": "semver_gt", "type": "person"}"""), + mapOf("v" to "1.2.3"), + PostHogUnresolvedFlagReason.UNSUPPORTED_DEFINITION, + ), + Row( + "unloaded dependency", + condition( + """{"key": "gone", "value": true, "operator": "flag_evaluates_to", "type": "flag", "dependency_chain": ["gone"]}""", + ), + emptyMap(), + PostHogUnresolvedFlagReason.UNSUPPORTED_DEFINITION, + ), + Row( + "first inconclusive condition wins", + condition("""{"key": "plan", "value": "pro", "operator": "exact", "type": "person"}""") + "," + + condition("""{"key": "id", "value": 99, "operator": "in", "type": "cohort"}"""), + emptyMap(), + PostHogUnresolvedFlagReason.MISSING_CONTEXT, + ), + ) + for (row in rows) { + val json = """{"id": 1, "name": "f", "key": "f", "active": true, "filters": {"groups": [${row.groups}]}, "version": 1}""" + val flag = config.serializer.gson.fromJson(json, FlagDefinition::class.java) + val error = + kotlin.test.assertFailsWith(row.name) { + evaluator.matchFeatureFlagProperties(flag, "user-123", row.properties, emptyMap(), mapOf("f" to flag), mutableMapOf()) + } + assertEquals(row.name, row.reason, error.reason) + } + } } diff --git a/posthog-server/src/test/java/com/posthog/server/internal/PostHogFeatureFlagsTest.kt b/posthog-server/src/test/java/com/posthog/server/internal/PostHogFeatureFlagsTest.kt index dff366e11..9997f3560 100644 --- a/posthog-server/src/test/java/com/posthog/server/internal/PostHogFeatureFlagsTest.kt +++ b/posthog-server/src/test/java/com/posthog/server/internal/PostHogFeatureFlagsTest.kt @@ -4,6 +4,7 @@ import com.posthog.internal.PostHogApi import com.posthog.server.CountingDispatcher import com.posthog.server.PostHogBlockingFlagDefinitionCacheProvider import com.posthog.server.PostHogFlagDefinitionCacheProvider +import com.posthog.server.PostHogUnresolvedFlagReason import com.posthog.server.TestLogger import com.posthog.server.conclusiveFlagDefinition import com.posthog.server.createEmptyFlagsResponse @@ -3961,4 +3962,196 @@ internal class PostHogFeatureFlagsTest { } } } + + private fun unresolvedTestFlag( + key: String, + properties: String = "[]", + continuity: Boolean = false, + active: Boolean = true, + version: Int = 1, + ): String = + """ + { + "id": 1, + "name": "$key", + "key": "$key", + "active": $active, + "ensure_experience_continuity": $continuity, + "filters": {"groups": [{"properties": $properties, "rollout_percentage": 100}]}, + "version": $version + } + """.trimIndent() + + private fun localFeatureFlags( + mockServer: MockWebServer, + logger: TestLogger = TestLogger(), + ): PostHogFeatureFlags { + val config = createTestConfig(logger, mockServer.url("/").toString()) + return PostHogFeatureFlags( + config, + PostHogApi(config), + 60000, + 100, + localEvaluation = true, + personalApiKey = "test-personal-key", + pollerEnabled = false, + ) + } + + private fun PostHogFeatureFlags.evaluate( + onlyEvaluateLocally: Boolean, + flagKeys: List? = null, + ): EvaluateFlagsResult = + evaluateFlags( + distinctId = "user-123", + groups = null, + personProperties = null, + groupProperties = null, + flagKeys = flagKeys, + onlyEvaluateLocally = onlyEvaluateLocally, + disableGeoip = false, + ) + + @Test + fun `local only evaluation reports each unresolved flag with its reason`() { + val mockServer = + createMockHttp( + jsonResponse( + createLocalEvaluationResponseFrom( + unresolvedTestFlag("beta-ui"), + unresolvedTestFlag("continuity-flag", continuity = true), + unresolvedTestFlag( + "plan-flag", + """[{"key": "plan", "value": "pro", "operator": "exact", "type": "person"}]""", + ), + unresolvedTestFlag( + "static-cohort-flag", + """[{"key": "id", "value": 99, "operator": "in", "type": "cohort"}]""", + ), + unresolvedTestFlag( + "dependent-flag", + """[{"key": "continuity-flag", "type": "flag", "value": true, + "operator": "flag_evaluates_to", "dependency_chain": ["continuity-flag"]}]""", + ), + ), + ), + ) + val featureFlags = localFeatureFlags(mockServer) + + val result = featureFlags.evaluate(onlyEvaluateLocally = true) + + assertEquals(setOf("beta-ui"), result.flags.keys) + assertEquals( + mapOf( + "continuity-flag" to PostHogUnresolvedFlagReason.EXPERIENCE_CONTINUITY, + "plan-flag" to PostHogUnresolvedFlagReason.MISSING_CONTEXT, + "static-cohort-flag" to PostHogUnresolvedFlagReason.UNSUPPORTED_DEFINITION, + "dependent-flag" to PostHogUnresolvedFlagReason.UNRESOLVED_DEPENDENCY, + ), + result.unresolvedFlags, + ) + assertEquals(1, mockServer.requestCount, "only the definitions load is allowed") + + featureFlags.shutDown() + mockServer.shutdown() + } + + @Test + fun `inactive and unrequested flags are never unresolved`() { + val mockServer = + createMockHttp( + jsonResponse( + createLocalEvaluationResponseFrom( + unresolvedTestFlag("beta-ui"), + unresolvedTestFlag("disabled-flag", continuity = true, active = false), + unresolvedTestFlag("continuity-flag", continuity = true), + ), + ), + ) + val featureFlags = localFeatureFlags(mockServer) + + val result = featureFlags.evaluate(onlyEvaluateLocally = true, flagKeys = listOf("beta-ui", "disabled-flag")) + + assertEquals(false, result.flags["disabled-flag"]?.enabled) + assertTrue(result.unresolvedFlags.isEmpty()) + + featureFlags.shutDown() + mockServer.shutdown() + } + + @Test + fun `a flag stays unresolved only when the remote fallback does not fill it`() { + val definitions = jsonResponse(createLocalEvaluationResponseFrom(unresolvedTestFlag("checkout", continuity = true))) + + val resolvedServer = createMockHttp(definitions, jsonResponse(createFlagsResponse("checkout", enabled = true))) + val resolvedFlags = localFeatureFlags(resolvedServer) + val resolved = resolvedFlags.evaluate(onlyEvaluateLocally = false) + assertEquals(true, resolved.flags["checkout"]?.enabled) + assertTrue(resolved.unresolvedFlags.isEmpty()) + + for (fallback in listOf(errorResponse(500), jsonResponse(createEmptyFlagsResponse()))) { + val server = createMockHttp(definitions, fallback) + val flags = localFeatureFlags(server) + val result = flags.evaluate(onlyEvaluateLocally = false) + assertNull(result.flags["checkout"]) + assertEquals(mapOf("checkout" to PostHogUnresolvedFlagReason.EXPERIENCE_CONTINUITY), result.unresolvedFlags) + flags.shutDown() + server.shutdown() + } + + resolvedFlags.shutDown() + resolvedServer.shutdown() + } + + @Test + fun `a requested key without a local definition is never unresolved`() { + val mockServer = + createMockHttp( + jsonResponse(createLocalEvaluationResponseFrom(unresolvedTestFlag("checkout", continuity = true))), + jsonResponse(createEmptyFlagsResponse()), + ) + val featureFlags = localFeatureFlags(mockServer) + + val result = featureFlags.evaluate(onlyEvaluateLocally = false, flagKeys = listOf("checkout", "typo-flag")) + + assertEquals(mapOf("checkout" to PostHogUnresolvedFlagReason.EXPERIENCE_CONTINUITY), result.unresolvedFlags) + assertEquals(2, mockServer.requestCount, "one fallback covers both keys") + + featureFlags.shutDown() + mockServer.shutdown() + } + + @Test + fun `loading a continuity flag warns once per definition version`() { + val logger = TestLogger() + val mockServer = + createMockHttp( + jsonResponse( + createLocalEvaluationResponseFrom( + unresolvedTestFlag("checkout", continuity = true), + unresolvedTestFlag("disabled-flag", continuity = true, active = false), + ), + ), + jsonResponse( + createLocalEvaluationResponseFrom( + unresolvedTestFlag("checkout", continuity = true), + unresolvedTestFlag("beta-ui"), + ), + ), + jsonResponse(createLocalEvaluationResponseFrom(unresolvedTestFlag("checkout", continuity = true, version = 2))), + ) + val featureFlags = localFeatureFlags(mockServer, logger) + val warning = "Feature flag 'checkout' can never be evaluated locally (reason: experience_continuity)" + + featureFlags.loadFeatureFlagDefinitions() + featureFlags.loadFeatureFlagDefinitions() + assertEquals(1, logger.countLogs(warning)) + assertFalse(logger.containsLog("'disabled-flag' can never be evaluated locally"), "inactive flags resolve to false") + + featureFlags.loadFeatureFlagDefinitions() + assertEquals(2, logger.countLogs(warning), "a new version warns again") + + featureFlags.shutDown() + mockServer.shutdown() + } }