Skip to content

feat: add evaluate_flags/2 API for single-call flag evaluation#103

Merged
dmarticus merged 4 commits intomainfrom
posthog-code/elixir-evaluate-flags-api
May 1, 2026
Merged

feat: add evaluate_flags/2 API for single-call flag evaluation#103
dmarticus merged 4 commits intomainfrom
posthog-code/elixir-evaluate-flags-api

Conversation

@dmarticus
Copy link
Copy Markdown
Contributor

@dmarticus dmarticus commented Apr 27, 2026

Summary

{:ok, snapshot} = PostHog.FeatureFlags.evaluate_flags("user-123")

if PostHog.FeatureFlags.Evaluations.enabled?(snapshot, "new-dashboard") do
  render_new_dashboard()
end

PostHog.FeatureFlags.set_in_context(snapshot)
PostHog.capture("page_viewed", %{distinct_id: "user-123"})

One /flags call, multiple flag checks, plus event enrichment from the same snapshot — replacing N check/3 round-trips.

  • New PostHog.FeatureFlags.evaluate_flags/2 returns an Evaluations snapshot. Pass flag_keys: [...] to scope the underlying request body (forwarded as flag_keys_to_evaluate). Returns an empty snapshot when distinct_id can't be resolved — matching the cross-SDK behavior; events short-circuit on empty distinct_id.
  • Snapshot exposes enabled?/2, get_flag/2, get_flag_payload/2, only/2, only_accessed/1, accessed/1, keys/1, event_properties/1. The first three record access (Agent-backed); enabled?/2 and get_flag/2 fire $feature_flag_called with full metadata; get_flag_payload/2 records access without firing an event.
  • set_in_context/2 copies the snapshot's $feature/<key> and $active_feature_flags into the per-process context so subsequent PostHog.capture/3 calls pick them up — no new capture arity needed.
  • %PostHog.FeatureFlags.Result{} now carries :id, :version, :reason, :request_id, :evaluated_at, and :errors_while_computing. $feature_flag_called events from both paths attach the matching $feature_flag_* properties (including $feature/<key>, $feature_flag_payload, $feature_flag_error) when the response provides them. errors_while_computing_flags and flag_missing are combined comma-style. JSON-encoded payloads are decoded before being attached.
  • Pass flag_keys: [...] to evaluate_flags/2 to scope the underlying /flags request itself.
  • check/3, check!/3, get_feature_flag_result/4, and get_feature_flag_result!/4 are now @deprecated (compile-time warnings + admonitions in @doc). They continue to return the same values; removal is planned for the next major.

RFC: https://github.com/PostHog/requests-for-comments-internal/pull/1020. Reference implementations: posthog-python#539, posthog-js#3476.

Design decisions

  • No capture(flags: snapshot) argument. Elixir's SDK already propagates $feature/* through the per-process context that check/3 writes to and capture/3 reads back. set_in_context/2 slots into that mechanism instead of fighting it.
  • Access tracking via a small Agent linked to the calling process. Elixir is immutable; an Agent is the cleanest way to share mutable access state across pure-looking query functions. Cleanup is automatic — the Agent dies with the caller. Filtered snapshots get fresh agents so calls on filtered views don't back-propagate to the parent.
  • only_accessed/1 returns an empty snapshot when nothing has been accessed. "Nothing accessed → all flags" is more confusing than helpful; an early set_in_context(only_accessed(snap)) should not surface flags the developer never touched. Mirrors the final behavior in posthog-python#539.
  • Predicate is enabled?/2, not is_enabled/2. Idiomatic Elixir; is_* is reserved for guard-safe predicates and Credo flags it.
  • Both paths share build_result/3 and log_feature_flag_usage/4. Single source of truth for $feature_flag_called properties — including the rich metadata, payload normalization, and the new $feature_flag_error codes.
  • Snapshot fires flag_missing events with $feature_flag_response: nil; existing single-flag path keeps its {:error, ...} / {:ok, nil} contract for missing flags. Mirrors posthog-python's per-call telemetry from a snapshot while not changing existing check/3 behavior.
  • Payloads are JSON-decoded. PostHog stores flag payloads as JSON-encoded strings; the SDK now parses them before attaching. Falls back to the raw string on parse failure.
  • No dedup cache. The Elixir SDK has no per-distinct_id dedup for $feature_flag_called; both paths fire on every access. Adding the cache is a separate behavior change to existing call sites and lands in its own PR.
  • No new config option. only/2 silently drops unknown keys (matching Map.take/2), so there's no warning to silence.

Out of scope (future PRs)

  • Per-distinct_id dedup cache for $feature_flag_called events, applied to both paths.
  • Local-evaluation poller, with locally_evaluated, $feature_flag_reason: "Evaluated locally", and $feature_flag_definitions_loaded_at plumbed through the snapshot.
  • Phase 3: removal of the deprecated single-flag methods in the next major.

237/237 tests pass, format + Credo strict clean. Compile warnings on the four deprecated functions are expected — they mark call sites that need migrating.


Created with PostHog Code

Introduce PostHog.FeatureFlags.evaluate_flags/2 returning an Evaluations
snapshot that powers branching across multiple flags and event enrichment
from a single /flags request. Pairs with PostHog.FeatureFlags.set_in_context/2
for the idiomatic Elixir capture-enrichment flow via the existing per-process
context, and with Evaluations.event_properties/1 for explicit one-off attach.

%PostHog.FeatureFlags.Result{} gains :id, :version, :reason, :request_id, and
:evaluated_at. The existing check/3, check!/3, and get_feature_flag_result/4
paths now attach $feature_flag_id, $feature_flag_version, $feature_flag_reason,
and $feature_flag_request_id to $feature_flag_called events when the response
provides them - needed for experiment exposure tracking.

Generated-By: PostHog Code
Task-Id: c6aa804c-618a-4229-b6a3-dc8c9ccff778
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 27, 2026

posthog-elixir Compliance Report

Date: 2026-04-30 21:21:27 UTC
Duration: 106047ms

⚠️ Some Tests Failed

29/30 tests passed, 1 failed


Capture Tests

29/29 tests passed

View Details
Test Status Duration
Format Validation.Event Has Required Fields 610ms
Format Validation.Event Has Uuid 612ms
Format Validation.Event Has Lib Properties 610ms
Format Validation.Distinct Id Is String 610ms
Format Validation.Token Is Present 609ms
Format Validation.Custom Properties Preserved 611ms
Format Validation.Event Has Timestamp 611ms
Retry Behavior.Retries On 503 5615ms
Retry Behavior.Does Not Retry On 400 2613ms
Retry Behavior.Does Not Retry On 401 2613ms
Retry Behavior.Respects Retry After Header 5615ms
Retry Behavior.Implements Backoff 15626ms
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 625ms
Deduplication.Preserves Uuid On Retry 5615ms
Deduplication.Preserves Uuid And Timestamp On Retry 10621ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry 5615ms
Deduplication.No Duplicate Events In Batch 617ms
Deduplication.Different Events Have Different Uuids 614ms
Compression.Sends Gzip When Enabled 611ms
Batch Format.Uses Proper Batch Structure 610ms
Batch Format.Flush With No Events Sends Nothing 607ms
Batch Format.Multiple Events Batched Together 617ms
Error Handling.Does Not Retry On 403 2626ms
Error Handling.Does Not Retry On 413 2612ms
Error Handling.Retries On 408 5617ms

Feature_Flags Tests

⚠️ 0/1 tests passed, 1 failed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id 9ms

Failures

request_payload.request_with_person_properties_device_id

Field 'token' not found in /flags request body at path 'token'. Available keys: ['groups', 'api_key', 'distinct_id', 'flag_keys_to_evaluate', 'geoip_disable', 'group_properties', 'person_properties']

@dmarticus dmarticus marked this pull request as ready for review April 27, 2026 22:12
@dmarticus dmarticus requested review from a team and rafaeelaudibert as code owners April 27, 2026 22:12
Address PR feedback (mirrors posthog-python#539's 95eb1e9 fix). The /flags
response carries `errorsWhileComputingFlags` to signal partial-evaluation
failure, but events fired from both the existing single-flag path and the
new snapshot path were dropping it.

%PostHog.FeatureFlags.Result{} gains :errors_while_computing, populated from
the response body. log_feature_flag_usage/3 now builds a comma-joined
$feature_flag_error property when errors are present.

The snapshot path also fires $feature_flag_called for unknown flags with
$feature_flag_error: "flag_missing" — useful telemetry for "user asked for
a flag that does not exist" — combined with errors_while_computing_flags
when both apply ("errors_while_computing_flags,flag_missing"). The existing
single-flag path is unchanged for missing flags (still returns nil/error).

Generated-By: PostHog Code
Task-Id: c6aa804c-618a-4229-b6a3-dc8c9ccff778
Adds @deprecated to the four legacy single-flag entry points and their
auto-generated lower-arity overloads, pointing users at evaluate_flags/2 +
Evaluations. Compile-time warnings emit on every call site; functions
continue to return the same values. Removal is planned for the next major.

Each function also gains a "Deprecated" admonition in its @doc so generated
HexDocs surface the migration guidance.

Generated-By: PostHog Code
Task-Id: c6aa804c-618a-4229-b6a3-dc8c9ccff778
def only(%__MODULE__{flags: flags} = snapshot, keys) when is_list(keys) do
%{snapshot | flags: Map.take(flags, keys)}
end

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.

We didn't include only_accessed in this implementation - is it not needed?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch — added in 144c934. Snapshot now exposes only_accessed/1 and accessed/1, backed by a small Agent started in Evaluations.new/3 and linked to the calling process (so cleanup is automatic). enabled?/2, get_flag/2, and get_flag_payload/2 all record access; only/2 and only_accessed/1 clone with a fresh agent so filtered views don't back-propagate to the parent.

Empty access set returns an empty snapshot rather than falling back to all flags — matches the final behavior in posthog-python#539 (the original fallback was contradictory with only_accessed's name).

New tests: only_accessed/1 describe block (6 tests) covering each access path, the empty-access case, the back-propagation isolation, and dropping accessed-but-missing keys.

Comment thread lib/posthog/feature_flags.ex Outdated
@spec evaluate_flags(PostHog.supervisor_name(), PostHog.distinct_id() | map() | nil) ::
{:ok, __MODULE__.Evaluations.t()} | {:error, Exception.t()}
def evaluate_flags(name \\ PostHog, distinct_id_or_body \\ nil) do
with {:ok, %{distinct_id: distinct_id} = body} <- body_for_flags(distinct_id_or_body),
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.

The other implementations return an empty snapshot if distinct_id resolves to nil or empty. I think it's worth standardizing on that behavior here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good call — fixed in 144c934. evaluate_flags/2 now returns {:ok, %Evaluations{distinct_id: "", flags: %{}}} when distinct_id can't be resolved (instead of the previous {:error, %PostHog.Error{}}). The empty distinct_id short-circuits event firing in enabled?/2 and get_flag/2, so accessing flags on the empty snapshot is a safe no-op.

Updated test (returns an empty snapshot when distinct_id cannot be resolved) plus new tests in the empty snapshot fallback describe block.

end

defp missing_result(%__MODULE__{errors_while_computing: ewc}, key) do
%Result{key: key, enabled: false, errors_while_computing: ewc}
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.

nit: Missing flags will have their $feature_flag_response set to false

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 144c934. Missing flags now fire with $feature_flag_response: nil (matches what get_flag/2 returns to the caller and what posthog-python emits). Implementation: log_feature_flag_usage/4 checks for "flag_missing" in extra_errors and uses nil instead of Result.value(result).

Existing flag_missing tests updated to assert nil instead of false.

Comment on lines +540 to 545
properties =
%{
distinct_id: distinct_id,
"$feature_flag": flag_name,
"$feature_flag_response": variant
"$feature_flag": result.key,
"$feature_flag_response": value
}
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.

nit: Compared to the other SDKs, we're missing $feature/<key>, $feature_flag_payload

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch — added in 144c934. log_feature_flag_usage/4 now attaches $feature/<key> (string key, e.g. "$feature/my-flag" => "variant1") and $feature_flag_payload (omitted when nil) on every $feature_flag_called event. Both the existing single-flag path and the new snapshot path emit them.

New assertions in the "fires $feature_flag_called with full metadata" test cover both properties.

Comment thread lib/posthog/feature_flags.ex Outdated
key: flag_name,
enabled: enabled,
variant: variant,
payload: get_in(flag_data, ["metadata", "payload"]),
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.

We should normalize the payload value to a JSON value

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in 144c934. build_result/3 now passes the payload through a private normalize_payload/1 helper that JSON-decodes string payloads via Jason.decode/1 (Jason is already in the dep tree via req). Falls back to the raw string on parse failure; nil and already-decoded values pass through unchanged.

New tests in the payload normalization describe block: JSON-decoded objects, non-JSON strings left as-is, nil left as nil.

Five changes in response to dustinbyrne's review on #103:

1. Add only_accessed/1 + accessed/1 with Agent-backed access tracking. The
   Agent is linked to the calling process; cleanup is automatic. enabled?/2,
   get_flag/2, and get_flag_payload/2 now record access. Filtered snapshots
   from only/2 and only_accessed/1 get fresh access trackers — calls on a
   filtered view do not back-propagate to the parent. Empty access set
   returns an empty snapshot (matches the python PR's final behavior).

2. evaluate_flags/2 now returns an empty snapshot ({:ok, %Evaluations{...}})
   when distinct_id cannot be resolved, instead of {:error, ...}. The empty
   snapshot has distinct_id: "" which short-circuits event firing in
   enabled?/2 and get_flag/2.

3. flag_missing events now use $feature_flag_response: nil to match the
   cross-SDK behavior and what get_flag/2 returns to the caller.

4. log_feature_flag_usage/4 now attaches $feature/<key> and
   $feature_flag_payload to $feature_flag_called events on both the new
   snapshot path and the existing single-flag path. Brings parity with
   posthog-python and posthog-js.

5. JSON-decode payload strings in build_result/3. PostHog stores payloads as
   JSON-encoded strings; the SDK now parses them before attaching to events
   and the Result struct's :payload field. Falls back to the raw string when
   parsing fails. nil and already-decoded values pass through.

Generated-By: PostHog Code
Task-Id: c6aa804c-618a-4229-b6a3-dc8c9ccff778
@dmarticus dmarticus merged commit 65d520d into main May 1, 2026
21 checks passed
@dmarticus dmarticus deleted the posthog-code/elixir-evaluate-flags-api branch May 1, 2026 19:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants