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)
end

The 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
end

user_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
end

3. 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))}
end

But 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"))}
end

Pick 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)
  )
end

Delivery 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.