Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .changeset/unresolved-local-flags.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 12 additions & 0 deletions posthog-server/api/posthog-server.api
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}

Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,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,22 @@ 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 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<String, PostHogUnresolvedFlagReason> =
Collections.unmodifiableMap(LinkedHashMap(unresolvedFlags))

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

Expand Down Expand Up @@ -246,7 +259,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,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.
*/
Comment on lines +19 to +22

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Missing group keys are not reported
A group flag evaluated without its group key resolves to false; it does not appear in unresolvedFlags with MISSING_CONTEXT. This documentation tells callers to expect a signal they will not receive. Remove “group key” from the description, or change evaluation to report that case as inconclusive.

Suggested change
/**
* 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.
*/
/**
* 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.
*/

Knowledge Base Used: Server feature-flag evaluation

Prompt To Fix With AI
This is a comment left during a code review.
Path: posthog-server/src/main/java/com/posthog/server/PostHogUnresolvedFlagReason.kt
Line: 19-22

Comment:
**Missing group keys are not reported**
A group flag evaluated without its group key resolves to `false`; it does not appear in `unresolvedFlags` with `MISSING_CONTEXT`. This documentation tells callers to expect a signal they will not receive. Remove “group key” from the description, or change evaluation to report that case as inconclusive.

```suggestion
    /**
     * 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.
     */
```

**Knowledge Base Used:** [Server feature-flag evaluation](https://app.greptile.com/posthog-org-19734/-/custom-context/knowledge-base/posthog/posthog-android/-/docs/server-feature-flag-evaluation.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

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"),
}
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