Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/unresolved-local-flags.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"posthog-server": minor
---

Add `unresolvedFlags` to the `evaluateFlags()` snapshot. It lists the requested 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` or when the fallback fails. Each entry has a `PostHogUnresolvedFlagReason`: `EXPERIENCE_CONTINUITY`, `UNSUPPORTED_DEFINITION`, `UNRESOLVED_DEPENDENCY` or `MISSING_CONTEXT`. When several causes apply, the first reason in that order is reported. Snapshots from `only()` and `onlyAccessed()` have no unresolved flags.

Behavior changes:

- A condition with a filter that local evaluation cannot check and a filter that does not match now resolves locally as no match. Before, the flag fell back to a `/flags` request, or stayed absent with `onlyEvaluateLocally`.
- `$feature_flag_called` events from snapshot reads of an unresolved flag report `$feature_flag_error: local_evaluation_inconclusive` instead of `flag_missing`, so insights that filter on `flag_missing` count fewer events.
- When definitions load, the SDK logs a warning, also with `debug` off, that names the flags local evaluation can never resolve: flags with experience continuity, and flags that use a static cohort, an unknown operator, a malformed value, or a missing or circular dependency. Each flag is warned about once until its definition changes.
12 changes: 12 additions & 0 deletions posthog-server/api/posthog-server.api
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,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;
Expand Down Expand Up @@ -515,6 +516,17 @@ 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;
}

public final class com/posthog/server/otel/PostHogContextSpanProcessor : io/opentelemetry/sdk/trace/SpanProcessor {
public fun <init> (Lcom/posthog/server/PostHogInterface;)V
public fun forceFlush ()Lio/opentelemetry/sdk/common/CompletableResultCode;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,7 @@ public class PostHog : PostHogStateless(), PostHogInterface {
responseError = result.responseError,
host = evaluationsHost,
groups = groups,
unresolvedFlags = result.unresolvedFlags,
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -38,11 +40,24 @@ public class PostHogFeatureFlagEvaluations internal constructor(
private val host: EvaluationsHost,
initialAccessed: Set<String> = emptySet(),
groups: Map<String, String>? = null,
unresolvedFlags: Map<String, PostHogUnresolvedFlagReason> = emptyMap(),
) {
private val flagMap: Map<String, FeatureFlag> = Collections.unmodifiableMap(LinkedHashMap(flagMap))
private val locallyEvaluated: Map<String, Boolean> = Collections.unmodifiableMap(HashMap(locallyEvaluated))
private val groups: Map<String, String>? = groups?.let { Collections.unmodifiableMap(HashMap(it)) }

/**
* Flags in the requested scope 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 or does not return the flag.
* 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 causes apply to a flag, the reason is the
* first one in the order of [PostHogUnresolvedFlagReason]. Always empty on snapshots returned by
* [only] and [onlyAccessed].
*/
public val unresolvedFlags: Map<String, PostHogUnresolvedFlagReason> =
Collections.unmodifiableMap(LinkedHashMap(unresolvedFlags))

private val accessLock = Any()
private val accessed: MutableSet<String> = HashSet(initialAccessed)

Expand Down Expand Up @@ -246,7 +261,15 @@ public class PostHogFeatureFlagEvaluations internal constructor(
val error =
buildList<String> {
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(",")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
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.
* When several causes apply to one flag, the SDK reports the first reason in the order listed here.
*
* @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, a malformed value, a dependency with no loaded definition or a circular
* dependency. Change the flag or upgrade the SDK.
*/
UNSUPPORTED_DEFINITION("unsupported_definition"),

/**
* 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"),

/**
* The call did not supply a property or group property the flag's conditions need, or supplied
* it in a form the evaluator cannot use. Pass it.
*/
MISSING_CONTEXT("missing_context"),
}
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<String, PostHogUnresolvedFlagReason>,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand Down
Loading
Loading