Skip to content

fix(server): keep locally resolvable flags in evaluateFlags instead of falling back for the whole set - #678

Draft
posthog[bot] wants to merge 1 commit into
mainfrom
posthog-self-driving/fixserver-keep-locally-resolvable-flags-568374
Draft

fix(server): keep locally resolvable flags in evaluateFlags instead of falling back for the whole set#678
posthog[bot] wants to merge 1 commit into
mainfrom
posthog-self-driving/fixserver-keep-locally-resolvable-flags-568374

Conversation

@posthog

@posthog posthog Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

💡 Motivation and Context

Reported by a customer on the server SDK using local evaluation: evaluateFlags threw away every locally resolved flag the moment a single flag couldn't be resolved locally, forcing an unnecessary /flags round trip for the whole set. That means extra latency and extra billed /flags requests on requests that should have resolved entirely in-process — a carryover of the legacy getFeatureFlags all-or-nothing behaviour into the new API.

Three compounding defects in PostHogFeatureFlags.kt:

  • getFeatureFlagsFromLocalEvaluation() did a bare return null on the first InconclusiveMatchException, discarding everything it had already computed.
  • flagKeys didn't scope local evaluation — the loop walked every definition and the requested keys were filtered out afterwards, so a flag the caller never asked about could trigger the bail.
  • FeatureFlagCacheKey omitted onlyEvaluateLocally, so the two-pass workaround (local-only pass, then a fallback pass) hit the same cached entry on both passes.

The fix mirrors the shape the node SDK already uses (packages/node): catch inconclusive matches per flag, keep the successes, and only fetch the unresolved keys.

Changes

  • Keep successes, merge the rest. getFeatureFlagsFromLocalEvaluation() now evaluates each flag independently, keeps every one that resolves, and reports whether any requested flag was inconclusive. evaluateFlags() merges the /flags response in for the unresolved keys, letting locally resolved flags win instead of being overwritten.
  • Scope by flagKeys before the loop, so an unrelated inconclusive flag can't force a remote fallback.
  • Add onlyEvaluateLocally to FeatureFlagCacheKey, so the local-only and fallback passes no longer share a cached entry.
  • Legacy getFeatureFlags() keeps its documented all-or-nothing contract.

💚 How did you test it?

  • ./gradlew :posthog-server:test — full server suite green, including three new tests in PostHogFeatureFlagsTest:
    • locally resolved flags survive while only the inconclusive flag is fetched from /flags
    • flagKeys scoping keeps an unrelated inconclusive flag from triggering a remote fallback (no /flags request)
    • onlyEvaluateLocally passes no longer share a cache entry
  • ./gradlew spotlessCheck and :posthog-server:apiCheck pass (no public API change).

📝 Checklist

  • I reviewed the submitted code.
  • I added tests to verify the changes.
  • I updated the docs if needed.
  • No breaking change or entry added to the changelog.

If releasing new changes

  • Ran pnpm changeset to generate a changeset file

🤖 Agent context

Autonomy: Fully autonomous

  • Authored by Claude Code (Opus 4.8) via the PostHog Code harness, from an inbox report on the posthog/posthog-js repo. The report referenced the node implementation as the reference and correctly noted the fix belongs in posthog-android's posthog-server module, not in posthog-js — verified the node code (getAllFlagsAndPayloads + the locallyEvaluatedKeys merge in client.ts) already implements the correct behaviour and ported its shape here.
  • Considered but rejected changing the legacy getFeatureFlags() semantics — kept its all-or-nothing contract to limit blast radius; the reported defect is specific to evaluateFlags.
  • Chose to overwrite the raw-remote cache entry with the merged view so later cache hits stay consistent with the first response (the hit path recomputes locallyEvaluated per flag from each flag's reason code).

Created with PostHog Desktop from this inbox report.

…f falling back for the whole set

getFeatureFlagsFromLocalEvaluation() discarded every already-resolved flag on the
first InconclusiveMatchException, so evaluateFlags() sent the entire set to /flags
whenever any one flag needed server evaluation.

- Keep each flag that resolves locally and only mark that a remote fallback is needed;
  merge the /flags response in for the unresolved keys instead of overwriting local results.
- Filter the definition map by flagKeys before the evaluation loop so a flag the caller
  never asked about can't trigger a remote fallback.
- Add onlyEvaluateLocally to FeatureFlagCacheKey so a local-only pass and a fallback pass
  no longer share a cached entry.

Generated-By: PostHog Code
Task-Id: 6ed876d2-adb9-403f-ba35-1071a9a782ec
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants