Current section
Files
Jump to
Current section
Files
lib/lucerna.ex
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