diff --git a/.sampo/changesets/evaluate-flags-api.md b/.sampo/changesets/evaluate-flags-api.md new file mode 100644 index 0000000..9504ea4 --- /dev/null +++ b/.sampo/changesets/evaluate-flags-api.md @@ -0,0 +1,22 @@ +--- +hex/posthog: minor +--- + +Add `PostHog.FeatureFlags.evaluate_flags/2` and the `PostHog.FeatureFlags.Evaluations` snapshot so a single `/flags` call can power both flag branching and event enrichment for one request: + +```elixir +{: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"}) +``` + +The snapshot exposes `enabled?/2`, `get_flag/2`, `get_flag_payload/2`, `only/2`, `keys/1`, and `event_properties/1`. Pass `flag_keys: [...]` to `evaluate_flags/2` to scope the underlying `/flags` request itself. + +`$feature_flag_called` events fired from `check/3`, `check!/3`, `get_feature_flag_result/4`, and the new snapshot path now attach `$feature_flag_id`, `$feature_flag_version`, `$feature_flag_reason`, `$feature_flag_request_id`, and `$feature_flag_error` (combining `errors_while_computing_flags` and, on the snapshot path, `flag_missing`) when the response provides them. `%PostHog.FeatureFlags.Result{}` gains matching `:id`, `:version`, `:reason`, `:request_id`, `:evaluated_at`, and `:errors_while_computing` fields. + +`check/3`, `check!/3`, `get_feature_flag_result/4`, and `get_feature_flag_result!/4` are now marked `@deprecated` and emit compile-time warnings pointing at `evaluate_flags/2`. They continue to return the same values; removal is planned for the next major. diff --git a/lib/posthog/feature_flags.ex b/lib/posthog/feature_flags.ex index 0b2ae86..aac1746 100644 --- a/lib/posthog/feature_flags.ex +++ b/lib/posthog/feature_flags.ex @@ -86,13 +86,124 @@ defmodule PostHog.FeatureFlags do end end + @doc false + def evaluate_flags(distinct_id_or_body) when not is_atom(distinct_id_or_body), + do: evaluate_flags(PostHog, distinct_id_or_body) + + @doc """ + Evaluates feature flags for a `distinct_id` and returns a snapshot. + + Returns `{:ok, %PostHog.FeatureFlags.Evaluations{}}` on success. The snapshot + represents a single `/flags` call and lets you branch on multiple flags and + enrich captured events from the same fetch — see + `PostHog.FeatureFlags.Evaluations` for the full snapshot API and + `set_in_context/2` for the recommended capture-enrichment flow. + + Accepts an optional `distinct_id` or a request body map. If neither is + passed, attempts to read `distinct_id` from the context. + + ## Body options + + When passing a map, the following keys are forwarded to the `/flags` request + body unchanged: + + - `:distinct_id` (required, unless found in context) + - `:groups` + - `:person_properties` + - `:group_properties` + - `:disable_geoip` + + Plus one snapshot-specific option: + + - `:flag_keys` - list of flag keys. Forwarded to the request as + `flag_keys_to_evaluate` so the server returns only those flags. This + scopes the network response, distinct from + `PostHog.FeatureFlags.Evaluations.only/2` which filters an already-fetched + snapshot in memory. + + ## Examples + + Evaluate flags for a `distinct_id`: + + {:ok, snapshot} = PostHog.FeatureFlags.evaluate_flags("user123") + PostHog.FeatureFlags.Evaluations.enabled?(snapshot, "new-dashboard") + + Evaluate a scoped set of flags with person properties: + + PostHog.FeatureFlags.evaluate_flags(%{ + distinct_id: "user123", + person_properties: %{plan: "enterprise"}, + flag_keys: ["new-dashboard", "beta-checkout"] + }) + + Evaluate through a named PostHog instance: + + PostHog.FeatureFlags.evaluate_flags(MyPostHog, "user123") + """ + @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), + body = translate_flag_keys(body), + {:ok, %{body: response_body}} <- flags(name, body) do + {:ok, __MODULE__.Evaluations.new(name, distinct_id, response_body)} + end + end + + @doc false + def set_in_context(%__MODULE__.Evaluations{} = snapshot), + do: set_in_context(PostHog, snapshot) + + @doc """ + Copies a snapshot's `$feature/` and `$active_feature_flags` properties + into the per-process PostHog context. + + Any subsequent `PostHog.capture/3` from this process automatically attaches + these properties to the captured event — no additional `/flags` request, + with the values guaranteed to match what the snapshot already evaluated. + + This is the idiomatic Elixir way to enrich captured events from an + `evaluate_flags/2` snapshot. For one-off enrichment without touching context, + merge `PostHog.FeatureFlags.Evaluations.event_properties/1` into a capture's + properties directly. + + ## Examples + + {:ok, snapshot} = PostHog.FeatureFlags.evaluate_flags("user123") + PostHog.FeatureFlags.set_in_context(snapshot) + + # All subsequent captures pick up $feature/* and $active_feature_flags + PostHog.capture("page_viewed", %{distinct_id: "user123"}) + """ + @spec set_in_context(PostHog.supervisor_name(), __MODULE__.Evaluations.t()) :: :ok + def set_in_context(name, %__MODULE__.Evaluations{} = snapshot) when is_atom(name) do + PostHog.set_context(name, __MODULE__.Evaluations.event_properties(snapshot)) + end + + defp translate_flag_keys(%{flag_keys: flag_keys} = body) when is_list(flag_keys) do + body + |> Map.delete(:flag_keys) + |> Map.put(:flag_keys_to_evaluate, flag_keys) + end + + defp translate_flag_keys(body), do: body + + @deprecated "Use PostHog.FeatureFlags.evaluate_flags/2 with PostHog.FeatureFlags.Evaluations.enabled?/2 or get_flag/2" @doc false def check(flag_name, distinct_id_or_body) when not is_atom(flag_name), do: check(PostHog, flag_name, distinct_id_or_body) + @deprecated "Use PostHog.FeatureFlags.evaluate_flags/2 with PostHog.FeatureFlags.Evaluations.enabled?/2 or get_flag/2" @doc """ Checks feature flag + > #### Deprecated {: .warning} + > + > Use `PostHog.FeatureFlags.evaluate_flags/2` plus + > `PostHog.FeatureFlags.Evaluations.enabled?/2` or + > `PostHog.FeatureFlags.Evaluations.get_flag/2` instead. The snapshot lets + > one `/flags` call serve multiple flag checks plus event enrichment. + If there is a variant assigned, returns `{:ok, variant}`. Otherwise, `{:ok, true}` or `{:ok, false}`. @@ -141,19 +252,29 @@ defmodule PostHog.FeatureFlags do end end + @deprecated "Use PostHog.FeatureFlags.evaluate_flags/2 with PostHog.FeatureFlags.Evaluations" @doc false def get_feature_flag_result(flag_name, distinct_id_or_body) when not is_atom(flag_name) and not is_list(distinct_id_or_body), do: get_feature_flag_result(PostHog, flag_name, distinct_id_or_body, []) + @deprecated "Use PostHog.FeatureFlags.evaluate_flags/2 with PostHog.FeatureFlags.Evaluations" @doc false def get_feature_flag_result(flag_name, distinct_id_or_body, opts) when not is_atom(flag_name) and is_list(opts), do: get_feature_flag_result(PostHog, flag_name, distinct_id_or_body, opts) + @deprecated "Use PostHog.FeatureFlags.evaluate_flags/2 with PostHog.FeatureFlags.Evaluations" @doc """ Gets the full feature flag result including value and payload. + > #### Deprecated {: .warning} + > + > Use `PostHog.FeatureFlags.evaluate_flags/2` and access flags from the + > returned `PostHog.FeatureFlags.Evaluations` snapshot. The snapshot + > exposes the same metadata (id, version, reason, payload) plus filter + > helpers and capture enrichment via `set_in_context/2`. + Returns `{:ok, %PostHog.FeatureFlags.Result{}}` on success, `{:ok, nil}` if the flag is not found, or `{:error, reason}` on failure. @@ -220,19 +341,27 @@ defmodule PostHog.FeatureFlags do end end + @deprecated "Use PostHog.FeatureFlags.evaluate_flags/2 with PostHog.FeatureFlags.Evaluations" @doc false def get_feature_flag_result!(flag_name, distinct_id_or_body) when not is_atom(flag_name) and not is_list(distinct_id_or_body), do: get_feature_flag_result!(PostHog, flag_name, distinct_id_or_body, []) + @deprecated "Use PostHog.FeatureFlags.evaluate_flags/2 with PostHog.FeatureFlags.Evaluations" @doc false def get_feature_flag_result!(flag_name, distinct_id_or_body, opts) when not is_atom(flag_name) and is_list(opts), do: get_feature_flag_result!(PostHog, flag_name, distinct_id_or_body, opts) + @deprecated "Use PostHog.FeatureFlags.evaluate_flags/2 with PostHog.FeatureFlags.Evaluations" @doc """ Gets the full feature flag result or raises on error. + > #### Deprecated {: .warning} + > + > Use `PostHog.FeatureFlags.evaluate_flags/2` and access flags from the + > returned `PostHog.FeatureFlags.Evaluations` snapshot. + This is a wrapper around `get_feature_flag_result/4` that returns the result directly or raises an exception on error. This follows the Elixir convention where functions ending with `!` raise exceptions instead of returning error @@ -288,21 +417,10 @@ defmodule PostHog.FeatureFlags do {:ok, %{body: body}} <- flags(name, body) do case body do %{"flags" => %{^flag_name => flag_data}} -> - {enabled, variant} = extract_flag_enabled_and_variant(flag_data) - payload = get_in(flag_data, ["metadata", "payload"]) - - flag_result = %__MODULE__.Result{ - key: flag_name, - enabled: enabled, - variant: variant, - payload: payload - } - - evaluated_at = Map.get(body, "evaluatedAt") + flag_result = build_result(flag_name, flag_data, body) if send_event do - value = __MODULE__.Result.value(flag_result) - log_feature_flag_usage(name, distinct_id, flag_name, {:ok, value}, evaluated_at) + log_feature_flag_usage(name, distinct_id, flag_result) end {:ok, flag_result, body} @@ -315,19 +433,46 @@ defmodule PostHog.FeatureFlags do end end + @doc false + @spec build_result(String.t(), map(), map()) :: __MODULE__.Result.t() + def build_result(flag_name, flag_data, body) do + {enabled, variant} = extract_flag_enabled_and_variant(flag_data) + + %__MODULE__.Result{ + key: flag_name, + enabled: enabled, + variant: variant, + payload: get_in(flag_data, ["metadata", "payload"]), + id: get_in(flag_data, ["metadata", "id"]), + version: get_in(flag_data, ["metadata", "version"]), + reason: Map.get(flag_data, "reason"), + request_id: Map.get(body, "requestId"), + evaluated_at: Map.get(body, "evaluatedAt"), + errors_while_computing: Map.get(body, "errorsWhileComputingFlags") == true + } + end + defp extract_flag_enabled_and_variant(flag_data) do enabled = Map.get(flag_data, "enabled", false) == true variant = Map.get(flag_data, "variant") {enabled, variant} end + @deprecated "Use PostHog.FeatureFlags.evaluate_flags/2 with PostHog.FeatureFlags.Evaluations.enabled?/2 or get_flag/2" @doc false def check!(flag_name, distinct_id_or_body) when not is_atom(flag_name), do: check!(PostHog, flag_name, distinct_id_or_body) + @deprecated "Use PostHog.FeatureFlags.evaluate_flags/2 with PostHog.FeatureFlags.Evaluations.enabled?/2 or get_flag/2" @doc """ Checks feature flag and returns the variant or raises on error. + > #### Deprecated {: .warning} + > + > Use `PostHog.FeatureFlags.evaluate_flags/2` and + > `PostHog.FeatureFlags.Evaluations.enabled?/2` / + > `PostHog.FeatureFlags.Evaluations.get_flag/2` instead. + This is a wrapper around `check/3` that returns the variant directly or raises an exception on error. This follows the Elixir convention where functions ending with `!` raise exceptions instead of returning error tuples. @@ -368,27 +513,61 @@ defmodule PostHog.FeatureFlags do end end - defp log_feature_flag_usage(name, distinct_id, flag_name, result, evaluated_at) do - with {:ok, variant} <- result do - properties = %{ + @doc false + @spec log_feature_flag_usage( + PostHog.supervisor_name(), + PostHog.distinct_id(), + __MODULE__.Result.t() + ) :: + :ok | {:error, :missing_distinct_id} + def log_feature_flag_usage(name, distinct_id, %__MODULE__.Result{} = result) do + log_feature_flag_usage(name, distinct_id, result, []) + end + + @doc false + @spec log_feature_flag_usage( + PostHog.supervisor_name(), + PostHog.distinct_id(), + __MODULE__.Result.t(), + [String.t()] + ) :: + :ok | {:error, :missing_distinct_id} + def log_feature_flag_usage(name, distinct_id, %__MODULE__.Result{} = result, extra_errors) + when is_list(extra_errors) do + value = __MODULE__.Result.value(result) + errors = build_error_codes(result, extra_errors) + + properties = + %{ distinct_id: distinct_id, - "$feature_flag": flag_name, - "$feature_flag_response": variant + "$feature_flag": result.key, + "$feature_flag_response": value } + |> maybe_put(:"$feature_flag_id", result.id) + |> maybe_put(:"$feature_flag_version", result.version) + |> maybe_put(:"$feature_flag_reason", result.reason) + |> maybe_put(:"$feature_flag_request_id", result.request_id) + |> maybe_put(:"$feature_flag_evaluated_at", result.evaluated_at) + |> maybe_put(:"$feature_flag_error", errors) - properties = - if evaluated_at do - Map.put(properties, :"$feature_flag_evaluated_at", evaluated_at) - else - properties - end - - PostHog.capture(name, "$feature_flag_called", properties) + PostHog.capture(name, "$feature_flag_called", properties) - PostHog.set_context(name, %{"$feature/#{flag_name}" => variant}) + if extra_errors == [] do + PostHog.set_context(name, %{"$feature/#{result.key}" => value}) + else + :ok end end + defp build_error_codes(%__MODULE__.Result{errors_while_computing: true}, extra), + do: ["errors_while_computing_flags" | extra] |> Enum.join(",") + + defp build_error_codes(%__MODULE__.Result{}, []), do: nil + defp build_error_codes(%__MODULE__.Result{}, extra), do: Enum.join(extra, ",") + + defp maybe_put(map, _key, nil), do: map + defp maybe_put(map, key, value), do: Map.put(map, key, value) + defp body_for_flags(distinct_id_or_body) do case distinct_id_or_body do %{distinct_id: _distinct_id} = body -> diff --git a/lib/posthog/feature_flags/evaluations.ex b/lib/posthog/feature_flags/evaluations.ex new file mode 100644 index 0000000..8de4309 --- /dev/null +++ b/lib/posthog/feature_flags/evaluations.ex @@ -0,0 +1,216 @@ +defmodule PostHog.FeatureFlags.Evaluations do + @moduledoc """ + Snapshot of feature flag evaluations for a single `distinct_id`. + + An `Evaluations` struct represents the result of a single `/flags` call. It is + built by `PostHog.FeatureFlags.evaluate_flags/2` and lets you branch on + multiple flags and enrich captured events from the same fetch — without + paying the cost of one round-trip per flag. + + The struct itself is a plain immutable map of flag key to + `PostHog.FeatureFlags.Result`. Functions in this module are pure: they query + or filter a snapshot but never mutate it. + + ## Querying + + Use `enabled?/2`, `get_flag/2`, and `get_flag_payload/2` to read individual + flags. `enabled?/2` and `get_flag/2` fire a `$feature_flag_called` event + with full metadata (id, version, reason, request_id) on each call; + `get_flag_payload/2` does not fire an event. + + {:ok, snapshot} = PostHog.FeatureFlags.evaluate_flags("user-123") + + if PostHog.FeatureFlags.Evaluations.enabled?(snapshot, "new-dashboard") do + render_new_dashboard() + end + + ## Enriching captures + + Call `PostHog.FeatureFlags.set_in_context/2` to copy the snapshot's + `$feature/` and `$active_feature_flags` properties into the per-process + context. Any subsequent `PostHog.capture/3` automatically picks them up — no + additional `/flags` request, and the values match what you branched on. + + PostHog.FeatureFlags.set_in_context(snapshot) + PostHog.capture("page_viewed", %{distinct_id: "user-123"}) + + Or merge `event_properties/1` directly into a capture's properties for an + explicit, one-off attach without touching context. + + ## Filtering + + Use `only/2` to narrow a snapshot to a specific list of flag keys before + calling `set_in_context/2` or `event_properties/1`. Unknown keys are dropped. + + narrowed = PostHog.FeatureFlags.Evaluations.only(snapshot, ["new-dashboard"]) + PostHog.FeatureFlags.set_in_context(narrowed) + """ + + alias PostHog.FeatureFlags.Result + + @typedoc """ + Snapshot of evaluated flags for a single `distinct_id`. + + - `:supervisor_name` - PostHog instance the snapshot was produced from; used + when `enabled?/2` and `get_flag/2` fire `$feature_flag_called` events. + - `:distinct_id` - resolved distinct ID the `/flags` request was made for. + - `:flags` - map of flag key to `t:PostHog.FeatureFlags.Result.t/0`. + - `:request_id` - request ID returned by `/flags`. + - `:evaluated_at` - server-side evaluation timestamp. + - `:errors_while_computing` - whether the response signaled + `errorsWhileComputingFlags`. When `true`, every event fired from this + snapshot includes `errors_while_computing_flags` in its + `$feature_flag_error` property. + """ + @type t :: %__MODULE__{ + supervisor_name: PostHog.supervisor_name(), + distinct_id: PostHog.distinct_id(), + flags: %{String.t() => Result.t()}, + request_id: String.t() | nil, + evaluated_at: integer() | nil, + errors_while_computing: boolean() + } + + @enforce_keys [:supervisor_name, :distinct_id, :flags] + defstruct [ + :supervisor_name, + :distinct_id, + :flags, + :request_id, + :evaluated_at, + errors_while_computing: false + ] + + @doc false + @spec new(PostHog.supervisor_name(), PostHog.distinct_id(), map()) :: t() + def new(supervisor_name, distinct_id, %{"flags" => flags_data} = body) + when is_map(flags_data) do + flags = + Map.new(flags_data, fn {key, flag_data} -> + {key, PostHog.FeatureFlags.build_result(key, flag_data, body)} + end) + + %__MODULE__{ + supervisor_name: supervisor_name, + distinct_id: distinct_id, + flags: flags, + request_id: Map.get(body, "requestId"), + evaluated_at: Map.get(body, "evaluatedAt"), + errors_while_computing: Map.get(body, "errorsWhileComputingFlags") == true + } + end + + @doc """ + Returns whether the named flag is enabled in this snapshot. + + Returns `false` for unknown flags. Fires a `$feature_flag_called` event with + full metadata when the flag is present, or with + `$feature_flag_error: "flag_missing"` when it is not. + """ + @spec enabled?(t(), String.t()) :: boolean() + def enabled?(%__MODULE__{} = snapshot, key) when is_binary(key) do + case fetch_and_log(snapshot, key) do + {:ok, %Result{enabled: enabled}} -> enabled + :error -> false + end + end + + @doc """ + Returns the variant string, the enabled boolean, or `nil` for unknown flags. + + Fires a `$feature_flag_called` event with full metadata when the flag is + present, or with `$feature_flag_error: "flag_missing"` when it is not. + """ + @spec get_flag(t(), String.t()) :: String.t() | boolean() | nil + def get_flag(%__MODULE__{} = snapshot, key) when is_binary(key) do + case fetch_and_log(snapshot, key) do + {:ok, %Result{} = result} -> Result.value(result) + :error -> nil + end + end + + @doc """ + Returns the configured payload for the flag, or `nil` for unknown flags or + flags without a payload. + + Does **not** fire a `$feature_flag_called` event. + """ + @spec get_flag_payload(t(), String.t()) :: any() | nil + def get_flag_payload(%__MODULE__{flags: flags}, key) when is_binary(key) do + case Map.fetch(flags, key) do + {:ok, %Result{payload: payload}} -> payload + :error -> nil + end + end + + @doc """ + Returns the sorted list of flag keys present in the snapshot. + """ + @spec keys(t()) :: [String.t()] + def keys(%__MODULE__{flags: flags}), do: flags |> Map.keys() |> Enum.sort() + + @doc """ + Returns a copy of the snapshot scoped to the given keys. Unknown keys are + silently dropped — the resulting snapshot contains only the intersection of + the requested keys with the snapshot's keys. + """ + @spec only(t(), [String.t()]) :: t() + def only(%__MODULE__{flags: flags} = snapshot, keys) when is_list(keys) do + %{snapshot | flags: Map.take(flags, keys)} + end + + @doc """ + Returns the `$feature/` and `$active_feature_flags` properties for this + snapshot, suitable for merging into a captured event's properties. + + - `$feature/` is set to the variant string when present, or to the + enabled boolean otherwise. Disabled flags are included with `false`. + - `$active_feature_flags` is the sorted list of keys whose flag is enabled. + + ## Examples + + properties = PostHog.FeatureFlags.Evaluations.event_properties(snapshot) + PostHog.capture("page_viewed", Map.merge(%{distinct_id: "u1"}, properties)) + """ + @spec event_properties(t()) :: %{String.t() => any()} + def event_properties(%__MODULE__{flags: flags}) do + {properties, active} = + Enum.reduce(flags, {%{}, []}, fn {key, %Result{} = result}, {props, active} -> + value = Result.value(result) + props = Map.put(props, "$feature/#{key}", value) + active = if result.enabled, do: [key | active], else: active + {props, active} + end) + + case active do + [] -> properties + keys -> Map.put(properties, :"$active_feature_flags", Enum.sort(keys)) + end + end + + defp fetch_and_log(%__MODULE__{flags: flags} = snapshot, key) do + case Map.fetch(flags, key) do + {:ok, %Result{} = result} -> + log(snapshot, result, []) + {:ok, result} + + :error -> + log(snapshot, missing_result(snapshot, key), ["flag_missing"]) + :error + end + end + + defp missing_result(%__MODULE__{errors_while_computing: ewc}, key) do + %Result{key: key, enabled: false, errors_while_computing: ewc} + end + + defp log(%__MODULE__{distinct_id: ""}, _result, _extra_errors), do: :ok + + defp log( + %__MODULE__{supervisor_name: name, distinct_id: distinct_id}, + %Result{} = result, + extra_errors + ) do + PostHog.FeatureFlags.log_feature_flag_usage(name, distinct_id, result, extra_errors) + end +end diff --git a/lib/posthog/feature_flags/result.ex b/lib/posthog/feature_flags/result.ex index ba55524..7c7453a 100644 --- a/lib/posthog/feature_flags/result.ex +++ b/lib/posthog/feature_flags/result.ex @@ -3,10 +3,25 @@ defmodule PostHog.FeatureFlags.Result do Represents the result of a feature flag evaluation. This struct contains all the information returned when evaluating a feature flag: + - `key` - The name of the feature flag - `enabled` - Whether the flag is enabled for this user - `variant` - The variant assigned to this user (nil for boolean flags) - `payload` - The JSON payload configured for this flag/variant (nil if not set) + - `id` - Numeric flag ID from the PostHog backend (when available) + - `version` - Flag version from the PostHog backend (when available) + - `reason` - Reason map describing why this evaluation produced its value + - `request_id` - Request ID returned by the `/flags` endpoint (useful for experiment exposure tracking) + - `evaluated_at` - Server-side evaluation timestamp from the response + - `errors_while_computing` - Whether the response signaled + `errorsWhileComputingFlags`; values for some flags may be incomplete or + stale. Forwarded as `$feature_flag_error: "errors_while_computing_flags"` + on `$feature_flag_called` events. + + The metadata fields are populated when the `/flags` response includes them + and are forwarded as `$feature_flag_id`, `$feature_flag_version`, `$feature_flag_reason`, + `$feature_flag_request_id`, and `$feature_flag_evaluated_at` properties on + `$feature_flag_called` events. ## Examples @@ -18,12 +33,17 @@ defmodule PostHog.FeatureFlags.Result do payload: nil } - # Multivariant flag result with payload + # Multivariant flag result with payload and metadata %PostHog.FeatureFlags.Result{ key: "my-experiment", enabled: true, variant: "control", - payload: %{"button_color" => "blue"} + payload: %{"button_color" => "blue"}, + id: 154_429, + version: 4, + reason: %{"code" => "condition_match", "description" => "Matched condition set 1"}, + request_id: "0d23f243-399a-4904-b1a8-ec2037834b72", + evaluated_at: 1_234_567_890 } """ @@ -33,11 +53,28 @@ defmodule PostHog.FeatureFlags.Result do key: String.t(), enabled: boolean(), variant: String.t() | nil, - payload: json() + payload: json(), + id: integer() | nil, + version: integer() | nil, + reason: map() | nil, + request_id: String.t() | nil, + evaluated_at: integer() | nil, + errors_while_computing: boolean() } @enforce_keys [:key, :enabled] - defstruct [:key, :enabled, :variant, :payload] + defstruct [ + :key, + :enabled, + :variant, + :payload, + :id, + :version, + :reason, + :request_id, + :evaluated_at, + errors_while_computing: false + ] @doc """ Returns the value of the feature flag result. diff --git a/test/posthog/feature_flags/evaluations_test.exs b/test/posthog/feature_flags/evaluations_test.exs new file mode 100644 index 0000000..04f41bd --- /dev/null +++ b/test/posthog/feature_flags/evaluations_test.exs @@ -0,0 +1,452 @@ +defmodule PostHog.FeatureFlags.EvaluationsTest do + use PostHog.Case, + async: Version.match?(System.version(), ">= 1.18.0"), + group: PostHog + + @moduletag config: [supervisor_name: PostHog] + + import Mox + + alias PostHog.API + alias PostHog.FeatureFlags + alias PostHog.FeatureFlags.Evaluations + alias PostHog.FeatureFlags.Result + + setup :setup_supervisor + setup :verify_on_exit! + + defp stub_flags_response do + %{ + status: 200, + body: %{ + "flags" => %{ + "boolean-flag" => %{ + "enabled" => true, + "key" => "boolean-flag", + "variant" => nil, + "metadata" => %{"id" => 1, "version" => 2, "payload" => nil}, + "reason" => %{"code" => "condition_match", "description" => "matched"} + }, + "variant-flag" => %{ + "enabled" => true, + "key" => "variant-flag", + "variant" => "control", + "metadata" => %{"id" => 2, "version" => 5, "payload" => %{"copy" => "hi"}}, + "reason" => %{"code" => "condition_match", "description" => "matched"} + }, + "disabled-flag" => %{ + "enabled" => false, + "key" => "disabled-flag", + "variant" => nil, + "metadata" => %{"id" => 3, "version" => 1, "payload" => nil}, + "reason" => %{"code" => "no_condition_match", "description" => "did not match"} + } + }, + "requestId" => "req-abc", + "evaluatedAt" => 1_700_000_000 + } + } + end + + describe "evaluate_flags/2" do + test "returns a snapshot containing every evaluated flag" do + expect(API.Mock, :request, fn _client, :post, "/flags", _opts -> + {:ok, stub_flags_response()} + end) + + assert {:ok, %Evaluations{distinct_id: "foo"} = snapshot} = + FeatureFlags.evaluate_flags("foo") + + assert Evaluations.keys(snapshot) == ["boolean-flag", "disabled-flag", "variant-flag"] + assert snapshot.request_id == "req-abc" + assert snapshot.evaluated_at == 1_700_000_000 + end + + test "makes exactly one /flags request" do + expect(API.Mock, :request, 1, fn _client, :post, "/flags", _opts -> + {:ok, stub_flags_response()} + end) + + assert {:ok, _} = FeatureFlags.evaluate_flags("foo") + end + + test "does not fire any $feature_flag_called events on construction" do + expect(API.Mock, :request, fn _client, _method, _url, _opts -> + {:ok, stub_flags_response()} + end) + + assert {:ok, _} = FeatureFlags.evaluate_flags("foo") + assert all_captured() == [] + end + + test "translates :flag_keys into flag_keys_to_evaluate in the request body" do + expect(API.Mock, :request, fn _client, :post, "/flags", opts -> + assert opts[:json] == %{ + distinct_id: "foo", + flag_keys_to_evaluate: ["boolean-flag", "variant-flag"] + } + + {:ok, stub_flags_response()} + end) + + assert {:ok, _} = + FeatureFlags.evaluate_flags(%{ + distinct_id: "foo", + flag_keys: ["boolean-flag", "variant-flag"] + }) + end + + test "forwards person_properties unchanged in the request body" do + expect(API.Mock, :request, fn _client, :post, "/flags", opts -> + assert opts[:json] == %{distinct_id: "foo", person_properties: %{plan: "enterprise"}} + {:ok, stub_flags_response()} + end) + + assert {:ok, _} = + FeatureFlags.evaluate_flags(%{ + distinct_id: "foo", + person_properties: %{plan: "enterprise"} + }) + end + + test "reads distinct_id from context when none is provided" do + PostHog.set_context(%{distinct_id: "from-context"}) + + expect(API.Mock, :request, fn _client, :post, "/flags", opts -> + assert opts[:json] == %{distinct_id: "from-context"} + {:ok, stub_flags_response()} + end) + + assert {:ok, %Evaluations{distinct_id: "from-context"}} = FeatureFlags.evaluate_flags() + end + + test "returns an error when distinct_id cannot be resolved" do + assert {:error, %PostHog.Error{}} = FeatureFlags.evaluate_flags(nil) + assert all_captured() == [] + end + + @tag config: [supervisor_name: MyPostHog] + test "supports a named PostHog instance" do + expect(API.Mock, :request, fn _client, :post, "/flags", _opts -> + {:ok, stub_flags_response()} + end) + + assert {:ok, %Evaluations{supervisor_name: MyPostHog}} = + FeatureFlags.evaluate_flags(MyPostHog, "foo") + end + end + + describe "enabled?/2 and get_flag/2" do + setup do + expect(API.Mock, :request, fn _client, _method, _url, _opts -> + {:ok, stub_flags_response()} + end) + + {:ok, snapshot} = FeatureFlags.evaluate_flags("foo") + %{snapshot: snapshot} + end + + test "enabled?/2 returns the boolean for known flags", %{snapshot: snapshot} do + assert Evaluations.enabled?(snapshot, "boolean-flag") == true + assert Evaluations.enabled?(snapshot, "variant-flag") == true + assert Evaluations.enabled?(snapshot, "disabled-flag") == false + end + + test "enabled?/2 returns false for unknown flags and fires a flag_missing event", + %{snapshot: snapshot} do + assert Evaluations.enabled?(snapshot, "unknown-flag") == false + + assert [ + %{ + event: "$feature_flag_called", + distinct_id: "foo", + properties: %{ + "$feature_flag": "unknown-flag", + "$feature_flag_response": false, + "$feature_flag_error": "flag_missing" + } + } + ] = all_captured() + end + + test "get_flag/2 returns the variant when present", %{snapshot: snapshot} do + assert Evaluations.get_flag(snapshot, "variant-flag") == "control" + end + + test "get_flag/2 returns true/false for boolean flags", %{snapshot: snapshot} do + assert Evaluations.get_flag(snapshot, "boolean-flag") == true + assert Evaluations.get_flag(snapshot, "disabled-flag") == false + end + + test "get_flag/2 returns nil for unknown flags and fires a flag_missing event", + %{snapshot: snapshot} do + assert Evaluations.get_flag(snapshot, "unknown-flag") == nil + + assert [ + %{ + event: "$feature_flag_called", + distinct_id: "foo", + properties: %{ + "$feature_flag": "unknown-flag", + "$feature_flag_response": false, + "$feature_flag_error": "flag_missing" + } + } + ] = all_captured() + end + + test "fires $feature_flag_called with full metadata", %{snapshot: snapshot} do + assert Evaluations.get_flag(snapshot, "variant-flag") == "control" + + assert [ + %{ + event: "$feature_flag_called", + distinct_id: "foo", + properties: %{ + "$feature_flag": "variant-flag", + "$feature_flag_response": "control", + "$feature_flag_id": 2, + "$feature_flag_version": 5, + "$feature_flag_reason": %{"code" => "condition_match"}, + "$feature_flag_request_id": "req-abc", + "$feature_flag_evaluated_at": 1_700_000_000 + } + } + ] = all_captured() + end + + test "fires on every access (no dedup in this PR)", %{snapshot: snapshot} do + Evaluations.enabled?(snapshot, "boolean-flag") + Evaluations.enabled?(snapshot, "boolean-flag") + Evaluations.get_flag(snapshot, "boolean-flag") + + assert length(all_captured()) == 3 + end + end + + describe "get_flag_payload/2" do + setup do + expect(API.Mock, :request, fn _client, _method, _url, _opts -> + {:ok, stub_flags_response()} + end) + + {:ok, snapshot} = FeatureFlags.evaluate_flags("foo") + %{snapshot: snapshot} + end + + test "returns the configured payload", %{snapshot: snapshot} do + assert Evaluations.get_flag_payload(snapshot, "variant-flag") == %{"copy" => "hi"} + end + + test "returns nil when no payload is configured", %{snapshot: snapshot} do + assert Evaluations.get_flag_payload(snapshot, "boolean-flag") == nil + end + + test "returns nil for unknown flags", %{snapshot: snapshot} do + assert Evaluations.get_flag_payload(snapshot, "unknown-flag") == nil + end + + test "does not fire a $feature_flag_called event", %{snapshot: snapshot} do + Evaluations.get_flag_payload(snapshot, "variant-flag") + Evaluations.get_flag_payload(snapshot, "unknown-flag") + + assert all_captured() == [] + end + end + + describe "only/2" do + setup do + expect(API.Mock, :request, fn _client, _method, _url, _opts -> + {:ok, stub_flags_response()} + end) + + {:ok, snapshot} = FeatureFlags.evaluate_flags("foo") + %{snapshot: snapshot} + end + + test "narrows the snapshot to the requested keys", %{snapshot: snapshot} do + narrowed = Evaluations.only(snapshot, ["boolean-flag", "variant-flag"]) + assert Evaluations.keys(narrowed) == ["boolean-flag", "variant-flag"] + end + + test "silently drops unknown keys", %{snapshot: snapshot} do + narrowed = Evaluations.only(snapshot, ["boolean-flag", "does-not-exist"]) + assert Evaluations.keys(narrowed) == ["boolean-flag"] + end + + test "preserves snapshot metadata", %{snapshot: snapshot} do + narrowed = Evaluations.only(snapshot, ["boolean-flag"]) + + assert narrowed.distinct_id == snapshot.distinct_id + assert narrowed.supervisor_name == snapshot.supervisor_name + assert narrowed.request_id == snapshot.request_id + assert narrowed.evaluated_at == snapshot.evaluated_at + end + end + + describe "event_properties/1" do + setup do + expect(API.Mock, :request, fn _client, _method, _url, _opts -> + {:ok, stub_flags_response()} + end) + + {:ok, snapshot} = FeatureFlags.evaluate_flags("foo") + %{snapshot: snapshot} + end + + test "produces $feature/ entries for every flag", %{snapshot: snapshot} do + properties = Evaluations.event_properties(snapshot) + + assert properties["$feature/boolean-flag"] == true + assert properties["$feature/variant-flag"] == "control" + assert properties["$feature/disabled-flag"] == false + end + + test "produces $active_feature_flags sorted, enabled-only", %{snapshot: snapshot} do + properties = Evaluations.event_properties(snapshot) + + assert properties[:"$active_feature_flags"] == ["boolean-flag", "variant-flag"] + end + + test "omits $active_feature_flags when no flag is enabled" do + snapshot = %Evaluations{ + supervisor_name: PostHog, + distinct_id: "foo", + flags: %{ + "off" => %Result{key: "off", enabled: false} + } + } + + properties = Evaluations.event_properties(snapshot) + + refute Map.has_key?(properties, :"$active_feature_flags") + assert properties["$feature/off"] == false + end + end + + describe "set_in_context/2" do + test "merges $feature/ and $active_feature_flags into the context" do + expect(API.Mock, :request, fn _client, _method, _url, _opts -> + {:ok, stub_flags_response()} + end) + + {:ok, snapshot} = FeatureFlags.evaluate_flags("foo") + :ok = FeatureFlags.set_in_context(snapshot) + + context = PostHog.get_context() + assert context["$feature/boolean-flag"] == true + assert context["$feature/variant-flag"] == "control" + assert context["$feature/disabled-flag"] == false + assert context[:"$active_feature_flags"] == ["boolean-flag", "variant-flag"] + end + + test "captures triggered after set_in_context attach the snapshot's properties" do + expect(API.Mock, :request, fn _client, _method, _url, _opts -> + {:ok, stub_flags_response()} + end) + + {:ok, snapshot} = FeatureFlags.evaluate_flags("foo") + :ok = FeatureFlags.set_in_context(snapshot) + + :ok = PostHog.capture("page_viewed", %{distinct_id: "foo"}) + + assert [ + %{ + event: "page_viewed", + distinct_id: "foo", + properties: properties + } + ] = all_captured() + + assert properties["$feature/boolean-flag"] == true + assert properties["$feature/variant-flag"] == "control" + assert properties[:"$active_feature_flags"] == ["boolean-flag", "variant-flag"] + end + + test "does not trigger an additional /flags request when capturing" do + expect(API.Mock, :request, 1, fn _client, _method, _url, _opts -> + {:ok, stub_flags_response()} + end) + + {:ok, snapshot} = FeatureFlags.evaluate_flags("foo") + :ok = FeatureFlags.set_in_context(snapshot) + :ok = PostHog.capture("page_viewed", %{distinct_id: "foo"}) + end + end + + describe "errors_while_computing propagation" do + defp errored_flags_response do + response = stub_flags_response() + put_in(response, [:body, "errorsWhileComputingFlags"], true) + end + + test "attaches errors_while_computing_flags to events for known flags" do + expect(API.Mock, :request, fn _client, _method, _url, _opts -> + {:ok, errored_flags_response()} + end) + + {:ok, snapshot} = FeatureFlags.evaluate_flags("foo") + assert snapshot.errors_while_computing == true + Evaluations.enabled?(snapshot, "boolean-flag") + + assert [ + %{ + event: "$feature_flag_called", + properties: %{ + "$feature_flag": "boolean-flag", + "$feature_flag_error": "errors_while_computing_flags" + } + } + ] = all_captured() + end + + test "combines errors_while_computing_flags with flag_missing for missing flags" do + expect(API.Mock, :request, fn _client, _method, _url, _opts -> + {:ok, errored_flags_response()} + end) + + {:ok, snapshot} = FeatureFlags.evaluate_flags("foo") + Evaluations.enabled?(snapshot, "missing-flag") + + assert [ + %{ + event: "$feature_flag_called", + properties: %{ + "$feature_flag": "missing-flag", + "$feature_flag_error": "errors_while_computing_flags,flag_missing" + } + } + ] = all_captured() + end + + test "omits $feature_flag_error when there are no errors", %{} do + # uses the default stub_flags_response with errorsWhileComputingFlags absent + expect(API.Mock, :request, fn _client, _method, _url, _opts -> + {:ok, stub_flags_response()} + end) + + {:ok, snapshot} = FeatureFlags.evaluate_flags("foo") + Evaluations.enabled?(snapshot, "boolean-flag") + + assert [%{properties: properties}] = all_captured() + refute Map.has_key?(properties, :"$feature_flag_error") + end + end + + describe "empty distinct_id" do + test "manually-constructed snapshot with empty distinct_id does not fire events" do + snapshot = %Evaluations{ + supervisor_name: PostHog, + distinct_id: "", + flags: %{ + "flag" => %Result{key: "flag", enabled: true} + } + } + + Evaluations.enabled?(snapshot, "flag") + Evaluations.get_flag(snapshot, "flag") + + assert all_captured() == [] + end + end +end diff --git a/test/posthog/feature_flags_test.exs b/test/posthog/feature_flags_test.exs index 6f2b3b3..ea96673 100644 --- a/test/posthog/feature_flags_test.exs +++ b/test/posthog/feature_flags_test.exs @@ -307,6 +307,70 @@ defmodule PostHog.FeatureFlagsTest do ] = all_captured() end + test "attaches $feature_flag_error: errors_while_computing_flags when response signals errors" do + expect(API.Mock, :request, fn _client, _method, _url, _opts -> + {:ok, + %{ + status: 200, + body: %{ + "flags" => %{"myflag" => %{"enabled" => true, "variant" => "variant1"}}, + "errorsWhileComputingFlags" => true + } + }} + end) + + assert {:ok, "variant1"} = FeatureFlags.check("myflag", "foo") + + assert [ + %{ + event: "$feature_flag_called", + properties: %{ + "$feature_flag": "myflag", + "$feature_flag_error": "errors_while_computing_flags" + } + } + ] = all_captured() + end + + test "attaches rich metadata to $feature_flag_called when the response provides it" do + expect(API.Mock, :request, fn _client, _method, _url, _opts -> + {:ok, + %{ + status: 200, + body: %{ + "flags" => %{ + "myflag" => %{ + "enabled" => true, + "variant" => "variant1", + "metadata" => %{"id" => 42, "version" => 7, "payload" => nil}, + "reason" => %{"code" => "condition_match"} + } + }, + "requestId" => "req-xyz", + "evaluatedAt" => 1_700_000_000 + } + }} + end) + + assert {:ok, "variant1"} = FeatureFlags.check("myflag", "foo") + + assert [ + %{ + event: "$feature_flag_called", + distinct_id: "foo", + properties: %{ + "$feature_flag": "myflag", + "$feature_flag_response": "variant1", + "$feature_flag_id": 42, + "$feature_flag_version": 7, + "$feature_flag_reason": %{"code" => "condition_match"}, + "$feature_flag_request_id": "req-xyz", + "$feature_flag_evaluated_at": 1_700_000_000 + } + } + ] = all_captured() + end + @tag config: [supervisor_name: MyPostHog] test "custom PostHog instance" do expect(API.Mock, :request, fn client, method, url, opts ->