Lucerna in Phoenix
The supervision child spec is the Phoenix integration — there is no lucerna_phoenix package and no plug you must install. Add one child to your application tree, then read gates in plugs, controllers, and LiveViews.
1. Start the instance
Add Lucerna to application.ex, alongside your Repo and Endpoint:
# lib/my_app/application.ex
def start(_type, _args) do
children = [
MyApp.Repo,
{Lucerna, server_key: System.fetch_env!("LUCERNA_SERVER_KEY")},
MyAppWeb.Endpoint
]
Supervisor.start_link(children, strategy: :one_for_one, name: MyApp.Supervisor)
endThe instance downloads the compiled runtime at boot and polls every refresh_interval (default 10s). Reads are safe before the first download completes — they answer safe fallbacks (see the README's guarantees table). If a screen must not render until the runtime is loaded, call Lucerna.Gates.wait_until_ready/1 in a release task; a web request should never block on it.
2. Build the identity once, in a plug
Resolve the current user into a Lucerna.Identity once per request and stash it on the conn. Every downstream read shares it — never rebuild it per call.
# lib/my_app_web/plugs/lucerna_identity.ex
defmodule MyAppWeb.Plugs.LucernaIdentity do
import Plug.Conn
def init(opts), do: opts
def call(conn, _opts) do
case conn.assigns[:current_user] do
nil ->
assign(conn, :identity, nil)
user ->
assign(conn, :identity, Lucerna.Identity.new!(user_id: user.public_id, email: user.email))
end
end
enduser_id must be a stable pseudonymous id (user.public_id), never the email. email/name are carried on the identity but are only sent to People by Lucerna.identify/1 — gates reads transmit nothing.
Wire it into the browser pipeline after authentication:
pipeline :browser do
# ...
plug MyAppWeb.Plugs.AuthenticateUser
plug MyAppWeb.Plugs.LucernaIdentity
end3. Evaluate at the edge, assign, then render
Read gates in the controller and pass plain booleans/strings into the template. Do not call Lucerna.Gates.* from inside HEEx — templates render markup, they don't make decisions.
def index(conn, _params) do
identity = conn.assigns.identity
conn
|> assign(:sso_enabled, Lucerna.Gates.flag("settings_sso", identity))
|> assign(:checkout_live, Lucerna.Gates.switch("checkout"))
|> render(:index)
end<.link :if={@sso_enabled} navigate={~p"/settings/sso"}>Single sign-on</.link>
<.checkout :if={@checkout_live} />4. LiveView: the freeze caveat
A flag read in mount/3 is evaluated once and then frozen for the life of that LiveView process. That's correct for feature flags — a user shouldn't see the UI reshuffle mid-session — so evaluate in mount and assign:
def mount(_params, session, socket) do
identity = Lucerna.Identity.new!(user_id: session["user_id"])
{:ok, assign(socket, :new_editor, Lucerna.Gates.flag("new_editor", identity))}
endBut a kill switch is meant to bite immediately — that's the whole point of one. A value frozen in mount won't. For guarded paths that must react to a kill within the poll window, re-evaluate on a timer and push the new value into the socket:
def mount(_params, session, socket) do
identity = Lucerna.Identity.new!(user_id: session["user_id"])
if connected?(socket), do: :timer.send_interval(15_000, :recheck_kills)
{:ok, assign(socket, :checkout_live, Lucerna.Gates.switch("checkout"))}
end
def handle_info(:recheck_kills, socket) do
{:noreply, assign(socket, :checkout_live, Lucerna.Gates.switch("checkout"))}
endPick an interval at or above the runtime refresh_interval (10s) — a tighter timer just re-reads the same ETS snapshot. Pushing snapshot flips over Phoenix.PubSub so LiveViews re-evaluate the instant the runtime changes is a planned enhancement; the timer is the shipped pattern.
5. Identify users
Sync a user to People when you learn who they are (login, signup, trait change). This is the only call that transmits email/name:
def handle_user_signed_in(user) do
Lucerna.identify(
Lucerna.Identity.new!(user_id: user.public_id, email: user.email, name: user.name)
)
endDelivery is async and fire-and-forget; the server upserts idempotently, so you never need to await it in a request. Repeat sends of an unchanged payload are skipped.
6. Observe with telemetry
Lucerna emits :telemetry events instead of logging. Attach them to your existing telemetry pipeline:
[:lucerna, :gates, :evaluation]— every point read.[:lucerna, :gates, :snapshot]— a new runtime landed (%{flags, experiments, kills}counts).[:lucerna, :sync, :success | :error]— each poll (%{duration}).[:lucerna, :reporting, :flush | :error]— exposure/identify delivery.
See the README for the full metadata shape of each event.