defmodule Lucerna do @moduledoc """ Lucerna SDK for Elixir — feature gates, experiments and identity. Add one child to your supervision tree: children = [ {Lucerna, server_key: System.fetch_env!("LUCERNA_SERVER_KEY")} ] then read anywhere — no process on the hot path, reads never raise: identity = Lucerna.Identity.new!(user_id: "u_42", email: "sofia@acme.com") Lucerna.Gates.flag("settings_sso", identity) #=> true / false Lucerna.Gates.experiment("checkout_copy", identity) #=> "urgent" / nil Lucerna.Gates.switch("checkout") #=> false means killed Lucerna.identify(identity) # self-sync to People — the only call # that transmits email/name The tree is: a Finch pool (unless you inject your own transport), the ETS snapshot `Lucerna.Gates.Store`, and the `Lucerna.Gates.Sync` poller. Sync fetches the compiled runtime at boot and every `refresh_interval` (default 10s); fetch failures serve the stale snapshot — never "off". See `Lucerna.Config` for every option. Multiple isolated instances: give each a `:name` and pass `name: MyName` to reads. """ use Supervisor alias Lucerna.{Config, Identity, Reporting} alias Lucerna.Gates.{Store, Sync} @doc """ Starts a Lucerna instance. Options are validated here — see `Lucerna.Config.new!/1`; bad options raise at boot, never later. """ @spec start_link(keyword()) :: Supervisor.on_start() def start_link(opts) do config = Config.new!(opts) Supervisor.start_link(__MODULE__, config, name: config.name) end @doc false def child_spec(opts) do %{ id: opts[:name] || __MODULE__, start: {__MODULE__, :start_link, [opts]}, type: :supervisor } end @doc """ Sends an identity to People — the self-sync, and the **only** path that transmits `email`/`name` (gates reads never transmit anything). Accepts a `Lucerna.Identity`, map, or keyword list; unlike gates reads this validates strictly — a bad or missing identity is a programming error and raises `ArgumentError`. Delivery is async and fire-and-forget through `Lucerna.Reporting` under `:identity_key` (defaults to `:server_key`): one attempt, no retry — the server upserts idempotently, so the next `identify` is the retry. An exact resend of the last delivered payload is skipped. `Lucerna.flush/1` returns only after every identify queued before it has been attempted. """ @spec identify(Identity.t() | map() | keyword(), keyword()) :: :ok def identify(identity, opts \\ []) do identity = Identity.wrap(identity) || raise ArgumentError, "identify requires an identity (got nil)" reporting = Config.reporting_name(opts[:name] || __MODULE__) Reporting.identify(reporting, Identity.to_payload(identity)) end @doc """ Delivers queued events (experiment exposures) now instead of on the refresh cadence — for scripts and tests; a running app never needs it. Always `:ok`, even when the instance isn't running. """ @spec flush(keyword()) :: :ok def flush(opts \\ []) do reporting = Config.reporting_name(opts[:name] || __MODULE__) GenServer.call(reporting, :flush, opts[:timeout] || 10_000) catch :exit, _reason -> :ok end @impl true def init(%Config{} = config) do children = if config.start_finch? do [{Finch, name: config.finch_name}] else [] end ++ [ {Store, config}, {Reporting, config}, {Sync, config} ] # one_for_one: a Sync crash must never take the Store (and its # snapshot table) down. The Store-restart case is covered by the # etag living in the table itself — see Lucerna.Gates.Sync. # Shutdown runs in reverse start order, so Reporting drains its # queue (terminate/2, shutdown: 2_000) while the Finch pool is # still up. Supervisor.init(children, strategy: :one_for_one) end end