Skip to content

feat(flags): add local feature flag evaluation - #192

Open
marandaneto wants to merge 9 commits into
mainfrom
feat/local-feature-flag-evaluation
Open

feat(flags): add local feature flag evaluation#192
marandaneto wants to merge 9 commits into
mainfrom
feat/local-feature-flag-evaluation

Conversation

@marandaneto

@marandaneto marandaneto commented Aug 25, 2026

Copy link
Copy Markdown
Member

💡 Motivation and Context

PostHog Elixir currently evaluates feature flags through the remote /flags endpoint. Applications cannot poll flag definitions and evaluate supported flags locally, which adds network latency and makes flag checks depend on a successful request.

This adds local feature flag evaluation for applications that configure a project secret key or a suitably scoped personal API key. Existing applications without a secret key remain remote-only.

Closes #148.

Changes

  • Poll /flags/definitions with ETag support and keep the last valid definitions when a refresh fails. Evaluations can continue reading the cached definitions while a refresh is in progress.
  • Evaluate supported person, group, cohort, dependency, rollout, and multivariate conditions locally, including payloads. The evaluator adds the built-in distinct_id and $group_key properties when callers do not provide overrides.
  • Match is_set using property-key presence, including explicitly provided nil, to match the flags-service runtime and the corrected contract in sdk-specs#49.
  • Fall back to at most one /flags request when local data is unavailable or inconclusive. Empty unscoped definition snapshots also fall back unless local-only mode is requested.
  • Bound and invalidate knowledge about missing flag keys. Probe ownership prevents delayed omissions from reinstalling knowledge cleared by newer positive responses.
  • Translate disable_geoip to the /flags wire key geoip_disable.
  • Preserve normalized group context in snapshots and $feature_flag_called deduplication. Called events also report whether a result was evaluated locally.
  • Skip malformed individual remote flag entries while preserving valid local and remote results.
  • Add an optional cache provider behavior for sharing definition snapshots between PostHog instances.
  • Add configuration for polling and request timeouts, plus a public secret_key option.

💚 How did you test it?

  • mix format --check-formatted
  • mix compile --warnings-as-errors
  • mix credo --strict
  • mix posthog.public_api --check
  • mix test - 400 tests passed and 18 integration-tagged tests were excluded
  • git diff --check origin/main...HEAD

The new tests cover local evaluator behavior, definition polling, ETag responses, stale definitions during refresh, empty definitions, cache providers, malformed and failed remote fallbacks, GeoIP request translation, group-aware called-event deduplication, local-evaluation provenance, named instances, concurrent missing-key probes, delayed omission ordering, and public API integration with mocked PostHog responses.

📝 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 sampo add to generate a changeset file

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Implemented with the Pi coding agent. The implementation was compared with the flags-service runtime, the current SDK specification, and maintained server SDKs. Final autoreview reported no actionable findings for the branch tree signed in commit 95b7f02.

@marandaneto marandaneto self-assigned this Aug 25, 2026
@github-actions

github-actions Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

posthog-elixir Compliance Report

Date: 2026-08-26 16:12:03 UTC
Duration: 117643ms

✅ All Tests Passed!

46/46 tests passed


Capture Tests

29/29 tests passed

View Details
Test Status Duration
Format Validation.Event Has Required Fields 609ms
Format Validation.Event Has Uuid 609ms
Format Validation.Event Has Lib Properties 609ms
Format Validation.Distinct Id Is String 609ms
Format Validation.Token Is Present 609ms
Format Validation.Custom Properties Preserved 610ms
Format Validation.Event Has Timestamp 610ms
Retry Behavior.Retries On 503 5614ms
Retry Behavior.Does Not Retry On 400 2612ms
Retry Behavior.Does Not Retry On 401 2612ms
Retry Behavior.Respects Retry After Header 5615ms
Retry Behavior.Implements Backoff 15625ms
Retry Behavior.Retries On 500 5615ms
Retry Behavior.Retries On 502 5616ms
Retry Behavior.Retries On 504 5616ms
Retry Behavior.Max Retries Respected 15626ms
Deduplication.Generates Unique Uuids 621ms
Deduplication.Preserves Uuid On Retry 5614ms
Deduplication.Preserves Uuid And Timestamp On Retry 10621ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry 5617ms
Deduplication.No Duplicate Events In Batch 616ms
Deduplication.Different Events Have Different Uuids 612ms
Compression.Sends Gzip When Enabled 609ms
Batch Format.Uses Proper Batch Structure 609ms
Batch Format.Flush With No Events Sends Nothing 606ms
Batch Format.Multiple Events Batched Together 613ms
Error Handling.Does Not Retry On 403 2611ms
Error Handling.Does Not Retry On 413 2612ms
Error Handling.Retries On 408 5615ms

Feature_Flags Tests

17/17 tests passed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id 610ms
Request Payload.Flags Request Uses V2 Query Param 609ms
Request Payload.Flags Request Hits Flags Path Not Decide 609ms
Request Payload.Flags Request Omits Authorization Header 609ms
Request Payload.Token In Flags Body Matches Init 609ms
Request Payload.Groups Round Trip 609ms
Request Payload.Groups Default To Empty Object 609ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False 609ms
Request Payload.Disable Geoip Omitted Defaults To False 609ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key 609ms
Request Lifecycle.No Flags Request On Init Alone 4ms
Request Lifecycle.No Flags Request On Normal Capture 609ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests 1214ms
Request Lifecycle.Mock Response Value Is Returned To Caller 609ms
Retry Behavior.Retries Flags On 502 912ms
Retry Behavior.Retries Flags On 504 912ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event 1210ms

@marandaneto
marandaneto marked this pull request as ready for review August 25, 2026 16:32
@marandaneto
marandaneto requested a review from a team as a code owner August 25, 2026 16:32
defp apply_operator(operator, property, filter, _now) when operator in ["regex", "not_regex"] do
case Regex.compile(to_string(filter)) do
{:ok, regex} ->
matched = Regex.match?(regex, to_string(property))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Low: Unbounded regular-expression evaluation

A definitions response can supply a backtracking-heavy pattern that is matched synchronously against request-derived property values. Repeated evaluations can consume scheduler CPU before the evaluator's exception handling runs; Regex.match?/2 provides no option to set a smaller per-call match limit. citeturn0search0turn0search1 Compile and validate regexes when definitions are loaded, then evaluate them with a conservative match limit or inside a time-bounded worker.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This is a valid hardening concern, but I am leaving it for a focused follow-up. Regex patterns come from authenticated project definitions, and adding a match limit or worker timeout needs a documented evaluator contract plus parity tests across SDKs. I am leaving this thread unresolved.

Comment thread lib/posthog/feature_flags.ex
@marandaneto
marandaneto requested a review from a team August 25, 2026 16:36
@veria-ai

veria-ai Bot commented Aug 25, 2026

Copy link
Copy Markdown

PR overview

This pull request adds local evaluation of PostHog feature flags, including property matching with regular-expression conditions.

Three security issues have been addressed, with one concern remaining. Feature flag definitions can contain computationally expensive regular expressions that are evaluated synchronously against request-derived values, potentially causing excessive scheduler CPU use. Exploitation depends on an attacker being able to influence the supplied definitions and matching inputs.

Open issues (1)

Fixed/addressed: 3 · PR risk: 4/10

@greptile-apps

greptile-apps Bot commented Aug 25, 2026

Copy link
Copy Markdown
Prompt To Fix All With AI
### Issue 1
lib/posthog/feature_flags.ex:343-347
**Malformed fallback response crashes**

When `/flags` returns HTTP 200 with a non-map `flags` value, `request_flags/2` accepts the response but neither branch here matches it, causing `evaluate_flags/2` to raise `CaseClauseError` instead of returning the safe local subset.

```suggestion
        merged = Map.merge(remote_results, local_results)
        {:ok, __MODULE__.Evaluations.from_results(name, distinct_id, merged, response_body)}

      {:ok, _response} ->
        snapshot_from_results(name, distinct_id, local_results)

      {:error, _reason} ->
        snapshot_from_results(name, distinct_id, local_results)
```

---

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): add local feature flag eval..." | Re-trigger Greptile

Comment thread lib/posthog/feature_flags.ex Outdated
Comment thread lib/posthog/feature_flags/local_evaluator.ex

@dustinbyrne dustinbyrne left a comment

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.

Agent-led review, human-reviewed before posting.

Thanks for the substantial work here. I left a few inline comments for correctness and cross-SDK consistency. I reviewed the current head (a68e720d).

Comment thread lib/posthog/feature_flags/local_evaluator.ex Outdated
Comment thread lib/posthog/feature_flags.ex
Comment thread lib/posthog/feature_flags.ex
Comment thread lib/posthog/feature_flags.ex Outdated
Comment thread lib/posthog/feature_flags/evaluations.ex
Comment thread lib/posthog/feature_flags.ex Outdated
@marandaneto
marandaneto requested a review from a team August 25, 2026 18:33
Comment thread lib/posthog/api.ex
Preserve exact remote flag scopes while bypassing per-key coordination and negative caching for oversized missing-key requests. Keep retained negative knowledge updates bounded and linear.

@dustinbyrne dustinbyrne left a comment

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.

Agent-led review, human-reviewed before posting.

Thanks for the follow-up fixes. I re-reviewed the current head (089ae9e4) and left two material correctness/performance comments, two non-blocking no-throw/telemetry comments, and one compatibility consideration.

Comment thread lib/posthog/feature_flags/local_evaluator.ex Outdated
Comment thread lib/posthog/feature_flags.ex
Comment thread lib/posthog/feature_flags.ex Outdated
Comment thread lib/posthog/feature_flags/evaluations.ex
Comment thread lib/posthog/feature_flags/local_evaluator.ex
@dustinbyrne
dustinbyrne self-requested a review August 25, 2026 21:45
@dustinbyrne
dustinbyrne dismissed their stale review August 25, 2026 21:46

Submitted as CHANGES_REQUESTED by mistake; the inline feedback was intended to be non-blocking review comments.

@marandaneto
marandaneto requested a review from a team August 26, 2026 06:25

@ioannisj ioannisj left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Left some comments my agent spotted and made sense to me. Could not follow 100% so discount these accordingly

end
end

defp handle_response(state, {:ok, %{status: 304} = response}, _store?) do

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I think the 304 branch should stop calling successful_definition_refresh/2. It resets negative_keys/negative_order and bumps definition_generation, which is right after a 200 carrying a new snapshot, but a 304 means the definitions provably didn't change, so nothing we learned about missing keys has been invalidated.

In steady state that drops the missing-key knowledge every poll interval, so the next call naming an unknown key re-enters the probe path, takes the :global lock and fires another /flags request, against a definition set that never changes. The bounding described in the PR body holds within a poll cycle but not across one.

Worth having the 304 clause only touch :etag, :loaded_at and quota_backoff_ms? missing_key_knowledge_test.exs covers persistence across reloads but nothing pins the 304 case, so a regression here would still pass.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Thanks for raising this. The current behavior is intentional under the evaluate-flags sdk-spec: negative knowledge SHALL be cleared after every successful definitions refresh, explicitly including an unchanged/304 response. The bound is therefore one probe caused solely by that key per successful refresh interval, rather than indefinite suppression. The existing clean omission is retained sequentially and successful refresh invalidates it regression pins this distinction by retaining across a 503 and then asserting that a 304 makes the next probe eligible. I’m keeping the 304 behavior aligned with that contract.


defp acquire_missing_probe_locks([key | rest], name, callback) do
lock = {{__MODULE__, :missing_probe, name, key}, self()}
:global.trans(lock, fn -> acquire_missing_probe_locks(rest, name, callback) end)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I think the lock wants releasing before the HTTP call. acquire_missing_probe_locks/3 wraps callback.() in :global.trans, and that callback runs all the way through to PostHog.API.flags, so the lock is held for the whole retrying round trip rather than just long enough to claim the probe. :global.trans/2 also defaults to infinite retries when contended.

On a multi-node deploy that makes one unknown flag key a cluster-wide serialization point: every caller naming that key queues behind a single in-flight request, with no bound on the wait. It's worst exactly when PostHog is slow, which is when local evaluation is meant to be insulating callers from it.

Could we claim the probe under the lock (an in-flight marker in the node-local loader state), release, then issue the request outside it, and pass an explicit Retries count to :global.trans/3 so a contended waiter falls back to its own call? Worth a comment saying the cluster-wide dedup is deliberate too, posthog-python has no cross-process probe coordination at all so the next reader will wonder.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Thanks — the lock spanning the remote request is deliberate. The evaluate-flags sdk-spec requires overlapping evaluations to coordinate while the existence probe is in flight and forbids a duplicate request when the shared probe cleanly omits the key. Releasing after a node-local claim and allowing a contended caller to issue its own request would break that guarantee; holding the sorted per-key locks also lets disjoint keys proceed independently and avoids multi-key deadlocks. I added a clarifying comment in 335c3eb, but I’m keeping the coordination behavior. A bounded-wait/duplicate-probe tradeoff would need a corresponding contract change.

Comment thread lib/posthog/supervisor.ex
Comment thread lib/posthog/feature_flags/local_evaluator.ex
@marandaneto
marandaneto requested review from a team and ioannisj August 26, 2026 13:07
@github-project-automation github-project-automation Bot moved this from In Review to Approved in Feature Flags Aug 26, 2026

@dustinbyrne dustinbyrne left a comment

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.

Agent-led review, approved on behalf of my beloved human. 🫡

I rechecked the current head (335c3ebd). The prior findings are addressed, the focused SDK tests pass, and the remaining cast-ordering concern is non-blocking. I left one P2 inline.

Comment thread lib/posthog/feature_flags/definition_loader.ex Outdated
@posthog-project-board-bot posthog-project-board-bot Bot moved this from Approved to In Review in Feature Flags Aug 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In Review

Development

Successfully merging this pull request may close these issues.

Add local feature flag evaluation support

3 participants