feat: add evaluate_flags/2 API for single-call flag evaluation#103
feat: add evaluate_flags/2 API for single-call flag evaluation#103
Conversation
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
posthog-elixir Compliance ReportDate: 2026-04-30 21:21:27 UTC
|
| 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
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']
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 | ||
|
|
There was a problem hiding this comment.
We didn't include only_accessed in this implementation - is it not needed?
There was a problem hiding this comment.
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.
| @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), |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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} |
There was a problem hiding this comment.
nit: Missing flags will have their $feature_flag_response set to false
There was a problem hiding this comment.
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.
| properties = | ||
| %{ | ||
| distinct_id: distinct_id, | ||
| "$feature_flag": flag_name, | ||
| "$feature_flag_response": variant | ||
| "$feature_flag": result.key, | ||
| "$feature_flag_response": value | ||
| } |
There was a problem hiding this comment.
nit: Compared to the other SDKs, we're missing $feature/<key>, $feature_flag_payload
There was a problem hiding this comment.
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.
| key: flag_name, | ||
| enabled: enabled, | ||
| variant: variant, | ||
| payload: get_in(flag_data, ["metadata", "payload"]), |
There was a problem hiding this comment.
We should normalize the payload value to a JSON value
There was a problem hiding this comment.
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
Summary
One
/flagscall, multiple flag checks, plus event enrichment from the same snapshot — replacing Ncheck/3round-trips.PostHog.FeatureFlags.evaluate_flags/2returns anEvaluationssnapshot. Passflag_keys: [...]to scope the underlying request body (forwarded asflag_keys_to_evaluate). Returns an empty snapshot whendistinct_idcan't be resolved — matching the cross-SDK behavior; events short-circuit on empty distinct_id.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?/2andget_flag/2fire$feature_flag_calledwith full metadata;get_flag_payload/2records access without firing an event.set_in_context/2copies the snapshot's$feature/<key>and$active_feature_flagsinto the per-process context so subsequentPostHog.capture/3calls pick them up — no newcapturearity needed.%PostHog.FeatureFlags.Result{}now carries:id,:version,:reason,:request_id,:evaluated_at, and:errors_while_computing.$feature_flag_calledevents 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_flagsandflag_missingare combined comma-style. JSON-encoded payloads are decoded before being attached.flag_keys: [...]toevaluate_flags/2to scope the underlying/flagsrequest itself.check/3,check!/3,get_feature_flag_result/4, andget_feature_flag_result!/4are 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
capture(flags: snapshot)argument. Elixir's SDK already propagates$feature/*through the per-process context thatcheck/3writes to andcapture/3reads back.set_in_context/2slots into that mechanism instead of fighting it.only_accessed/1returns an empty snapshot when nothing has been accessed. "Nothing accessed → all flags" is more confusing than helpful; an earlyset_in_context(only_accessed(snap))should not surface flags the developer never touched. Mirrors the final behavior in posthog-python#539.enabled?/2, notis_enabled/2. Idiomatic Elixir;is_*is reserved for guard-safe predicates and Credo flags it.build_result/3andlog_feature_flag_usage/4. Single source of truth for$feature_flag_calledproperties — including the rich metadata, payload normalization, and the new$feature_flag_errorcodes.flag_missingevents 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 existingcheck/3behavior.$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.only/2silently drops unknown keys (matchingMap.take/2), so there's no warning to silence.Out of scope (future PRs)
$feature_flag_calledevents, applied to both paths.locally_evaluated,$feature_flag_reason: "Evaluated locally", and$feature_flag_definitions_loaded_atplumbed through the snapshot.Created with PostHog Code