feat(flags): surface payloads from local flag evaluation - #199
Conversation
Prompt To Fix All With AI### Issue 1
src/feature_flags.rs:1068
**Cyclic cohort recursion is unbounded**
When definitions contain cyclic cohort references, `match_nested_cohort` repeatedly re-enters `match_cohort_by_id` without tracking active IDs or limiting depth, causing local flag evaluation to exhaust the thread stack and potentially abort the process.
### Issue 2
src/client/async_client.rs:1067
**Payload crosses definitions snapshots**
If the definitions poller updates a flag after local evaluation but before this lookup, `flag_payload` reads the replacement cache and pairs the previously evaluated value with a new, changed, or absent payload, returning an internally inconsistent flag record. The blocking client has the same split lookup.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.Reviews (1): Last reviewed commit: "feat(flags): surface payloads from local..." | Re-trigger Greptile |
| .and_then(|n| n.as_bool()) | ||
| .unwrap_or(false); | ||
|
|
||
| let is_member = match_cohort_by_id(&cohort_id, properties, ctx)?; |
There was a problem hiding this comment.
Cyclic cohort recursion is unbounded
When definitions contain cyclic cohort references, match_nested_cohort repeatedly re-enters match_cohort_by_id without tracking active IDs or limiting depth, causing local flag evaluation to exhaust the thread stack and potentially abort the process.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/feature_flags.rs
Line: 1068
Comment:
**Cyclic cohort recursion is unbounded**
When definitions contain cyclic cohort references, `match_nested_cohort` repeatedly re-enters `match_cohort_by_id` without tracking active IDs or limiting depth, causing local flag evaluation to exhaust the thread stack and potentially abort the process.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.The definitions manifest carries each flag's payloads, but locally
evaluated flags always reported `payload: None`, so `get_flag_payload`
returned nothing for them. Worse, turning local evaluation on removed
payload access that remote-only callers had: when `flag_keys` is fully
covered locally there is no `/flags` round trip left to recover the
payload from, and locally evaluated keys are skipped when merging remote
records.
Local evaluation now resolves the payload for the matched value ("true"
for a boolean match, the variant key for a multivariate one) and decodes
it through the same `normalize_payload` the remote path uses, so a flag
returns the same payload whichever path evaluated it. A flag that
evaluated false gets no payload, matching `/flags`, which only attaches
one to a matching flag.
Generated-By: PostHog Code
Task-Id: 52072a7c-7431-4b59-8de1-90d9ababf0db
f99eafc to
533e153
Compare
|
Heads up for anyone mid-review: I force-pushed a rebase just now, sorry for the churn. #187 was squash-merged, so the five commits this branch inherited from its branch were no longer in main's history. Even after GitHub retargeted the base to The branch is now a single commit replayed onto current Separately: the cohort recursion Greptile flagged looks like a genuine bug in the merged code, not a false positive. (Claude in PostHog Code here, replying via Steven's account.) |
posthog-rs-v0 Compliance ReportDate: 2026-08-10 08:17:07 UTC ✅ All Tests Passed!46/46 tests passed Capture Tests✅ 29/29 tests passed View Details
Feature_Flags Tests✅ 17/17 tests passed View Details
|
posthog-rs-v1 Compliance ReportDate: 2026-08-10 08:18:14 UTC ✅ All Tests Passed!111/111 tests passed Capture_V1 Tests✅ 94/94 tests passed View Details
Feature_Flags Tests✅ 17/17 tests passed View Details
|
|
pushed a fix |
Problem
Local flag evaluation never surfaced feature flag payloads, even though the payloads ship in the definitions manifest and were already deserialized into the cache (
FeatureFlagFilters.payloads).local_record()hardcodedpayload: None, soFeatureFlagEvaluations::get_flag_payload()returnedNonefor every locally-evaluated flag, silently.It is worse than a missing feature: turning local evaluation on removes payload access that remote-only callers have. In
evaluate_flags, the/flagsround trip is skipped entirely when the caller passedflag_keysand local evaluation covered all of them, and even when/flagsis called, locally-evaluated keys are skipped when merging the remote records. Either path loses the payload. The only thing that still worked was the deprecatedget_feature_flag_payload(), which posts straight to/flagsand costs a billed request per call.Rust was the only PostHog server SDK with local evaluation that did not resolve payloads locally. Python, Go, and Node all do.
Reported by a customer using server-side local evaluation.
What changed
(See also #200 )
Wiring only. No public API change, no new types, no signature changes to the public evaluator methods (
api/public-api.txtregenerates clean).FlagCache::flag_payload(key, value)(src/local_evaluation.rs) looks the payload up by the matched value:"true"for a boolean match, the variant key for a multivariate one. It mirrors the existinghas_experimentaccessor, so it reads one field under the read lock instead of cloning the wholeFeatureFlag.local_record()(src/client/common.rs) takes the resolved payload and runs it through the pre-existingnormalize_payload(), the same helper the remote path already uses.blocking.rsandasync_client.rs) pass it through.On the double-encoding trap
Payloads arrive JSON-encoded from both endpoints:
filters.payloads[key]in the definitions manifest andmetadata.payloadin/flags?v=2are literally the same stored value, and the flags service does not decode either one. So the correctness requirement is to apply the same decode on both paths, whichnormalize_payloadalready does for remote (parse aValue::String, fall back to the raw string when it does not parse). Reusing it means the same flag returns the same payload whichever path evaluated it. Tests cover all four shapes: JSON-encoded object, already-parsed object, double-encoded string, and an undecodable string that falls back raw.A flag that evaluated false gets no payload
Deliberate, for parity with
/flags: the flags service only computesget_matching_payloadon a matching flag, so a disabled flag has no payload remotely either. Worth flagging that posthog-go and posthog-python do an unconditional"false"key lookup here, while posthog-js guards it out the way this PR does. Since PostHog never stores a"false"payload and the server never returns one, matching our own remote path seemed more important than matching Go's lookup shape. Happy to flip it if you disagree.Data shape change, please read
Once the payload is populated,
build_called_event_propertiesstarts attaching$feature_flag_payloadto$feature_flag_calledevents for locally-evaluated flags, where today it does not. That is the intended parity with remote evaluation, and the event-minimization allowlist strips the property when the gate is on (existing tests inv0_capture.rs/v1_capture.rsassert this and still pass). But the gate defaults to off, so for customers who have not opted into minimization this is an observable change to their own event data with a small ingestion cost attached. Calling it out rather than leaving it to be discovered.A security review specifically checked where a payload can now travel: only into
$feature_flag_calledproperties, which is the customer's own project. It does not reach logs, error hooks, panic messages, or exception capture, and the per-variant lookup cannot return another flag's or another variant's payload.Tests
The bug shipped because of a test blind spot: every fixture in the repo built
payloads: HashMap::new(), so the non-empty payload path had never been exercised (the same shape of gap that hid the cohort bug in #187, where every fixture used"cohorts": {}). New fixtures use a real non-empty payloads map:"true"keyNone"true"payload, asserting both that the flag is present asBoolean(false)and that its payload isNoneevaluate_flagswithflag_keysset solocal_covers_requestis true, asserting/flagsis never calledUnit tests run against both the blocking and async clients, since the two duplicate this call site.
Not in this PR
The deprecated
get_feature_flag_payload()still posts to/flagsunconditionally instead of trying local first (which is what Go does). It is a real win, since it would stop charging people for payload lookups they could resolve locally, but it touches deprecated surface and is a separate behavior change. Happy to do it as a follow-up.Base
Branched from #187 (cohort deserialization), which is open and touches the same files. Review that one first. This PR does not touch the cohort work.
Created with PostHog Code