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, &</2)
op when op in ["greater_than_or_equal", "gte"] ->
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