defmodule BilldogEng.Flags do @moduledoc """ Feature-flag client supporting both remote and local (server-side) evaluation. Local evaluation (`local_evaluation: true`) fetches flag DEFINITIONS once, caches them in a `GenServer` with a 5-minute TTL, and evaluates each flag deterministically on this process — the correct home for flag evaluation in a server SDK and the cross-platform-identical algorithm shared with web / iOS / Android. Remote evaluation falls back to `POST /experiment-config`, which returns a pre-evaluated `feature_flags` map for the given user. Mirrors the Node SDK `flags.ts`. """ use GenServer alias BilldogEng.{Murmur, Transport} # TTL for cached flag definitions: 5 minutes (spec §D). @ttl_ms 5 * 60 * 1000 defstruct [ :api_key, :transport, :local_evaluation, :enable_logging, definitions: %{}, fetched_at: 0, definitions_set: false ] # ── Lifecycle ─────────────────────────────────────────────────────────────── @doc false def start_link(opts) do GenServer.start_link(__MODULE__, opts) end @impl true def init(opts) do state = %__MODULE__{ api_key: Keyword.fetch!(opts, :api_key), transport: Keyword.fetch!(opts, :transport), local_evaluation: Keyword.get(opts, :local_evaluation, false), enable_logging: Keyword.get(opts, :enable_logging, false) } {:ok, state} end # ── Public API ────────────────────────────────────────────────────────────── @doc """ Get a flag's value for a user. Returns `true`/`false` for a simple flag, the variant key string for a multivariate flag, or `nil` when the flag is unknown. """ @spec get_feature_flag(pid(), String.t(), String.t(), keyword()) :: boolean() | String.t() | nil def get_feature_flag(pid, key, distinct_id, opts \\ []) do GenServer.call(pid, {:get_feature_flag, key, distinct_id, opts}, :infinity) end @doc "Boolean view of `get_feature_flag/4` (a variant string counts as ON)." @spec is_feature_enabled(pid(), String.t(), String.t(), keyword()) :: boolean() def is_feature_enabled(pid, key, distinct_id, opts \\ []) do case get_feature_flag(pid, key, distinct_id, opts) do nil -> false false -> false _ -> true end end @doc """ Get a flag's payload (variant config). Only meaningful under local evaluation. Returns the matched variant's payload, else the flag-level payload, else `nil`. """ @spec get_feature_flag_payload(pid(), String.t(), String.t(), keyword()) :: term() def get_feature_flag_payload(pid, key, distinct_id, opts \\ []) do GenServer.call(pid, {:get_feature_flag_payload, key, distinct_id, opts}, :infinity) end @doc "Evaluate every known flag for a user." @spec get_all_flags(pid(), String.t(), keyword()) :: map() def get_all_flags(pid, distinct_id, opts \\ []) do GenServer.call(pid, {:get_all_flags, distinct_id, opts}, :infinity) end @doc "Force a reload of the cached flag definitions (local mode)." @spec reload_feature_flag_definitions(pid()) :: :ok def reload_feature_flag_definitions(pid) do GenServer.call(pid, :reload_definitions, :infinity) end @doc """ Inject flag definitions directly, bypassing the network. Primarily for tests and for hosts that distribute definitions through their own channel. """ @spec set_definitions(pid(), [map()]) :: :ok def set_definitions(pid, defs) do GenServer.call(pid, {:set_definitions, defs}, :infinity) end # ── Server callbacks ───────────────────────────────────────────────────────── @impl true def handle_call({:get_feature_flag, key, distinct_id, opts}, _from, state) do if state.local_evaluation do state = ensure_definitions(state) result = if Map.has_key?(state.definitions, key) do evaluate_locally(state, key, distinct_id, person_props(opts)) else nil end {:reply, result, state} else map = fetch_remote(state, distinct_id, person_props(opts)) result = if Map.has_key?(map, key), do: Map.get(map, key), else: nil {:reply, result, state} end end def handle_call({:get_feature_flag_payload, key, distinct_id, opts}, _from, state) do state = ensure_definitions(state) def = Map.get(state.definitions, key) result = cond do is_nil(def) -> nil true -> verdict = evaluate_locally(state, key, distinct_id, person_props(opts)) cond do verdict == false -> nil is_binary(verdict) and is_list(def["variants"]) -> variant = Enum.find(def["variants"], fn v -> v["key"] == verdict end) if variant && Map.has_key?(variant, "payload") do variant["payload"] else Map.get(def, "payload") end true -> Map.get(def, "payload") end end {:reply, result, state} end def handle_call({:get_all_flags, distinct_id, opts}, _from, state) do if state.local_evaluation do state = ensure_definitions(state) out = Enum.into(state.definitions, %{}, fn {key, _def} -> {key, evaluate_locally(state, key, distinct_id, person_props(opts))} end) {:reply, out, state} else {:reply, fetch_remote(state, distinct_id, person_props(opts)), state} end end def handle_call(:reload_definitions, _from, state) do {:reply, :ok, fetch_definitions(state)} end def handle_call({:set_definitions, defs}, _from, state) do definitions = defs |> Enum.map(&normalize_def/1) |> Map.new(fn d -> {d["key"], d} end) {:reply, :ok, %{state | definitions: definitions, fetched_at: now_ms(), definitions_set: true}} end # ── Internals ──────────────────────────────────────────────────────────────── defp person_props(opts) do Keyword.get(opts, :person_properties, %{}) end defp ensure_definitions(state) do fresh? = (state.definitions_set or map_size(state.definitions) > 0) and now_ms() - state.fetched_at < @ttl_ms if fresh? do state else fetch_definitions(state) end end defp fetch_definitions(state) do case Transport.request(state.transport, path: "/feature-flag-definitions", body: %{api_key: state.api_key}, headers: [{"x-api-key", state.api_key}], gzip: false ) do {:ok, data} -> flags = (is_map(data) && Map.get(data, "flags")) || [] definitions = flags |> Enum.map(&normalize_def/1) |> Map.new(fn d -> {d["key"], d} end) %{state | definitions: definitions, fetched_at: now_ms()} {:error, _err} -> # Graceful: keep any existing cache. state end end defp fetch_remote(state, distinct_id, attributes) do case Transport.request(state.transport, path: "/experiment-config", body: %{api_key: state.api_key, user_id: distinct_id, attributes: attributes || %{}}, headers: [{"x-api-key", state.api_key}], gzip: false ) do {:ok, data} when is_map(data) -> Map.get(data, "feature_flags") || %{} _ -> %{} end end @doc """ Deterministic local flag evaluation (spec §D): 1. Missing/inactive → false. 2. ALL `targeting_rules` must match `attributes`, else false. 3. bucket = murmurhash3("{key}.{distinctId}") % 100; ON iff bucket < rollout. 4. Multivariate: walk variants by cumulative rollout within the ON bucket. Returns `false` (off), `true` (on, boolean) or the chosen variant key. Exposed for direct use against an explicit definition map. """ @spec evaluate_locally(t :: %__MODULE__{}, String.t(), String.t(), map() | nil) :: boolean() | String.t() def evaluate_locally(state, key, distinct_id, attributes) do def = Map.get(state.definitions, key) cond do is_nil(def) or def["active"] != true -> false not targeting_matches?(def["targeting_rules"], attributes || %{}) -> false true -> bucket = Murmur.hash("#{key}.#{distinct_id}", 0) |> rem(100) if bucket >= (def["rollout_percentage"] || 0) do false else resolve_variants(def["variants"], bucket) end end end defp targeting_matches?(rules, _attrs) when rules in [nil, []], do: true defp targeting_matches?(rules, attrs) when is_list(rules) do Enum.all?(rules, fn rule -> matches_rule?(rule, Map.get(attrs, rule["attribute"])) end) end defp resolve_variants(variants, _bucket) when variants in [nil, []], do: true defp resolve_variants(variants, bucket) when is_list(variants) do case walk_variants(variants, bucket, 0) do {:variant, key} -> key :none -> true end end defp walk_variants([], _bucket, _cumulative), do: :none defp walk_variants([variant | rest], bucket, cumulative) do cumulative = cumulative + (variant["rollout_percentage"] || 0) if bucket < cumulative do {:variant, variant["key"]} else walk_variants(rest, bucket, cumulative) end end # Full operator set mirroring `supabase/functions/_shared/edge-serving/pure.ts` # `compare()` (+ `tryParseDate`). `rule` carries the operator and (for most # operators) an expected `value`; `actual` is the resolved attribute value. defp matches_rule?(rule, actual) when is_map(rule) do op = rule["operator"] expected = rule["value"] actual_str = to_comparable(actual) cond do op == "exists" -> actual_str != nil and String.length(actual_str) > 0 op == "not_exists" -> actual_str == nil or String.length(actual_str) == 0 # For all other operators: missing actual → false. actual_str == nil -> false true -> compare(op, actual, actual_str, expected) end end defp matches_rule?(_rule, _value), do: false defp compare(op, actual, actual_str, expected) do case op do op when op in ["is", "equals"] -> actual_str == to_string_value(expected) op when op in ["is_not", "not_equals"] -> actual_str != to_string_value(expected) "any_of" -> is_list(expected) and actual_str in Enum.map(expected, &to_string_value/1) "not_any_of" -> is_list(expected) and actual_str not in Enum.map(expected, &to_string_value/1) "contains" -> String.contains?(actual_str, to_string_value(expected)) "not_contains" -> not String.contains?(actual_str, to_string_value(expected)) op when op in ["greater_than", "gt"] -> date_or_number_compare(actual, actual_str, expected, &>/2) op when op in ["less_than", "lt"] -> date_or_number_compare(actual, actual_str, expected, & date_or_number_compare(actual, actual_str, expected, &>=/2) op when op in ["less_than_or_equal", "lte"] -> date_or_number_compare(actual, actual_str, expected, &<=/2) _ -> false end end # Try to parse BOTH actual and expected as ISO dates (date-shaped regex guard # first, mirroring `tryParseDate`); if BOTH parse, compare epoch ms. Otherwise # fall back to numeric comparison via Float.parse. defp date_or_number_compare(actual, actual_str, expected, op) do case {try_parse_date(actual), try_parse_date(expected)} do {act_ms, exp_ms} when is_integer(act_ms) and is_integer(exp_ms) -> op.(act_ms, exp_ms) _ -> case {to_number(actual_str), to_number(expected)} do {a, b} when is_number(a) and is_number(b) -> op.(a, b) _ -> false end end end # Mirror pure.ts `tryParseDate`: only strings matching `^\d{4}-\d{2}-\d{2}` # that actually parse become dates. Returns epoch milliseconds or nil. defp try_parse_date(%Date{} = d), do: date_to_ms(d) defp try_parse_date(%DateTime{} = dt), do: DateTime.to_unix(dt, :millisecond) defp try_parse_date(value) when is_binary(value) do if Regex.match?(~r/^\d{4}-\d{2}-\d{2}/, value) do case DateTime.from_iso8601(value) do {:ok, dt, _offset} -> DateTime.to_unix(dt, :millisecond) _ -> case Date.from_iso8601(String.slice(value, 0, 10)) do {:ok, d} -> date_to_ms(d) _ -> nil end end else nil end end defp try_parse_date(_), do: nil defp date_to_ms(%Date{} = d) do {:ok, dt, _} = DateTime.from_iso8601("#{Date.to_iso8601(d)}T00:00:00Z") DateTime.to_unix(dt, :millisecond) end # String(actual) coercion — nil/undefined → nil (mirrors `toComparable`). defp to_comparable(nil), do: nil defp to_comparable(v) when is_binary(v), do: v defp to_comparable(v), do: to_string_value(v) # String(expected) coercion mirroring JS `String()`. defp to_string_value(nil), do: "" defp to_string_value(v) when is_binary(v), do: v defp to_string_value(true), do: "true" defp to_string_value(false), do: "false" defp to_string_value(v) when is_integer(v), do: Integer.to_string(v) defp to_string_value(v) when is_float(v), do: stringify_float(v) defp to_string_value(v), do: to_string(v) # JS prints whole-valued floats without a trailing ".0". defp stringify_float(v) do if v == Float.round(v) and abs(v) < 1.0e16 do v |> trunc() |> Integer.to_string() else Float.to_string(v) end end defp to_number(n) when is_number(n), do: n defp to_number(s) when is_binary(s) do case Float.parse(s) do {f, _} -> f :error -> :nan end end defp to_number(_), do: :nan # Normalize a definition into a string-keyed map regardless of input atom/string keys. defp normalize_def(def) when is_map(def) do Map.new(def, fn {k, v} -> {to_string_key(k), normalize_nested(k, v)} end) end defp normalize_nested(k, list) when is_list(list) and k in [:variants, "variants", :targeting_rules, "targeting_rules"] do Enum.map(list, fn item when is_map(item) -> Map.new(item, fn {ik, iv} -> {to_string_key(ik), iv} end) item -> item end) end defp normalize_nested(_k, v), do: v defp to_string_key(k) when is_atom(k), do: Atom.to_string(k) defp to_string_key(k), do: k defp now_ms, do: System.system_time(:millisecond) end